├── .dockerignore ├── .editorconfig ├── .github ├── img │ ├── access_token.png │ └── preview.png └── workflows │ ├── audit.yml │ └── workflow.yml ├── .gitignore ├── .npmrc ├── .prettierrc ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── angular.json ├── api ├── .env.example ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── config.example.toml └── src │ ├── artifact │ ├── artifact_handler.rs │ ├── artifact_service.rs │ └── mod.rs │ ├── branch │ ├── branch_handler.rs │ ├── branch_service.rs │ ├── mod.rs │ └── pipeline_aggregator.rs │ ├── config │ ├── config_app.rs │ ├── config_file.rs │ ├── config_handler.rs │ └── mod.rs │ ├── error.rs │ ├── gitlab.rs │ ├── group │ ├── group_handler.rs │ ├── group_service.rs │ └── mod.rs │ ├── job │ ├── job_handler.rs │ ├── job_service.rs │ └── mod.rs │ ├── main.rs │ ├── model │ ├── branch.rs │ ├── commit.rs │ ├── group.rs │ ├── job.rs │ ├── mod.rs │ ├── pipeline.rs │ ├── project.rs │ ├── schedule.rs │ └── user.rs │ ├── pipeline │ ├── mod.rs │ ├── pipeline_handler.rs │ ├── pipeline_service.rs │ └── util.rs │ ├── project │ ├── mod.rs │ ├── pipeline_aggregator.rs │ ├── project_handler.rs │ └── project_service.rs │ ├── schedule │ ├── mod.rs │ ├── pipeline_aggregator.rs │ ├── schedule_handler.rs │ └── schedule_service.rs │ ├── spa.rs │ └── util │ ├── deserialize.rs │ ├── iter.rs │ └── mod.rs ├── docker-compose.yml ├── jest.config.js ├── package-lock.json ├── package.json ├── proxy.conf.js ├── renovate.json ├── src ├── app │ ├── app.component.html │ ├── app.component.scss │ ├── app.component.spec.ts │ ├── app.component.ts │ ├── app.config.ts │ ├── groups │ │ ├── group-tabs │ │ │ ├── favorites │ │ │ │ ├── favorite.service.ts │ │ │ │ ├── favorites-icon │ │ │ │ │ ├── favorites-icon.component.html │ │ │ │ │ ├── favorites-icon.component.scss │ │ │ │ │ └── favorites-icon.component.ts │ │ │ │ ├── favorites.component.html │ │ │ │ ├── favorites.component.scss │ │ │ │ └── favorites.component.ts │ │ │ ├── feature-tabs │ │ │ │ ├── components │ │ │ │ │ ├── download-artifacts-icon │ │ │ │ │ │ ├── download-artifacts-icon.component.html │ │ │ │ │ │ ├── download-artifacts-icon.component.scss │ │ │ │ │ │ └── download-artifacts-icon.component.ts │ │ │ │ │ ├── jobs │ │ │ │ │ │ ├── jobs.component.html │ │ │ │ │ │ ├── jobs.component.scss │ │ │ │ │ │ └── jobs.component.ts │ │ │ │ │ ├── open-gitlab-icon │ │ │ │ │ │ ├── open-gitlab-icon.component.html │ │ │ │ │ │ ├── open-gitlab-icon.component.scss │ │ │ │ │ │ └── open-gitlab-icon.component.ts │ │ │ │ │ ├── project-filter │ │ │ │ │ │ ├── project-filter.component.html │ │ │ │ │ │ ├── project-filter.component.scss │ │ │ │ │ │ └── project-filter.component.ts │ │ │ │ │ ├── topic-filter │ │ │ │ │ │ ├── topic-filter.component.html │ │ │ │ │ │ ├── topic-filter.component.scss │ │ │ │ │ │ └── topic-filter.component.ts │ │ │ │ │ └── write-actions-icon │ │ │ │ │ │ ├── cancel-pipeline-action │ │ │ │ │ │ ├── cancel-pipeline-action.component.html │ │ │ │ │ │ ├── cancel-pipeline-action.component.scss │ │ │ │ │ │ └── cancel-pipeline-action.component.ts │ │ │ │ │ │ ├── retry-pipeline-action │ │ │ │ │ │ ├── retry-pipeline-action.component.html │ │ │ │ │ │ ├── retry-pipeline-action.component.scss │ │ │ │ │ │ └── retry-pipeline-action.component.ts │ │ │ │ │ │ ├── start-pipeline-action │ │ │ │ │ │ ├── start-pipeline-action.component.html │ │ │ │ │ │ ├── start-pipeline-action.component.scss │ │ │ │ │ │ ├── start-pipeline-action.component.ts │ │ │ │ │ │ └── start-pipeline-modal │ │ │ │ │ │ │ ├── start-pipeline-modal.component.html │ │ │ │ │ │ │ ├── start-pipeline-modal.component.scss │ │ │ │ │ │ │ ├── start-pipeline-modal.component.ts │ │ │ │ │ │ │ └── variables-form │ │ │ │ │ │ │ ├── variables-form.component.html │ │ │ │ │ │ │ ├── variables-form.component.scss │ │ │ │ │ │ │ └── variables-form.component.ts │ │ │ │ │ │ ├── write-actions-icon.component.html │ │ │ │ │ │ ├── write-actions-icon.component.scss │ │ │ │ │ │ └── write-actions-icon.component.ts │ │ │ │ ├── feature-tabs.component.html │ │ │ │ ├── feature-tabs.component.scss │ │ │ │ ├── feature-tabs.component.ts │ │ │ │ ├── latest-pipelines │ │ │ │ │ ├── latest-pipelines.component.html │ │ │ │ │ ├── latest-pipelines.component.scss │ │ │ │ │ ├── latest-pipelines.component.ts │ │ │ │ │ ├── pipeline-status-tabs │ │ │ │ │ │ ├── pipeline-status-tabs.component.html │ │ │ │ │ │ ├── pipeline-status-tabs.component.scss │ │ │ │ │ │ ├── pipeline-status-tabs.component.ts │ │ │ │ │ │ └── pipeline-table │ │ │ │ │ │ │ ├── pipeline-table-branch │ │ │ │ │ │ │ ├── latest-branch-filter │ │ │ │ │ │ │ │ ├── latest-branch-filter.component.html │ │ │ │ │ │ │ │ ├── latest-branch-filter.component.scss │ │ │ │ │ │ │ │ └── latest-branch-filter.component.ts │ │ │ │ │ │ │ ├── pipeline-table-branch.component.html │ │ │ │ │ │ │ ├── pipeline-table-branch.component.scss │ │ │ │ │ │ │ └── pipeline-table-branch.component.ts │ │ │ │ │ │ │ ├── pipeline-table.component.html │ │ │ │ │ │ │ ├── pipeline-table.component.scss │ │ │ │ │ │ │ └── pipeline-table.component.ts │ │ │ │ │ └── service │ │ │ │ │ │ └── latest-pipeline.service.ts │ │ │ │ ├── pipelines │ │ │ │ │ ├── components │ │ │ │ │ │ ├── branch-filter │ │ │ │ │ │ │ ├── branch-filter.component.html │ │ │ │ │ │ │ ├── branch-filter.component.scss │ │ │ │ │ │ │ └── branch-filter.component.ts │ │ │ │ │ │ └── status-filter │ │ │ │ │ │ │ ├── status-filter.component.html │ │ │ │ │ │ │ ├── status-filter.component.scss │ │ │ │ │ │ │ └── status-filter.component.ts │ │ │ │ │ ├── pipeline-table │ │ │ │ │ │ ├── pipeline-table.component.html │ │ │ │ │ │ ├── pipeline-table.component.scss │ │ │ │ │ │ └── pipeline-table.component.ts │ │ │ │ │ ├── pipelines.component.html │ │ │ │ │ ├── pipelines.component.scss │ │ │ │ │ ├── pipelines.component.ts │ │ │ │ │ └── service │ │ │ │ │ │ └── pipelines.service.ts │ │ │ │ ├── pipes │ │ │ │ │ ├── max-length.pipe.ts │ │ │ │ │ └── status-color.pipe.ts │ │ │ │ └── schedules │ │ │ │ │ ├── schedule-table │ │ │ │ │ ├── pipes │ │ │ │ │ │ └── next-run-at.pipe.ts │ │ │ │ │ ├── schedule-pipeline-table │ │ │ │ │ │ ├── schedule-pipeline-table.component.html │ │ │ │ │ │ ├── schedule-pipeline-table.component.scss │ │ │ │ │ │ └── schedule-pipeline-table.component.ts │ │ │ │ │ ├── schedule-table.component.html │ │ │ │ │ ├── schedule-table.component.scss │ │ │ │ │ └── schedule-table.component.ts │ │ │ │ │ ├── schedules.component.html │ │ │ │ │ ├── schedules.component.scss │ │ │ │ │ ├── schedules.component.ts │ │ │ │ │ └── service │ │ │ │ │ └── schedule.service.ts │ │ │ ├── group-tabs.component.html │ │ │ ├── group-tabs.component.scss │ │ │ └── group-tabs.component.ts │ │ ├── http.ts │ │ ├── model │ │ │ ├── branch.ts │ │ │ ├── group.ts │ │ │ ├── job.ts │ │ │ ├── pipeline.ts │ │ │ ├── project.ts │ │ │ ├── schedule.ts │ │ │ ├── status.ts │ │ │ └── user.ts │ │ ├── service │ │ │ ├── branch.service.ts │ │ │ └── group.service.ts │ │ └── util │ │ │ ├── compare.ts │ │ │ ├── filter.ts │ │ │ ├── fork.ts │ │ │ ├── identity.ts │ │ │ ├── status-color.ts │ │ │ ├── status-scope.ts │ │ │ └── table.ts │ ├── header │ │ ├── header.component.html │ │ ├── header.component.scss │ │ └── header.component.ts │ └── service │ │ ├── config.service.ts │ │ └── error.service.ts ├── assets │ └── .gitkeep ├── favicon.svg ├── index.html ├── main.ts └── styles.scss ├── tsconfig.app.json ├── tsconfig.json └── tsconfig.spec.json /.dockerignore: -------------------------------------------------------------------------------- 1 | **/**/node_modules 2 | **/**/dist 3 | **/**/*.env 4 | **/**/.env.example 5 | **/**/target 6 | **/**/.vscode 7 | **/**/.idea 8 | **/**/.github 9 | **/**/.angular -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.github/img/access_token.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/larscom/gitlab-ci-dashboard/28d2236dbbcbfb16b872527b0f6c5e5d927ad18b/.github/img/access_token.png -------------------------------------------------------------------------------- /.github/img/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/larscom/gitlab-ci-dashboard/28d2236dbbcbfb16b872527b0f6c5e5d927ad18b/.github/img/preview.png -------------------------------------------------------------------------------- /.github/workflows/audit.yml: -------------------------------------------------------------------------------- 1 | name: Cargo Audit 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | audit: 10 | runs-on: ubuntu-latest 11 | defaults: 12 | run: 13 | working-directory: ./api 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: dtolnay/rust-toolchain@stable 17 | - run: cargo install cargo-audit --locked 18 | - run: cargo audit 19 | -------------------------------------------------------------------------------- /.github/workflows/workflow.yml: -------------------------------------------------------------------------------- 1 | name: workflow 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*.*.*' 7 | branches: 8 | - '**' 9 | pull_request: 10 | branches: 11 | - main 12 | 13 | jobs: 14 | build-ng: 15 | runs-on: ubuntu-latest 16 | env: 17 | TZ: Europe/Amsterdam 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: actions/setup-node@v4 21 | with: 22 | node-version: 20 23 | - run: npm ci --ignore-scripts --legacy-peer-deps 24 | - run: npm run test 25 | 26 | build-rs: 27 | runs-on: ubuntu-latest 28 | env: 29 | TZ: Europe/Amsterdam 30 | defaults: 31 | run: 32 | working-directory: ./api 33 | steps: 34 | - uses: actions/checkout@v4 35 | - run: cargo build --verbose 36 | - run: cargo test --verbose 37 | - run: cargo clippy --verbose -- -D warnings 38 | 39 | build-docker: 40 | if: github.event_name != 'pull_request' 41 | needs: [build-rs, build-ng] 42 | runs-on: ubuntu-latest 43 | steps: 44 | - uses: actions/checkout@v4 45 | - run: | 46 | REF_NAME="${{ github.ref_name }}" 47 | REF_NAME_LOWER=$(echo "$REF_NAME" | tr '[:upper:]' '[:lower:]') 48 | REF_NAME_CLEAN=$(echo "$REF_NAME_LOWER" | tr -cd '[:alnum:]._-') 49 | REF_NAME_CLEAN=$(echo "$REF_NAME_CLEAN" | sed 's/^[^a-z0-9]*//;s/[^a-z0-9]*$//') 50 | if [[ ! "$REF_NAME_CLEAN" =~ ^[a-z0-9] ]]; then 51 | REF_NAME_CLEAN="0$REF_NAME_CLEAN" 52 | fi 53 | echo "CLEANED_REF_NAME=$REF_NAME_CLEAN" >> $GITHUB_ENV 54 | - uses: docker/setup-qemu-action@v3 55 | - uses: docker/setup-buildx-action@v3 56 | - uses: docker/login-action@v3 57 | with: 58 | username: ${{ secrets.DOCKERHUB_USERNAME }} 59 | password: ${{ secrets.DOCKERHUB_TOKEN }} 60 | - uses: docker/build-push-action@v6 61 | if: startsWith(github.ref, 'refs/tags/') 62 | with: 63 | push: true 64 | tags: ${{ github.repository }}:latest,${{ github.repository }}:${{ github.ref_name }} 65 | build-args: VERSION_ARG=v${{ github.ref_name }} 66 | - uses: docker/build-push-action@v6 67 | if: startsWith(github.ref, 'refs/heads/') 68 | with: 69 | push: true 70 | tags: ${{ github.repository }}:${{ env.CLEANED_REF_NAME }} 71 | build-args: VERSION_ARG=${{ github.sha }}@${{ env.CLEANED_REF_NAME }} 72 | - uses: peter-evans/dockerhub-description@v4 73 | if: github.ref == 'refs/heads/main' 74 | with: 75 | username: ${{ secrets.DOCKERHUB_USERNAME }} 76 | password: ${{ secrets.DOCKERHUB_PASSWORD }} 77 | repository: ${{ github.repository }} 78 | short-description: ${{ github.event.repository.description }} 79 | -------------------------------------------------------------------------------- /.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 | /api/main 9 | 10 | # Node 11 | /node_modules 12 | npm-debug.log 13 | yarn-error.log 14 | 15 | # IDEs and editors 16 | .idea/ 17 | .project 18 | .classpath 19 | .c9/ 20 | *.launch 21 | .settings/ 22 | *.sublime-workspace 23 | 24 | # Visual Studio Code 25 | .vscode/* 26 | !.vscode/settings.json 27 | !.vscode/tasks.json 28 | !.vscode/launch.json 29 | !.vscode/extensions.json 30 | .history/* 31 | 32 | # Miscellaneous 33 | /.angular/cache 34 | .sass-cache/ 35 | /connect.lock 36 | /coverage 37 | /libpeerconnection.log 38 | testem.log 39 | /typings 40 | **/cover.html 41 | **/cover.out 42 | 43 | # System files 44 | .DS_Store 45 | Thumbs.db 46 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" 2 | save-exact=true 3 | package-lock=true 4 | legacy-peer-deps=true 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": false, 3 | "printWidth": 120, 4 | "trailingComma": "none", 5 | "singleQuote": true, 6 | "semi": false, 7 | "disableLanguages": [] 8 | } 9 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22.16.0-alpine AS fe 2 | WORKDIR /builder 3 | COPY . . 4 | RUN npm ci --legacy-peer-deps --ignore-scripts && npm run build 5 | 6 | FROM rust:latest AS be 7 | WORKDIR /builder 8 | COPY api ./ 9 | RUN cargo build --release 10 | 11 | FROM gcr.io/distroless/cc-debian12 12 | WORKDIR /app 13 | ARG VERSION_ARG 14 | ENV VERSION=$VERSION_ARG 15 | ENV RUST_LOG="info" 16 | COPY --from=fe /builder/dist/gitlab-ci-dashboard/browser ./spa 17 | COPY --from=be /builder/target/release/gcd_api ./gcd_api 18 | CMD ["/app/gcd_api"] 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Lars Kniep 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | dev: 2 | make -j 2 run_fe run_api 3 | .PHONY: dev 4 | 5 | run_api: 6 | cd ./api && cargo watch -q -c -w ./src -x 'run -q' 7 | .PHONY: run_api 8 | 9 | run_fe: 10 | npm start 11 | .PHONY: run_fe 12 | 13 | test: 14 | cd ./api && cargo test --verbose 15 | .PHONY: test -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "gitlab-ci-dashboard": { 7 | "projectType": "application", 8 | "schematics": { 9 | "@schematics/angular:component": { 10 | "style": "scss" 11 | } 12 | }, 13 | "root": "", 14 | "sourceRoot": "src", 15 | "prefix": "gcd", 16 | "architect": { 17 | "build": { 18 | "builder": "@angular/build:application", 19 | "options": { 20 | "outputPath": "dist/gitlab-ci-dashboard", 21 | "index": "src/index.html", 22 | "browser": "src/main.ts", 23 | "polyfills": [], 24 | "tsConfig": "tsconfig.app.json", 25 | "inlineStyleLanguage": "scss", 26 | "assets": [ 27 | "src/favicon.svg", 28 | "src/assets", 29 | { 30 | "glob": "**/*", 31 | "input": "./node_modules/@ant-design/icons-angular/src/inline-svg/", 32 | "output": "/assets/" 33 | } 34 | ], 35 | "styles": [ 36 | "node_modules/@fontsource/roboto/400.css", 37 | "node_modules/normalize.css/normalize.css", 38 | "node_modules/ng-zorro-antd/ng-zorro-antd.min.css", 39 | "src/styles.scss" 40 | ], 41 | "scripts": [] 42 | }, 43 | "configurations": { 44 | "production": { 45 | "budgets": [ 46 | { 47 | "type": "initial", 48 | "maximumWarning": "1600kb", 49 | "maximumError": "2500kb" 50 | }, 51 | { 52 | "type": "anyComponentStyle", 53 | "maximumWarning": "2kb", 54 | "maximumError": "4kb" 55 | } 56 | ], 57 | "outputHashing": "all" 58 | }, 59 | "development": { 60 | "optimization": false, 61 | "extractLicenses": false, 62 | "sourceMap": true 63 | } 64 | }, 65 | "defaultConfiguration": "production" 66 | }, 67 | "serve": { 68 | "builder": "@angular/build:dev-server", 69 | "options": { 70 | "proxyConfig": "proxy.conf.js" 71 | }, 72 | "configurations": { 73 | "production": { 74 | "buildTarget": "gitlab-ci-dashboard:build:production" 75 | }, 76 | "development": { 77 | "buildTarget": "gitlab-ci-dashboard:build:development" 78 | } 79 | }, 80 | "defaultConfiguration": "development" 81 | }, 82 | "extract-i18n": { 83 | "builder": "@angular/build:extract-i18n", 84 | "options": { 85 | "buildTarget": "gitlab-ci-dashboard:build" 86 | } 87 | }, 88 | "test": { 89 | "builder": "@angular-builders/jest:run", 90 | "options": { 91 | "tsConfig": "tsconfig.spec.json", 92 | "polyfills": ["zone.js", "zone.js/testing"], 93 | "include": ["src/**/*.spec.ts"], 94 | "coverage": false 95 | } 96 | } 97 | } 98 | } 99 | }, 100 | "cli": { 101 | "analytics": false 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /api/.env.example: -------------------------------------------------------------------------------- 1 | # required 2 | GITLAB_BASE_URL=https://gitlab.com 3 | GITLAB_API_TOKEN=abc123 4 | 5 | # optional 6 | #GITLAB_GROUP_SKIP_IDS=10023,10024 7 | #GITLAB_GROUP_ONLY_IDS=10001,10002 8 | #GITLAB_GROUP_ONLY_TOP_LEVEL=false 9 | #GITLAB_GROUP_CACHE_TTL_SECONDS=300 10 | #GITLAB_PROJECT_CACHE_TTL_SECONDS=300 11 | #GITLAB_PIPELINE_CACHE_TTL_SECONDS=10 12 | -------------------------------------------------------------------------------- /api/.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | config.toml 3 | -------------------------------------------------------------------------------- /api/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "gcd_api" 3 | version = "0.0.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | actix-web = "4.11.0" 8 | actix-web-prom = { version = "0.10.0", features = ["process"] } 9 | actix-files = { version = "0.6.6" } 10 | actix-service = { version = "2.0.3" } 11 | serde = { version = "1.0.219", features = ["derive"] } 12 | serde_json = "1.0.140" 13 | serde-querystring-actix = "0.3.0" 14 | chrono = { version = "0.4.41", features = ["serde"] } 15 | dotenv = "0.15.0" 16 | env_logger = "0.11.8" 17 | log = "0.4.27" 18 | moka = { version = "0.12.10", features = ["future"] } 19 | reqwest = { version = "0.12.19", features = ["json"] } 20 | tokio = { version = "1.45.1", features = ["sync"] } 21 | async-trait = "0.1.88" 22 | futures = "0.3.31" 23 | toml = "0.8.23" 24 | 25 | [dev-dependencies] 26 | serial_test = "3.2.0" 27 | -------------------------------------------------------------------------------- /api/config.example.toml: -------------------------------------------------------------------------------- 1 | [gitlab] 2 | url = "https://gitlab.com" 3 | token = "your-token-here" 4 | 5 | [server] 6 | ip = "0.0.0.0" 7 | port = 8080 8 | workers = 4 9 | 10 | [cache] 11 | ttl_group_seconds = 300 12 | ttl_project_seconds = 300 13 | ttl_branch_seconds = 60 14 | ttl_job_seconds = 5 15 | ttl_pipeline_seconds = 5 16 | ttl_schedule_seconds = 300 17 | ttl_artifact_seconds = 1800 18 | 19 | [pipeline] 20 | history_days = 5 21 | 22 | [project] 23 | skip_ids = [] 24 | 25 | [group] 26 | only_ids = [] 27 | skip_ids = [] 28 | only_top_level = true 29 | include_subgroups = true 30 | 31 | [ui] 32 | read_only = true 33 | hide_write_actions = false -------------------------------------------------------------------------------- /api/src/artifact/artifact_handler.rs: -------------------------------------------------------------------------------- 1 | use crate::artifact::ArtifactService; 2 | use crate::error::ApiError; 3 | use actix_web::web; 4 | use actix_web::web::{Bytes, Data}; 5 | use serde::Deserialize; 6 | use serde_querystring_actix::QueryString; 7 | 8 | pub fn setup_handlers(cfg: &mut web::ServiceConfig) { 9 | cfg.route("/artifacts", web::get().to(get_artifact)); 10 | } 11 | 12 | #[derive(Deserialize)] 13 | struct GetQuery { 14 | project_id: u64, 15 | job_id: u64, 16 | } 17 | 18 | async fn get_artifact( 19 | QueryString(GetQuery { project_id, job_id }): QueryString, 20 | artifact_service: Data, 21 | ) -> Result { 22 | artifact_service.get_artifact(project_id, job_id).await 23 | } 24 | -------------------------------------------------------------------------------- /api/src/artifact/artifact_service.rs: -------------------------------------------------------------------------------- 1 | use crate::config::config_app::AppConfig; 2 | use crate::error::ApiError; 3 | use crate::gitlab::GitlabApi; 4 | use actix_web::web::Bytes; 5 | use moka::future::Cache; 6 | use std::sync::Arc; 7 | 8 | #[derive(Debug, Clone, PartialEq, Eq, Hash)] 9 | pub struct CacheKey { 10 | project_id: u64, 11 | job_id: u64, 12 | } 13 | 14 | impl CacheKey { 15 | pub fn new(project_id: u64, job_id: u64) -> Self { 16 | Self { project_id, job_id } 17 | } 18 | } 19 | #[derive(Clone)] 20 | pub struct ArtifactService { 21 | cache: Cache, 22 | client: Arc, 23 | } 24 | 25 | impl ArtifactService { 26 | pub fn new(client: Arc, config: AppConfig) -> Self { 27 | let cache = Cache::builder() 28 | .time_to_live(config.ttl_artifact_cache) 29 | .build(); 30 | 31 | Self { cache, client } 32 | } 33 | } 34 | 35 | impl ArtifactService { 36 | pub async fn get_artifact(&self, project_id: u64, job_id: u64) -> Result { 37 | self.cache 38 | .try_get_with(CacheKey::new(project_id, job_id), async { 39 | self.client.artifact(project_id, job_id).await 40 | }) 41 | .await 42 | .map_err(|error| error.as_ref().to_owned()) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /api/src/artifact/mod.rs: -------------------------------------------------------------------------------- 1 | mod artifact_handler; 2 | mod artifact_service; 3 | 4 | pub use artifact_handler::*; 5 | pub use artifact_service::*; 6 | -------------------------------------------------------------------------------- /api/src/branch/branch_handler.rs: -------------------------------------------------------------------------------- 1 | use crate::branch::PipelineAggregator; 2 | use crate::error::ApiError; 3 | use crate::model::{Branch, BranchPipeline}; 4 | use actix_web::web; 5 | use actix_web::web::{Data, Json}; 6 | use serde::Deserialize; 7 | use serde_querystring_actix::QueryString; 8 | 9 | use super::BranchService; 10 | 11 | pub fn setup_handlers(cfg: &mut web::ServiceConfig) { 12 | cfg.route( 13 | "/branches/latest-pipelines", 14 | web::get().to(get_with_latest_pipeline), 15 | ); 16 | cfg.route("/branches", web::get().to(get_branches)); 17 | } 18 | 19 | #[derive(Deserialize)] 20 | struct GetQuery { 21 | project_id: u64, 22 | } 23 | 24 | async fn get_branches( 25 | QueryString(GetQuery { project_id }): QueryString, 26 | branch_service: Data, 27 | ) -> Result>, ApiError> { 28 | let result = branch_service.get_branches(project_id).await?; 29 | Ok(Json(result)) 30 | } 31 | 32 | async fn get_with_latest_pipeline( 33 | QueryString(GetQuery { project_id }): QueryString, 34 | aggregator: Data, 35 | ) -> Result>, ApiError> { 36 | let result = aggregator 37 | .get_branches_with_latest_pipeline(project_id) 38 | .await?; 39 | Ok(Json(result)) 40 | } 41 | -------------------------------------------------------------------------------- /api/src/branch/branch_service.rs: -------------------------------------------------------------------------------- 1 | use crate::config::config_app::AppConfig; 2 | use crate::error::ApiError; 3 | use crate::gitlab::GitlabApi; 4 | use crate::model::Branch; 5 | use moka::future::Cache; 6 | use std::sync::Arc; 7 | 8 | #[derive(Clone)] 9 | pub struct BranchService { 10 | cache: Cache>, 11 | client: Arc, 12 | } 13 | 14 | impl BranchService { 15 | pub fn new(client: Arc, config: AppConfig) -> Self { 16 | let cache = Cache::builder() 17 | .time_to_live(config.ttl_branch_cache) 18 | .build(); 19 | 20 | Self { cache, client } 21 | } 22 | } 23 | 24 | impl BranchService { 25 | pub async fn get_branches(&self, project_id: u64) -> Result, ApiError> { 26 | self.cache 27 | .try_get_with(project_id, async { self.client.branches(project_id).await }) 28 | .await 29 | .map_err(|error| error.as_ref().to_owned()) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /api/src/branch/mod.rs: -------------------------------------------------------------------------------- 1 | mod pipeline_aggregator; 2 | mod branch_handler; 3 | mod branch_service; 4 | 5 | pub use pipeline_aggregator::*; 6 | pub use branch_handler::*; 7 | pub use branch_service::*; 8 | -------------------------------------------------------------------------------- /api/src/branch/pipeline_aggregator.rs: -------------------------------------------------------------------------------- 1 | use crate::branch::branch_service::BranchService; 2 | use crate::error::ApiError; 3 | use crate::model::{Branch, BranchPipeline}; 4 | use crate::pipeline; 5 | use crate::pipeline::PipelineService; 6 | use crate::util::iter::try_collect_with_buffer; 7 | use pipeline::sort_by_updated_date; 8 | 9 | pub struct PipelineAggregator { 10 | branch_service: BranchService, 11 | pipeline_service: PipelineService, 12 | } 13 | 14 | impl PipelineAggregator { 15 | pub fn new(branch_service: BranchService, pipeline_service: PipelineService) -> Self { 16 | Self { 17 | branch_service, 18 | pipeline_service, 19 | } 20 | } 21 | } 22 | 23 | impl PipelineAggregator { 24 | pub async fn get_branches_with_latest_pipeline( 25 | &self, 26 | project_id: u64, 27 | ) -> Result, ApiError> { 28 | let branches = self.branch_service.get_branches(project_id).await?; 29 | let mut result = self.get_latest_pipelines(project_id, branches).await?; 30 | 31 | result.sort_unstable_by(|a, b| { 32 | sort_by_updated_date(a.pipeline.as_ref(), b.pipeline.as_ref()) 33 | }); 34 | 35 | Ok(result) 36 | } 37 | 38 | async fn get_latest_pipelines( 39 | &self, 40 | project_id: u64, 41 | branches: Vec, 42 | ) -> Result, ApiError> { 43 | try_collect_with_buffer(branches, |branch| async move { 44 | let pipeline = self 45 | .pipeline_service 46 | .get_latest_pipeline(project_id, branch.name.clone()) 47 | .await?; 48 | Ok(BranchPipeline { branch, pipeline }) 49 | }) 50 | .await 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /api/src/config/config_file.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use std::fs; 3 | 4 | #[derive(Clone, Debug)] 5 | pub enum Error { 6 | Read, 7 | Deserialize(String), 8 | } 9 | 10 | #[derive(Clone, Debug, Serialize, Deserialize)] 11 | pub struct FileConfig { 12 | pub gitlab: Gitlab, 13 | pub server: Server, 14 | pub cache: Cache, 15 | pub pipeline: Pipeline, 16 | pub project: Project, 17 | pub group: Group, 18 | pub ui: Ui, 19 | } 20 | 21 | impl FileConfig { 22 | pub fn load_from_toml() -> Result { 23 | let toml = fs::read_to_string("config.toml").map_err(|_| Error::Read)?; 24 | toml::from_str(&toml) 25 | .map_err(|e| Error::Deserialize(format!("TOML error: {}", e.message()))) 26 | } 27 | } 28 | 29 | #[derive(Clone, Debug, Serialize, Deserialize)] 30 | pub struct Gitlab { 31 | pub url: String, 32 | pub token: String, 33 | } 34 | 35 | #[derive(Clone, Debug, Serialize, Deserialize)] 36 | pub struct Server { 37 | pub ip: String, 38 | pub port: u16, 39 | pub workers: usize, 40 | } 41 | 42 | #[derive(Clone, Debug, Serialize, Deserialize)] 43 | pub struct Cache { 44 | pub ttl_group_seconds: u64, 45 | pub ttl_project_seconds: u64, 46 | pub ttl_branch_seconds: u64, 47 | pub ttl_job_seconds: u64, 48 | pub ttl_pipeline_seconds: u64, 49 | pub ttl_schedule_seconds: u64, 50 | pub ttl_artifact_seconds: u64, 51 | } 52 | 53 | #[derive(Clone, Debug, Serialize, Deserialize)] 54 | pub struct Pipeline { 55 | pub history_days: i64, 56 | } 57 | 58 | #[derive(Clone, Debug, Serialize, Deserialize)] 59 | pub struct Project { 60 | pub skip_ids: Vec, 61 | } 62 | 63 | #[derive(Clone, Debug, Serialize, Deserialize)] 64 | pub struct Group { 65 | pub only_ids: Vec, 66 | pub skip_ids: Vec, 67 | pub only_top_level: bool, 68 | pub include_subgroups: bool, 69 | } 70 | 71 | #[derive(Clone, Debug, Serialize, Deserialize)] 72 | pub struct Ui { 73 | pub read_only: bool, 74 | pub hide_write_actions: bool, 75 | } 76 | 77 | #[cfg(test)] 78 | mod tests { 79 | use super::*; 80 | 81 | #[test] 82 | fn test_print_toml() { 83 | let config = FileConfig { 84 | gitlab: Gitlab { 85 | url: "https://gitlab.example.com".to_string(), 86 | token: "your-token-here".to_string(), 87 | }, 88 | server: Server { 89 | ip: "0.0.0.0".to_string(), 90 | port: 8080, 91 | workers: 4, 92 | }, 93 | cache: Cache { 94 | ttl_group_seconds: 3600, 95 | ttl_project_seconds: 3600, 96 | ttl_branch_seconds: 3600, 97 | ttl_job_seconds: 3600, 98 | ttl_pipeline_seconds: 3600, 99 | ttl_schedule_seconds: 3600, 100 | ttl_artifact_seconds: 3600, 101 | }, 102 | pipeline: Pipeline { history_days: 30 }, 103 | project: Project { 104 | skip_ids: vec![123, 456, 789], 105 | }, 106 | group: Group { 107 | only_ids: vec![1, 2, 3], 108 | skip_ids: vec![4, 5], 109 | only_top_level: true, 110 | include_subgroups: false, 111 | }, 112 | ui: Ui { 113 | read_only: false, 114 | hide_write_actions: false, 115 | }, 116 | }; 117 | 118 | let toml = toml::to_string(&config).unwrap(); 119 | println!("{}", toml); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /api/src/config/config_handler.rs: -------------------------------------------------------------------------------- 1 | use actix_web::web; 2 | use actix_web::web::{Data, Json}; 3 | use crate::config::config_app::ApiConfig; 4 | 5 | pub fn setup_handlers(cfg: &mut web::ServiceConfig) { 6 | cfg.route("/config", web::get().to(get_api_config)); 7 | } 8 | 9 | async fn get_api_config(api_config: Data) -> Json { 10 | let config = api_config.as_ref(); 11 | Json(config.clone()) 12 | } -------------------------------------------------------------------------------- /api/src/config/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod config_app; 2 | pub mod config_file; 3 | pub mod config_handler; 4 | 5 | pub use config_handler::*; 6 | -------------------------------------------------------------------------------- /api/src/error.rs: -------------------------------------------------------------------------------- 1 | use actix_web::{ 2 | http::{header::ContentType, StatusCode}, 3 | HttpResponse, ResponseError, 4 | }; 5 | use serde::Serialize; 6 | use std::error::Error; 7 | use std::fmt::{Display, Formatter}; 8 | 9 | #[derive(Clone, Debug, Serialize)] 10 | pub struct ApiError { 11 | status_code: u16, 12 | message: String, 13 | } 14 | 15 | impl ApiError { 16 | pub fn new(status_code: StatusCode, message: String) -> Self { 17 | Self { 18 | status_code: status_code.as_u16(), 19 | message, 20 | } 21 | } 22 | 23 | pub fn with_u16_code(status_code: u16, message: String) -> Self { 24 | Self { 25 | status_code, 26 | message, 27 | } 28 | } 29 | 30 | pub fn server_error(message: String) -> Self { 31 | Self::new(StatusCode::INTERNAL_SERVER_ERROR, message) 32 | } 33 | 34 | pub fn bad_request(message: String) -> Self { 35 | Self::new(StatusCode::BAD_REQUEST, message) 36 | } 37 | } 38 | 39 | impl Default for ApiError { 40 | fn default() -> Self { 41 | Self::new( 42 | StatusCode::INTERNAL_SERVER_ERROR, 43 | "an internal server error occured".into(), 44 | ) 45 | } 46 | } 47 | 48 | impl Display for ApiError { 49 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 50 | write!(f, "Error {}: {}", self.status_code, self.message) 51 | } 52 | } 53 | 54 | impl Error for ApiError {} 55 | 56 | impl From for ApiError { 57 | fn from(error: reqwest::Error) -> Self { 58 | if error.is_status() { 59 | return error.status().map_or_else(ApiError::default, |code| { 60 | ApiError::with_u16_code(code.as_u16(), error.to_string()) 61 | }); 62 | } 63 | match error.source() { 64 | Some(source) => ApiError::server_error(source.to_string()), 65 | None => ApiError::server_error(error.to_string()), 66 | } 67 | } 68 | } 69 | 70 | impl ResponseError for ApiError { 71 | fn status_code(&self) -> StatusCode { 72 | StatusCode::from_u16(self.status_code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR) 73 | } 74 | 75 | fn error_response(&self) -> HttpResponse { 76 | let status_code = self.status_code(); 77 | let error_message = self.message.clone(); 78 | serde_json::to_string(&ApiError::new(status_code, error_message)).map_or( 79 | HttpResponse::build(status_code) 80 | .insert_header(ContentType::plaintext()) 81 | .body(self.to_string()), 82 | |json| { 83 | HttpResponse::build(status_code) 84 | .insert_header(ContentType::json()) 85 | .body(json) 86 | }, 87 | ) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /api/src/group/group_handler.rs: -------------------------------------------------------------------------------- 1 | use crate::error::ApiError; 2 | use crate::group::group_service::GroupService; 3 | use crate::model::Group; 4 | use actix_web::web::{Data, Json}; 5 | use actix_web::{web, HttpRequest}; 6 | 7 | pub fn setup_handlers(cfg: &mut web::ServiceConfig) { 8 | cfg.route("/groups", web::get().to(get_groups)); 9 | } 10 | 11 | async fn get_groups( 12 | req: HttpRequest, 13 | group_service: Data, 14 | ) -> Result>, ApiError> { 15 | let result = group_service.get_groups(req.path()).await?; 16 | Ok(Json(result)) 17 | } 18 | -------------------------------------------------------------------------------- /api/src/group/group_service.rs: -------------------------------------------------------------------------------- 1 | use crate::config::config_app::AppConfig; 2 | use crate::error::ApiError; 3 | use crate::gitlab::GitlabApi; 4 | use crate::model::Group; 5 | use moka::future::Cache; 6 | use std::sync::Arc; 7 | 8 | pub struct GroupService { 9 | cache: Cache>, 10 | client: Arc, 11 | config: AppConfig, 12 | } 13 | 14 | impl GroupService { 15 | pub fn new(client: Arc, config: AppConfig) -> Self { 16 | let cache = Cache::builder() 17 | .time_to_live(config.ttl_group_cache) 18 | .build(); 19 | 20 | Self { 21 | cache, 22 | client, 23 | config, 24 | } 25 | } 26 | } 27 | 28 | impl GroupService { 29 | pub async fn get_groups(&self, cache_key: &str) -> Result, ApiError> { 30 | let only_ids = &self.config.group_only_ids; 31 | let skip_groups = &self.config.group_skip_ids; 32 | let top_level = self.config.group_only_top_level; 33 | self.cache 34 | .try_get_with_by_ref(cache_key, async { 35 | let mut groups = self 36 | .client 37 | .groups(skip_groups, top_level) 38 | .await? 39 | .into_iter() 40 | .filter(|group| only_ids.is_empty() || only_ids.contains(&group.id)) 41 | .collect::>(); 42 | 43 | groups.sort_unstable_by(|a, b| a.name.cmp(&b.name)); 44 | 45 | Ok::, ApiError>(groups) 46 | }) 47 | .await 48 | .map_err(|error| error.as_ref().to_owned()) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /api/src/group/mod.rs: -------------------------------------------------------------------------------- 1 | mod group_handler; 2 | mod group_service; 3 | 4 | pub use group_handler::*; 5 | pub use group_service::*; 6 | -------------------------------------------------------------------------------- /api/src/job/job_handler.rs: -------------------------------------------------------------------------------- 1 | use crate::error::ApiError; 2 | use crate::job::JobService; 3 | use crate::model::{Job, JobStatus}; 4 | use actix_web::web; 5 | use actix_web::web::{Data, Json}; 6 | use serde::Deserialize; 7 | use serde_querystring_actix::QueryString; 8 | 9 | pub fn setup_handlers(cfg: &mut web::ServiceConfig) { 10 | cfg.route("/jobs", web::get().to(get_jobs)); 11 | } 12 | 13 | #[derive(Deserialize)] 14 | struct GetQuery { 15 | project_id: u64, 16 | pipeline_id: u64, 17 | scope: Vec, 18 | } 19 | 20 | async fn get_jobs( 21 | QueryString(GetQuery { 22 | project_id, 23 | pipeline_id, 24 | scope, 25 | }): QueryString, 26 | job_service: Data, 27 | ) -> Result>, ApiError> { 28 | let result = job_service 29 | .get_jobs(project_id, pipeline_id, &scope) 30 | .await?; 31 | Ok(Json(result)) 32 | } 33 | -------------------------------------------------------------------------------- /api/src/job/job_service.rs: -------------------------------------------------------------------------------- 1 | use crate::config::config_app::AppConfig; 2 | use crate::error::ApiError; 3 | use crate::gitlab::GitlabApi; 4 | use crate::model::{Job, JobStatus}; 5 | use moka::future::Cache; 6 | use std::sync::Arc; 7 | 8 | #[derive(Debug, Clone, PartialEq, Eq, Hash)] 9 | pub struct CacheKey { 10 | project_id: u64, 11 | pipeline_id: u64, 12 | scope: Vec, 13 | } 14 | 15 | impl CacheKey { 16 | pub fn new(project_id: u64, pipeline_id: u64, scope: Vec) -> Self { 17 | Self { 18 | project_id, 19 | pipeline_id, 20 | scope, 21 | } 22 | } 23 | } 24 | 25 | pub struct JobService { 26 | cache: Cache>, 27 | client: Arc, 28 | } 29 | 30 | impl JobService { 31 | pub fn new(client: Arc, config: AppConfig) -> Self { 32 | let cache = Cache::builder().time_to_live(config.ttl_job_cache).build(); 33 | 34 | Self { cache, client } 35 | } 36 | } 37 | 38 | impl JobService { 39 | pub async fn get_jobs( 40 | &self, 41 | project_id: u64, 42 | pipeline_id: u64, 43 | scope: &[JobStatus], 44 | ) -> Result, ApiError> { 45 | self.cache 46 | .try_get_with( 47 | CacheKey::new(project_id, pipeline_id, scope.to_vec()), 48 | async { 49 | self.client 50 | .jobs(project_id, pipeline_id, scope) 51 | .await 52 | .map(|mut jobs| { 53 | jobs.sort_unstable_by(|a, b| a.created_at.cmp(&b.created_at)); 54 | jobs 55 | }) 56 | }, 57 | ) 58 | .await 59 | .map_err(|error| error.as_ref().to_owned()) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /api/src/job/mod.rs: -------------------------------------------------------------------------------- 1 | mod job_handler; 2 | mod job_service; 3 | 4 | pub use job_handler::*; 5 | pub use job_service::*; 6 | -------------------------------------------------------------------------------- /api/src/model/branch.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use crate::model::commit::Commit; 4 | use crate::model::Pipeline; 5 | 6 | #[derive(Clone, Debug, Serialize, Deserialize)] 7 | pub struct Branch { 8 | pub name: String, 9 | pub merged: bool, 10 | pub protected: bool, 11 | pub default: bool, 12 | pub can_push: bool, 13 | pub web_url: String, 14 | pub commit: Commit, 15 | } 16 | 17 | #[derive(Clone, Debug, Serialize, Deserialize)] 18 | pub struct BranchPipeline { 19 | pub branch: Branch, 20 | #[serde(skip_serializing_if = "Option::is_none")] 21 | pub pipeline: Option, 22 | } 23 | 24 | #[cfg(test)] 25 | mod tests { 26 | use serde_json::json; 27 | 28 | use crate::model::{Branch, BranchPipeline, test}; 29 | 30 | #[test] 31 | fn branch_deserialize() { 32 | let value = json!({ 33 | "name": "branch-1", 34 | "commit": { 35 | "id": "6797a390bf73f89096af2e1dbade0a23a89e3a9e", 36 | "short_id": "6797a390", 37 | "created_at": "2024-06-01T10:41:16.000+00:00", 38 | "parent_ids": [ 39 | "97a41ecb5ff1e1f75cf24952511c59c18e376b9c" 40 | ], 41 | "title": "Update .gitlab-ci.yml file", 42 | "message": "Update .gitlab-ci.yml file", 43 | "author_name": "Gitlab CI Dashboard", 44 | "author_email": "gitlab.ci.dashboard@gmail.com", 45 | "authored_date": "2024-06-01T10:41:16.000+00:00", 46 | "committer_name": "Gitlab CI Dashboard", 47 | "committer_email": "gitlab.ci.dashboard@gmail.com", 48 | "committed_date": "2024-06-01T10:41:16.000+00:00", 49 | "trailers": {}, 50 | "extended_trailers": {}, 51 | "web_url": "web_url" 52 | }, 53 | "merged": false, 54 | "protected": false, 55 | "developers_can_push": false, 56 | "developers_can_merge": false, 57 | "can_push": true, 58 | "default": false, 59 | "web_url": "web_url" 60 | }); 61 | 62 | let deserialized = serde_json::from_value::(value).unwrap(); 63 | assert_eq!(deserialized.name, "branch-1"); 64 | } 65 | 66 | #[test] 67 | fn branch_serialize() { 68 | let value = test::new_branch(); 69 | 70 | let json = serde_json::to_string(&value).unwrap(); 71 | let expected = "{\"name\":\"branch-1\",\"merged\":false,\"protected\":false,\"default\":false,\"can_push\":false,\"web_url\":\"web_url\",\"commit\":{\"id\":\"id\",\"author_name\":\"author_name\",\"committer_name\":\"committer_name\",\"committed_date\":\"1970-01-01T00:00:00Z\",\"title\":\"title\",\"message\":\"message\"}}"; 72 | assert_eq!(expected, json); 73 | } 74 | 75 | #[test] 76 | fn branch_pipeline_serialize_none_pipeline() { 77 | let value = BranchPipeline { 78 | branch: test::new_branch(), 79 | pipeline: None, 80 | }; 81 | 82 | let json = serde_json::to_string(&value).unwrap(); 83 | let expected = "{\"branch\":{\"name\":\"branch-1\",\"merged\":false,\"protected\":false,\"default\":false,\"can_push\":false,\"web_url\":\"web_url\",\"commit\":{\"id\":\"id\",\"author_name\":\"author_name\",\"committer_name\":\"committer_name\",\"committed_date\":\"1970-01-01T00:00:00Z\",\"title\":\"title\",\"message\":\"message\"}}}"; 84 | assert_eq!(expected, json); 85 | } 86 | 87 | #[test] 88 | fn branch_pipeline_serialize_some_pipeline() { 89 | let value = BranchPipeline { 90 | branch: test::new_branch(), 91 | pipeline: Some(test::new_pipeline()), 92 | }; 93 | 94 | let json = serde_json::to_string(&value).unwrap(); 95 | let expected = "{\"branch\":{\"name\":\"branch-1\",\"merged\":false,\"protected\":false,\"default\":false,\"can_push\":false,\"web_url\":\"web_url\",\"commit\":{\"id\":\"id\",\"author_name\":\"author_name\",\"committer_name\":\"committer_name\",\"committed_date\":\"1970-01-01T00:00:00Z\",\"title\":\"title\",\"message\":\"message\"}},\"pipeline\":{\"id\":1,\"iid\":2,\"project_id\":3,\"sha\":\"sha\",\"ref\":\"branch\",\"status\":\"running\",\"source\":\"web\",\"created_at\":\"1970-01-01T00:00:00Z\",\"updated_at\":\"1970-01-01T00:00:00Z\",\"web_url\":\"web_url\"}}"; 96 | assert_eq!(expected, json); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /api/src/model/commit.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, Utc}; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | #[derive(Clone, Debug, Serialize, Deserialize)] 5 | pub struct Commit { 6 | pub id: String, 7 | pub author_name: String, 8 | pub committer_name: String, 9 | pub committed_date: DateTime, 10 | pub title: String, 11 | pub message: String, 12 | } 13 | 14 | #[cfg(test)] 15 | mod tests { 16 | use serde_json::json; 17 | 18 | use crate::model::commit::Commit; 19 | use crate::model::test; 20 | 21 | #[test] 22 | fn commit_deserialize() { 23 | let value = json!({ 24 | "id": "6797a390bf73f89096af2e1dbade0a23a89e3a9e", 25 | "short_id": "6797a390", 26 | "created_at": "2024-06-01T10:41:16.000+00:00", 27 | "parent_ids": [ 28 | "97a41ecb5ff1e1f75cf24952511c59c18e376b9c" 29 | ], 30 | "title": "Update .gitlab-ci.yml file", 31 | "message": "Update .gitlab-ci.yml file", 32 | "author_name": "Gitlab CI Dashboard", 33 | "author_email": "gitlab.ci.dashboard@gmail.com", 34 | "authored_date": "2024-06-01T10:41:16.000+00:00", 35 | "committer_name": "Gitlab CI Dashboard", 36 | "committer_email": "gitlab.ci.dashboard@gmail.com", 37 | "committed_date": "2024-06-01T10:41:16.000+00:00", 38 | "trailers": {}, 39 | "extended_trailers": {}, 40 | "web_url": "web_url" 41 | }); 42 | 43 | let deserialized = serde_json::from_value::(value).unwrap(); 44 | assert_eq!(deserialized.id, "6797a390bf73f89096af2e1dbade0a23a89e3a9e"); 45 | } 46 | 47 | #[test] 48 | fn commit_serialize() { 49 | let value = test::new_commit(); 50 | 51 | let json = serde_json::to_string(&value).unwrap(); 52 | let expected = "{\"id\":\"id\",\"author_name\":\"author_name\",\"committer_name\":\"committer_name\",\"committed_date\":\"1970-01-01T00:00:00Z\",\"title\":\"title\",\"message\":\"message\"}"; 53 | assert_eq!(expected, json); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /api/src/model/group.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Clone, Debug, Serialize, Deserialize)] 4 | pub struct Group { 5 | pub id: u64, 6 | pub name: String, 7 | } 8 | 9 | #[cfg(test)] 10 | mod tests { 11 | use serde_json::json; 12 | 13 | use crate::model::{Group, test}; 14 | 15 | #[test] 16 | fn group_deserialize() { 17 | let value = json!({ 18 | "id": 61012723, 19 | "web_url": "web_url", 20 | "name": "Go", 21 | "path": "go179", 22 | "description": "", 23 | "visibility": "public", 24 | "share_with_group_lock": false, 25 | "require_two_factor_authentication": false, 26 | "two_factor_grace_period": 48, 27 | "project_creation_level": "developer", 28 | "auto_devops_enabled": null, 29 | "subgroup_creation_level": "maintainer", 30 | "emails_disabled": false, 31 | "emails_enabled": true, 32 | "mentions_disabled": null, 33 | "lfs_enabled": true, 34 | "math_rendering_limits_enabled": true, 35 | "lock_math_rendering_limits_enabled": false, 36 | "default_branch": null, 37 | "default_branch_protection": 2, 38 | "default_branch_protection_defaults": { 39 | "allowed_to_push": [ 40 | { 41 | "access_level": 40 42 | } 43 | ], 44 | "allow_force_push": false, 45 | "allowed_to_merge": [ 46 | { 47 | "access_level": 40 48 | } 49 | ] 50 | }, 51 | "avatar_url": null, 52 | "request_access_enabled": true, 53 | "full_name": "Go", 54 | "full_path": "go179", 55 | "created_at": "2022-12-02T07:34:45.914Z", 56 | "parent_id": null, 57 | "organization_id": 1, 58 | "shared_runners_setting": "enabled", 59 | "ldap_cn": null, 60 | "ldap_access": null, 61 | "wiki_access_level": "enabled" 62 | }); 63 | 64 | let deserialized = serde_json::from_value::(value).unwrap(); 65 | assert_eq!(deserialized.id, 61012723); 66 | } 67 | 68 | #[test] 69 | fn group_serialize() { 70 | let value = test::new_group(); 71 | 72 | let json = serde_json::to_string(&value).unwrap(); 73 | let expected = "{\"id\":1,\"name\":\"name\"}"; 74 | assert_eq!(expected, json); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /api/src/model/mod.rs: -------------------------------------------------------------------------------- 1 | pub use branch::*; 2 | pub use group::*; 3 | pub use job::*; 4 | pub use pipeline::*; 5 | pub use project::*; 6 | pub use schedule::*; 7 | 8 | pub mod pipeline; 9 | pub mod project; 10 | 11 | pub mod branch; 12 | pub mod commit; 13 | pub mod group; 14 | pub mod job; 15 | pub mod schedule; 16 | pub mod user; 17 | 18 | #[cfg(test)] 19 | pub mod test { 20 | use crate::model::commit::Commit; 21 | use crate::model::user::User; 22 | use crate::model::{Branch, Group, Job, JobStatus, Namespace, Pipeline, PipelineSource, PipelineStatus, Project, Schedule}; 23 | 24 | pub fn new_commit() -> Commit { 25 | Commit { 26 | id: "id".to_string(), 27 | author_name: "author_name".to_string(), 28 | committer_name: "committer_name".to_string(), 29 | committed_date: Default::default(), 30 | title: "title".to_string(), 31 | message: "message".to_string(), 32 | } 33 | } 34 | 35 | pub fn new_branch() -> Branch { 36 | Branch { 37 | name: "branch-1".to_string(), 38 | merged: false, 39 | protected: false, 40 | default: false, 41 | can_push: false, 42 | web_url: "web_url".to_string(), 43 | commit: new_commit(), 44 | } 45 | } 46 | 47 | pub fn new_pipeline() -> Pipeline { 48 | Pipeline { 49 | id: 1, 50 | iid: 2, 51 | project_id: 3, 52 | sha: "sha".to_string(), 53 | branch: "branch".to_string(), 54 | status: PipelineStatus::Running, 55 | source: PipelineSource::Web, 56 | created_at: Default::default(), 57 | updated_at: Default::default(), 58 | web_url: "web_url".to_string(), 59 | } 60 | } 61 | 62 | pub fn new_group() -> Group { 63 | Group { 64 | id: 1, 65 | name: "name".to_string(), 66 | } 67 | } 68 | 69 | pub fn new_job() -> Job { 70 | Job { 71 | id: 1, 72 | created_at: Default::default(), 73 | allow_failure: false, 74 | name: "name".to_string(), 75 | branch: "branch".to_string(), 76 | stage: "stage".to_string(), 77 | status: JobStatus::Success, 78 | web_url: "web_url".to_string(), 79 | pipeline: new_pipeline(), 80 | commit: new_commit(), 81 | user: new_user(), 82 | } 83 | } 84 | 85 | pub fn new_user() -> User { 86 | User { 87 | id: 123, 88 | username: "username".to_string(), 89 | name: "name".to_string(), 90 | state: "state".to_string(), 91 | is_admin: false, 92 | } 93 | } 94 | 95 | pub fn new_project() -> Project { 96 | Project { 97 | id: 456, 98 | name: "name".to_string(), 99 | web_url: "web_url".to_string(), 100 | default_branch: Some("default_branch".to_string()), 101 | topics: vec!["topic".to_string()], 102 | namespace: Namespace { 103 | id: 123, 104 | name: "namespace".to_string(), 105 | path: "namespace".to_string(), 106 | } 107 | } 108 | } 109 | 110 | pub fn new_schedule() -> Schedule { 111 | Schedule { 112 | id: 789, 113 | description: "description".to_string(), 114 | branch: "branch".to_string(), 115 | cron: "cron".to_string(), 116 | cron_timezone: "cron_timezone".to_string(), 117 | next_run_at: Default::default(), 118 | active: false, 119 | created_at: Default::default(), 120 | updated_at: Default::default(), 121 | owner: new_user(), 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /api/src/model/user.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Clone, Debug, Serialize, Deserialize)] 4 | pub struct User { 5 | pub id: u64, 6 | pub username: String, 7 | pub name: String, 8 | pub state: String, 9 | #[serde(default)] 10 | pub is_admin: bool, 11 | } 12 | 13 | #[cfg(test)] 14 | mod tests { 15 | use serde_json::json; 16 | 17 | use crate::model::test; 18 | use crate::model::user::User; 19 | 20 | #[test] 21 | fn user_deserialize() { 22 | let value = json!({ 23 | "id": 13081321, 24 | "username": "gitlab.ci.dashboard", 25 | "name": "Gitlab CI Dashboard", 26 | "state": "active", 27 | "locked": false, 28 | "avatar_url": "avatar_url", 29 | "web_url": "web_url", 30 | "created_at": "2022-11-18T17:46:13.632Z", 31 | "bio": "", 32 | "location": "", 33 | "public_email": "", 34 | "skype": "", 35 | "linkedin": "", 36 | "twitter": "", 37 | "discord": "", 38 | "website_url": "", 39 | "organization": "", 40 | "job_title": "", 41 | "pronouns": "", 42 | "bot": false, 43 | "work_information": null, 44 | "followers": 0, 45 | "following": 0, 46 | "local_time": "7:12 PM" 47 | }); 48 | 49 | let deserialized = serde_json::from_value::(value).unwrap(); 50 | assert_eq!(deserialized.id, 13081321); 51 | assert!(!deserialized.is_admin) 52 | } 53 | 54 | #[test] 55 | fn user_serialize() { 56 | let value = test::new_user(); 57 | 58 | let json = serde_json::to_string(&value).unwrap(); 59 | let expected = "{\"id\":123,\"username\":\"username\",\"name\":\"name\",\"state\":\"state\",\"is_admin\":false}"; 60 | assert_eq!(expected, json); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /api/src/pipeline/mod.rs: -------------------------------------------------------------------------------- 1 | mod pipeline_handler; 2 | mod pipeline_service; 3 | mod util; 4 | 5 | pub use pipeline_handler::*; 6 | pub use pipeline_service::*; 7 | pub use util::*; 8 | -------------------------------------------------------------------------------- /api/src/pipeline/pipeline_handler.rs: -------------------------------------------------------------------------------- 1 | use crate::config::config_app::ApiConfig; 2 | use crate::error::ApiError; 3 | use crate::model::{Pipeline, PipelineSource}; 4 | use crate::pipeline::PipelineService; 5 | use actix_web::web; 6 | use actix_web::web::{Data, Json}; 7 | use serde::{Deserialize, Serialize}; 8 | use serde_querystring_actix::QueryString; 9 | use std::collections::HashMap; 10 | 11 | pub fn setup_handlers(cfg: &mut web::ServiceConfig) { 12 | cfg.route("/pipelines", web::get().to(get_pipelines)); 13 | cfg.route("/pipelines/start", web::post().to(start_pipeline)); 14 | cfg.route("/pipelines/retry", web::post().to(retry_pipeline)); 15 | cfg.route("/pipelines/cancel", web::post().to(cancel_pipeline)); 16 | } 17 | 18 | #[derive(Deserialize)] 19 | struct GetQuery { 20 | project_id: u64, 21 | source: Option, 22 | } 23 | 24 | async fn get_pipelines( 25 | QueryString(GetQuery { project_id, source }): QueryString, 26 | pipeline_service: Data, 27 | ) -> Result>, ApiError> { 28 | let pipelines = pipeline_service.get_pipelines(project_id, source).await?; 29 | Ok(Json(pipelines)) 30 | } 31 | 32 | #[derive(Deserialize)] 33 | struct PostQuery { 34 | project_id: u64, 35 | pipeline_id: u64, 36 | } 37 | 38 | async fn retry_pipeline( 39 | QueryString(PostQuery { 40 | project_id, 41 | pipeline_id, 42 | }): QueryString, 43 | pipeline_service: Data, 44 | api_config: Data, 45 | ) -> Result, ApiError> { 46 | if api_config.read_only { 47 | return Err(ApiError::bad_request( 48 | "can't retry pipeline when in 'read only' mode".into(), 49 | )); 50 | } 51 | 52 | let pipeline = pipeline_service 53 | .retry_pipeline(project_id, pipeline_id) 54 | .await?; 55 | 56 | Ok(Json(pipeline)) 57 | } 58 | 59 | async fn cancel_pipeline( 60 | QueryString(PostQuery { 61 | project_id, 62 | pipeline_id, 63 | }): QueryString, 64 | pipeline_service: Data, 65 | api_config: Data, 66 | ) -> Result, ApiError> { 67 | if api_config.read_only { 68 | return Err(ApiError::bad_request( 69 | "can't cancel pipeline when in 'read only' mode".into(), 70 | )); 71 | } 72 | 73 | let pipeline = pipeline_service 74 | .cancel_pipeline(project_id, pipeline_id) 75 | .await?; 76 | 77 | Ok(Json(pipeline)) 78 | } 79 | 80 | #[derive(Deserialize, Serialize)] 81 | struct PostBody { 82 | project_id: u64, 83 | branch: String, 84 | env_vars: Option>, 85 | } 86 | 87 | async fn start_pipeline( 88 | Json(PostBody { 89 | project_id, 90 | branch, 91 | env_vars, 92 | }): Json, 93 | pipeline_service: Data, 94 | api_config: Data, 95 | ) -> Result, ApiError> { 96 | if api_config.read_only { 97 | return Err(ApiError::bad_request( 98 | "can't start a new pipeline when in 'read only' mode".into(), 99 | )); 100 | } 101 | 102 | let pipeline = pipeline_service 103 | .start_pipeline(project_id, branch, env_vars) 104 | .await?; 105 | 106 | Ok(Json(pipeline)) 107 | } 108 | -------------------------------------------------------------------------------- /api/src/pipeline/pipeline_service.rs: -------------------------------------------------------------------------------- 1 | use crate::config::config_app::AppConfig; 2 | use crate::error::ApiError; 3 | use crate::gitlab::GitlabApi; 4 | use crate::model::{Pipeline, PipelineSource}; 5 | use chrono::{Duration, Utc}; 6 | use moka::future::Cache; 7 | use std::collections::HashMap; 8 | use std::sync::Arc; 9 | 10 | #[derive(Debug, Clone, PartialEq, Eq, Hash)] 11 | pub struct CacheKey { 12 | project_id: u64, 13 | branch: String, 14 | } 15 | 16 | impl CacheKey { 17 | pub fn new(project_id: u64, branch: String) -> Self { 18 | Self { project_id, branch } 19 | } 20 | } 21 | 22 | #[derive(Clone)] 23 | pub struct PipelineService { 24 | cache_latest: Cache>, 25 | cache_all: Cache>, 26 | client: Arc, 27 | config: AppConfig, 28 | } 29 | 30 | impl PipelineService { 31 | pub fn new(client: Arc, config: AppConfig) -> Self { 32 | let cache_latest = Cache::builder() 33 | .time_to_live(config.ttl_pipeline_cache) 34 | .build(); 35 | let cache_all = Cache::builder() 36 | .time_to_live(config.ttl_pipeline_cache) 37 | .build(); 38 | 39 | Self { 40 | cache_latest, 41 | cache_all, 42 | client, 43 | config, 44 | } 45 | } 46 | } 47 | 48 | impl PipelineService { 49 | pub async fn retry_pipeline( 50 | &self, 51 | project_id: u64, 52 | pipeline_id: u64, 53 | ) -> Result { 54 | self.client.retry_pipeline(project_id, pipeline_id).await 55 | } 56 | 57 | pub async fn start_pipeline( 58 | &self, 59 | project_id: u64, 60 | branch: String, 61 | env_vars: Option>, 62 | ) -> Result { 63 | self.client 64 | .start_pipeline(project_id, branch, env_vars) 65 | .await 66 | } 67 | 68 | pub async fn cancel_pipeline( 69 | &self, 70 | project_id: u64, 71 | pipeline_id: u64, 72 | ) -> Result { 73 | self.client.cancel_pipeline(project_id, pipeline_id).await 74 | } 75 | 76 | pub async fn get_latest_pipeline( 77 | &self, 78 | project_id: u64, 79 | branch: String, 80 | ) -> Result, ApiError> { 81 | self.cache_latest 82 | .try_get_with(CacheKey::new(project_id, branch.clone()), async { 83 | self.client.latest_pipeline(project_id, branch).await 84 | }) 85 | .await 86 | .map_err(|error| error.as_ref().to_owned()) 87 | } 88 | 89 | pub async fn get_pipelines( 90 | &self, 91 | project_id: u64, 92 | source: Option, 93 | ) -> Result, ApiError> { 94 | let minus_days = self.config.pipeline_history_days; 95 | let updated_after = Utc::now() + Duration::days(-minus_days); 96 | let all_pipelines = self 97 | .cache_all 98 | .try_get_with(project_id, async { 99 | self.client.pipelines(project_id, Some(updated_after)).await 100 | }) 101 | .await 102 | .map_err(|error| error.as_ref().to_owned()); 103 | 104 | match source { 105 | None => all_pipelines, 106 | Some(source) => all_pipelines.map(|pipelines| { 107 | pipelines 108 | .into_iter() 109 | .filter(|p| p.source == source) 110 | .collect() 111 | }), 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /api/src/pipeline/util.rs: -------------------------------------------------------------------------------- 1 | use crate::model::Pipeline; 2 | use std::cmp::Ordering; 3 | 4 | pub fn sort_by_updated_date(a: Option<&Pipeline>, b: Option<&Pipeline>) -> Ordering { 5 | match (a, b) { 6 | (Some(a), Some(b)) => b.updated_at.cmp(&a.updated_at), 7 | (None, Some(_)) => Ordering::Less, 8 | (Some(_), None) => Ordering::Greater, 9 | _ => Ordering::Equal, 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /api/src/project/mod.rs: -------------------------------------------------------------------------------- 1 | mod pipeline_aggregator; 2 | mod project_handler; 3 | mod project_service; 4 | 5 | pub use pipeline_aggregator::*; 6 | pub use project_handler::*; 7 | pub use project_service::*; 8 | -------------------------------------------------------------------------------- /api/src/project/pipeline_aggregator.rs: -------------------------------------------------------------------------------- 1 | use crate::error::ApiError; 2 | use crate::model::{Project, ProjectPipeline, ProjectPipelines}; 3 | use crate::pipeline::{sort_by_updated_date, PipelineService}; 4 | use crate::project::ProjectService; 5 | use crate::util::iter::try_collect_with_buffer; 6 | 7 | pub struct PipelineAggregator { 8 | project_service: ProjectService, 9 | pipeline_service: PipelineService, 10 | } 11 | 12 | impl PipelineAggregator { 13 | pub fn new(project_service: ProjectService, pipeline_service: PipelineService) -> Self { 14 | Self { 15 | project_service, 16 | pipeline_service, 17 | } 18 | } 19 | } 20 | 21 | impl PipelineAggregator { 22 | pub async fn get_projects_with_latest_pipeline( 23 | &self, 24 | group_id: u64, 25 | project_ids: Option>, 26 | ) -> Result, ApiError> { 27 | let projects = self 28 | .project_service 29 | .get_projects(group_id, project_ids) 30 | .await?; 31 | 32 | let mut result = self.with_latest_pipeline(group_id, projects).await?; 33 | 34 | result.sort_unstable_by(|a, b| { 35 | sort_by_updated_date(a.pipeline.as_ref(), b.pipeline.as_ref()) 36 | }); 37 | 38 | Ok(result) 39 | } 40 | 41 | async fn with_latest_pipeline( 42 | &self, 43 | group_id: u64, 44 | projects: Vec, 45 | ) -> Result, ApiError> { 46 | try_collect_with_buffer(projects, |project| async move { 47 | let default_branch = project.default_branch.clone(); 48 | let pipeline = if let Some(default_branch) = default_branch { 49 | self.pipeline_service 50 | .get_latest_pipeline(project.id, default_branch) 51 | .await? 52 | } else { 53 | None 54 | }; 55 | 56 | Ok(ProjectPipeline { 57 | group_id, 58 | project, 59 | pipeline, 60 | }) 61 | }) 62 | .await 63 | } 64 | 65 | pub async fn get_projects_with_pipelines( 66 | &self, 67 | group_id: u64, 68 | project_ids: Option>, 69 | ) -> Result, ApiError> { 70 | let projects = self 71 | .project_service 72 | .get_projects(group_id, project_ids) 73 | .await?; 74 | self.with_pipelines(group_id, projects).await 75 | } 76 | 77 | async fn with_pipelines( 78 | &self, 79 | group_id: u64, 80 | projects: Vec, 81 | ) -> Result, ApiError> { 82 | try_collect_with_buffer(projects, |project| async move { 83 | let pipelines = if project.default_branch.is_some() { 84 | self.pipeline_service 85 | .get_pipelines(project.id, None) 86 | .await? 87 | } else { 88 | Vec::default() 89 | }; 90 | Ok(ProjectPipelines { 91 | group_id, 92 | project, 93 | pipelines, 94 | }) 95 | }) 96 | .await 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /api/src/project/project_handler.rs: -------------------------------------------------------------------------------- 1 | use crate::error::ApiError; 2 | use crate::model::{ProjectPipeline, ProjectPipelines}; 3 | use crate::project::PipelineAggregator; 4 | use actix_web::web; 5 | use actix_web::web::{Data, Json}; 6 | use serde::Deserialize; 7 | use serde_querystring_actix::QueryString; 8 | 9 | pub fn setup_handlers(cfg: &mut web::ServiceConfig) { 10 | cfg.route( 11 | "/projects/latest-pipelines", 12 | web::get().to(get_with_latest_pipeline), 13 | ); 14 | cfg.route("/projects/pipelines", web::get().to(get_with_pipelines)); 15 | } 16 | 17 | #[derive(Deserialize)] 18 | struct GetQuery { 19 | group_id: u64, 20 | project_ids: Option>, 21 | } 22 | 23 | async fn get_with_latest_pipeline( 24 | QueryString(GetQuery { 25 | group_id, 26 | project_ids, 27 | }): QueryString, 28 | aggregator: Data, 29 | ) -> Result>, ApiError> { 30 | let result = aggregator 31 | .get_projects_with_latest_pipeline(group_id, project_ids) 32 | .await?; 33 | Ok(Json(result)) 34 | } 35 | 36 | async fn get_with_pipelines( 37 | QueryString(GetQuery { 38 | group_id, 39 | project_ids, 40 | }): QueryString, 41 | aggregator: Data, 42 | ) -> Result>, ApiError> { 43 | let result = aggregator 44 | .get_projects_with_pipelines(group_id, project_ids) 45 | .await?; 46 | Ok(Json(result)) 47 | } 48 | -------------------------------------------------------------------------------- /api/src/project/project_service.rs: -------------------------------------------------------------------------------- 1 | use crate::config::config_app::AppConfig; 2 | use crate::error::ApiError; 3 | use crate::gitlab::GitlabApi; 4 | use crate::model::Project; 5 | use moka::future::Cache; 6 | use std::sync::Arc; 7 | 8 | #[derive(Clone)] 9 | pub struct ProjectService { 10 | cache: Cache>, 11 | client: Arc, 12 | config: AppConfig, 13 | } 14 | 15 | impl ProjectService { 16 | pub fn new(client: Arc, config: AppConfig) -> Self { 17 | let cache = Cache::builder() 18 | .time_to_live(config.ttl_project_cache) 19 | .build(); 20 | 21 | Self { 22 | cache, 23 | client, 24 | config, 25 | } 26 | } 27 | } 28 | 29 | impl ProjectService { 30 | pub async fn get_projects( 31 | &self, 32 | group_id: u64, 33 | project_ids: Option>, 34 | ) -> Result, ApiError> { 35 | let cached_projects = self 36 | .cache 37 | .try_get_with(group_id, async { 38 | let skip_projects = &self.config.project_skip_ids; 39 | let projects = self 40 | .client 41 | .projects(group_id, self.config.group_include_subgroups) 42 | .await? 43 | .into_iter() 44 | .filter(|project| { 45 | skip_projects.is_empty() || !skip_projects.contains(&project.id) 46 | }) 47 | .collect::>(); 48 | Ok::, ApiError>(projects) 49 | }) 50 | .await 51 | .map_err(|error| error.as_ref().to_owned())?; 52 | 53 | let projects = match project_ids { 54 | None => cached_projects, 55 | Some(project_ids) => cached_projects 56 | .into_iter() 57 | .filter(|project| project_ids.contains(&project.id)) 58 | .collect::>(), 59 | }; 60 | 61 | Ok(projects) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /api/src/schedule/mod.rs: -------------------------------------------------------------------------------- 1 | mod pipeline_aggregator; 2 | mod schedule_handler; 3 | mod schedule_service; 4 | 5 | pub use pipeline_aggregator::*; 6 | pub use schedule_handler::*; 7 | pub use schedule_service::*; 8 | -------------------------------------------------------------------------------- /api/src/schedule/pipeline_aggregator.rs: -------------------------------------------------------------------------------- 1 | use crate::error::ApiError; 2 | use crate::model::{Project, Schedule, ScheduleProjectPipeline}; 3 | use crate::pipeline::PipelineService; 4 | use crate::project::ProjectService; 5 | use crate::schedule::ScheduleService; 6 | use crate::util::iter::try_collect_with_buffer; 7 | 8 | pub struct PipelineAggregator { 9 | schedule_service: ScheduleService, 10 | project_service: ProjectService, 11 | pipeline_service: PipelineService, 12 | } 13 | 14 | impl PipelineAggregator { 15 | pub fn new( 16 | schedule_service: ScheduleService, 17 | project_service: ProjectService, 18 | pipeline_service: PipelineService, 19 | ) -> Self { 20 | Self { 21 | schedule_service, 22 | project_service, 23 | pipeline_service, 24 | } 25 | } 26 | } 27 | 28 | impl PipelineAggregator { 29 | pub async fn get_schedules_with_latest_pipeline( 30 | &self, 31 | group_id: u64, 32 | project_ids: Option>, 33 | ) -> Result, ApiError> { 34 | let projects = self 35 | .project_service 36 | .get_projects(group_id, project_ids) 37 | .await?; 38 | 39 | let mut result = self.get_schedules(group_id, projects).await?; 40 | 41 | result.sort_unstable_by(|a, b| a.schedule.id.cmp(&b.schedule.id)); 42 | 43 | Ok(result) 44 | } 45 | 46 | async fn get_schedules( 47 | &self, 48 | group_id: u64, 49 | projects: Vec, 50 | ) -> Result, ApiError> { 51 | let result = try_collect_with_buffer(projects, |project| async move { 52 | let schedules = if project.default_branch.is_some() { 53 | self.schedule_service.get_schedules(project.id).await? 54 | } else { 55 | Vec::default() 56 | }; 57 | let result = self 58 | .with_latest_pipeline(group_id, &project, schedules) 59 | .await?; 60 | Ok::, ApiError>(result) 61 | }) 62 | .await? 63 | .into_iter() 64 | .flatten() 65 | .collect(); 66 | 67 | Ok(result) 68 | } 69 | 70 | async fn with_latest_pipeline( 71 | &self, 72 | group_id: u64, 73 | project: &Project, 74 | schedules: Vec, 75 | ) -> Result, ApiError> { 76 | try_collect_with_buffer(schedules, |schedule| async move { 77 | let project = project.clone(); 78 | let pipeline = self 79 | .pipeline_service 80 | .get_latest_pipeline(project.id, schedule.branch.clone()) 81 | .await?; 82 | let schedule = schedule.clone(); 83 | Ok(ScheduleProjectPipeline { 84 | group_id, 85 | schedule, 86 | pipeline, 87 | project, 88 | }) 89 | }) 90 | .await 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /api/src/schedule/schedule_handler.rs: -------------------------------------------------------------------------------- 1 | use crate::error::ApiError; 2 | use crate::model::ScheduleProjectPipeline; 3 | use crate::schedule::PipelineAggregator; 4 | use actix_web::web; 5 | use actix_web::web::{Data, Json}; 6 | use serde::Deserialize; 7 | use serde_querystring_actix::QueryString; 8 | 9 | pub fn setup_handlers(cfg: &mut web::ServiceConfig) { 10 | cfg.route( 11 | "/schedules/latest-pipelines", 12 | web::get().to(get_with_latest_pipeline), 13 | ); 14 | } 15 | 16 | #[derive(Deserialize)] 17 | struct GetQuery { 18 | group_id: u64, 19 | project_ids: Option>, 20 | } 21 | 22 | async fn get_with_latest_pipeline( 23 | QueryString(GetQuery { 24 | group_id, 25 | project_ids, 26 | }): QueryString, 27 | aggregator: Data, 28 | ) -> Result>, ApiError> { 29 | let result = aggregator 30 | .get_schedules_with_latest_pipeline(group_id, project_ids) 31 | .await?; 32 | 33 | Ok(Json(result)) 34 | } 35 | -------------------------------------------------------------------------------- /api/src/schedule/schedule_service.rs: -------------------------------------------------------------------------------- 1 | use crate::config::config_app::AppConfig; 2 | use crate::error::ApiError; 3 | use crate::gitlab::GitlabApi; 4 | use crate::model::Schedule; 5 | use moka::future::Cache; 6 | use std::sync::Arc; 7 | 8 | pub struct ScheduleService { 9 | cache: Cache>, 10 | client: Arc, 11 | } 12 | 13 | impl ScheduleService { 14 | pub fn new(client: Arc, config: AppConfig) -> Self { 15 | let cache = Cache::builder() 16 | .time_to_live(config.ttl_schedule_cache) 17 | .build(); 18 | 19 | Self { cache, client } 20 | } 21 | } 22 | 23 | impl ScheduleService { 24 | pub async fn get_schedules(&self, project_id: u64) -> Result, ApiError> { 25 | self.cache 26 | .try_get_with(project_id, async { 27 | self.client.schedules(project_id).await 28 | }) 29 | .await 30 | .map_err(|error| error.as_ref().to_owned()) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /api/src/spa.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | 3 | use actix_files::{Files, NamedFile}; 4 | use actix_service::fn_service; 5 | use actix_web::dev::{HttpServiceFactory, ResourceDef, ServiceRequest, ServiceResponse}; 6 | 7 | #[derive(Debug, Clone)] 8 | pub struct Spa { 9 | index_file: Cow<'static, str>, 10 | static_resources_mount: Cow<'static, str>, 11 | static_resources_location: Cow<'static, str>, 12 | } 13 | 14 | impl Spa { 15 | pub fn new( 16 | index_file: impl Into>, 17 | static_resources_mount: impl Into>, 18 | static_resources_location: impl Into>, 19 | ) -> Self { 20 | Self { 21 | index_file: index_file.into(), 22 | static_resources_mount: static_resources_mount.into(), 23 | static_resources_location: static_resources_location.into(), 24 | } 25 | } 26 | 27 | /// Constructs the service for use in a `.service()` call. 28 | pub fn finish(self) -> impl HttpServiceFactory { 29 | let index_file = self.index_file.into_owned(); 30 | let static_resources_location = self.static_resources_location.into_owned(); 31 | let static_resources_mount = self.static_resources_mount.into_owned(); 32 | 33 | let files = { 34 | let index_file = index_file.clone(); 35 | Files::new(&static_resources_mount, static_resources_location) 36 | // HACK: FilesService will try to read a directory listing unless index_file is provided 37 | // FilesService will fail to load the index_file and will then call our default_handler 38 | .index_file("extremely-unlikely-to-exist-!@$%^&*.txt") 39 | .default_handler(move |req| serve_index(req, index_file.clone())) 40 | }; 41 | 42 | SpaService { index_file, files } 43 | } 44 | } 45 | 46 | #[derive(Debug)] 47 | struct SpaService { 48 | index_file: String, 49 | files: Files, 50 | } 51 | 52 | impl HttpServiceFactory for SpaService { 53 | fn register(self, config: &mut actix_web::dev::AppService) { 54 | // let Files register its mount path as-is 55 | self.files.register(config); 56 | 57 | // also define a root prefix handler directed towards our SPA index 58 | let rdef = ResourceDef::root_prefix(""); 59 | config.register_service( 60 | rdef, 61 | None, 62 | fn_service(move |req| serve_index(req, self.index_file.clone())), 63 | None, 64 | ); 65 | } 66 | } 67 | 68 | async fn serve_index( 69 | req: ServiceRequest, 70 | index_file: String, 71 | ) -> Result { 72 | let (req, _) = req.into_parts(); 73 | let file = NamedFile::open_async(&index_file).await?; 74 | let res = file.into_response(&req); 75 | Ok(ServiceResponse::new(req, res)) 76 | } 77 | 78 | impl Default for Spa { 79 | fn default() -> Self { 80 | Self::new("./index.html", "/", "./") 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /api/src/util/deserialize.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Deserializer}; 2 | 3 | pub fn from_ref<'de, D>(deserializer: D) -> Result 4 | where 5 | D: Deserializer<'de>, 6 | { 7 | let value: String = Deserialize::deserialize(deserializer)?; 8 | Ok(value.rsplit('/').next().map(|b| b.into()).unwrap_or(value)) 9 | } 10 | -------------------------------------------------------------------------------- /api/src/util/iter.rs: -------------------------------------------------------------------------------- 1 | use std::future::Future; 2 | 3 | use futures::{stream::iter, StreamExt, TryStreamExt}; 4 | 5 | pub async fn try_collect_with_buffer(items: Vec, mapper: M) -> Result, E> 6 | where 7 | E: std::error::Error, 8 | M: Fn(I) -> F, 9 | F: Future>, 10 | { 11 | if items.is_empty() { 12 | return Ok(Vec::default()); 13 | } 14 | 15 | let buffer = items.len(); 16 | iter(items).map(mapper).buffered(buffer).try_collect().await 17 | } 18 | -------------------------------------------------------------------------------- /api/src/util/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod deserialize; 2 | pub mod iter; 3 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | gitlab-ci-dashboard: 3 | image: larscom/gitlab-ci-dashboard 4 | build: 5 | context: . 6 | args: 7 | VERSION_ARG: docker 8 | env_file: 9 | - ./api/.env 10 | # volumes: 11 | # - ./api/config.toml:/app/config.toml 12 | environment: 13 | - 'TZ=Europe/Amsterdam' 14 | ports: 15 | - '8080:8080' 16 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'jest-preset-angular', 3 | transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$|@ngneat|ng-zorro-antd)'], 4 | moduleNameMapper: { 5 | '\\$groups/(.*)': '/src/app/groups/$1', 6 | '\\$header/(.*)': '/src/app/header/$1', 7 | '\\$service/(.*)': '/src/app/service/$1', 8 | '\\$store/(.*)': '/src/app/store/$1' 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gitlab-ci-dashboard", 3 | "version": "2.9.0", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "ng serve", 7 | "build": "ng build", 8 | "test": "ng test" 9 | }, 10 | "private": true, 11 | "dependencies": { 12 | "@angular/animations": "19.2.14", 13 | "@angular/common": "19.2.14", 14 | "@angular/compiler": "19.2.14", 15 | "@angular/core": "19.2.14", 16 | "@angular/forms": "19.2.14", 17 | "@angular/platform-browser": "19.2.14", 18 | "@angular/platform-browser-dynamic": "19.2.14", 19 | "@angular/router": "19.2.14", 20 | "@fontsource/roboto": "5.2.6", 21 | "file-saver": "2.0.5", 22 | "ng-zorro-antd": "19.3.1", 23 | "normalize.css": "8.0.1", 24 | "rxjs": "7.8.2", 25 | "tslib": "2.8.1", 26 | "zone.js": "0.15.1" 27 | }, 28 | "devDependencies": { 29 | "@angular-builders/jest": "19.0.1", 30 | "@angular/build": "^19.0.0", 31 | "@angular/cli": "19.2.14", 32 | "@angular/compiler-cli": "19.2.14", 33 | "@types/file-saver": "2.0.7", 34 | "@types/jest": "29.5.14", 35 | "jest": "29.7.0", 36 | "jest-preset-angular": "14.6.0", 37 | "typescript": "5.8.3" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /proxy.conf.js: -------------------------------------------------------------------------------- 1 | // const target = 'https://gitlab-ci-dashboard.larscom.nl' 2 | const target = 'http://localhost:8080' 3 | 4 | module.exports = { 5 | '/api/**': { 6 | target, 7 | secure: false, 8 | logLevel: 'debug', 9 | changeOrigin: true 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ], 6 | "packageRules": [ 7 | { 8 | "description": "Automerge non-major updates", 9 | "matchUpdateTypes": [ 10 | "minor", 11 | "patch" 12 | ], 13 | "automerge": true 14 | }, 15 | { 16 | "description": "Use bump strategy for Cargo", 17 | "matchManagers": [ 18 | "cargo" 19 | ], 20 | "rangeStrategy": "bump" 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /src/app/app.component.html: -------------------------------------------------------------------------------- 1 | 2 | @if (error(); as error) { 3 |
4 | 5 | 6 | 7 | 8 |
9 | } @else { 10 |
11 | 12 |
13 | } 14 | -------------------------------------------------------------------------------- /src/app/app.component.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | margin-right: auto; 3 | margin-left: auto; 4 | min-width: 768px; 5 | padding: 1.5rem; 6 | } 7 | 8 | .alert { 9 | margin-right: auto; 10 | margin-left: auto; 11 | max-width: 500px; 12 | padding: 1rem; 13 | } 14 | -------------------------------------------------------------------------------- /src/app/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { provideHttpClient } from '@angular/common/http' 2 | import { provideHttpClientTesting } from '@angular/common/http/testing' 3 | import { TestBed } from '@angular/core/testing' 4 | import { AppComponent } from './app.component' 5 | 6 | describe('AppComponent', () => { 7 | beforeEach(async () => { 8 | await TestBed.configureTestingModule({ 9 | imports: [AppComponent], 10 | providers: [provideHttpClient(), provideHttpClientTesting()] 11 | }).compileComponents() 12 | }) 13 | 14 | it('should create the app', () => { 15 | const fixture = TestBed.createComponent(AppComponent) 16 | const app = fixture.componentInstance 17 | expect(app).toBeTruthy() 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { ErrorService } from '$service/error.service' 2 | 3 | import { Component, computed, inject } from '@angular/core' 4 | import { RouterOutlet } from '@angular/router' 5 | import { NzAlertModule } from 'ng-zorro-antd/alert' 6 | import { NzButtonModule } from 'ng-zorro-antd/button' 7 | import { HeaderComponent } from './header/header.component' 8 | 9 | @Component({ 10 | selector: 'gcd-root', 11 | imports: [RouterOutlet, HeaderComponent, NzAlertModule, NzButtonModule], 12 | templateUrl: './app.component.html', 13 | styleUrls: ['./app.component.scss'] 14 | }) 15 | export class AppComponent { 16 | errorService = inject(ErrorService) 17 | 18 | error = this.errorService.error 19 | 20 | get title() { 21 | return computed(() => { 22 | const error = this.error() 23 | if (!error) return '' 24 | 25 | const { statusCode } = error 26 | return `Error ${statusCode}` 27 | }) 28 | } 29 | 30 | get message() { 31 | return computed(() => { 32 | const error = this.error() 33 | return error ? error.message : '' 34 | }) 35 | } 36 | 37 | onClick(): void { 38 | window.location.reload() 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/app/app.config.ts: -------------------------------------------------------------------------------- 1 | import { GroupTabsComponent } from '$groups/group-tabs/group-tabs.component' 2 | import { registerLocaleData } from '@angular/common' 3 | import { provideHttpClient } from '@angular/common/http' 4 | import en from '@angular/common/locales/en' 5 | import nl from '@angular/common/locales/nl' 6 | import { 7 | ApplicationConfig, 8 | inject, 9 | provideAppInitializer, 10 | provideExperimentalZonelessChangeDetection 11 | } from '@angular/core' 12 | import { provideNoopAnimations } from '@angular/platform-browser/animations' 13 | import { Route, provideRouter, withHashLocation } from '@angular/router' 14 | import { NzI18nService, en_US, nl_NL } from 'ng-zorro-antd/i18n' 15 | 16 | registerLocaleData(en) 17 | registerLocaleData(nl) 18 | 19 | const routes: Route[] = [ 20 | { path: '', component: GroupTabsComponent }, 21 | { path: ':groupId', component: GroupTabsComponent }, 22 | { path: ':groupId/:featureId', component: GroupTabsComponent }, 23 | { path: '**', redirectTo: '' } 24 | ] 25 | 26 | export const appConfig: ApplicationConfig = { 27 | providers: [ 28 | provideNoopAnimations(), 29 | provideExperimentalZonelessChangeDetection(), 30 | provideHttpClient(), 31 | provideRouter(routes, withHashLocation()), 32 | provideI18n() 33 | ] 34 | } 35 | 36 | function provideI18n() { 37 | return provideAppInitializer(() => { 38 | const i18n = inject(NzI18nService) 39 | if (navigator.languages.includes('nl')) { 40 | i18n.setLocale(nl_NL) 41 | } else { 42 | i18n.setLocale(en_US) 43 | } 44 | }) 45 | } 46 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/favorites/favorite.service.ts: -------------------------------------------------------------------------------- 1 | import { GroupId } from '$groups/model/group' 2 | import { ProjectId } from '$groups/model/project' 3 | import { ErrorContext, ErrorService } from '$service/error.service' 4 | import { HttpStatusCode } from '@angular/common/http' 5 | import { Injectable, Signal, computed, effect, inject, signal } from '@angular/core' 6 | 7 | const STORAGE_KEY = 'favorite_projects' 8 | 9 | @Injectable({ providedIn: 'root' }) 10 | export class FavoriteService { 11 | private _favorites = signal>>(this.getFromStorage()) 12 | private errorService = inject(ErrorService) 13 | 14 | readonly favorites = this._favorites.asReadonly() 15 | 16 | constructor() { 17 | effect(() => { 18 | const error = this.errorService.error() 19 | if (error) { 20 | this.removeGroupWhen404(error) 21 | } 22 | }) 23 | } 24 | 25 | anyProject(groupId: GroupId, projectId: ProjectId): Signal { 26 | return computed(() => { 27 | const map = this._favorites() 28 | if (map.has(groupId)) { 29 | const projectIds = map.get(groupId)! 30 | return projectIds.has(projectId) 31 | } 32 | return false 33 | }) 34 | } 35 | 36 | addProject(groupId: GroupId, projectId: ProjectId) { 37 | const map = new Map(this._favorites()) 38 | 39 | if (map.has(groupId)) { 40 | const projectIds = map.get(groupId)! 41 | map.set(groupId, projectIds.add(projectId)) 42 | } else { 43 | map.set(groupId, new Set([projectId])) 44 | } 45 | 46 | this._favorites.set(map) 47 | 48 | this.saveToStorage(map) 49 | } 50 | 51 | removeProject(groupId: GroupId, projectId: ProjectId) { 52 | const map = new Map(this._favorites()) 53 | if (!map.has(groupId)) return 54 | 55 | const projectIds = map.get(groupId)! 56 | projectIds.delete(projectId) 57 | 58 | if (projectIds.size > 0) { 59 | map.set(groupId, new Set(projectIds)) 60 | this._favorites.set(map) 61 | this.saveToStorage(map) 62 | } else { 63 | this.removeGroup(groupId) 64 | } 65 | } 66 | 67 | removeGroup(groupId: GroupId) { 68 | const map = new Map(this._favorites()) 69 | if (!map.has(groupId)) return 70 | 71 | map.delete(groupId) 72 | 73 | this._favorites.set(map) 74 | 75 | this.saveToStorage(map) 76 | } 77 | 78 | removeAll() { 79 | const map = new Map() 80 | this._favorites.set(map) 81 | this.saveToStorage(map) 82 | } 83 | 84 | private removeGroupWhen404({ statusCode, groupId }: ErrorContext) { 85 | if (statusCode === HttpStatusCode.NotFound && groupId) { 86 | this.removeGroup(groupId) 87 | } 88 | } 89 | 90 | private saveToStorage(favorites: Map>) { 91 | const record = Object.fromEntries( 92 | Array.from(favorites.entries()).map(([groupId, projectIds]) => [groupId, Array.from(projectIds)]) 93 | ) 94 | 95 | try { 96 | localStorage.setItem(STORAGE_KEY, JSON.stringify(record)) 97 | } catch (_) {} 98 | } 99 | 100 | private getFromStorage(): Map> { 101 | try { 102 | const item = localStorage.getItem(STORAGE_KEY) 103 | if (item) { 104 | const record: Record = JSON.parse(item) 105 | return new Map(Object.entries(record).map(([groupId, projectIds]) => [Number(groupId), new Set(projectIds)])) 106 | } 107 | } catch (_) {} 108 | 109 | return new Map() 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/favorites/favorites-icon/favorites-icon.component.html: -------------------------------------------------------------------------------- 1 | 14 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/favorites/favorites-icon/favorites-icon.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/larscom/gitlab-ci-dashboard/28d2236dbbcbfb16b872527b0f6c5e5d927ad18b/src/app/groups/group-tabs/favorites/favorites-icon/favorites-icon.component.scss -------------------------------------------------------------------------------- /src/app/groups/group-tabs/favorites/favorites-icon/favorites-icon.component.ts: -------------------------------------------------------------------------------- 1 | import { GroupId } from '$groups/model/group' 2 | import { ProjectId } from '$groups/model/project' 3 | import { CommonModule } from '@angular/common' 4 | import { Component, computed, inject, input, signal } from '@angular/core' 5 | import { NzButtonModule } from 'ng-zorro-antd/button' 6 | import { NzIconModule } from 'ng-zorro-antd/icon' 7 | import { NzToolTipModule } from 'ng-zorro-antd/tooltip' 8 | import { FavoriteService } from '../favorite.service' 9 | 10 | @Component({ 11 | selector: 'gcd-favorites-icon', 12 | imports: [CommonModule, NzToolTipModule, NzIconModule, NzButtonModule], 13 | templateUrl: './favorites-icon.component.html', 14 | styleUrls: ['./favorites-icon.component.scss'] 15 | }) 16 | export class FavoritesIconComponent { 17 | private favoriteService = inject(FavoriteService) 18 | 19 | groupId = input.required() 20 | projectId = input.required() 21 | 22 | tooltipTitle = computed(() => { 23 | return this.hasFavorite() ? 'Remove from favorites' : 'Add to favorites' 24 | }) 25 | 26 | remove(e: Event) { 27 | e.stopPropagation() 28 | 29 | this.favoriteService.removeProject(this.groupId(), this.projectId()) 30 | } 31 | 32 | add(e: Event) { 33 | e.stopPropagation() 34 | 35 | this.favoriteService.addProject(this.groupId(), this.projectId()) 36 | } 37 | 38 | get hasFavorite() { 39 | return this.favoriteService.anyProject(this.groupId(), this.projectId()) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/favorites/favorites.component.html: -------------------------------------------------------------------------------- 1 | 2 | @if (hasFavorites()) { 3 |
4 | 16 |
17 | } 18 |
19 | 20 | 28 | 29 | @if (hasFavorites()) { 30 | 31 | } @else { 32 | 33 | 34 |
35 | No favorites yet, start adding favorites by clicking on the 36 | next to a project. 37 |
38 |
39 | 40 | 41 | 42 |
43 | } 44 |
45 |
46 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/favorites/favorites.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/larscom/gitlab-ci-dashboard/28d2236dbbcbfb16b872527b0f6c5e5d927ad18b/src/app/groups/group-tabs/favorites/favorites.component.scss -------------------------------------------------------------------------------- /src/app/groups/group-tabs/favorites/favorites.component.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common' 2 | import { ChangeDetectionStrategy, Component, computed, inject, output } from '@angular/core' 3 | import { NzButtonModule } from 'ng-zorro-antd/button' 4 | import { NzDrawerModule } from 'ng-zorro-antd/drawer' 5 | import { NzIconModule } from 'ng-zorro-antd/icon' 6 | import { NzPopconfirmModule } from 'ng-zorro-antd/popconfirm' 7 | import { FeatureTabsComponent } from '../feature-tabs/feature-tabs.component' 8 | import { FavoriteService } from './favorite.service' 9 | import { NzEmptyModule } from 'ng-zorro-antd/empty' 10 | 11 | @Component({ 12 | selector: 'gcd-favorites', 13 | imports: [ 14 | CommonModule, 15 | NzDrawerModule, 16 | NzEmptyModule, 17 | NzPopconfirmModule, 18 | NzIconModule, 19 | NzButtonModule, 20 | FeatureTabsComponent 21 | ], 22 | templateUrl: './favorites.component.html', 23 | styleUrls: ['./favorites.component.scss'], 24 | changeDetection: ChangeDetectionStrategy.OnPush 25 | }) 26 | export class FavoritesComponent { 27 | private favoriteService = inject(FavoriteService) 28 | 29 | close = output() 30 | 31 | favorites = this.favoriteService.favorites 32 | 33 | hasFavorites = computed(() => Array.from(this.favorites().values()).some((ids) => ids.size > 0)) 34 | 35 | onConfirm() { 36 | this.favoriteService.removeAll() 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/components/download-artifacts-icon/download-artifacts-icon.component.html: -------------------------------------------------------------------------------- 1 | 13 | 14 |
    15 | @for (job of jobs(); track job.id) { 16 |
  • 17 | 28 |
  • 29 | } 30 |
31 |
32 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/components/download-artifacts-icon/download-artifacts-icon.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/larscom/gitlab-ci-dashboard/28d2236dbbcbfb16b872527b0f6c5e5d927ad18b/src/app/groups/group-tabs/feature-tabs/components/download-artifacts-icon/download-artifacts-icon.component.scss -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/components/download-artifacts-icon/download-artifacts-icon.component.ts: -------------------------------------------------------------------------------- 1 | import { Job, JobId } from '$groups/model/job' 2 | import { PipelineId } from '$groups/model/pipeline' 3 | import { ProjectId } from '$groups/model/project' 4 | import { CommonModule } from '@angular/common' 5 | import { HttpClient, HttpErrorResponse, HttpStatusCode } from '@angular/common/http' 6 | import { ChangeDetectionStrategy, Component, HostListener, inject, Injector, input, signal } from '@angular/core' 7 | import { toObservable, toSignal } from '@angular/core/rxjs-interop' 8 | import FileSaver from 'file-saver' 9 | import { NzButtonModule } from 'ng-zorro-antd/button' 10 | import { NzDropDownModule } from 'ng-zorro-antd/dropdown' 11 | import { NzIconModule } from 'ng-zorro-antd/icon' 12 | import { NzNotificationService } from 'ng-zorro-antd/notification' 13 | import { NzToolTipModule } from 'ng-zorro-antd/tooltip' 14 | import { combineLatest, finalize, switchMap } from 'rxjs' 15 | import { StatusColorPipe } from '../../pipes/status-color.pipe' 16 | 17 | @Component({ 18 | selector: 'gcd-download-artifacts-icon', 19 | imports: [CommonModule, NzButtonModule, NzIconModule, NzDropDownModule, NzToolTipModule, StatusColorPipe], 20 | templateUrl: './download-artifacts-icon.component.html', 21 | styleUrls: ['./download-artifacts-icon.component.scss'], 22 | changeDetection: ChangeDetectionStrategy.OnPush 23 | }) 24 | export class DownloadArtifactsIconComponent { 25 | private http = inject(HttpClient) 26 | private injector = inject(Injector) 27 | private notification = inject(NzNotificationService) 28 | private loadingJobIds = signal([]) 29 | 30 | projectId = input.required() 31 | pipelineId = input.required() 32 | 33 | jobs = toSignal( 34 | combineLatest([ 35 | toObservable(this.projectId, { injector: this.injector }), 36 | toObservable(this.pipelineId, { injector: this.injector }) 37 | ]).pipe( 38 | switchMap(([projectId, pipelineId]) => { 39 | const params = { 40 | project_id: projectId, 41 | pipeline_id: pipelineId, 42 | scope: '' 43 | } 44 | 45 | return this.http.get('/api/jobs', { params }) 46 | }) 47 | ), 48 | { initialValue: [] } 49 | ) 50 | 51 | isLoading(jobId: JobId): boolean { 52 | return this.loadingJobIds().includes(jobId) 53 | } 54 | 55 | download(e: MouseEvent, { id, name }: Job) { 56 | e.stopPropagation() 57 | 58 | this.loadingJobIds.set([...this.loadingJobIds(), id]) 59 | 60 | const params = { 61 | project_id: this.projectId(), 62 | job_id: id 63 | } 64 | 65 | this.http 66 | .get('/api/artifacts', { params, responseType: 'blob' }) 67 | .pipe(finalize(() => this.loadingJobIds.set(this.loadingJobIds().filter((jobId) => jobId !== id)))) 68 | .subscribe({ 69 | next: (blob) => FileSaver.saveAs(blob, `${name}_${id}.zip`), 70 | error: ({ status, error }: HttpErrorResponse) => { 71 | if (status === HttpStatusCode.NotFound) { 72 | this.notification.error('Not Found', `Failed to download artifact, it's missing from the server`) 73 | } else { 74 | this.notification.error(`Error ${status}`, error ? error.message : 'Failed to download artifact') 75 | } 76 | } 77 | }) 78 | } 79 | 80 | @HostListener('click', ['$event']) onClick(e: MouseEvent) { 81 | e.stopPropagation() 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/components/jobs/jobs.component.html: -------------------------------------------------------------------------------- 1 |
2 | @if (loading()) { 3 | 4 | } @else if (tags().length) { 5 | @for (tag of tags(); track trackById(tag.job)) { 6 | 13 | 14 | {{ tag.job.name | maxLength: 12 }} 15 | 16 | } 17 | } @else { 18 | - 19 | } 20 |
21 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/components/jobs/jobs.component.scss: -------------------------------------------------------------------------------- 1 | nz-spin { 2 | float: left; 3 | } 4 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/components/jobs/jobs.component.ts: -------------------------------------------------------------------------------- 1 | import { FETCH_REFRESH_INTERVAL, retryConfig } from '$groups/http' 2 | import { Job, JobId } from '$groups/model/job' 3 | import { PipelineId } from '$groups/model/pipeline' 4 | import { ProjectId } from '$groups/model/project' 5 | import { Status } from '$groups/model/status' 6 | import { CommonModule } from '@angular/common' 7 | import { HttpClient } from '@angular/common/http' 8 | import { 9 | ChangeDetectionStrategy, 10 | Component, 11 | Injector, 12 | OnChanges, 13 | OnDestroy, 14 | SimpleChanges, 15 | inject, 16 | input, 17 | runInInjectionContext, 18 | signal 19 | } from '@angular/core' 20 | import { NzIconModule } from 'ng-zorro-antd/icon' 21 | import { NzSpinModule } from 'ng-zorro-antd/spin' 22 | import { NzTagModule } from 'ng-zorro-antd/tag' 23 | import { NzToolTipModule } from 'ng-zorro-antd/tooltip' 24 | import { Subscription, identity, map, repeat, retry, tap } from 'rxjs' 25 | import { MaxLengthPipe } from '../../pipes/max-length.pipe' 26 | import { StatusColorPipe } from '../../pipes/status-color.pipe' 27 | 28 | interface Tag { 29 | job: Job 30 | icon: string 31 | spin: boolean 32 | } 33 | 34 | const MAX_JOB_COUNT = 6 35 | const RUNNABLE_STATUSES = [ 36 | Status.CREATED, 37 | Status.WAITING_FOR_RESOURCE, 38 | Status.PREPARING, 39 | Status.PENDING, 40 | Status.RUNNING, 41 | Status.MANUAL, 42 | Status.SCHEDULED 43 | ] 44 | 45 | @Component({ 46 | selector: 'gcd-jobs', 47 | imports: [CommonModule, NzTagModule, NzIconModule, NzSpinModule, NzToolTipModule, StatusColorPipe, MaxLengthPipe], 48 | templateUrl: './jobs.component.html', 49 | styleUrls: ['./jobs.component.scss'], 50 | changeDetection: ChangeDetectionStrategy.OnPush 51 | }) 52 | export class JobsComponent implements OnChanges, OnDestroy { 53 | private http = inject(HttpClient) 54 | private injector = inject(Injector) 55 | private subscription?: Subscription 56 | 57 | projectId = input.required() 58 | pipelineId = input.required() 59 | scope = input([]) 60 | 61 | tags = signal([]) 62 | loading = signal(true) 63 | 64 | ngOnChanges({ scope }: SimpleChanges): void { 65 | const current: Status[] = scope?.currentValue ?? [] 66 | const previous: Status[] = scope?.previousValue ?? [] 67 | if (this.isSameArray(current, previous)) { 68 | return 69 | } 70 | runInInjectionContext(this.injector, () => this.subscribeToJobs()) 71 | } 72 | 73 | ngOnDestroy(): void { 74 | this.subscription?.unsubscribe() 75 | } 76 | 77 | trackById({ id }: Job): JobId { 78 | return id 79 | } 80 | 81 | onActionClick(e: Event, { web_url }: Job): void { 82 | e.stopPropagation() 83 | window.open(web_url, '_blank') 84 | } 85 | 86 | private isSameArray(a: T[], b: T[]): boolean { 87 | return a.length === b.length && a.every((value, index) => value === b[index]) 88 | } 89 | 90 | private subscribeToJobs(): void { 91 | this.subscription?.unsubscribe() 92 | 93 | const project_id = this.projectId() 94 | const pipeline_id = this.pipelineId() 95 | const scope = this.scope().join(',') 96 | const params = { project_id, pipeline_id, scope } 97 | 98 | this.subscription = this.http 99 | .get('/api/jobs', { params }) 100 | .pipe( 101 | retry(retryConfig), 102 | this.withRepeat() ? repeat({ delay: FETCH_REFRESH_INTERVAL }) : identity, 103 | tap(() => this.loading.set(false)), 104 | map((jobs) => { 105 | return jobs.slice(0, MAX_JOB_COUNT).map((job) => { 106 | const icon = this.getTagIcon(job) 107 | const spin = RUNNABLE_STATUSES.includes(job.status) 108 | return { job, icon, spin } 109 | }) 110 | }) 111 | ) 112 | .subscribe((tags) => this.tags.set(tags)) 113 | } 114 | 115 | getStatus(job: Job): Status { 116 | if (job.status === Status.FAILED && job.allow_failure) { 117 | return Status.FAILED_ALLOW_FAILURE 118 | } 119 | return job.status 120 | } 121 | 122 | private withRepeat(): boolean { 123 | return this.scope().some((scope) => RUNNABLE_STATUSES.includes(scope)) 124 | } 125 | 126 | private getTagIcon(job: Job): string { 127 | if (job.status === Status.SUCCESS) { 128 | return 'check-circle' 129 | } 130 | if (job.status === Status.FAILED && job.allow_failure) { 131 | return 'exclamation-circle' 132 | } 133 | if ([Status.FAILED, Status.CANCELED].includes(job.status)) { 134 | return 'close-circle' 135 | } 136 | if (RUNNABLE_STATUSES.includes(job.status)) { 137 | return 'sync' 138 | } 139 | 140 | return 'clock-circle' 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/components/open-gitlab-icon/open-gitlab-icon.component.html: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/components/open-gitlab-icon/open-gitlab-icon.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/larscom/gitlab-ci-dashboard/28d2236dbbcbfb16b872527b0f6c5e5d927ad18b/src/app/groups/group-tabs/feature-tabs/components/open-gitlab-icon/open-gitlab-icon.component.scss -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/components/open-gitlab-icon/open-gitlab-icon.component.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common' 2 | import { ChangeDetectionStrategy, Component, input } from '@angular/core' 3 | import { NzButtonModule } from 'ng-zorro-antd/button' 4 | import { NzIconModule } from 'ng-zorro-antd/icon' 5 | import { NzToolTipModule } from 'ng-zorro-antd/tooltip' 6 | 7 | @Component({ 8 | selector: 'gcd-open-gitlab-icon', 9 | imports: [CommonModule, NzButtonModule, NzIconModule, NzToolTipModule], 10 | templateUrl: './open-gitlab-icon.component.html', 11 | styleUrls: ['./open-gitlab-icon.component.scss'], 12 | changeDetection: ChangeDetectionStrategy.OnPush 13 | }) 14 | export class OpenGitlabIconComponent { 15 | url = input.required() 16 | 17 | onClick(e: MouseEvent) { 18 | e.stopPropagation() 19 | window.open(this.url(), '_blank') 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/components/project-filter/project-filter.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | 10 | 11 | 12 | 21 | 22 |
23 |
24 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/components/project-filter/project-filter.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/larscom/gitlab-ci-dashboard/28d2236dbbcbfb16b872527b0f6c5e5d927ad18b/src/app/groups/group-tabs/feature-tabs/components/project-filter/project-filter.component.scss -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/components/project-filter/project-filter.component.ts: -------------------------------------------------------------------------------- 1 | import { Project } from '$groups/model/project' 2 | 3 | import { ChangeDetectionStrategy, Component, computed, input, model } from '@angular/core' 4 | import { FormsModule } from '@angular/forms' 5 | import { NzButtonModule } from 'ng-zorro-antd/button' 6 | import { NzIconModule } from 'ng-zorro-antd/icon' 7 | import { NzInputModule } from 'ng-zorro-antd/input' 8 | import { NzSpinModule } from 'ng-zorro-antd/spin' 9 | import { NzTagModule } from 'ng-zorro-antd/tag' 10 | import { NzToolTipModule } from 'ng-zorro-antd/tooltip' 11 | 12 | @Component({ 13 | selector: 'gcd-project-filter', 14 | imports: [NzIconModule, NzInputModule, NzTagModule, NzButtonModule, NzToolTipModule, NzSpinModule, FormsModule], 15 | templateUrl: './project-filter.component.html', 16 | styleUrls: ['./project-filter.component.scss'], 17 | changeDetection: ChangeDetectionStrategy.OnPush 18 | }) 19 | export class ProjectFilterComponent { 20 | projects = input.required() 21 | filterText = model.required() 22 | 23 | projectCount = computed(() => new Set(this.projects().map(({ id }) => id)).size) 24 | } 25 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/components/topic-filter/topic-filter.component.html: -------------------------------------------------------------------------------- 1 | @if (loading()) { 2 | 3 | } @else { 4 | @if (topics().size > 0) { 5 |
6 | Filter by topic 7 | @for (topic of topics(); track topic) { 8 | 13 | {{ topic | lowercase }} 14 | 15 | } 16 |
17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/components/topic-filter/topic-filter.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/larscom/gitlab-ci-dashboard/28d2236dbbcbfb16b872527b0f6c5e5d927ad18b/src/app/groups/group-tabs/feature-tabs/components/topic-filter/topic-filter.component.scss -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/components/topic-filter/topic-filter.component.ts: -------------------------------------------------------------------------------- 1 | import { Project } from '$groups/model/project' 2 | import { CommonModule } from '@angular/common' 3 | import { ChangeDetectionStrategy, Component, Signal, computed, input, model } from '@angular/core' 4 | import { NzSpinModule } from 'ng-zorro-antd/spin' 5 | import { NzTagModule } from 'ng-zorro-antd/tag' 6 | 7 | @Component({ 8 | selector: 'gcd-topic-filter', 9 | imports: [CommonModule, NzTagModule, NzSpinModule], 10 | templateUrl: './topic-filter.component.html', 11 | styleUrls: ['./topic-filter.component.scss'], 12 | changeDetection: ChangeDetectionStrategy.OnPush 13 | }) 14 | export class TopicFilterComponent { 15 | projects = input.required() 16 | loading = input(false) 17 | 18 | filterTopics = model.required() 19 | 20 | topics: Signal> = computed( 21 | () => 22 | new Set( 23 | this.projects() 24 | .flatMap(({ topics }) => topics) 25 | .sort((a, b) => a.localeCompare(b)) 26 | ) 27 | ) 28 | 29 | onTopicChange(checked: boolean, topic: string): void { 30 | const selected = this.filterTopics() 31 | if (checked) { 32 | this.filterTopics.set([...selected, topic]) 33 | } else { 34 | this.filterTopics.set(selected.filter((t) => t !== topic)) 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/components/write-actions-icon/cancel-pipeline-action/cancel-pipeline-action.component.html: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/components/write-actions-icon/cancel-pipeline-action/cancel-pipeline-action.component.scss: -------------------------------------------------------------------------------- 1 | .ant-btn-link { 2 | color: inherit; 3 | } 4 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/components/write-actions-icon/cancel-pipeline-action/cancel-pipeline-action.component.ts: -------------------------------------------------------------------------------- 1 | import { retryConfig } from '$groups/http' 2 | import { PipelineId } from '$groups/model/pipeline' 3 | import { ProjectId } from '$groups/model/project' 4 | import { CommonModule } from '@angular/common' 5 | import { HttpClient, HttpErrorResponse, HttpStatusCode } from '@angular/common/http' 6 | import { ChangeDetectionStrategy, Component, inject, input, signal } from '@angular/core' 7 | import { NzButtonModule } from 'ng-zorro-antd/button' 8 | import { NzIconModule } from 'ng-zorro-antd/icon' 9 | import { NzNotificationService } from 'ng-zorro-antd/notification' 10 | import { finalize, retry } from 'rxjs' 11 | 12 | @Component({ 13 | selector: 'gcd-cancel-pipeline-action', 14 | imports: [CommonModule, NzIconModule, NzButtonModule], 15 | templateUrl: './cancel-pipeline-action.component.html', 16 | styleUrls: ['./cancel-pipeline-action.component.scss'], 17 | changeDetection: ChangeDetectionStrategy.OnPush 18 | }) 19 | export class CancelPipelineActionComponent { 20 | private http = inject(HttpClient) 21 | private notification = inject(NzNotificationService) 22 | 23 | projectId = input.required() 24 | pipelineId = input.required() 25 | 26 | loading = signal(false) 27 | 28 | cancel(): void { 29 | const params = { project_id: this.projectId(), pipeline_id: this.pipelineId() } 30 | 31 | this.loading.set(true) 32 | 33 | this.http 34 | .post('/api/pipelines/cancel', null, { params }) 35 | .pipe( 36 | retry(retryConfig), 37 | finalize(() => this.loading.set(false)) 38 | ) 39 | .subscribe({ 40 | complete: () => this.notification.success('Success', 'Canceled pipeline.'), 41 | error: ({ status, error }: HttpErrorResponse) => { 42 | if (status === HttpStatusCode.Forbidden) { 43 | this.notification.error('Forbidden', 'Failed to cancel pipeline, a read/write access token is required.') 44 | } else { 45 | this.notification.error(`Error ${status}`, error ? error.message : 'Failed to cancel pipeline') 46 | } 47 | } 48 | }) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/components/write-actions-icon/retry-pipeline-action/retry-pipeline-action.component.html: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/components/write-actions-icon/retry-pipeline-action/retry-pipeline-action.component.scss: -------------------------------------------------------------------------------- 1 | .ant-btn-link { 2 | color: inherit; 3 | } 4 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/components/write-actions-icon/retry-pipeline-action/retry-pipeline-action.component.ts: -------------------------------------------------------------------------------- 1 | import { retryConfig } from '$groups/http' 2 | import { PipelineId } from '$groups/model/pipeline' 3 | import { ProjectId } from '$groups/model/project' 4 | import { CommonModule } from '@angular/common' 5 | import { HttpClient, HttpErrorResponse, HttpStatusCode } from '@angular/common/http' 6 | import { ChangeDetectionStrategy, Component, inject, input, signal } from '@angular/core' 7 | import { NzButtonModule } from 'ng-zorro-antd/button' 8 | import { NzIconModule } from 'ng-zorro-antd/icon' 9 | import { NzNotificationService } from 'ng-zorro-antd/notification' 10 | import { finalize, retry } from 'rxjs' 11 | 12 | @Component({ 13 | selector: 'gcd-retry-pipeline-action', 14 | imports: [CommonModule, NzIconModule, NzButtonModule], 15 | templateUrl: './retry-pipeline-action.component.html', 16 | styleUrls: ['./retry-pipeline-action.component.scss'], 17 | changeDetection: ChangeDetectionStrategy.OnPush 18 | }) 19 | export class RetryPipelineActionComponent { 20 | private http = inject(HttpClient) 21 | private notification = inject(NzNotificationService) 22 | 23 | projectId = input.required() 24 | pipelineId = input.required() 25 | 26 | loading = signal(false) 27 | 28 | retry() { 29 | const params = { project_id: this.projectId(), pipeline_id: this.pipelineId() } 30 | 31 | this.loading.set(true) 32 | 33 | this.http 34 | .post('/api/pipelines/retry', null, { params }) 35 | .pipe( 36 | retry(retryConfig), 37 | finalize(() => this.loading.set(false)) 38 | ) 39 | .subscribe({ 40 | complete: () => this.notification.success('Success', 'Restarted pipeline.'), 41 | error: ({ status, error }: HttpErrorResponse) => { 42 | if (status === HttpStatusCode.Forbidden) { 43 | this.notification.error('Forbidden', 'Failed to retry pipeline, a read/write access token is required.') 44 | } else { 45 | this.notification.error(`Error ${status}`, error ? error.message : 'Failed to retry pipeline') 46 | } 47 | } 48 | }) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/components/write-actions-icon/start-pipeline-action/start-pipeline-action.component.html: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/components/write-actions-icon/start-pipeline-action/start-pipeline-action.component.scss: -------------------------------------------------------------------------------- 1 | .ant-btn-link { 2 | color: inherit; 3 | } 4 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/components/write-actions-icon/start-pipeline-action/start-pipeline-action.component.ts: -------------------------------------------------------------------------------- 1 | import { ProjectId } from '$groups/model/project' 2 | import { CommonModule } from '@angular/common' 3 | import { ChangeDetectionStrategy, Component, inject, input, output, signal } from '@angular/core' 4 | import { NzButtonModule } from 'ng-zorro-antd/button' 5 | import { NzIconModule } from 'ng-zorro-antd/icon' 6 | import { NzModalModule, NzModalService } from 'ng-zorro-antd/modal' 7 | import { ModalData, StartPipelineModalComponent } from './start-pipeline-modal/start-pipeline-modal.component' 8 | 9 | @Component({ 10 | selector: 'gcd-start-pipeline-action', 11 | imports: [CommonModule, NzIconModule, NzButtonModule, NzModalModule], 12 | templateUrl: './start-pipeline-action.component.html', 13 | styleUrls: ['./start-pipeline-action.component.scss'], 14 | changeDetection: ChangeDetectionStrategy.OnPush 15 | }) 16 | export class StartPipelineActionComponent { 17 | private modal = inject(NzModalService) 18 | 19 | projectId = input.required() 20 | branch = input.required() 21 | 22 | 23 | loading = signal(false) 24 | 25 | start() { 26 | const nzData: ModalData = { projectId: this.projectId(), branch: this.branch(), loadingIcon: this.loading } 27 | 28 | this.modal.create({ 29 | nzTitle: 'Start a new pipeline', 30 | nzWidth: '30vw', 31 | nzContent: StartPipelineModalComponent, 32 | nzData 33 | }) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/components/write-actions-icon/start-pipeline-action/start-pipeline-modal/start-pipeline-modal.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | 5 |
6 |
Branch
7 | 14 | 15 | @for (branch of branches(); track branch.name) { 16 | 17 | } 18 | 19 |
20 |
21 | 22 | 23 |
24 |
Variables
25 | 26 |
27 |
28 |
29 |
30 |
31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/components/write-actions-icon/start-pipeline-action/start-pipeline-modal/start-pipeline-modal.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/larscom/gitlab-ci-dashboard/28d2236dbbcbfb16b872527b0f6c5e5d927ad18b/src/app/groups/group-tabs/feature-tabs/components/write-actions-icon/start-pipeline-action/start-pipeline-modal/start-pipeline-modal.component.scss -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/components/write-actions-icon/start-pipeline-action/start-pipeline-modal/start-pipeline-modal.component.ts: -------------------------------------------------------------------------------- 1 | import { retryConfig } from '$groups/http' 2 | import { ProjectId } from '$groups/model/project' 3 | import { BranchService } from '$groups/service/branch.service' 4 | import { CommonModule } from '@angular/common' 5 | import { HttpClient, HttpErrorResponse, HttpStatusCode } from '@angular/common/http' 6 | import { Component, inject, signal, WritableSignal } from '@angular/core' 7 | import { toSignal } from '@angular/core/rxjs-interop' 8 | import { FormsModule } from '@angular/forms' 9 | import { NzButtonModule } from 'ng-zorro-antd/button' 10 | import { NzFormModule } from 'ng-zorro-antd/form' 11 | import { NzIconModule } from 'ng-zorro-antd/icon' 12 | import { NzModalModule, NzModalRef } from 'ng-zorro-antd/modal' 13 | import { NzNotificationService } from 'ng-zorro-antd/notification' 14 | import { NzSelectModule } from 'ng-zorro-antd/select' 15 | import { NzSpaceModule } from 'ng-zorro-antd/space' 16 | import { finalize, map, retry } from 'rxjs' 17 | import { VariablesFormComponent } from './variables-form/variables-form.component' 18 | 19 | export interface ModalData { 20 | projectId: ProjectId 21 | branch: string 22 | loadingIcon: WritableSignal 23 | } 24 | 25 | @Component({ 26 | selector: 'gcd-start-pipeline-modal', 27 | imports: [ 28 | CommonModule, 29 | VariablesFormComponent, 30 | NzButtonModule, 31 | NzModalModule, 32 | NzSelectModule, 33 | NzIconModule, 34 | NzSpaceModule, 35 | NzFormModule, 36 | FormsModule 37 | ], 38 | templateUrl: './start-pipeline-modal.component.html', 39 | styleUrls: ['./start-pipeline-modal.component.scss'] 40 | }) 41 | export class StartPipelineModalComponent { 42 | private modal = inject(NzModalRef) 43 | private http = inject(HttpClient) 44 | private notification = inject(NzNotificationService) 45 | private branchService = inject(BranchService) 46 | 47 | private modalData: ModalData = this.modal.getConfig().nzData 48 | 49 | variables = signal>(Object()) 50 | 51 | branchesLoading = signal(true) 52 | branches = toSignal( 53 | this.branchService.getBranches(this.modalData.projectId).pipe( 54 | map((branches) => branches.filter(({ name }) => name !== this.defaultBranch)), 55 | finalize(() => this.branchesLoading.set(false)) 56 | ) 57 | ) 58 | 59 | selectedBranch = this.defaultBranch 60 | 61 | get defaultBranch() { 62 | return this.modalData.branch 63 | } 64 | 65 | start() { 66 | const { projectId, loadingIcon } = this.modalData 67 | loadingIcon.set(true) 68 | 69 | const variables = this.variables() 70 | 71 | this.http 72 | .post('/api/pipelines/start', { 73 | project_id: projectId, 74 | branch: this.selectedBranch, 75 | env_vars: Object.keys(variables).length > 0 ? variables : undefined 76 | }) 77 | .pipe( 78 | retry(retryConfig), 79 | finalize(() => loadingIcon.set(false)) 80 | ) 81 | .subscribe({ 82 | complete: () => { 83 | this.notification.success('Success', 'Started new pipeline.') 84 | this.close() 85 | }, 86 | error: ({ status, error }: HttpErrorResponse) => { 87 | if (status === HttpStatusCode.Forbidden) { 88 | this.notification.error( 89 | 'Forbidden', 90 | 'Failed to start new pipeline, a read/write access token is required.' 91 | ) 92 | } else { 93 | this.notification.error(`Error ${status}`, error ? error.message : 'Failed to start new pipeline') 94 | } 95 | } 96 | }) 97 | 98 | this.close() 99 | } 100 | 101 | close() { 102 | this.modal.destroy() 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/components/write-actions-icon/start-pipeline-action/start-pipeline-modal/variables-form/variables-form.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | @for (control of variables.controls; track $index) { 4 | 5 | 6 |
7 | 8 | 9 | 10 |
11 |
12 |
13 | } 14 | @if (canAddVariables) { 15 | 16 | 17 | 21 | 22 | 23 | } 24 |
25 |
26 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/components/write-actions-icon/start-pipeline-action/start-pipeline-modal/variables-form/variables-form.component.scss: -------------------------------------------------------------------------------- 1 | nz-form-item { 2 | margin: 0; 3 | } 4 | 5 | .anticon-minus-circle-o { 6 | cursor: pointer; 7 | } 8 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/components/write-actions-icon/start-pipeline-action/start-pipeline-modal/variables-form/variables-form.component.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common' 2 | import { Component, DestroyRef, inject, OnInit, output } from '@angular/core' 3 | import { takeUntilDestroyed } from '@angular/core/rxjs-interop' 4 | import { FormArray, NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms' 5 | import { NzButtonModule } from 'ng-zorro-antd/button' 6 | import { NzFormModule } from 'ng-zorro-antd/form' 7 | import { NzIconModule } from 'ng-zorro-antd/icon' 8 | import { filter } from 'rxjs' 9 | 10 | const MAX_FORM_FIELDS = 15 11 | 12 | type Variable = { key: string; value: string } 13 | 14 | @Component({ 15 | selector: 'gcd-variables-form', 16 | imports: [CommonModule, NzButtonModule, NzIconModule, NzFormModule, ReactiveFormsModule], 17 | templateUrl: './variables-form.component.html', 18 | styleUrls: ['./variables-form.component.scss'] 19 | }) 20 | export class VariablesFormComponent implements OnInit { 21 | private formBuilder = inject(NonNullableFormBuilder) 22 | private destroyRef = inject(DestroyRef) 23 | 24 | onChange = output>() 25 | 26 | formGroup = this.formBuilder.group({ 27 | [this.formArray]: this.formBuilder.array([]) 28 | }) 29 | 30 | get formArray() { 31 | return 'variables' 32 | } 33 | 34 | get variables(): FormArray { 35 | return this.formGroup.get(this.formArray) as FormArray 36 | } 37 | 38 | get canAddVariables() { 39 | return this.variables.length < MAX_FORM_FIELDS 40 | } 41 | 42 | ngOnInit(): void { 43 | this.variables.valueChanges 44 | .pipe( 45 | takeUntilDestroyed(this.destroyRef), 46 | filter(() => this.variables.valid) 47 | ) 48 | .subscribe((variables: Variable[]) => { 49 | const vars = variables.reduce((prev, { key, value }) => { 50 | return { 51 | ...prev, 52 | [key]: value 53 | } 54 | }, Object()) 55 | 56 | this.onChange.emit(vars) 57 | }) 58 | } 59 | 60 | addVariables(e: MouseEvent) { 61 | e.preventDefault() 62 | 63 | this.variables.push( 64 | this.formBuilder.group({ 65 | key: ['', Validators.required], 66 | value: ['', Validators.required] 67 | }) 68 | ) 69 | } 70 | 71 | removeVariables(index: number, e: MouseEvent) { 72 | e.preventDefault() 73 | 74 | this.variables.removeAt(index) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/components/write-actions-icon/write-actions-icon.component.html: -------------------------------------------------------------------------------- 1 | 15 | 16 |
    17 |
  • 18 | 19 |
  • 20 |
  • 21 | 22 |
  • 23 |
  • 24 | 25 |
  • 26 |
27 |
28 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/components/write-actions-icon/write-actions-icon.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/larscom/gitlab-ci-dashboard/28d2236dbbcbfb16b872527b0f6c5e5d927ad18b/src/app/groups/group-tabs/feature-tabs/components/write-actions-icon/write-actions-icon.component.scss -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/components/write-actions-icon/write-actions-icon.component.ts: -------------------------------------------------------------------------------- 1 | import { PipelineId } from '$groups/model/pipeline' 2 | import { ProjectId } from '$groups/model/project' 3 | import { ConfigService } from '$service/config.service' 4 | import { CommonModule } from '@angular/common' 5 | import { ChangeDetectionStrategy, Component, computed, HostListener, inject, input, signal } from '@angular/core' 6 | import { NzButtonModule } from 'ng-zorro-antd/button' 7 | import { NzDropDownModule } from 'ng-zorro-antd/dropdown' 8 | import { NzIconModule } from 'ng-zorro-antd/icon' 9 | import { NzToolTipModule } from 'ng-zorro-antd/tooltip' 10 | import { CancelPipelineActionComponent } from './cancel-pipeline-action/cancel-pipeline-action.component' 11 | import { RetryPipelineActionComponent } from './retry-pipeline-action/retry-pipeline-action.component' 12 | import { StartPipelineActionComponent } from './start-pipeline-action/start-pipeline-action.component' 13 | 14 | @Component({ 15 | selector: 'gcd-write-actions-icon', 16 | imports: [ 17 | CommonModule, 18 | NzButtonModule, 19 | NzIconModule, 20 | NzDropDownModule, 21 | NzToolTipModule, 22 | StartPipelineActionComponent, 23 | CancelPipelineActionComponent, 24 | RetryPipelineActionComponent 25 | ], 26 | templateUrl: './write-actions-icon.component.html', 27 | styleUrls: ['./write-actions-icon.component.scss'], 28 | changeDetection: ChangeDetectionStrategy.OnPush 29 | }) 30 | export class WriteActionsIconComponent { 31 | private config = inject(ConfigService) 32 | 33 | projectId = input.required() 34 | pipelineId = input.required() 35 | branch = input.required() 36 | 37 | visible = signal(false) 38 | 39 | readOnly = this.config.readOnly 40 | tooltipTitle = computed(() => (this.readOnly() ? 'Write actions are disabled' : null)) 41 | 42 | @HostListener('click', ['$event']) onClick(e: MouseEvent) { 43 | e.stopPropagation() 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/feature-tabs.component.html: -------------------------------------------------------------------------------- 1 | 9 | @for (tab of tabs; track tab) { 10 | 11 | 12 | {{ tab.title }} 13 | 14 | 15 |
16 | @switch (tab.id) { 17 | @case ('latest-pipelines') { 18 | 19 | } 20 | @case ('pipelines') { 21 | 22 | } 23 | @case ('schedules') { 24 | 25 | } 26 | } 27 |
28 |
29 |
30 | } 31 |
32 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/feature-tabs.component.scss: -------------------------------------------------------------------------------- 1 | .tab-content { 2 | min-height: 200px; 3 | } 4 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/feature-tabs.component.ts: -------------------------------------------------------------------------------- 1 | import { filterNotNull } from '$groups/util/filter' 2 | import { CommonModule } from '@angular/common' 3 | import { ChangeDetectionStrategy, Component, input } from '@angular/core' 4 | import { takeUntilDestroyed } from '@angular/core/rxjs-interop' 5 | import { ActivatedRoute, Router } from '@angular/router' 6 | import { NzIconModule } from 'ng-zorro-antd/icon' 7 | import { NzTabChangeEvent, NzTabsModule } from 'ng-zorro-antd/tabs' 8 | import { map } from 'rxjs' 9 | import { LatestPipelinesComponent } from './latest-pipelines/latest-pipelines.component' 10 | import { PipelinesComponent } from './pipelines/pipelines.component' 11 | import { SchedulesComponent } from './schedules/schedules.component' 12 | import { GroupId } from '$groups/model/group' 13 | import { ProjectId } from '$groups/model/project' 14 | 15 | interface Tab { 16 | id: 'latest-pipelines' | 'pipelines' | 'schedules' 17 | title: string 18 | icon: string 19 | } 20 | 21 | const tabs: Tab[] = [ 22 | { 23 | id: 'latest-pipelines', 24 | title: 'Pipelines (latest)', 25 | icon: 'to-top' 26 | }, 27 | { 28 | id: 'pipelines', 29 | title: 'Pipelines', 30 | icon: 'unordered-list' 31 | }, 32 | { 33 | id: 'schedules', 34 | title: 'Schedules', 35 | icon: 'schedule' 36 | } 37 | ] 38 | 39 | @Component({ 40 | selector: 'gcd-feature-tabs', 41 | imports: [CommonModule, NzTabsModule, NzIconModule, LatestPipelinesComponent, PipelinesComponent, SchedulesComponent], 42 | templateUrl: './feature-tabs.component.html', 43 | styleUrls: ['./feature-tabs.component.scss'], 44 | changeDetection: ChangeDetectionStrategy.OnPush 45 | }) 46 | export class FeatureTabsComponent { 47 | groupMap = input.required>>() 48 | disableRouting = input(false) 49 | 50 | tabs: Tab[] = tabs 51 | 52 | selectedIndex$ = this.route.paramMap.pipe( 53 | map((map) => map.get('featureId')), 54 | filterNotNull, 55 | map((featureId) => this.tabs.findIndex(({ id }) => id === featureId)) 56 | ) 57 | 58 | constructor( 59 | private route: ActivatedRoute, 60 | private router: Router 61 | ) { 62 | this.route.paramMap 63 | .pipe( 64 | takeUntilDestroyed(), 65 | map((map) => map.get('featureId')) 66 | ) 67 | .subscribe((featureId) => { 68 | if (!this.tabs.map(({ id }) => id).includes(featureId as Tab['id'])) { 69 | this.onChange({ index: 0, tab: null }) 70 | } 71 | }) 72 | } 73 | 74 | onChange({ index }: NzTabChangeEvent): void { 75 | if (this.disableRouting()) return 76 | 77 | const { id } = this.tabs[index!] 78 | const currentSegments = this.route.snapshot.url.map(({ path }) => path) 79 | this.router.navigate([...currentSegments.slice(0, -1), id]) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/latest-pipelines/latest-pipelines.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 9 | 15 |
16 |
17 | @if (loading()) { 18 | 19 | } @else { 20 | 25 | } 26 |
27 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/latest-pipelines/latest-pipelines.component.scss: -------------------------------------------------------------------------------- 1 | nz-spin { 2 | height: 25px; 3 | } 4 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/latest-pipelines/latest-pipelines.component.ts: -------------------------------------------------------------------------------- 1 | import { FETCH_REFRESH_INTERVAL } from '$groups/http' 2 | import { GroupId } from '$groups/model/group' 3 | import { ProjectId, ProjectPipeline } from '$groups/model/project' 4 | import { forkJoinFlatten } from '$groups/util/fork' 5 | import { CommonModule } from '@angular/common' 6 | import { ChangeDetectionStrategy, Component, DestroyRef, OnInit, computed, inject, input, signal } from '@angular/core' 7 | import { takeUntilDestroyed } from '@angular/core/rxjs-interop' 8 | import { NzSpinModule } from 'ng-zorro-antd/spin' 9 | import { finalize, interval, switchMap } from 'rxjs' 10 | import { ProjectFilterComponent } from '../components/project-filter/project-filter.component' 11 | import { TopicFilterComponent } from '../components/topic-filter/topic-filter.component' 12 | import { PipelineStatusTabsComponent } from './pipeline-status-tabs/pipeline-status-tabs.component' 13 | import { LatestPipelineService } from './service/latest-pipeline.service' 14 | 15 | @Component({ 16 | selector: 'gcd-latest-pipelines', 17 | imports: [CommonModule, NzSpinModule, PipelineStatusTabsComponent, ProjectFilterComponent, TopicFilterComponent], 18 | templateUrl: './latest-pipelines.component.html', 19 | styleUrls: ['./latest-pipelines.component.scss'], 20 | changeDetection: ChangeDetectionStrategy.OnPush 21 | }) 22 | export class LatestPipelinesComponent implements OnInit { 23 | private latestPipelineService = inject(LatestPipelineService) 24 | private destroyRef = inject(DestroyRef) 25 | 26 | groupMap = input.required>>() 27 | 28 | filterText = signal('') 29 | filterTopics = signal([]) 30 | projectPipelines = signal([]) 31 | loading = signal(false) 32 | 33 | projects = computed(() => { 34 | return this.projectPipelines() 35 | .filter(({ pipeline }) => pipeline != null) 36 | .map(({ project }) => project) 37 | }) 38 | 39 | ngOnInit(): void { 40 | this.loading.set(true) 41 | 42 | forkJoinFlatten( 43 | this.groupMap(), 44 | this.latestPipelineService.getProjectsWithLatestPipeline.bind(this.latestPipelineService) 45 | ) 46 | .pipe(finalize(() => this.loading.set(false))) 47 | .subscribe((projectPipelines) => this.projectPipelines.set(projectPipelines)) 48 | 49 | interval(FETCH_REFRESH_INTERVAL) 50 | .pipe( 51 | takeUntilDestroyed(this.destroyRef), 52 | switchMap(() => 53 | forkJoinFlatten( 54 | this.groupMap(), 55 | this.latestPipelineService.getProjectsWithLatestPipeline.bind(this.latestPipelineService) 56 | ) 57 | ) 58 | ) 59 | .subscribe((projectPipelines) => this.projectPipelines.set(projectPipelines)) 60 | } 61 | 62 | onFilterTopicsChanged(topics: string[]): void { 63 | this.filterTopics.set(topics) 64 | } 65 | 66 | onFilterTextChanged(filterText: string): void { 67 | this.filterText.set(filterText) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/latest-pipelines/pipeline-status-tabs/pipeline-status-tabs.component.html: -------------------------------------------------------------------------------- 1 | @if (tabs().length) { 2 | 3 | @for (tab of tabs(); track trackByStatus(tab)) { 4 | 5 | 6 |
7 | {{ tab.status }} 8 | 14 |
15 |
16 | 17 | 18 | 19 |
20 | } 21 |
22 | } @else { 23 | 24 | } 25 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/latest-pipelines/pipeline-status-tabs/pipeline-status-tabs.component.scss: -------------------------------------------------------------------------------- 1 | .status { 2 | text-transform: capitalize; 3 | } 4 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/latest-pipelines/pipeline-status-tabs/pipeline-status-tabs.component.ts: -------------------------------------------------------------------------------- 1 | import { StatusColorPipe } from '$groups/group-tabs/feature-tabs/pipes/status-color.pipe' 2 | import { ProjectPipeline } from '$groups/model/project' 3 | import { Status } from '$groups/model/status' 4 | import { filterProject } from '$groups/util/filter' 5 | import { CommonModule } from '@angular/common' 6 | import { ChangeDetectionStrategy, Component, Signal, computed, input } from '@angular/core' 7 | import { NzBadgeModule } from 'ng-zorro-antd/badge' 8 | import { NzEmptyModule } from 'ng-zorro-antd/empty' 9 | import { NzTabsModule } from 'ng-zorro-antd/tabs' 10 | import { PipelineTableComponent } from './pipeline-table/pipeline-table.component' 11 | 12 | interface Tab { 13 | status: Status 14 | projects: ProjectPipeline[] 15 | } 16 | 17 | @Component({ 18 | selector: 'gcd-pipeline-status-tabs', 19 | imports: [CommonModule, NzTabsModule, NzBadgeModule, NzEmptyModule, PipelineTableComponent, StatusColorPipe], 20 | templateUrl: './pipeline-status-tabs.component.html', 21 | styleUrls: ['./pipeline-status-tabs.component.scss'], 22 | changeDetection: ChangeDetectionStrategy.OnPush 23 | }) 24 | export class PipelineStatusTabsComponent { 25 | projectPipelines = input.required() 26 | filterText = input.required() 27 | filterTopics = input.required() 28 | 29 | tabs: Signal = computed(() => { 30 | return Array.from( 31 | this.projectPipelines() 32 | .filter(({ project }) => filterProject(project, this.filterText(), this.filterTopics())) 33 | .filter(({ pipeline }) => pipeline != null) 34 | .reduce((current, { group_id, pipeline, project }) => { 35 | const { status } = pipeline! 36 | const projects = current.get(status) 37 | const next: ProjectPipeline = { group_id, project, pipeline: pipeline! } 38 | return projects ? current.set(status, [...projects, next]) : current.set(status, [next]) 39 | }, new Map()) 40 | ) 41 | .map(([status, projects]) => ({ status, projects })) 42 | .sort((a, b) => a.status.localeCompare(b.status)) 43 | }) 44 | 45 | trackByStatus({ status }: Tab): Status { 46 | return status 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/latest-pipelines/pipeline-status-tabs/pipeline-table/pipeline-table-branch/latest-branch-filter/latest-branch-filter.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 9 | 10 | 11 | 20 | 21 |
22 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/latest-pipelines/pipeline-status-tabs/pipeline-table/pipeline-table-branch/latest-branch-filter/latest-branch-filter.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/larscom/gitlab-ci-dashboard/28d2236dbbcbfb16b872527b0f6c5e5d927ad18b/src/app/groups/group-tabs/feature-tabs/latest-pipelines/pipeline-status-tabs/pipeline-table/pipeline-table-branch/latest-branch-filter/latest-branch-filter.component.scss -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/latest-pipelines/pipeline-status-tabs/pipeline-table/pipeline-table-branch/latest-branch-filter/latest-branch-filter.component.ts: -------------------------------------------------------------------------------- 1 | import { Branch } from '$groups/model/branch' 2 | import { ChangeDetectionStrategy, Component, input, model } from '@angular/core' 3 | import { FormsModule } from '@angular/forms' 4 | import { NzButtonModule } from 'ng-zorro-antd/button' 5 | import { NzIconModule } from 'ng-zorro-antd/icon' 6 | import { NzInputModule } from 'ng-zorro-antd/input' 7 | import { NzToolTipModule } from 'ng-zorro-antd/tooltip' 8 | 9 | @Component({ 10 | selector: 'gcd-latest-branch-filter', 11 | imports: [NzIconModule, NzInputModule, NzButtonModule, NzToolTipModule, FormsModule], 12 | templateUrl: './latest-branch-filter.component.html', 13 | styleUrls: ['./latest-branch-filter.component.scss'], 14 | changeDetection: ChangeDetectionStrategy.OnPush 15 | }) 16 | export class LatestBranchFilterComponent { 17 | branchCount = input.required() 18 | filterText = model.required() 19 | } 20 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/latest-pipelines/pipeline-status-tabs/pipeline-table/pipeline-table-branch/pipeline-table-branch.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 8 |
9 | 17 | 18 | 19 | @for (header of headers; track header.title) { 20 | 21 | {{ header.title }} 22 | 23 | } 24 | Jobs 25 | Action 26 | 27 | 28 | 29 | @for (data of nzTable.data; track trackByBranchName(data)) { 30 | 31 | {{ data.branch.name }} 32 | 33 | @if (data.pipeline; as pipeline) { 34 | 35 | } @else { - } 36 | 37 | {{ data.pipeline?.source || '-' }} 38 | 39 | @if (data.pipeline; as pipeline) { 40 | {{ pipeline.updated_at | date : 'medium' : timeZone : locale }} 41 | } @else { - } 42 | 43 | 44 | @if (data.pipeline; as pipeline) { 45 | 46 | } @else { - } 47 | 48 | 49 | @if (data.pipeline; as pipeline) { @if (showWriteActions()) { 50 | 55 | } 56 | 57 | 58 | } @else { - } 59 | 60 | 61 | } 62 | 63 | 64 |
65 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/latest-pipelines/pipeline-status-tabs/pipeline-table/pipeline-table-branch/pipeline-table-branch.component.scss: -------------------------------------------------------------------------------- 1 | th { 2 | font-weight: bold !important; 3 | } 4 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/latest-pipelines/pipeline-status-tabs/pipeline-table/pipeline-table-branch/pipeline-table-branch.component.ts: -------------------------------------------------------------------------------- 1 | import { DownloadArtifactsIconComponent } from '$groups/group-tabs/feature-tabs/components/download-artifacts-icon/download-artifacts-icon.component' 2 | import { JobsComponent } from '$groups/group-tabs/feature-tabs/components/jobs/jobs.component' 3 | import { OpenGitlabIconComponent } from '$groups/group-tabs/feature-tabs/components/open-gitlab-icon/open-gitlab-icon.component' 4 | import { WriteActionsIconComponent } from '$groups/group-tabs/feature-tabs/components/write-actions-icon/write-actions-icon.component' 5 | import { StatusColorPipe } from '$groups/group-tabs/feature-tabs/pipes/status-color.pipe' 6 | import { BranchPipeline } from '$groups/model/branch' 7 | import { Status } from '$groups/model/status' 8 | import { compareString, compareStringDate } from '$groups/util/compare' 9 | import { filterString } from '$groups/util/filter' 10 | import { statusToScope } from '$groups/util/status-scope' 11 | import { Header } from '$groups/util/table' 12 | import { ConfigService } from '$service/config.service' 13 | import { CommonModule } from '@angular/common' 14 | import { ChangeDetectionStrategy, Component, computed, inject, input, Signal, signal } from '@angular/core' 15 | import { NzBadgeModule } from 'ng-zorro-antd/badge' 16 | import { NzButtonModule } from 'ng-zorro-antd/button' 17 | import { NzI18nService } from 'ng-zorro-antd/i18n' 18 | import { NzIconModule } from 'ng-zorro-antd/icon' 19 | import { NzTableModule } from 'ng-zorro-antd/table' 20 | import { NzToolTipModule } from 'ng-zorro-antd/tooltip' 21 | import { LatestBranchFilterComponent } from './latest-branch-filter/latest-branch-filter.component' 22 | 23 | const headers: Header[] = [ 24 | { title: 'Branch', sortable: true, compare: (a, b) => compareString(a.branch.name, b.branch.name) }, 25 | { 26 | title: 'Status', 27 | sortable: true, 28 | compare: (a, b) => compareString(a.pipeline?.status, b.pipeline?.status) 29 | }, 30 | { 31 | title: 'Trigger', 32 | sortable: true, 33 | compare: (a, b) => compareString(a.pipeline?.source, b.pipeline?.source) 34 | }, 35 | { 36 | title: 'Last Run', 37 | sortable: true, 38 | compare: (a, b) => compareStringDate(a.pipeline?.updated_at, b.pipeline?.updated_at) 39 | } 40 | ] 41 | 42 | @Component({ 43 | selector: 'gcd-pipeline-table-branch', 44 | imports: [ 45 | CommonModule, 46 | LatestBranchFilterComponent, 47 | JobsComponent, 48 | StatusColorPipe, 49 | NzTableModule, 50 | NzToolTipModule, 51 | NzButtonModule, 52 | NzIconModule, 53 | NzBadgeModule, 54 | WriteActionsIconComponent, 55 | OpenGitlabIconComponent, 56 | DownloadArtifactsIconComponent 57 | ], 58 | templateUrl: './pipeline-table-branch.component.html', 59 | styleUrls: ['./pipeline-table-branch.component.scss'], 60 | changeDetection: ChangeDetectionStrategy.OnPush 61 | }) 62 | export class PipelineTableBranchComponent { 63 | private i18n = inject(NzI18nService) 64 | private config = inject(ConfigService) 65 | 66 | branchPipelines = input.required() 67 | loading = input.required() 68 | 69 | filterText = signal('') 70 | 71 | filteredBranches = computed(() => 72 | this.branchPipelines().filter(({ branch: { name } }) => filterString(name, this.filterText())) 73 | ) 74 | branchCount = computed(() => this.branchPipelines().length) 75 | 76 | headers: Header[] = headers 77 | 78 | get showWriteActions(): Signal { 79 | return computed(() => !this.config.hideWriteActions()) 80 | } 81 | 82 | get locale(): string { 83 | const { locale } = this.i18n.getLocale() 84 | return locale 85 | } 86 | 87 | get timeZone(): string { 88 | const { timeZone } = Intl.DateTimeFormat().resolvedOptions() 89 | return timeZone 90 | } 91 | 92 | onFilterTextChanged(filterText: string) { 93 | this.filterText.set(filterText) 94 | } 95 | 96 | getScope(status?: Status): Status[] { 97 | return statusToScope(status) 98 | } 99 | 100 | trackByBranchName({ branch: { name } }: BranchPipeline): string { 101 | return name 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/latest-pipelines/pipeline-status-tabs/pipeline-table/pipeline-table.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | @for (header of headers; track header.title) { 5 | 6 | {{ header.title }} 7 | 8 | } 9 | Jobs 10 | Action 11 | 12 | 13 | 14 | @for (data of nzTable.data; track trackByProjectId(data)) { 15 | 21 | {{ data.project.name }} 22 | {{ data.project.namespace.name }} 23 | {{ data.project.default_branch }} 24 | {{ data.project.topics.length ? data.project.topics.join(',') : '-' }} 25 | {{ data.pipeline?.source || '-' }} 26 | 27 | @if (data.pipeline; as pipeline) { 28 | {{ pipeline.updated_at | date: 'medium' : timeZone : locale }} 29 | } @else { 30 | - 31 | } 32 | 33 | 34 | @if (data.pipeline; as pipeline) { 35 | 36 | } @else { 37 | - 38 | } 39 | 40 | 41 | @if (data.pipeline; as pipeline) { 42 | @if (showWriteActions()) { 43 | 48 | } 49 | 50 | 51 | 52 | 53 | 54 | 55 | } @else { 56 | - 57 | } 58 | 59 | 60 | 61 | @if (data.project.id === selectedProjectId()) { 62 | 63 | } 64 | 65 | } 66 | 67 | 68 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/latest-pipelines/pipeline-status-tabs/pipeline-table/pipeline-table.component.scss: -------------------------------------------------------------------------------- 1 | th { 2 | font-weight: bold !important; 3 | } 4 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/latest-pipelines/service/latest-pipeline.service.ts: -------------------------------------------------------------------------------- 1 | import { createParams, retryConfig } from '$groups/http' 2 | import { BranchPipeline } from '$groups/model/branch' 3 | import { GroupId } from '$groups/model/group' 4 | import { ProjectId, ProjectPipeline } from '$groups/model/project' 5 | import { ErrorService } from '$service/error.service' 6 | import { HttpClient, HttpErrorResponse } from '@angular/common/http' 7 | import { Injectable, inject } from '@angular/core' 8 | import { Observable, catchError, map, of, retry } from 'rxjs' 9 | 10 | @Injectable({ providedIn: 'root' }) 11 | export class LatestPipelineService { 12 | private http = inject(HttpClient) 13 | private errorService = inject(ErrorService) 14 | 15 | getProjectsWithLatestPipeline(groupId: GroupId, projectIds?: Set): Observable { 16 | const url = '/api/projects/latest-pipelines' 17 | const params = createParams(groupId, projectIds) 18 | 19 | return this.http.get(url, { params }).pipe( 20 | retry(retryConfig), 21 | catchError(({ status, error }: HttpErrorResponse) => { 22 | this.errorService.setError({ 23 | message: error.message, 24 | statusCode: status, 25 | groupId 26 | }) 27 | return of([]) 28 | }) 29 | ) 30 | } 31 | 32 | getBranchesWithLatestPipeline(projectId: ProjectId): Observable { 33 | const url = '/api/branches/latest-pipelines' 34 | const params = { project_id: projectId } 35 | 36 | return this.http.get(url, { params }).pipe( 37 | map((branches) => branches.filter(({ branch }) => !branch.default)), 38 | retry(retryConfig), 39 | catchError(({ status, error }: HttpErrorResponse) => { 40 | this.errorService.setError({ 41 | statusCode: status, 42 | message: error.message 43 | }) 44 | return of([]) 45 | }) 46 | ) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/pipelines/components/branch-filter/branch-filter.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 5 | 11 | 12 | 13 | 22 | 23 |
24 |
25 |
26 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/pipelines/components/branch-filter/branch-filter.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/larscom/gitlab-ci-dashboard/28d2236dbbcbfb16b872527b0f6c5e5d927ad18b/src/app/groups/group-tabs/feature-tabs/pipelines/components/branch-filter/branch-filter.component.scss -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/pipelines/components/branch-filter/branch-filter.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, computed, input, model } from '@angular/core' 2 | 3 | import { FormsModule } from '@angular/forms' 4 | import { NzButtonModule } from 'ng-zorro-antd/button' 5 | import { NzIconModule } from 'ng-zorro-antd/icon' 6 | import { NzInputModule } from 'ng-zorro-antd/input' 7 | import { NzSpinModule } from 'ng-zorro-antd/spin' 8 | import { NzToolTipModule } from 'ng-zorro-antd/tooltip' 9 | 10 | @Component({ 11 | selector: 'gcd-branch-filter', 12 | imports: [NzIconModule, NzInputModule, NzButtonModule, NzToolTipModule, NzSpinModule, FormsModule], 13 | templateUrl: './branch-filter.component.html', 14 | styleUrls: ['./branch-filter.component.scss'], 15 | changeDetection: ChangeDetectionStrategy.OnPush 16 | }) 17 | export class BranchFilterComponent { 18 | branches = input.required() 19 | 20 | filterText = model.required() 21 | 22 | branchCount = computed(() => new Set(this.branches()).size) 23 | } 24 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/pipelines/components/status-filter/status-filter.component.html: -------------------------------------------------------------------------------- 1 |
2 | Filter by status 3 | @for (status of statuses; track status) { 4 | 10 | {{ status }} 11 | 12 | } 13 |
14 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/pipelines/components/status-filter/status-filter.component.scss: -------------------------------------------------------------------------------- 1 | :host, 2 | nz-select { 3 | width: 100%; 4 | } 5 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/pipelines/components/status-filter/status-filter.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, model } from '@angular/core' 2 | 3 | import { StatusColorPipe } from '$groups/group-tabs/feature-tabs/pipes/status-color.pipe' 4 | import { Status } from '$groups/model/status' 5 | import { FormsModule } from '@angular/forms' 6 | import { NzSelectModule } from 'ng-zorro-antd/select' 7 | import { NzTagModule } from 'ng-zorro-antd/tag' 8 | 9 | @Component({ 10 | selector: 'gcd-status-filter', 11 | imports: [FormsModule, NzTagModule, NzSelectModule, StatusColorPipe], 12 | templateUrl: './status-filter.component.html', 13 | styleUrls: ['./status-filter.component.scss'], 14 | changeDetection: ChangeDetectionStrategy.OnPush 15 | }) 16 | export class StatusFilterComponent { 17 | filterStatuses = model.required() 18 | 19 | statuses = Object.values(Status) 20 | .filter((s) => s !== Status.FAILED_ALLOW_FAILURE) 21 | .sort() 22 | 23 | onChange(checked: boolean, status: Status): void { 24 | const selected = this.filterStatuses() 25 | if (checked) { 26 | this.filterStatuses.set([...selected, status]) 27 | } else { 28 | this.filterStatuses.set(selected.filter((s) => s !== status)) 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/pipelines/pipeline-table/pipeline-table.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | @for (header of headers; track header.title) { 5 | 6 | {{ header.title }} 7 | 8 | } 9 | Jobs 10 | Action 11 | 12 | 13 | 14 | @for (data of nzTable.data; track trackBy($index, data)) { 15 | 20 | {{ data.project.name }} 21 | {{ data.project.namespace.name }} 22 | 23 | @let ref = data.pipeline?.ref || ''; 24 | @if (isTag(ref)) { 25 | 26 | {{ ref }} 27 | 28 | } @else { 29 | {{ ref }} 30 | } 31 | 32 | {{ data.pipeline?.source || '' }} 33 | 34 | @if (data.pipeline; as pipeline) { 35 | {{ pipeline.updated_at | date: 'medium' : timeZone : locale }} 36 | } @else { 37 | - 38 | } 39 | 40 | 41 | @if (data.pipeline; as pipeline) { 42 | 43 | } @else { 44 | - 45 | } 46 | 47 | 48 | @if (data.pipeline; as pipeline) { 49 | 50 | } @else { 51 | - 52 | } 53 | 54 | 55 | @if (data.pipeline; as pipeline) { 56 | @if (showWriteActions()) { 57 | 62 | } 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 83 | } @else { 84 | - 85 | } 86 | 87 | 88 | } 89 | 90 | 91 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/pipelines/pipeline-table/pipeline-table.component.scss: -------------------------------------------------------------------------------- 1 | th { 2 | font-weight: bold !important; 3 | } 4 | 5 | .blue-bg { 6 | background-color: #f0f8ff; 7 | } 8 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/pipelines/pipeline-table/pipeline-table.component.ts: -------------------------------------------------------------------------------- 1 | import { FavoritesIconComponent } from '$groups/group-tabs/favorites/favorites-icon/favorites-icon.component' 2 | import { Pipeline, PipelineId } from '$groups/model/pipeline' 3 | import { ProjectPipeline } from '$groups/model/project' 4 | import { Status } from '$groups/model/status' 5 | import { compareString, compareStringDate } from '$groups/util/compare' 6 | import { statusToScope } from '$groups/util/status-scope' 7 | import { Header } from '$groups/util/table' 8 | import { ConfigService } from '$service/config.service' 9 | import { CommonModule } from '@angular/common' 10 | import { ChangeDetectionStrategy, Component, computed, inject, input, model, Signal } from '@angular/core' 11 | import { NzBadgeModule } from 'ng-zorro-antd/badge' 12 | import { NzButtonModule } from 'ng-zorro-antd/button' 13 | import { NzI18nService } from 'ng-zorro-antd/i18n' 14 | import { NzIconModule } from 'ng-zorro-antd/icon' 15 | import { NzSpinModule } from 'ng-zorro-antd/spin' 16 | import { NzTableModule } from 'ng-zorro-antd/table' 17 | import { NzTagModule } from 'ng-zorro-antd/tag' 18 | import { NzToolTipModule } from 'ng-zorro-antd/tooltip' 19 | import { DownloadArtifactsIconComponent } from '../../components/download-artifacts-icon/download-artifacts-icon.component' 20 | import { JobsComponent } from '../../components/jobs/jobs.component' 21 | import { OpenGitlabIconComponent } from '../../components/open-gitlab-icon/open-gitlab-icon.component' 22 | import { WriteActionsIconComponent } from '../../components/write-actions-icon/write-actions-icon.component' 23 | import { StatusColorPipe } from '../../pipes/status-color.pipe' 24 | 25 | const headers: Header[] = [ 26 | { title: 'Project', sortable: true, compare: (a, b) => compareString(a.project.name, b.project.name) }, 27 | { 28 | title: 'Group', 29 | sortable: true, 30 | compare: (a, b) => compareString(a.project.namespace.name, b.project.namespace.name) 31 | }, 32 | { 33 | title: 'Branch', 34 | sortable: true, 35 | compare: (a, b) => compareString(a.project.default_branch, b.project.default_branch) 36 | }, 37 | { 38 | title: 'Trigger', 39 | sortable: true, 40 | compare: (a, b) => compareString(a.pipeline?.source, b.pipeline?.source) 41 | }, 42 | { 43 | title: 'Last Run', 44 | sortable: true, 45 | compare: (a, b) => compareStringDate(a.pipeline?.updated_at, b.pipeline?.updated_at) 46 | }, 47 | { 48 | title: 'Status', 49 | sortable: true, 50 | compare: (a, b) => compareString(a.pipeline?.status, b.pipeline?.status) 51 | } 52 | ] 53 | 54 | const semverRegex = 55 | /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/ 56 | 57 | @Component({ 58 | selector: 'gcd-pipeline-table', 59 | imports: [ 60 | CommonModule, 61 | NzTableModule, 62 | NzToolTipModule, 63 | NzButtonModule, 64 | NzBadgeModule, 65 | NzIconModule, 66 | NzSpinModule, 67 | NzTagModule, 68 | StatusColorPipe, 69 | JobsComponent, 70 | FavoritesIconComponent, 71 | DownloadArtifactsIconComponent, 72 | WriteActionsIconComponent, 73 | OpenGitlabIconComponent 74 | ], 75 | templateUrl: './pipeline-table.component.html', 76 | styleUrls: ['./pipeline-table.component.scss'], 77 | changeDetection: ChangeDetectionStrategy.OnPush 78 | }) 79 | export class PipelineTableComponent { 80 | private i18n = inject(NzI18nService) 81 | private config = inject(ConfigService) 82 | 83 | projectPipelines = input.required() 84 | pinnedPipelines = model.required() 85 | 86 | headers: Header[] = headers 87 | 88 | get showWriteActions(): Signal { 89 | return computed(() => !this.config.hideWriteActions()) 90 | } 91 | 92 | get locale(): string { 93 | const { locale } = this.i18n.getLocale() 94 | return locale 95 | } 96 | 97 | get timeZone(): string { 98 | const { timeZone } = Intl.DateTimeFormat().resolvedOptions() 99 | return timeZone 100 | } 101 | 102 | isPinned(id?: PipelineId): boolean { 103 | return id ? this.pinnedPipelines().includes(id) : false 104 | } 105 | 106 | isTag(ref: string): boolean { 107 | return semverRegex.test(ref) 108 | } 109 | 110 | getScope(status?: Status): Status[] { 111 | return statusToScope(status) 112 | } 113 | 114 | onPinClick(e: Event, { id }: Pipeline): void { 115 | e.stopPropagation() 116 | 117 | const selected = this.pinnedPipelines() 118 | if (selected.includes(id)) { 119 | this.pinnedPipelines.set(selected.filter((i) => i !== id)) 120 | } else { 121 | this.pinnedPipelines.set([...selected, id]) 122 | } 123 | } 124 | 125 | trackBy(index: number, { pipeline }: ProjectPipeline): PipelineId | number { 126 | return pipeline?.id || index 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/pipelines/pipelines.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 | 10 | 15 |
16 | 22 | 23 |
24 |
25 | @if (loading()) { 26 | 27 | } @else { 28 | 33 | } 34 |
35 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/pipelines/pipelines.component.scss: -------------------------------------------------------------------------------- 1 | nz-spin { 2 | height: 25px; 3 | } 4 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/pipelines/service/pipelines.service.ts: -------------------------------------------------------------------------------- 1 | import { createParams, retryConfig } from '$groups/http' 2 | import { GroupId } from '$groups/model/group' 3 | import { Pipeline, Source } from '$groups/model/pipeline' 4 | import { ProjectId, ProjectPipelines } from '$groups/model/project' 5 | import { ErrorService } from '$service/error.service' 6 | import { HttpClient, HttpErrorResponse } from '@angular/common/http' 7 | import { Injectable, inject } from '@angular/core' 8 | import { Observable, catchError, of, retry } from 'rxjs' 9 | 10 | @Injectable({ providedIn: 'root' }) 11 | export class PipelinesService { 12 | private http = inject(HttpClient) 13 | private errorService = inject(ErrorService) 14 | 15 | getProjectsWithPipelines(groupId: GroupId, projectIds?: Set): Observable { 16 | const url = '/api/projects/pipelines' 17 | const params = createParams(groupId, projectIds) 18 | 19 | return this.http.get(url, { params }).pipe( 20 | retry(retryConfig), 21 | catchError(({ status, error }: HttpErrorResponse) => { 22 | this.errorService.setError({ 23 | message: error.message, 24 | statusCode: status, 25 | groupId 26 | }) 27 | return of([]) 28 | }) 29 | ) 30 | } 31 | 32 | getPipelines(projectId: ProjectId, source?: Source): Observable { 33 | const url = '/api/pipelines' 34 | const params = { project_id: projectId } 35 | 36 | return this.http.get(url, { params: source ? { ...params, source } : params }).pipe( 37 | retry(retryConfig), 38 | catchError(({ status, error }: HttpErrorResponse) => { 39 | this.errorService.setError({ 40 | message: error.message, 41 | statusCode: status 42 | }) 43 | return of([]) 44 | }) 45 | ) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/pipes/max-length.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from '@angular/core' 2 | 3 | @Pipe({ 4 | name: 'maxLength', 5 | standalone: true 6 | }) 7 | export class MaxLengthPipe implements PipeTransform { 8 | transform(value: string, maxLength: number = 25): string { 9 | const suffix = value.length > maxLength ? '...' : '' 10 | return `${value.slice(0, maxLength)}${suffix}` 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/pipes/status-color.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Status } from '$groups/model/status' 2 | import { statusToColor } from '$groups/util/status-color' 3 | import { Pipe, PipeTransform } from '@angular/core' 4 | 5 | @Pipe({ 6 | name: 'statusColor', 7 | standalone: true 8 | }) 9 | export class StatusColorPipe implements PipeTransform { 10 | transform(status?: Status | string): string { 11 | return statusToColor(status) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/schedules/schedule-table/pipes/next-run-at.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from '@angular/core' 2 | 3 | @Pipe({ 4 | name: 'nextRunAt', 5 | standalone: true 6 | }) 7 | export class NextRunAtPipe implements PipeTransform { 8 | transform(dateTime: string): string { 9 | const now = Date.now() 10 | const next = new Date(dateTime).getTime() 11 | const diffHours = Math.round((next - now) / 3600000) 12 | 13 | return diffHours > 0 ? `in ${diffHours} hours` : '< 1 hour' 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/schedules/schedule-table/schedule-pipeline-table/schedule-pipeline-table.component.html: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | @for (header of headers; track header.title) { 12 | 13 | {{ header.title }} 14 | 15 | } 16 | Jobs 17 | Action 18 | 19 | 20 | 21 | @for (pipeline of nzTable.data; track trackById(pipeline)) { 22 | 23 | {{ pipeline.ref || '' }} 24 | {{ pipeline.source || '' }} 25 | {{ pipeline.updated_at | date: 'medium' : timeZone : locale }} 26 | 27 | 28 | 29 | 30 | 31 | @if (showWriteActions()) { 32 | 37 | } 38 | 39 | 40 | 41 | 42 | } 43 | 44 | 45 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/schedules/schedule-table/schedule-pipeline-table/schedule-pipeline-table.component.scss: -------------------------------------------------------------------------------- 1 | th { 2 | font-weight: bold !important; 3 | } 4 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/schedules/schedule-table/schedule-pipeline-table/schedule-pipeline-table.component.ts: -------------------------------------------------------------------------------- 1 | import { DownloadArtifactsIconComponent } from '$groups/group-tabs/feature-tabs/components/download-artifacts-icon/download-artifacts-icon.component' 2 | import { JobsComponent } from '$groups/group-tabs/feature-tabs/components/jobs/jobs.component' 3 | import { OpenGitlabIconComponent } from '$groups/group-tabs/feature-tabs/components/open-gitlab-icon/open-gitlab-icon.component' 4 | import { WriteActionsIconComponent } from '$groups/group-tabs/feature-tabs/components/write-actions-icon/write-actions-icon.component' 5 | import { StatusColorPipe } from '$groups/group-tabs/feature-tabs/pipes/status-color.pipe' 6 | import { Pipeline, PipelineId } from '$groups/model/pipeline' 7 | import { Status } from '$groups/model/status' 8 | import { compareString, compareStringDate } from '$groups/util/compare' 9 | import { statusToScope } from '$groups/util/status-scope' 10 | import { Header } from '$groups/util/table' 11 | import { ConfigService } from '$service/config.service' 12 | import { CommonModule } from '@angular/common' 13 | import { ChangeDetectionStrategy, Component, computed, inject, input, Signal } from '@angular/core' 14 | import { NzBadgeModule } from 'ng-zorro-antd/badge' 15 | import { NzButtonModule } from 'ng-zorro-antd/button' 16 | import { NzI18nService } from 'ng-zorro-antd/i18n' 17 | import { NzIconModule } from 'ng-zorro-antd/icon' 18 | import { NzTableModule } from 'ng-zorro-antd/table' 19 | import { NzToolTipModule } from 'ng-zorro-antd/tooltip' 20 | 21 | const headers: Header[] = [ 22 | { 23 | title: 'Branch', 24 | sortable: true, 25 | compare: (a, b) => compareString(a.ref, b.ref) 26 | }, 27 | { 28 | title: 'Trigger', 29 | sortable: true, 30 | compare: (a, b) => compareString(a.source, b.source) 31 | }, 32 | { 33 | title: 'Last Run', 34 | sortable: true, 35 | compare: (a, b) => compareStringDate(a.updated_at, b.updated_at) 36 | }, 37 | { 38 | title: 'Status', 39 | sortable: true, 40 | compare: (a, b) => compareString(a.status, b.status) 41 | } 42 | ] 43 | 44 | @Component({ 45 | selector: 'gcd-schedule-pipeline-table', 46 | imports: [ 47 | CommonModule, 48 | NzTableModule, 49 | NzToolTipModule, 50 | NzButtonModule, 51 | NzIconModule, 52 | NzBadgeModule, 53 | StatusColorPipe, 54 | JobsComponent, 55 | WriteActionsIconComponent, 56 | DownloadArtifactsIconComponent, 57 | OpenGitlabIconComponent 58 | ], 59 | templateUrl: './schedule-pipeline-table.component.html', 60 | styleUrls: ['./schedule-pipeline-table.component.scss'], 61 | changeDetection: ChangeDetectionStrategy.OnPush 62 | }) 63 | export class SchedulePipelineTableComponent { 64 | private i18n = inject(NzI18nService) 65 | private config = inject(ConfigService) 66 | 67 | pipelines = input.required() 68 | loading = input.required() 69 | 70 | headers: Header[] = headers 71 | 72 | get showWriteActions(): Signal { 73 | return computed(() => !this.config.hideWriteActions()) 74 | } 75 | 76 | get locale(): string { 77 | const { locale } = this.i18n.getLocale() 78 | return locale 79 | } 80 | 81 | get timeZone(): string { 82 | const { timeZone } = Intl.DateTimeFormat().resolvedOptions() 83 | return timeZone 84 | } 85 | 86 | getScope(status?: Status): Status[] { 87 | return statusToScope(status) 88 | } 89 | 90 | trackById({ id }: Pipeline): PipelineId { 91 | return id 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/schedules/schedule-table/schedule-table.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | @for (header of headers; track header.title) { 5 | 6 | {{ header.title }} 7 | 8 | } 9 | Jobs 10 | Action 11 | 12 | 13 | 14 | @for (data of nzTable.data; track trackByScheduleId(data)) { 15 | 24 | {{ data.project.name }} 25 | {{ data.project.namespace.name }} 26 | {{ data.schedule.description }} 27 | 28 | {{ data.schedule.ref }} 33 | 34 | 35 | 36 | {{ data.pipeline?.source || '' }} 37 | 38 | 39 | 40 | {{ data.pipeline?.updated_at | date: 'medium' : timeZone : locale }} 41 | 42 | 43 | {{ 44 | data.schedule.next_run_at | nextRunAt 45 | }} 46 | 47 | 48 | @if (data.pipeline; as pipeline) { 49 | 55 | } @else { 56 | - 57 | } 58 | 59 | 60 | @if (data.pipeline; as pipeline) { 61 | 62 | } @else { 63 | - 64 | } 65 | 66 | 67 | @if (data.pipeline; as pipeline) { 68 | @if (showWriteActions()) { 69 | 74 | } 75 | 76 | 77 | 78 | 79 | 80 | 81 | } @else { 82 | 85 | 86 | 87 | } 88 | 89 | 97 | 98 | 99 | 100 | @if (data.project.id === selectedProjectId()) { 101 | 102 | } 103 | 104 | } 105 | 106 | 107 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/schedules/schedule-table/schedule-table.component.scss: -------------------------------------------------------------------------------- 1 | th { 2 | font-weight: bold !important; 3 | } 4 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/schedules/schedules.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 9 | 15 | 16 |
17 |
18 | @if (loading()) { 19 | 20 | } @else { 21 | 22 | } 23 |
24 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/schedules/schedules.component.scss: -------------------------------------------------------------------------------- 1 | nz-spin { 2 | height: 25px; 3 | } 4 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/schedules/schedules.component.ts: -------------------------------------------------------------------------------- 1 | import { FETCH_REFRESH_INTERVAL } from '$groups/http' 2 | import { GroupId } from '$groups/model/group' 3 | import { ProjectId } from '$groups/model/project' 4 | import { ScheduleProjectPipeline } from '$groups/model/schedule' 5 | import { Status } from '$groups/model/status' 6 | import { filterPipeline, filterProject } from '$groups/util/filter' 7 | import { forkJoinFlatten } from '$groups/util/fork' 8 | import { CommonModule } from '@angular/common' 9 | import { ChangeDetectionStrategy, Component, DestroyRef, OnInit, computed, inject, input, signal } from '@angular/core' 10 | import { takeUntilDestroyed } from '@angular/core/rxjs-interop' 11 | import { NzSpinModule } from 'ng-zorro-antd/spin' 12 | import { finalize, interval, switchMap } from 'rxjs' 13 | import { ProjectFilterComponent } from '../components/project-filter/project-filter.component' 14 | import { TopicFilterComponent } from '../components/topic-filter/topic-filter.component' 15 | import { StatusFilterComponent } from '../pipelines/components/status-filter/status-filter.component' 16 | import { ScheduleTableComponent } from './schedule-table/schedule-table.component' 17 | import { ScheduleService } from './service/schedule.service' 18 | 19 | @Component({ 20 | selector: 'gcd-schedules', 21 | imports: [ 22 | CommonModule, 23 | NzSpinModule, 24 | ScheduleTableComponent, 25 | ProjectFilterComponent, 26 | TopicFilterComponent, 27 | StatusFilterComponent 28 | ], 29 | templateUrl: './schedules.component.html', 30 | styleUrls: ['./schedules.component.scss'], 31 | changeDetection: ChangeDetectionStrategy.OnPush 32 | }) 33 | export class SchedulesComponent implements OnInit { 34 | private scheduleService = inject(ScheduleService) 35 | private destroyRef = inject(DestroyRef) 36 | 37 | groupMap = input.required>>() 38 | 39 | filterText = signal('') 40 | filterTopics = signal([]) 41 | filterStatuses = signal([]) 42 | 43 | schedulePipelines = signal([]) 44 | loading = signal(false) 45 | 46 | filteredSchedulePipelines = computed(() => { 47 | return this.schedulePipelines().filter(({ project, pipeline }) => { 48 | const filter = filterProject(project, this.filterText(), this.filterTopics()) 49 | if (pipeline) { 50 | return filter && filterPipeline(pipeline, '', this.filterStatuses()) 51 | } 52 | return filter 53 | }) 54 | }) 55 | 56 | projects = computed(() => { 57 | const schedules = this.schedulePipelines() 58 | return schedules.map(({ project }) => project) 59 | }) 60 | 61 | ngOnInit(): void { 62 | this.loading.set(true) 63 | 64 | forkJoinFlatten(this.groupMap(), this.scheduleService.getSchedules.bind(this.scheduleService)) 65 | .pipe(finalize(() => this.loading.set(false))) 66 | .subscribe((schedulePipelines) => this.schedulePipelines.set(schedulePipelines)) 67 | 68 | interval(FETCH_REFRESH_INTERVAL) 69 | .pipe( 70 | takeUntilDestroyed(this.destroyRef), 71 | switchMap(() => forkJoinFlatten(this.groupMap(), this.scheduleService.getSchedules.bind(this.scheduleService))) 72 | ) 73 | .subscribe((schedulePipelines) => this.schedulePipelines.set(schedulePipelines)) 74 | } 75 | 76 | onFilterTopicsChanged(topics: string[]) { 77 | this.filterTopics.set(topics) 78 | } 79 | 80 | onFilterTextChanged(filterText: string) { 81 | this.filterText.set(filterText) 82 | } 83 | 84 | onFilterStatusesChanged(statuses: Status[]) { 85 | this.filterStatuses.set(statuses) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/feature-tabs/schedules/service/schedule.service.ts: -------------------------------------------------------------------------------- 1 | import { createParams, retryConfig } from '$groups/http' 2 | import { GroupId } from '$groups/model/group' 3 | import { ProjectId } from '$groups/model/project' 4 | import { ScheduleProjectPipeline } from '$groups/model/schedule' 5 | import { ErrorService } from '$service/error.service' 6 | import { HttpClient, HttpErrorResponse } from '@angular/common/http' 7 | import { Injectable, inject } from '@angular/core' 8 | import { Observable, catchError, of, retry } from 'rxjs' 9 | 10 | @Injectable({ providedIn: 'root' }) 11 | export class ScheduleService { 12 | private http = inject(HttpClient) 13 | private errorService = inject(ErrorService) 14 | 15 | getSchedules(groupId: GroupId, projectIds?: Set): Observable { 16 | const url = '/api/schedules/latest-pipelines' 17 | const params = createParams(groupId, projectIds) 18 | 19 | return this.http.get(url, { params }).pipe( 20 | retry(retryConfig), 21 | catchError(({ status, error }: HttpErrorResponse) => { 22 | this.errorService.setError({ 23 | message: error.message, 24 | statusCode: status, 25 | groupId 26 | }) 27 | return of([]) 28 | }) 29 | ) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/group-tabs.component.html: -------------------------------------------------------------------------------- 1 | @if (loading()) { 2 |
3 | 4 |
5 | } @else { 6 | @if (groups().length > 0) { 7 | 14 | @for (group of groups(); track trackById(group)) { 15 | 16 | 17 | {{ group.name | maxLength: 25 }} 25 | 26 | 27 | @if (selectedGroupId() == group.id) { 28 | 29 | } 30 | 31 | 32 | } 33 | 34 | 35 | 39 | @if (showFavorites()) { 40 | 41 | } 42 | 43 | } @else { 44 |
45 | 52 | 53 | 54 | 55 |
56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/group-tabs.component.scss: -------------------------------------------------------------------------------- 1 | .spinner { 2 | margin-top: 1rem; 3 | } 4 | 5 | .alert { 6 | margin-right: auto; 7 | margin-left: auto; 8 | max-width: 500px; 9 | padding: 1rem; 10 | } 11 | -------------------------------------------------------------------------------- /src/app/groups/group-tabs/group-tabs.component.ts: -------------------------------------------------------------------------------- 1 | import { Group, GroupId } from '$groups/model/group' 2 | import { GroupService } from '$groups/service/group.service' 3 | import { filterNotNull } from '$groups/util/filter' 4 | import { CommonModule } from '@angular/common' 5 | import { Component, DestroyRef, computed, effect, inject, signal } from '@angular/core' 6 | import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop' 7 | import { ActivatedRoute, Router } from '@angular/router' 8 | import { NzAlertModule } from 'ng-zorro-antd/alert' 9 | import { NzButtonModule } from 'ng-zorro-antd/button' 10 | import { NzIconModule } from 'ng-zorro-antd/icon' 11 | import { NzSpinModule } from 'ng-zorro-antd/spin' 12 | import { NzTabChangeEvent, NzTabsModule } from 'ng-zorro-antd/tabs' 13 | import { NzToolTipModule } from 'ng-zorro-antd/tooltip' 14 | import { finalize, map } from 'rxjs' 15 | import { FavoritesComponent } from './favorites/favorites.component' 16 | import { FeatureTabsComponent } from './feature-tabs/feature-tabs.component' 17 | import { MaxLengthPipe } from './feature-tabs/pipes/max-length.pipe' 18 | import { ProjectId } from '$groups/model/project' 19 | 20 | @Component({ 21 | selector: 'gcd-group-tabs', 22 | imports: [ 23 | CommonModule, 24 | NzAlertModule, 25 | NzButtonModule, 26 | NzTabsModule, 27 | NzSpinModule, 28 | NzToolTipModule, 29 | NzIconModule, 30 | FeatureTabsComponent, 31 | MaxLengthPipe, 32 | FavoritesComponent 33 | ], 34 | templateUrl: './group-tabs.component.html', 35 | styleUrls: ['./group-tabs.component.scss'] 36 | }) 37 | export class GroupTabsComponent { 38 | private groupService = inject(GroupService) 39 | private destroyRef = inject(DestroyRef) 40 | 41 | groups = signal([]) 42 | loading = signal(false) 43 | 44 | showFavorites = signal(false) 45 | selectedGroupId = signal(undefined) 46 | selectedIndex = computed(() => { 47 | const selectedGroupId = this.selectedGroupId() 48 | const groups = this.groups() 49 | return groups.findIndex(({ id }) => id === selectedGroupId) 50 | }) 51 | 52 | constructor( 53 | private route: ActivatedRoute, 54 | private router: Router 55 | ) { 56 | this.loading.set(true) 57 | this.groupService 58 | .getGroups() 59 | .pipe(finalize(() => this.loading.set(false))) 60 | .subscribe((groups) => this.groups.set(groups)) 61 | 62 | effect(() => { 63 | if (this.selectedIndex() === -1) { 64 | this.onChange({ index: 0, tab: null }) 65 | } 66 | }) 67 | 68 | const groupId = toSignal( 69 | this.route.paramMap.pipe( 70 | takeUntilDestroyed(this.destroyRef), 71 | map((map) => map.get('groupId')), 72 | filterNotNull, 73 | map(Number) 74 | ) 75 | ) 76 | 77 | effect(() => { 78 | const groups = this.groups() 79 | const gid = groupId() 80 | if (gid) { 81 | if (groups.length > 0 && !groups.map(({ id }) => id).includes(gid)) { 82 | this.nagivate(groups[0].id) 83 | } else { 84 | this.selectedGroupId.set(gid) 85 | } 86 | } 87 | }) 88 | } 89 | 90 | toggleFavorites(): void { 91 | this.showFavorites.set(!this.showFavorites()) 92 | } 93 | 94 | onReload(): void { 95 | window.location.reload() 96 | } 97 | 98 | onChange({ index }: NzTabChangeEvent): void { 99 | const groups = this.groups() 100 | if (groups.length > 0) { 101 | const { id } = groups.at(index!)! 102 | this.nagivate(id) 103 | } 104 | } 105 | 106 | trackById({ id }: Group): GroupId { 107 | return id 108 | } 109 | 110 | getGroupMap({ id }: Group): Map> { 111 | return new Map([[id, new Set()]]) 112 | } 113 | 114 | private nagivate(groupId: GroupId): void { 115 | const featureId = this.route.snapshot.params['featureId'] ?? 'latest-pipelines' 116 | this.router.navigate([groupId, featureId]) 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/app/groups/http.ts: -------------------------------------------------------------------------------- 1 | import { RetryConfig } from 'rxjs' 2 | import { GroupId } from './model/group' 3 | import { ProjectId } from './model/project' 4 | 5 | export const retryConfig: RetryConfig = { 6 | count: 5, 7 | delay: 500, 8 | resetOnSuccess: true 9 | } 10 | 11 | export const FETCH_REFRESH_INTERVAL = 2000 12 | 13 | export function createParams(groupId: GroupId, projectIds?: Set): { [key: string]: string } { 14 | const params = Object({ group_id: groupId }) 15 | if (projectIds && projectIds.size > 0) { 16 | return { ...params, project_ids: Array.from(projectIds).join(',') } 17 | } 18 | return params 19 | } 20 | -------------------------------------------------------------------------------- /src/app/groups/model/branch.ts: -------------------------------------------------------------------------------- 1 | import { Pipeline } from './pipeline' 2 | 3 | export interface BranchPipeline { 4 | branch: Branch 5 | pipeline?: Pipeline 6 | } 7 | 8 | export interface Branch { 9 | name: string 10 | merged: boolean 11 | protected: boolean 12 | default: boolean 13 | can_push: boolean 14 | web_url: string 15 | commit: Commit 16 | pipeline?: Pipeline 17 | } 18 | 19 | export interface Commit { 20 | id: string 21 | author_name: string 22 | committer_name: string 23 | committed_date: string 24 | title: string 25 | message: string 26 | } 27 | -------------------------------------------------------------------------------- /src/app/groups/model/group.ts: -------------------------------------------------------------------------------- 1 | export type GroupId = number 2 | export interface Group { 3 | id: GroupId 4 | name: string 5 | } 6 | -------------------------------------------------------------------------------- /src/app/groups/model/job.ts: -------------------------------------------------------------------------------- 1 | import { Commit } from './branch' 2 | import { Pipeline } from './pipeline' 3 | import { Status } from './status' 4 | import { User } from './user' 5 | 6 | export type JobId = number 7 | export interface Job { 8 | id: JobId 9 | commit: Commit 10 | allow_failure: boolean 11 | created_at: string 12 | name: string 13 | pipeline: Pipeline 14 | ref: string 15 | stage: string 16 | status: Status 17 | web_url: string 18 | user: User 19 | } 20 | -------------------------------------------------------------------------------- /src/app/groups/model/pipeline.ts: -------------------------------------------------------------------------------- 1 | import { ProjectId } from './project' 2 | import { Status } from './status' 3 | 4 | export enum Source { 5 | PUSH = 'push', 6 | WEB = 'web', 7 | TRIGGER = 'trigger', 8 | SCHEDULE = 'schedule', 9 | API = 'api', 10 | EXTERNAL = 'external', 11 | PIPELINE = 'pipeline', 12 | CHAT = 'chat', 13 | WEBIDE = 'webide', 14 | MERGE_REQUEST_EVENT = 'merge_request_event', 15 | EXTERNAL_PULL_REQUEST_EVENT = 'external_pull_request_event', 16 | PARENT_PIPELINE = 'parent_pipeline', 17 | ONDEMAND_DAST_SCAN = 'ondemand_dast_scan', 18 | ONDEMAND_DAST_VALIDATION = 'ondemand_dast_validation' 19 | } 20 | 21 | export type PipelineId = number 22 | export interface Pipeline { 23 | id: PipelineId 24 | project_id: ProjectId 25 | status: Status 26 | source: Source 27 | ref: string 28 | sha: string 29 | web_url: string 30 | updated_at: string 31 | created_at: string 32 | } 33 | -------------------------------------------------------------------------------- /src/app/groups/model/project.ts: -------------------------------------------------------------------------------- 1 | import { GroupId } from './group' 2 | import { Pipeline } from './pipeline' 3 | 4 | export interface ProjectPipeline { 5 | group_id: GroupId 6 | project: Project 7 | pipeline?: Pipeline 8 | } 9 | 10 | export interface ProjectPipelines { 11 | group_id: GroupId 12 | project: Project 13 | pipelines: Pipeline[] 14 | } 15 | 16 | export interface Namespace { 17 | id: number 18 | name: string 19 | path: string 20 | } 21 | 22 | export type ProjectId = number 23 | 24 | export interface Project { 25 | id: ProjectId 26 | name: string 27 | default_branch: string 28 | web_url: string 29 | topics: string[] 30 | description?: string 31 | namespace: Namespace 32 | } 33 | -------------------------------------------------------------------------------- /src/app/groups/model/schedule.ts: -------------------------------------------------------------------------------- 1 | import { GroupId } from './group' 2 | import { Pipeline } from './pipeline' 3 | import { Project } from './project' 4 | import { User } from './user' 5 | 6 | export interface ScheduleProjectPipeline { 7 | group_id: GroupId 8 | schedule: Schedule 9 | project: Project 10 | pipeline?: Pipeline 11 | } 12 | 13 | export type ScheduleId = number 14 | export interface Schedule { 15 | id: ScheduleId 16 | description: string 17 | ref: string 18 | cron: string 19 | cron_timezone: string 20 | next_run_at: string 21 | active: boolean 22 | created_at: string 23 | updated_at: string 24 | owner: User 25 | } 26 | -------------------------------------------------------------------------------- /src/app/groups/model/status.ts: -------------------------------------------------------------------------------- 1 | export enum Status { 2 | CREATED = 'created', 3 | WAITING_FOR_RESOURCE = 'waiting_for_resource', 4 | PREPARING = 'preparing', 5 | PENDING = 'pending', 6 | RUNNING = 'running', 7 | SUCCESS = 'success', 8 | FAILED = 'failed', 9 | CANCELED = 'canceled', 10 | SKIPPED = 'skipped', 11 | MANUAL = 'manual', 12 | SCHEDULED = 'scheduled', 13 | 14 | FAILED_ALLOW_FAILURE = 'failed_allow_failure' // custom status 15 | } 16 | -------------------------------------------------------------------------------- /src/app/groups/model/user.ts: -------------------------------------------------------------------------------- 1 | export interface User { 2 | id: number 3 | username: string 4 | name: string 5 | state: string 6 | is_admin: boolean 7 | } 8 | -------------------------------------------------------------------------------- /src/app/groups/service/branch.service.ts: -------------------------------------------------------------------------------- 1 | import { retryConfig } from '$groups/http' 2 | import { Branch } from '$groups/model/branch' 3 | import { ProjectId } from '$groups/model/project' 4 | import { ErrorService } from '$service/error.service' 5 | import { HttpClient, HttpErrorResponse } from '@angular/common/http' 6 | import { Injectable, inject } from '@angular/core' 7 | import { Observable, catchError, of, retry } from 'rxjs' 8 | 9 | @Injectable({ providedIn: 'root' }) 10 | export class BranchService { 11 | private http = inject(HttpClient) 12 | private errorService = inject(ErrorService) 13 | 14 | getBranches(projectId: ProjectId): Observable { 15 | const params = { project_id: projectId } 16 | return this.http.get('/api/branches', { params }).pipe( 17 | retry(retryConfig), 18 | catchError(({ status, error }: HttpErrorResponse) => { 19 | this.errorService.setError({ 20 | statusCode: status, 21 | message: error.message 22 | }) 23 | return of([]) 24 | }) 25 | ) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/app/groups/service/group.service.ts: -------------------------------------------------------------------------------- 1 | import { retryConfig } from '$groups/http' 2 | import { Group } from '$groups/model/group' 3 | import { ErrorService } from '$service/error.service' 4 | import { HttpClient, HttpErrorResponse } from '@angular/common/http' 5 | import { Injectable, inject } from '@angular/core' 6 | import { Observable, catchError, of, retry } from 'rxjs' 7 | 8 | @Injectable({ providedIn: 'root' }) 9 | export class GroupService { 10 | private http = inject(HttpClient) 11 | private errorService = inject(ErrorService) 12 | 13 | getGroups(): Observable { 14 | return this.http.get('/api/groups').pipe( 15 | retry(retryConfig), 16 | catchError(({ status, error }: HttpErrorResponse) => { 17 | this.errorService.setError({ 18 | statusCode: status, 19 | message: error.message 20 | }) 21 | return of([]) 22 | }) 23 | ) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/app/groups/util/compare.ts: -------------------------------------------------------------------------------- 1 | const dateMatcher = /(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})/ 2 | 3 | export function compareString(a: string = '', b: string = ''): number { 4 | return a.localeCompare(b) 5 | } 6 | 7 | export function compareStringDate(a: string = '', b: string = ''): number { 8 | const isDateString = dateMatcher.test(a) && dateMatcher.test(b) 9 | return isDateString ? Number(new Date(a)) - Number(new Date(b)) : 0 10 | } 11 | -------------------------------------------------------------------------------- /src/app/groups/util/filter.ts: -------------------------------------------------------------------------------- 1 | import { Pipeline } from '$groups/model/pipeline' 2 | import { Project } from '$groups/model/project' 3 | import { Status } from '$groups/model/status' 4 | import { Observable, filter } from 'rxjs' 5 | 6 | export function filterString(value: string, filterText: string): boolean { 7 | return value.toLocaleLowerCase().includes(filterText.toLocaleLowerCase()) 8 | } 9 | 10 | export function filterProject({ name, topics }: Project, filterText: string, filterTopics: string[]): boolean { 11 | const topicsMatch = filterTopics.length === 0 || filterTopics.every((filter) => topics.includes(filter)) 12 | return topicsMatch && filterString(name, filterText) 13 | } 14 | 15 | export function filterPipeline({ status, ref }: Pipeline, filterText: string, filterStatuses: Status[]): boolean { 16 | return ( 17 | (filterStatuses.length === 0 || filterStatuses.some((filter) => status.includes(filter))) && 18 | filterString(ref, filterText) 19 | ) 20 | } 21 | 22 | export function filterNotNull(source: Observable): Observable { 23 | return source.pipe(filter((value): value is T => value != null)) 24 | } 25 | 26 | export function filterArrayNotNull(source: Array): Array { 27 | return source.filter((value): value is T => value != null) 28 | } 29 | -------------------------------------------------------------------------------- /src/app/groups/util/fork.ts: -------------------------------------------------------------------------------- 1 | import { GroupId } from '$groups/model/group' 2 | import { ProjectId } from '$groups/model/project' 3 | import { Observable, forkJoin, map } from 'rxjs' 4 | 5 | export function forkJoinFlatten( 6 | groupMap: Map>, 7 | mapFn: (groupId: GroupId, projectIds: Set) => Observable> 8 | ) { 9 | return forkJoin(Array.from(groupMap.entries()).map(([groupId, projectIds]) => mapFn(groupId, projectIds))).pipe( 10 | map((all) => all.flat()) 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /src/app/groups/util/identity.ts: -------------------------------------------------------------------------------- 1 | export const identity = (value: T): T => value 2 | -------------------------------------------------------------------------------- /src/app/groups/util/status-color.ts: -------------------------------------------------------------------------------- 1 | import { Status } from '$groups/model/status' 2 | 3 | type Color = string 4 | 5 | const dark6 = '#25262B' 6 | const colorMap = new Map([ 7 | [Status.CREATED, '#74C0FC'], 8 | [Status.WAITING_FOR_RESOURCE, '#CED4DA'], 9 | [Status.PREPARING, '#4C6EF5'], 10 | [Status.PENDING, '#15AABF'], 11 | [Status.RUNNING, '#228BE6'], 12 | [Status.SUCCESS, '#087f5b'], 13 | [Status.FAILED, '#FA5252'], 14 | [Status.CANCELED, '#FF8787'], 15 | [Status.SKIPPED, '#FD7E14'], 16 | [Status.MANUAL, '#FAB005'], 17 | [Status.SCHEDULED, '#7950F2'], 18 | [Status.FAILED_ALLOW_FAILURE, 'warning'] 19 | ]) 20 | 21 | export function statusToColor(status?: Status | string): Color { 22 | return status ? colorMap.get(status) || dark6 : dark6 23 | } 24 | -------------------------------------------------------------------------------- /src/app/groups/util/status-scope.ts: -------------------------------------------------------------------------------- 1 | import { Status } from '$groups/model/status' 2 | 3 | export function statusToScope(status?: Status): Status[] { 4 | if (!status) { 5 | return [] 6 | } 7 | 8 | switch (status) { 9 | case Status.SUCCESS: { 10 | return [Status.FAILED] 11 | } 12 | case Status.RUNNING: { 13 | return [Status.RUNNING, Status.PENDING] 14 | } 15 | default: { 16 | return [status] 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/app/groups/util/table.ts: -------------------------------------------------------------------------------- 1 | export interface Header { 2 | title: string 3 | sortable: boolean 4 | compare: ((a: T, b: T) => number) | null 5 | } 6 | -------------------------------------------------------------------------------- /src/app/header/header.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |

Gitlab CI Dashboard

5 |
6 |
7 |
8 | {{ version() }} 9 |
10 | 13 |
14 |
15 | -------------------------------------------------------------------------------- /src/app/header/header.component.scss: -------------------------------------------------------------------------------- 1 | .header { 2 | padding: 1rem; 3 | color: white; 4 | display: flex; 5 | justify-content: space-between; 6 | align-items: center; 7 | height: 3.75rem; 8 | max-height: 3.75rem; 9 | min-width: 768px; 10 | background-color: rgb(37, 99, 235); 11 | } 12 | 13 | button { 14 | padding: 0; 15 | } 16 | 17 | .title { 18 | padding: 0; 19 | margin: 0; 20 | } 21 | -------------------------------------------------------------------------------- /src/app/header/header.component.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common' 2 | import { NzToolTipModule } from 'ng-zorro-antd/tooltip' 3 | 4 | import { ConfigService } from '$service/config.service' 5 | import { Component, inject } from '@angular/core' 6 | import { NzButtonModule } from 'ng-zorro-antd/button' 7 | import { NzIconModule } from 'ng-zorro-antd/icon' 8 | 9 | @Component({ 10 | selector: 'gcd-header', 11 | imports: [CommonModule, NzIconModule, NzButtonModule, NzToolTipModule], 12 | templateUrl: './header.component.html', 13 | styleUrls: ['./header.component.scss'] 14 | }) 15 | export class HeaderComponent { 16 | private config = inject(ConfigService) 17 | readonly version = this.config.version 18 | 19 | onClick(): void { 20 | window.open('https://github.com/larscom/gitlab-ci-dashboard', '_blank') 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/app/service/config.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient } from '@angular/common/http' 2 | import { computed, inject, Injectable } from '@angular/core' 3 | import { toSignal } from '@angular/core/rxjs-interop' 4 | 5 | export interface ApiConfig { 6 | api_version: string 7 | read_only: boolean 8 | hide_write_actions: boolean 9 | } 10 | 11 | @Injectable({ providedIn: 'root' }) 12 | export class ConfigService { 13 | private readonly config = toSignal(inject(HttpClient).get('/api/config')) 14 | 15 | readonly version = computed(() => { 16 | const version = this.config()?.api_version ?? '' 17 | const parts = version.split('@') 18 | return parts.length > 1 ? `${parts[0].slice(0, 7)}@${parts[1]}` : version 19 | }) 20 | 21 | readonly readOnly = computed(() => this.config()?.read_only) 22 | 23 | readonly hideWriteActions = computed(() => this.readOnly() && this.config()?.hide_write_actions) 24 | } 25 | -------------------------------------------------------------------------------- /src/app/service/error.service.ts: -------------------------------------------------------------------------------- 1 | import { GroupId } from '$groups/model/group' 2 | import { Injectable, signal } from '@angular/core' 3 | 4 | export interface ErrorContext { 5 | message: string 6 | statusCode: number 7 | groupId?: GroupId 8 | } 9 | 10 | @Injectable({ providedIn: 'root' }) 11 | export class ErrorService { 12 | private _error = signal(null) 13 | 14 | readonly error = this._error.asReadonly() 15 | 16 | setError(context: ErrorContext): void { 17 | this._error.set(context) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/larscom/gitlab-ci-dashboard/28d2236dbbcbfb16b872527b0f6c5e5d927ad18b/src/assets/.gitkeep -------------------------------------------------------------------------------- /src/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Gitlab CI Dashboard 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { bootstrapApplication } from '@angular/platform-browser' 2 | import { AppComponent } from './app/app.component' 3 | import { appConfig } from './app/app.config' 4 | 5 | bootstrapApplication(AppComponent, appConfig).catch((err) => console.error(err)) 6 | -------------------------------------------------------------------------------- /src/styles.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | --font-size-base: 1rem; 3 | --font-size-small: calc(var(--font-size-base) * 0.75); 4 | --font-size-medium: calc(var(--font-size-base) * 1.25); 5 | --font-size-large: calc(var(--font-size-base) * 1.5); 6 | } 7 | 8 | body { 9 | font-family: 'Roboto', 'Helvetica Neue', sans-serif; 10 | font-size: var(--font-size-base); 11 | } 12 | 13 | .ant-table.ant-table-small .ant-table-tbody .ant-table-wrapper:only-child .ant-table { 14 | margin: 0; 15 | } 16 | 17 | .lowercase { 18 | text-transform: lowercase; 19 | } 20 | 21 | .uppercase { 22 | text-transform: uppercase; 23 | } 24 | 25 | .pointer { 26 | cursor: pointer; 27 | } 28 | 29 | .max-width-275 { 30 | max-width: 275px; 31 | } 32 | 33 | .width-275 { 34 | width: 275px; 35 | } 36 | 37 | .font { 38 | font-size: var(--font-size-base); 39 | &-sm { 40 | font-size: var(--font-size-small); 41 | } 42 | &-md { 43 | font-size: var(--font-size-medium); 44 | } 45 | &-lg { 46 | font-size: var(--font-size-large); 47 | } 48 | &-bold { 49 | font-weight: bold; 50 | } 51 | &-white { 52 | color: white; 53 | } 54 | } 55 | 56 | .flex { 57 | display: flex; 58 | &-row { 59 | flex-direction: row; 60 | } 61 | &-col { 62 | flex-direction: column; 63 | } 64 | &-wrap { 65 | flex-wrap: wrap; 66 | } 67 | } 68 | 69 | .justify { 70 | &-center { 71 | justify-content: center; 72 | } 73 | &-between { 74 | justify-content: space-between; 75 | } 76 | &-end { 77 | justify-content: end; 78 | } 79 | } 80 | 81 | .items { 82 | &-center { 83 | align-items: center; 84 | } 85 | } 86 | 87 | .gap { 88 | &-025 { 89 | gap: 0.25rem; 90 | } 91 | &-050 { 92 | gap: 0.5rem; 93 | } 94 | &-1 { 95 | gap: 1rem; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /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": ["node"] 7 | }, 8 | "files": ["src/main.ts"], 9 | "include": ["src/**/*.d.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /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 | "strict": true, 9 | "esModuleInterop": 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": ["ES2022", "dom"], 23 | "paths": { 24 | "$groups/*": ["src/app/groups/*"], 25 | "$header/*": ["src/app/header/*"], 26 | "$service/*": ["src/app/service/*"], 27 | "$store/*": ["src/app/store/*"] 28 | } 29 | }, 30 | "angularCompilerOptions": { 31 | "enableI18nLegacyMessageIdFormat": false, 32 | "strictInjectionParameters": true, 33 | "strictInputAccessModifiers": true, 34 | "strictTemplates": true, 35 | "disableTypeScriptVersionCheck": true 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /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": ["jest", "node"] 7 | }, 8 | "include": ["src/**/*.spec.ts", "src/**/*.d.ts"] 9 | } 10 | --------------------------------------------------------------------------------