├── .github └── workflows │ └── test.yml ├── .gitignore ├── .nvmrc ├── LICENSE ├── README.md ├── bin └── gitlab-radiator.js ├── build-npm ├── eslint.config.mjs ├── package-lock.json ├── package.json ├── public ├── client.less ├── colors.less ├── favicon.ico ├── fonts │ ├── OpenSans-Regular.ttf │ └── OpenSans-Semibold.ttf ├── index.html ├── normalize.css └── opensans.css ├── screenshot.png ├── src ├── app.js ├── auth.js ├── client │ ├── arguments.ts │ ├── eslint.config.mjs │ ├── gitlab-types.ts │ ├── groupedProjects.tsx │ ├── groups.tsx │ ├── index.tsx │ ├── info.tsx │ ├── jobs.tsx │ ├── projects.tsx │ ├── stages.tsx │ └── timestamp.tsx ├── config.js ├── dev-assets.js ├── gitlab │ ├── client.js │ ├── index.js │ ├── pipelines.js │ ├── projects.js │ └── runners.js └── less.js ├── test └── gitlab-integration.js ├── tsconfig.json ├── webpack.common.cjs ├── webpack.dev.cjs └── webpack.prod.cjs /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | node-version: [18.x, 20.x, 22.x] 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Use Node.js ${{ matrix.node-version }} 14 | uses: actions/setup-node@v4 15 | with: 16 | node-version: ${{ matrix.node-version }} 17 | - run: npm ci 18 | - run: npm audit 19 | - run: npm run eslint 20 | - run: npm test 21 | - run: npm run build 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | build 4 | yarn.lock 5 | .idea 6 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 20.9.0 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Heikki Pora 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # The missing GitLab build radiator view 3 | 4 | ## Introduction 5 | 6 | `gitlab-radiator` is a small Node.js application for serving a [Jenkins Radiator View](https://wiki.jenkins-ci.org/display/JENKINS/Radiator+View+Plugin) inspired web view of your team's CI pipelines fetched from a GitLab CI installation running locally or remotely. 7 | 8 | 9 | 10 | [![npm version](https://badge.fury.io/js/gitlab-radiator.svg)](https://badge.fury.io/js/gitlab-radiator) 11 | ![Run tests](https://github.com/heikkipora/gitlab-radiator/workflows/Run%20tests/badge.svg) 12 | 13 | ## Pre-requisites 14 | 15 | - Node.js v18 or newer 16 | - An account in https://gitlab.com or an onsite installation of the [GitLab software package](https://about.gitlab.com/products). 17 | 18 | ## Installation 19 | 20 | npm install -g gitlab-radiator 21 | 22 | ## Usage 23 | 24 | Create a configuration file (see [Configuration](#configuration) below) and run: 25 | 26 | gitlab-radiator 27 | 28 | And if you have an onsite GitLab with HTTPS and self-signed certificates: 29 | 30 | NODE_TLS_REJECT_UNAUTHORIZED=0 gitlab-radiator 31 | 32 | You might prefer providing the CA file location in configuration instead of totally disabling TLS certificate checking. 33 | 34 | Then navigate with a browser to `http://localhost:3000` - or whatever port you did configure. 35 | 36 | ## URL parameters 37 | 38 | In addition to server-side configuration, `gitlab-radiator` views can be customized by providing URL parameters. This comes in handy especially when multiple physical screens are pointed to the same server instance. 39 | 40 | ### Multi-screen support 41 | 42 | It's possible to split the radiator view to multiple physical screens by specifying the total number of screens and the current screen with the `screens` URL parameter. The parameter value format is `XofY` where `X` is the number of the screen in question (1...total), and `Y` is the total number of screens (max 9). 43 | 44 | Example: `http://localhost:3000/?screen=2of3` 45 | 46 | ### Overriding number of columns and zoom level 47 | 48 | Want to display a radiator view on your own display with different layout from that displayed on multiple displays on the nearest wall? 49 | `columns` and `zoom` configuration values (see `Configuration` section below) can be overriden for a single user / browser by providing them as URL parameters. 50 | 51 | Example: `http://localhost:3000/?columns=2&zoom=0.8` 52 | 53 | ### Selecting projects to display based on GitLab Project Tags 54 | 55 | Tags can be used, for example, to only include a specific set of projects for a specific display on your wall. Multiple tags can be specified by separating them with a comma. 56 | 57 | Example: `http://localhost:3000/?tags=old,useful` 58 | 59 | Specifying an empty tag list `http://localhost:3000/?tags=` selects all projects which do not have any tags. This can be handily used to select "rest of the projects" that have not been tagged to a specific display, for example. 60 | 61 | ## Configuration 62 | 63 | `gitlab-radiator` looks for its mandatory configuration file at `~/.gitlab-radiator.yml` by default. 64 | It can be overridden by defining the `GITLAB_RADIATOR_CONFIG` environment variable. 65 | 66 | Mandatory configuration properties: 67 | 68 | - `gitlabs / url` - Root URL of your GitLab installation - or that of GitLab SaaS CI 69 | - `gitlabs / access-token` - A GitLab access token for allowing access to the GitLab API. One can be generated with GitLab's UI under Profile Settins / Personal Access Tokens. The value can alternatively be defined as `GITLAB_ACCESS_TOKEN` environment variable. 70 | 71 | Example yaml syntax: 72 | 73 | ``` 74 | gitlabs: 75 | - 76 | access-token: 12invalidtoken12 77 | url: https://gitlab.com 78 | ``` 79 | 80 | Optional configuration properties: 81 | 82 | - `gitlabs / projects / include` - Regular expression for inclusion of projects. Default is to include all projects. 83 | - `gitlabs / projects / exclude` - Regular expression for exclusion of projects. Default is to exclude no projects. 84 | - `gitlabs / projects / excludePipelineStatus` - Array of pipeline statuses, that should be excluded (i.e. hidden) (available statuses are `running, pending, success, failed, canceled, skipped`). 85 | - `gitlabs / maxNonFailedJobsVisible` - Number of non-failed jobs visible for a stage at maximum. Helps with highly concurrent project pipelines becoming uncomfortably high. Default values is unlimited. 86 | - `gitlabs / caFile` - CA file location to be passed to the request library when accessing the gitlab instance. 87 | - `gitlabs / ignoreArchived` - Ignore archived projects. Default value is `true` 88 | - `groupSuccessfulProjects` - If set to `true` projects with successful pipeline status are grouped by namespace. Projects with other pipeline statuses are still rendered seperately. Default value is `false`. 89 | - `horizontal` - If set to `true` jobs are ordered horizontally to stages. Default value is `false`. 90 | - `auth / username` - Enables HTTP basic authentication with the defined username and password. 91 | - `auth / password` - Enables HTTP basic authentication with the defined username and password. 92 | - `projectsOrder` - Array of project attributes to use for sorting projects. Default value is `['name']` (available attributes are `status, name, id, nameWithoutNamespace, group`). 93 | - `interval` - Number of seconds between updateing projects and pipelines from GitLabs. Default value is 10 seconds. 94 | - `port` - HTTP port to listen on. Default value is 3000. 95 | - `zoom` - View zoom factor (to make your projects fit a display nicely). Default value is 1.0 96 | - `columns` - Number of columns to display (to fit more projects on screen). Default value is 1 97 | - `colors` - Define some custom colors. Available colors `success-text, success-background, failed-text, failed-background, running-text, running-background, pending-text, pending-background, skipped-text, skipped-background, created-text, created-background, light-text, dark-text, background, project-background, group-background, error-message-text, error-message-background` (you may have a look at `/public/colors.less`, the colorNames from config will replace value for `@-color` less variable) 98 | 99 | Example yaml syntax: 100 | 101 | ``` 102 | gitlabs: 103 | - 104 | access-token: 12invalidtoken12 105 | url: https://gitlab.com 106 | projects: 107 | exclude: .*/.*-inactive-project 108 | excludePipelineStatus: ['canceled', 'pending'] 109 | maxNonFailedJobsVisible: 3 110 | projectsOrder: ['status', 'name'] 111 | auth: 112 | username: 'radiator' 113 | password: 'p455w0rd' 114 | interval: 30 115 | port: 8000 116 | zoom: 0.85 117 | columns: 4 118 | colors: 119 | success-background: 'rgb(0,255,0)' 120 | ``` 121 | 122 | ## Changelog 123 | 124 | See [releases](https://github.com/heikkipora/gitlab-radiator/releases). 125 | 126 | ## Contributing 127 | 128 | Pull requests are welcome. Kindly check that your code passes ESLint checks by running `npm run eslint` first. 129 | Tests are run automatically for pull requests by Github Actions against a test profile with a few CI pipelines on gitlab.com 130 | 131 | ## Contributors 132 | 133 | - Antti Oittinen ([codegeneralist](https://github.com/codegeneralist)) 134 | - Christian Wagner ([wagner-ch](https://github.com/wagner-ch/)) 135 | -------------------------------------------------------------------------------- /bin/gitlab-radiator.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | process.env.NODE_ENV = 'production' 4 | 5 | await import('../src/app.js') 6 | -------------------------------------------------------------------------------- /build-npm: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | rm -fr build 6 | mkdir -p build/src 7 | 8 | # Copy static resources 9 | cp -r public build 10 | 11 | # Copy LICENSE, README and package.json 12 | cp LICENSE package.json README.md build 13 | 14 | # Copy bin script 15 | cp -r bin build 16 | 17 | # Copy server 18 | cp -r src build 19 | rm -fr build/src/client 20 | rm -f build/src/dev-assets.js 21 | 22 | # Bundle and minify client JS 23 | npx webpack --config webpack.prod.cjs 24 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from 'globals' 2 | import js from '@eslint/js' 3 | import mocha from 'eslint-plugin-mocha' 4 | import path from 'node:path' 5 | import react from 'eslint-plugin-react' 6 | import {fileURLToPath} from 'node:url' 7 | import {FlatCompat} from '@eslint/eslintrc' 8 | 9 | const __filename = fileURLToPath(import.meta.url) 10 | const __dirname = path.dirname(__filename) 11 | const compat = new FlatCompat({ 12 | baseDirectory: __dirname, 13 | recommendedConfig: js.configs.recommended, 14 | allConfig: js.configs.all 15 | }) 16 | 17 | export default [...compat.extends('eslint:recommended'), { 18 | plugins: { 19 | mocha, 20 | react 21 | }, 22 | 23 | languageOptions: { 24 | globals: { 25 | ...globals.mocha, 26 | ...globals.node 27 | }, 28 | 29 | ecmaVersion: 2022, 30 | sourceType: 'module' 31 | }, 32 | }] -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gitlab-radiator", 3 | "author": { 4 | "name": "heikkipora", 5 | "email": "heikki.pora@gmail.com" 6 | }, 7 | "type": "module", 8 | "description": "The missing GitLab build radiator view", 9 | "version": "4.4.0", 10 | "license": "MIT", 11 | "bin": { 12 | "gitlab-radiator": "bin/gitlab-radiator.js" 13 | }, 14 | "scripts": { 15 | "build": "./build-npm", 16 | "start": "node src/app.js", 17 | "eslint": "eslint --fix src/* bin/* test/*", 18 | "test": "mocha --timeout 20000 test/*.js test/**/*.js" 19 | }, 20 | "engines": { 21 | "node": ">=18" 22 | }, 23 | "repository": { 24 | "type": "git", 25 | "url": "git+https://github.com/heikkipora/gitlab-radiator.git" 26 | }, 27 | "bugs": { 28 | "url": "https://github.com/heikkipora/gitlab-radiator/issues" 29 | }, 30 | "homepage": "https://github.com/heikkipora/gitlab-radiator#readme", 31 | "contributors": [ 32 | { 33 | "name": "codegeneralist", 34 | "email": "antti.oittinen@gmail.com" 35 | }, 36 | { 37 | "name": "wagner-ch", 38 | "email": "c.wagner@arcusx.com" 39 | } 40 | ], 41 | "dependencies": { 42 | "axios": "1.9.0", 43 | "basic-auth": "2.0.1", 44 | "compression": "1.8.0", 45 | "esm": "3.2.25", 46 | "express": "5.1.0", 47 | "js-yaml": "4.1.0", 48 | "less": "4.3.0", 49 | "lodash": "4.17.21", 50 | "moment": "2.30.1", 51 | "regenerator-runtime": "0.14.1", 52 | "socket.io": "4.8.1" 53 | }, 54 | "devDependencies": { 55 | "@eslint/eslintrc": "3.3.1", 56 | "@eslint/js": "9.28.0", 57 | "@types/lodash": "4.17.17", 58 | "@types/react": "19.1.7", 59 | "@types/react-dom": "19.1.6", 60 | "@types/webpack-env": "1.18.8", 61 | "@typescript-eslint/eslint-plugin": "8.34.0", 62 | "@typescript-eslint/parser": "8.34.0", 63 | "chai": "5.2.0", 64 | "core-js": "3.43.0", 65 | "css-loader": "7.1.2", 66 | "eslint": "9.28.0", 67 | "eslint-plugin-mocha": "11.1.0", 68 | "eslint-plugin-react": "7.37.5", 69 | "globals": "16.2.0", 70 | "less-loader": "12.3.0", 71 | "mocha": "11.6.0", 72 | "normalize.css": "8.0.1", 73 | "react": "19.1.0", 74 | "react-dom": "19.1.0", 75 | "style-loader": "4.0.0", 76 | "ts-loader": "9.5.2", 77 | "typescript": "5.8.3", 78 | "webpack": "5.99.9", 79 | "webpack-cli": "6.0.1", 80 | "webpack-dev-middleware": "7.4.2", 81 | "webpack-hot-middleware": "2.26.1", 82 | "webpack-merge": "6.0.1" 83 | }, 84 | "browserslist": [ 85 | "last 5 years", 86 | "Firefox > 43" 87 | ] 88 | } 89 | -------------------------------------------------------------------------------- /public/client.less: -------------------------------------------------------------------------------- 1 | @import (inline) "normalize.css"; 2 | @import (inline) "opensans.css"; 3 | @import "colors.less"; 4 | 5 | * { 6 | -webkit-user-select: none; 7 | -moz-user-select: none; 8 | user-select: none; 9 | box-sizing: border-box; 10 | } 11 | 12 | html, 13 | body { 14 | font-family: "Open Sans", sans-serif; 15 | -webkit-font-smoothing: antialiased; 16 | text-rendering: optimizeLegibility; 17 | -moz-osx-font-smoothing: grayscale; 18 | } 19 | 20 | body { 21 | font-size: 24px; 22 | line-height: 33px; 23 | } 24 | 25 | h2 { 26 | font-size: 32px; 27 | font-weight: 600; 28 | color: @light-text-color; 29 | } 30 | 31 | h4 { 32 | font-size: 24px; 33 | font-weight: 400; 34 | color: @light-text-color; 35 | } 36 | 37 | h6 { 38 | font-size: 16px; 39 | color: @light-text-color; 40 | } 41 | 42 | a { 43 | color: inherit; 44 | text-decoration: none; 45 | &:hover { 46 | text-decoration: underline; 47 | } 48 | } 49 | 50 | #app { 51 | position: fixed; 52 | width: 100%; 53 | height: 100%; 54 | overflow: hidden; 55 | background-color: @background-color; 56 | 57 | h2.loading { 58 | margin-left: 24px; 59 | } 60 | 61 | .error { 62 | position: absolute; 63 | bottom: 0; 64 | left: 0; 65 | right: 0; 66 | color: @error-message-text-color; 67 | background-color: @error-message-background-color; 68 | border-top: 2px solid @background-color; 69 | border-bottom: 2px solid @background-color; 70 | text-align: center; 71 | padding: 0.25em; 72 | z-index: 1; 73 | white-space: nowrap; 74 | overflow: hidden; 75 | text-overflow: ellipsis; 76 | } 77 | } 78 | 79 | li, 80 | ol { 81 | margin: 0; 82 | padding: 0; 83 | list-style-type: none; 84 | } 85 | 86 | ol.projects { 87 | list-style: none; 88 | width: 100vmax; 89 | display: flex; 90 | flex-direction: row; 91 | flex-wrap: wrap; 92 | transform-origin: top left; 93 | 94 | li.project { 95 | padding: 24px; 96 | border-radius: 6px; 97 | background-color: @project-background-color; 98 | display: flex; 99 | flex-direction: column; 100 | 101 | &.failed { 102 | background: fade(@failed-background-color, 50%); 103 | } 104 | 105 | &.running { 106 | background: repeating-linear-gradient( 107 | -45deg, 108 | @project-background-color, 109 | @project-background-color 20px, 110 | darken(@project-background-color, 2%) 20px, 111 | darken(@project-background-color, 2%) 40px 112 | ); 113 | } 114 | 115 | h2 { 116 | text-transform: uppercase; 117 | text-align: center; 118 | margin: 0 0 12px 0; 119 | overflow: hidden; 120 | text-overflow: ellipsis; 121 | white-space: nowrap; 122 | line-height: 1.2em; 123 | } 124 | 125 | h4, 126 | h6 { 127 | text-transform: lowercase; 128 | text-align: center; 129 | margin: 0; 130 | padding: 0 0px 6px 0; 131 | overflow: hidden; 132 | text-overflow: ellipsis; 133 | white-space: nowrap; 134 | line-height: 1.2em; 135 | } 136 | } 137 | } 138 | 139 | ol.stages { 140 | display: flex; 141 | flex-direction: row; 142 | flex-grow: 1; 143 | 144 | .stage { 145 | display: flex; 146 | flex-direction: column; 147 | flex-grow: 1; 148 | min-width: 0; 149 | 150 | &:not(:last-child) { 151 | margin-right: 5px; 152 | } 153 | 154 | .name { 155 | font-size: 80%; 156 | color: @light-text-color; 157 | text-align: center; 158 | margin-bottom: 6px; 159 | text-overflow: ellipsis; 160 | overflow: hidden; 161 | white-space: nowrap; 162 | } 163 | } 164 | } 165 | 166 | ol.jobs { 167 | display: flex; 168 | flex-direction: column; 169 | 170 | li { 171 | &:not(.hidden-jobs) { 172 | padding: 6px; 173 | color: @light-text-color; 174 | border: 1px solid @background-color; 175 | border-radius: 6px; 176 | text-align: center; 177 | text-overflow: ellipsis; 178 | overflow: hidden; 179 | white-space: nowrap; 180 | } 181 | 182 | &.hidden-jobs { 183 | text-align: center; 184 | color: @light-text-color; 185 | font-size: 80%; 186 | line-height: 100%; 187 | } 188 | 189 | &:not(:last-child) { 190 | margin-bottom: 5px; 191 | } 192 | 193 | &.skipped { 194 | color: @skipped-text-color; 195 | background-color: @skipped-background-color; 196 | } 197 | 198 | &.created { 199 | color: @created-text-color; 200 | background-color: @created-background-color; 201 | } 202 | 203 | &.pending { 204 | color: @pending-text-color; 205 | background-color: @pending-background-color; 206 | } 207 | 208 | &.failed { 209 | color: @failed-text-color; 210 | background-color: @failed-background-color; 211 | } 212 | 213 | &.success { 214 | color: @success-text-color; 215 | background-color: @success-background-color; 216 | } 217 | 218 | &.running { 219 | color: @running-text-color; 220 | background: repeating-linear-gradient( 221 | -45deg, 222 | @running-background-color, 223 | @running-background-color 10px, 224 | darken(@running-background-color, 8%) 10px, 225 | darken(@running-background-color, 8%) 20px 226 | ); 227 | } 228 | } 229 | } 230 | 231 | .horizontal { 232 | .stages { 233 | flex-direction: column; 234 | flex-wrap: nowrap; 235 | flex-grow: 1; 236 | 237 | .stage { 238 | flex-wrap: nowrap; 239 | align-items: flex-start; 240 | flex-direction: row; 241 | 242 | .name { 243 | min-width: 170px; 244 | margin-top: 6px; 245 | } 246 | 247 | .jobs { 248 | flex-direction: row; 249 | flex-wrap: wrap; 250 | 251 | li { 252 | margin-right: 5px; 253 | } 254 | 255 | :last-child { 256 | margin-bottom: 5px; 257 | } 258 | } 259 | } 260 | } 261 | } 262 | 263 | ol.groups { 264 | list-style: none; 265 | width: 100vmax; 266 | display: flex; 267 | flex-direction: row; 268 | flex-wrap: wrap; 269 | transform-origin: top left; 270 | 271 | li.group { 272 | margin: 12px; 273 | padding: 24px; 274 | border-radius: 6px; 275 | background-color: @group-background-color; 276 | display: flex; 277 | flex-direction: column; 278 | } 279 | 280 | .group-info { 281 | padding: 6px; 282 | color: @light-text-color; 283 | border: 1px solid @background-color; 284 | border-radius: 6px; 285 | text-align: center; 286 | text-overflow: ellipsis; 287 | overflow: hidden; 288 | white-space: nowrap; 289 | background-color: @success-background-color; 290 | } 291 | 292 | h2 { 293 | text-transform: uppercase; 294 | text-align: center; 295 | margin: 0 0 12px 0; 296 | overflow: hidden; 297 | text-overflow: ellipsis; 298 | white-space: nowrap; 299 | line-height: 1.2em; 300 | } 301 | 302 | h4, 303 | h6 { 304 | text-transform: lowercase; 305 | text-align: center; 306 | margin: 0; 307 | padding: 0 0px 6px 0; 308 | overflow: hidden; 309 | text-overflow: ellipsis; 310 | white-space: nowrap; 311 | line-height: 1.2em; 312 | } 313 | } 314 | 315 | .pipeline-info { 316 | color: @light-text-color; 317 | margin-top: 24px; 318 | display: flex; 319 | flex-direction: column; 320 | 321 | div { 322 | display: flex; 323 | justify-content: space-between; 324 | min-width: 0; 325 | 326 | span { 327 | overflow: hidden; 328 | white-space: nowrap; 329 | text-overflow: ellipsis; 330 | } 331 | } 332 | 333 | span:last-child { 334 | margin-left: 36px; 335 | text-align: right; 336 | } 337 | 338 | div:last-child { 339 | font-size: 80%; 340 | } 341 | } 342 | -------------------------------------------------------------------------------- /public/colors.less: -------------------------------------------------------------------------------- 1 | @background-color: rgb(34, 39, 43); 2 | @light-text-color: rgb(215, 215, 217); 3 | @dark-text-color: @background-color; 4 | @project-background-color: rgb(53, 58, 62); 5 | @group-background-color: lighten(@project-background-color, 10%); 6 | 7 | @created-text-color: @light-text-color; 8 | @created-background-color: @project-background-color; 9 | @skipped-text-color: @light-text-color; 10 | @skipped-background-color: @project-background-color; 11 | @pending-text-color: @light-text-color; 12 | @pending-background-color: @project-background-color; 13 | @success-text-color: @light-text-color; 14 | @success-background-color: rgb(34, 115, 110); 15 | @failed-text-color: @dark-text-color; 16 | @failed-background-color: rgb(204, 208, 0); 17 | @running-text-color: @light-text-color; 18 | @running-background-color: @success-background-color; 19 | 20 | @error-message-text-color: rgb(255, 0, 0); 21 | @error-message-background-color: rgb(139, 0, 0); 22 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/heikkipora/gitlab-radiator/12bca9dca9187914df9a86ab6ba64adbcbc87bb2/public/favicon.ico -------------------------------------------------------------------------------- /public/fonts/OpenSans-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/heikkipora/gitlab-radiator/12bca9dca9187914df9a86ab6ba64adbcbc87bb2/public/fonts/OpenSans-Regular.ttf -------------------------------------------------------------------------------- /public/fonts/OpenSans-Semibold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/heikkipora/gitlab-radiator/12bca9dca9187914df9a86ab6ba64adbcbc87bb2/public/fonts/OpenSans-Semibold.ttf -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | GitLab build radiator 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /public/normalize.css: -------------------------------------------------------------------------------- 1 | /*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */ 2 | 3 | /* Document 4 | ========================================================================== */ 5 | 6 | /** 7 | * 1. Correct the line height in all browsers. 8 | * 2. Prevent adjustments of font size after orientation changes in iOS. 9 | */ 10 | 11 | html { 12 | line-height: 1.15; /* 1 */ 13 | -webkit-text-size-adjust: 100%; /* 2 */ 14 | } 15 | 16 | /* Sections 17 | ========================================================================== */ 18 | 19 | /** 20 | * Remove the margin in all browsers. 21 | */ 22 | 23 | body { 24 | margin: 0; 25 | } 26 | 27 | /** 28 | * Render the `main` element consistently in IE. 29 | */ 30 | 31 | main { 32 | display: block; 33 | } 34 | 35 | /** 36 | * Correct the font size and margin on `h1` elements within `section` and 37 | * `article` contexts in Chrome, Firefox, and Safari. 38 | */ 39 | 40 | h1 { 41 | font-size: 2em; 42 | margin: 0.67em 0; 43 | } 44 | 45 | /* Grouping content 46 | ========================================================================== */ 47 | 48 | /** 49 | * 1. Add the correct box sizing in Firefox. 50 | * 2. Show the overflow in Edge and IE. 51 | */ 52 | 53 | hr { 54 | box-sizing: content-box; /* 1 */ 55 | height: 0; /* 1 */ 56 | overflow: visible; /* 2 */ 57 | } 58 | 59 | /** 60 | * 1. Correct the inheritance and scaling of font size in all browsers. 61 | * 2. Correct the odd `em` font sizing in all browsers. 62 | */ 63 | 64 | pre { 65 | font-family: monospace, monospace; /* 1 */ 66 | font-size: 1em; /* 2 */ 67 | } 68 | 69 | /* Text-level semantics 70 | ========================================================================== */ 71 | 72 | /** 73 | * Remove the gray background on active links in IE 10. 74 | */ 75 | 76 | a { 77 | background-color: transparent; 78 | } 79 | 80 | /** 81 | * 1. Remove the bottom border in Chrome 57- 82 | * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. 83 | */ 84 | 85 | abbr[title] { 86 | border-bottom: none; /* 1 */ 87 | text-decoration: underline; /* 2 */ 88 | text-decoration: underline dotted; /* 2 */ 89 | } 90 | 91 | /** 92 | * Add the correct font weight in Chrome, Edge, and Safari. 93 | */ 94 | 95 | b, 96 | strong { 97 | font-weight: bolder; 98 | } 99 | 100 | /** 101 | * 1. Correct the inheritance and scaling of font size in all browsers. 102 | * 2. Correct the odd `em` font sizing in all browsers. 103 | */ 104 | 105 | code, 106 | kbd, 107 | samp { 108 | font-family: monospace, monospace; /* 1 */ 109 | font-size: 1em; /* 2 */ 110 | } 111 | 112 | /** 113 | * Add the correct font size in all browsers. 114 | */ 115 | 116 | small { 117 | font-size: 80%; 118 | } 119 | 120 | /** 121 | * Prevent `sub` and `sup` elements from affecting the line height in 122 | * all browsers. 123 | */ 124 | 125 | sub, 126 | sup { 127 | font-size: 75%; 128 | line-height: 0; 129 | position: relative; 130 | vertical-align: baseline; 131 | } 132 | 133 | sub { 134 | bottom: -0.25em; 135 | } 136 | 137 | sup { 138 | top: -0.5em; 139 | } 140 | 141 | /* Embedded content 142 | ========================================================================== */ 143 | 144 | /** 145 | * Remove the border on images inside links in IE 10. 146 | */ 147 | 148 | img { 149 | border-style: none; 150 | } 151 | 152 | /* Forms 153 | ========================================================================== */ 154 | 155 | /** 156 | * 1. Change the font styles in all browsers. 157 | * 2. Remove the margin in Firefox and Safari. 158 | */ 159 | 160 | button, 161 | input, 162 | optgroup, 163 | select, 164 | textarea { 165 | font-family: inherit; /* 1 */ 166 | font-size: 100%; /* 1 */ 167 | line-height: 1.15; /* 1 */ 168 | margin: 0; /* 2 */ 169 | } 170 | 171 | /** 172 | * Show the overflow in IE. 173 | * 1. Show the overflow in Edge. 174 | */ 175 | 176 | button, 177 | input { /* 1 */ 178 | overflow: visible; 179 | } 180 | 181 | /** 182 | * Remove the inheritance of text transform in Edge, Firefox, and IE. 183 | * 1. Remove the inheritance of text transform in Firefox. 184 | */ 185 | 186 | button, 187 | select { /* 1 */ 188 | text-transform: none; 189 | } 190 | 191 | /** 192 | * Correct the inability to style clickable types in iOS and Safari. 193 | */ 194 | 195 | button, 196 | [type="button"], 197 | [type="reset"], 198 | [type="submit"] { 199 | -webkit-appearance: button; 200 | } 201 | 202 | /** 203 | * Remove the inner border and padding in Firefox. 204 | */ 205 | 206 | button::-moz-focus-inner, 207 | [type="button"]::-moz-focus-inner, 208 | [type="reset"]::-moz-focus-inner, 209 | [type="submit"]::-moz-focus-inner { 210 | border-style: none; 211 | padding: 0; 212 | } 213 | 214 | /** 215 | * Restore the focus styles unset by the previous rule. 216 | */ 217 | 218 | button:-moz-focusring, 219 | [type="button"]:-moz-focusring, 220 | [type="reset"]:-moz-focusring, 221 | [type="submit"]:-moz-focusring { 222 | outline: 1px dotted ButtonText; 223 | } 224 | 225 | /** 226 | * Correct the padding in Firefox. 227 | */ 228 | 229 | fieldset { 230 | padding: 0.35em 0.75em 0.625em; 231 | } 232 | 233 | /** 234 | * 1. Correct the text wrapping in Edge and IE. 235 | * 2. Correct the color inheritance from `fieldset` elements in IE. 236 | * 3. Remove the padding so developers are not caught out when they zero out 237 | * `fieldset` elements in all browsers. 238 | */ 239 | 240 | legend { 241 | box-sizing: border-box; /* 1 */ 242 | color: inherit; /* 2 */ 243 | display: table; /* 1 */ 244 | max-width: 100%; /* 1 */ 245 | padding: 0; /* 3 */ 246 | white-space: normal; /* 1 */ 247 | } 248 | 249 | /** 250 | * Add the correct vertical alignment in Chrome, Firefox, and Opera. 251 | */ 252 | 253 | progress { 254 | vertical-align: baseline; 255 | } 256 | 257 | /** 258 | * Remove the default vertical scrollbar in IE 10+. 259 | */ 260 | 261 | textarea { 262 | overflow: auto; 263 | } 264 | 265 | /** 266 | * 1. Add the correct box sizing in IE 10. 267 | * 2. Remove the padding in IE 10. 268 | */ 269 | 270 | [type="checkbox"], 271 | [type="radio"] { 272 | box-sizing: border-box; /* 1 */ 273 | padding: 0; /* 2 */ 274 | } 275 | 276 | /** 277 | * Correct the cursor style of increment and decrement buttons in Chrome. 278 | */ 279 | 280 | [type="number"]::-webkit-inner-spin-button, 281 | [type="number"]::-webkit-outer-spin-button { 282 | height: auto; 283 | } 284 | 285 | /** 286 | * 1. Correct the odd appearance in Chrome and Safari. 287 | * 2. Correct the outline style in Safari. 288 | */ 289 | 290 | [type="search"] { 291 | -webkit-appearance: textfield; /* 1 */ 292 | outline-offset: -2px; /* 2 */ 293 | } 294 | 295 | /** 296 | * Remove the inner padding in Chrome and Safari on macOS. 297 | */ 298 | 299 | [type="search"]::-webkit-search-decoration { 300 | -webkit-appearance: none; 301 | } 302 | 303 | /** 304 | * 1. Correct the inability to style clickable types in iOS and Safari. 305 | * 2. Change font properties to `inherit` in Safari. 306 | */ 307 | 308 | ::-webkit-file-upload-button { 309 | -webkit-appearance: button; /* 1 */ 310 | font: inherit; /* 2 */ 311 | } 312 | 313 | /* Interactive 314 | ========================================================================== */ 315 | 316 | /* 317 | * Add the correct display in Edge, IE 10+, and Firefox. 318 | */ 319 | 320 | details { 321 | display: block; 322 | } 323 | 324 | /* 325 | * Add the correct display in all browsers. 326 | */ 327 | 328 | summary { 329 | display: list-item; 330 | } 331 | 332 | /* Misc 333 | ========================================================================== */ 334 | 335 | /** 336 | * Add the correct display in IE 10+. 337 | */ 338 | 339 | template { 340 | display: none; 341 | } 342 | 343 | /** 344 | * Add the correct display in IE 10. 345 | */ 346 | 347 | [hidden] { 348 | display: none; 349 | } 350 | -------------------------------------------------------------------------------- /public/opensans.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Open Sans'; 3 | font-style: normal; 4 | font-weight: 400; 5 | font-stretch: normal; 6 | src: url('/fonts/OpenSans-Regular.ttf') format('truetype'); 7 | } 8 | 9 | @font-face { 10 | font-family: 'Open Sans'; 11 | font-style: normal; 12 | font-weight: 600; 13 | font-stretch: normal; 14 | src: url('/fonts/OpenSans-Semibold.ttf') format('truetype'); 15 | } 16 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/heikkipora/gitlab-radiator/12bca9dca9187914df9a86ab6ba64adbcbc87bb2/screenshot.png -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | import {basicAuth} from './auth.js' 2 | import compression from 'compression' 3 | import {config} from './config.js' 4 | import express from 'express' 5 | import fs from 'fs' 6 | import {fetchOfflineRunners} from './gitlab/runners.js' 7 | import http from 'http' 8 | import {serveLessAsCss} from './less.js' 9 | import {Server} from 'socket.io' 10 | import {update} from './gitlab/index.js' 11 | 12 | const app = express() 13 | const httpServer = http.Server(app) 14 | const socketIoServer = new Server(httpServer) 15 | 16 | if (process.env.NODE_ENV !== 'production' && fs.existsSync('./src/dev-assets.js')) { 17 | 18 | const {bindDevAssets} = await import('./dev-assets.js') 19 | bindDevAssets(app) 20 | } 21 | 22 | app.disable('x-powered-by') 23 | app.get('/client.css', serveLessAsCss) 24 | app.use(express.static('public')) 25 | app.use(compression()) 26 | app.use(basicAuth(config.auth)) 27 | 28 | httpServer.listen(config.port, () => { 29 | 30 | console.log(`Listening on port *:${config.port}`) 31 | }) 32 | 33 | const globalState = { 34 | projects: null, 35 | error: null, 36 | zoom: config.zoom, 37 | projectsOrder: config.projectsOrder, 38 | columns: config.columns, 39 | horizontal: config.horizontal, 40 | groupSuccessfulProjects: config.groupSuccessfulProjects 41 | } 42 | 43 | socketIoServer.on('connection', (socket) => { 44 | socket.emit('state', withDate(globalState)) 45 | }) 46 | 47 | async function runUpdate() { 48 | try { 49 | globalState.projects = await update(config) 50 | globalState.error = await errorIfRunnerOffline() 51 | socketIoServer.emit('state', withDate(globalState)) 52 | } catch (error) { 53 | 54 | console.error(error.message) 55 | globalState.error = `Failed to communicate with GitLab API: ${error.message}` 56 | socketIoServer.emit('state', withDate(globalState)) 57 | } 58 | setTimeout(runUpdate, config.interval) 59 | } 60 | 61 | async function errorIfRunnerOffline() { 62 | const offlineRunnersPerGitlab = await Promise.all(config.gitlabs.map(fetchOfflineRunners)) 63 | const {offline, totalCount} = offlineRunnersPerGitlab.reduce((acc, runner) => { 64 | return { 65 | offline: acc.offline.concat(runner.offline), 66 | totalCount: acc.totalCount + runner.totalCount 67 | } 68 | }, {offline: [], totalCount: 0}) 69 | 70 | if (offline.length > 0) { 71 | const names = offline.map(r => r.name).sort().join(', ') 72 | const counts = offline.length === totalCount ? 'All' : `${offline.length}/${totalCount}` 73 | return `${counts} runners offline: ${names}` 74 | } 75 | return null 76 | } 77 | 78 | await runUpdate() 79 | 80 | function withDate(state) { 81 | return { 82 | ...state, 83 | now: Date.now() 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/auth.js: -------------------------------------------------------------------------------- 1 | import authenticate from 'basic-auth' 2 | 3 | export function basicAuth(auth) { 4 | if (!auth || !auth.username || !auth.password) { 5 | 6 | console.log('No authentication configured') 7 | return (req, res, next) => next() 8 | } 9 | 10 | 11 | console.log('HTTP basic auth enabled') 12 | return (req, res, next) => { 13 | const {name, pass} = authenticate(req) || {} 14 | if (auth.username === name && auth.password === pass) { 15 | next() 16 | } else { 17 | res.setHeader('WWW-Authenticate', 'Basic realm="gitlab-radiator"') 18 | res.status(401).end() 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/client/arguments.ts: -------------------------------------------------------------------------------- 1 | interface ParsedQueryString { 2 | [key: string]: string | undefined 3 | } 4 | 5 | export function argumentsFromDocumentUrl(): {override: {columns?: number, zoom?: number}, includedTags: string[] | null, screen: {id: number, total: number}} { 6 | const args = parseQueryString(document.location.search) 7 | return { 8 | override: overrideArguments(args), 9 | includedTags: tagArguments(args), 10 | screen: screenArguments(args) 11 | } 12 | } 13 | 14 | function tagArguments(args: ParsedQueryString): string[] | null { 15 | if (args.tags === undefined) { 16 | return null 17 | } 18 | return args.tags 19 | .split(',') 20 | .map(t => t.toLowerCase().trim()) 21 | .filter(t => t) 22 | } 23 | 24 | function overrideArguments(args: ParsedQueryString): {columns?: number, zoom?: number} { 25 | return { 26 | ...parseColumns(args), 27 | ...parseZoom(args) 28 | } 29 | } 30 | 31 | function parseColumns(args: ParsedQueryString) { 32 | if (args.columns) { 33 | const columns = Number(args.columns) 34 | if (columns > 0 && columns <= 10) { 35 | return {columns} 36 | } 37 | } 38 | return {} 39 | } 40 | 41 | 42 | function parseZoom(args: ParsedQueryString) { 43 | if (args.zoom) { 44 | const zoom = Number(args.zoom) 45 | if (zoom > 0 && zoom <= 2) { 46 | return {zoom} 47 | } 48 | } 49 | return {} 50 | } 51 | 52 | function screenArguments(args: ParsedQueryString): {id: number, total: number} { 53 | const matches = (/(\d)of(\d)/).exec(args.screen || '') 54 | let id = matches ? Number(matches[1]) : 1 55 | const total = matches ? Number(matches[2]) : 1 56 | if (id > total) { 57 | id = total 58 | } 59 | return { 60 | id, 61 | total 62 | } 63 | } 64 | 65 | function parseQueryString(search: string): ParsedQueryString { 66 | const entries = search 67 | .slice(1) 68 | .split('&') 69 | .filter(parameter => parameter) 70 | .map((parameter: string): [string, string | undefined] => { 71 | const [key, value] = parameter.split('=') 72 | return [key, value] 73 | }) 74 | return Object.fromEntries(entries) 75 | } 76 | -------------------------------------------------------------------------------- /src/client/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import typescriptEslint from "@typescript-eslint/eslint-plugin"; 2 | import globals from "globals"; 3 | import tsParser from "@typescript-eslint/parser"; 4 | import path from "node:path"; 5 | import { fileURLToPath } from "node:url"; 6 | import js from "@eslint/js"; 7 | import { FlatCompat } from "@eslint/eslintrc"; 8 | 9 | const __filename = fileURLToPath(import.meta.url); 10 | const __dirname = path.dirname(__filename); 11 | const compat = new FlatCompat({ 12 | baseDirectory: __dirname, 13 | recommendedConfig: js.configs.recommended, 14 | allConfig: js.configs.all 15 | }); 16 | 17 | export default [...compat.extends( 18 | "eslint:recommended", 19 | "plugin:react/recommended", 20 | "plugin:@typescript-eslint/eslint-recommended", 21 | "plugin:@typescript-eslint/recommended", 22 | ), { 23 | plugins: { 24 | "@typescript-eslint": typescriptEslint, 25 | }, 26 | 27 | languageOptions: { 28 | globals: { 29 | ...globals.browser, 30 | }, 31 | 32 | parser: tsParser, 33 | }, 34 | 35 | settings: { 36 | react: { 37 | version: "17.0", 38 | }, 39 | }, 40 | }]; -------------------------------------------------------------------------------- /src/client/gitlab-types.ts: -------------------------------------------------------------------------------- 1 | 2 | export interface GlobalState { 3 | columns: number 4 | error: string | null 5 | groupSuccessfulProjects: boolean 6 | horizontal: boolean 7 | projects: Project[] | null 8 | projectsOrder: string[] 9 | zoom: number 10 | now: number 11 | } 12 | 13 | export interface Project { 14 | archived: false 15 | group: string 16 | id: number 17 | name: string 18 | nameWithoutNamespace: string 19 | tags: string[] 20 | url: string 21 | default_branch: string 22 | pipelines: Pipeline[] 23 | maxNonFailedJobsVisible: number 24 | status: 'success' | 'failed' 25 | } 26 | 27 | export interface Pipeline { 28 | commit: Commit | null 29 | id: number 30 | ref: string 31 | stages: Stage[] 32 | status: 'success' | 'failed' 33 | } 34 | 35 | export interface Commit { 36 | title: string 37 | author: string 38 | } 39 | 40 | export interface Stage { 41 | jobs: Job[] 42 | name: string 43 | } 44 | 45 | export interface Job { 46 | finishedAt: string | null 47 | id: number 48 | name: string 49 | stage: string 50 | startedAt: string | null 51 | status: JobStatus 52 | url: string 53 | } 54 | 55 | export type JobStatus = 'created' | 'failed' | 'manual' | 'pending' | 'running' | 'skipped' | 'success' 56 | -------------------------------------------------------------------------------- /src/client/groupedProjects.tsx: -------------------------------------------------------------------------------- 1 | import {Groups} from './groups' 2 | import groupBy from 'lodash/groupBy' 3 | import {Projects} from './projects' 4 | import React from 'react' 5 | import type {Project} from './gitlab-types' 6 | 7 | export function GroupedProjects({projects, projectsOrder, groupSuccessfulProjects, zoom, columns, now, screen}: {projects: Project[], projectsOrder: string[], groupSuccessfulProjects: boolean, zoom: number, columns: number, now: number, screen: {id: number, total: number}}) { 8 | if (groupSuccessfulProjects) { 9 | return renderProjectsGrouped(projects, projectsOrder, zoom, columns, now, screen) 10 | } 11 | return 12 | } 13 | 14 | function renderProjectsGrouped(projects: Project[], projectsOrder: string[], zoom: number, columns: number, now: number, screen: {id: number, total: number}) { 15 | const successfullProjects = projects.filter(({status}) => status === 'success') 16 | const otherProjects= projects.filter(({status}) => status !== 'success') 17 | const groupedProjects = groupBy(successfullProjects, 'group') 18 | 19 | return 20 | 21 | 22 | 23 | } 24 | -------------------------------------------------------------------------------- /src/client/groups.tsx: -------------------------------------------------------------------------------- 1 | import type {Pipeline, Project} from './gitlab-types' 2 | import React from 'react' 3 | import {Timestamp} from './timestamp' 4 | import {style, zoomStyle} from './projects' 5 | 6 | export function Groups({groupedProjects, now, zoom, columns}: {groupedProjects: {[groupname: string]: Project[]}, now: number, zoom: number, columns: number}) { 7 | return
    8 | {Object 9 | .entries(groupedProjects) 10 | .sort(([groupName1], [groupName2]) => groupName1.localeCompare(groupName2)) 11 | .map(([groupName, projects]) => ) 12 | } 13 |
14 | } 15 | 16 | function GroupElement({groupName, projects, now, columns}: {groupName: string, projects: Project[], now: number, columns: number}) { 17 | const pipelines: (Pipeline & {project: string})[] = [] 18 | projects.forEach((project) => { 19 | project.pipelines.forEach((pipeline) => { 20 | pipelines.push({ 21 | ...pipeline, 22 | project: project.nameWithoutNamespace 23 | }) 24 | }) 25 | }) 26 | 27 | return
  • 28 |

    {groupName}

    29 |
    {projects.length} Project{projects.length > 1 ? 's' : ''}
    30 | 31 |
  • 32 | } 33 | 34 | function GroupInfoElement({now, pipeline}: {now: number, pipeline: (Pipeline & {project: string})}) { 35 | return
    36 |
    37 | {pipeline.commit ? pipeline.commit.author : '-'} 38 | {pipeline.commit ? pipeline.project : '-'} 39 |
    40 |
    41 | 42 | on {pipeline.ref} 43 |
    44 |
    45 | } 46 | 47 | -------------------------------------------------------------------------------- /src/client/index.tsx: -------------------------------------------------------------------------------- 1 | import 'core-js/stable' 2 | import 'regenerator-runtime/runtime' 3 | 4 | import type {GlobalState, Project} from './gitlab-types' 5 | import {argumentsFromDocumentUrl} from './arguments' 6 | import {createRoot} from 'react-dom/client' 7 | import {GroupedProjects} from './groupedProjects' 8 | import React from 'react' 9 | 10 | class RadiatorApp extends React.Component { 11 | public args: {override: {columns?: number, zoom?: number}, includedTags: string[] | null, screen: {id: number, total: number}} 12 | 13 | constructor(props: unknown) { 14 | super(props) 15 | this.state = { 16 | columns: 1, 17 | error: null, 18 | groupSuccessfulProjects: false, 19 | horizontal: false, 20 | projects: null, 21 | projectsOrder: [], 22 | now: 0, 23 | zoom: 1 24 | } 25 | 26 | this.args = argumentsFromDocumentUrl() 27 | } 28 | 29 | componentDidMount = () => { 30 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 31 | const socket = (window as any).io() 32 | socket.on('state', this.onServerStateUpdated.bind(this)) 33 | socket.on('disconnect', this.onDisconnect.bind(this)) 34 | } 35 | 36 | render = () => { 37 | const {screen} = this.args 38 | const {now, zoom, columns, projects, projectsOrder, groupSuccessfulProjects, horizontal} = this.state 39 | return
    40 | {this.renderErrorMessage()} 41 | {this.renderProgressMessage()} 42 | 43 | {projects && 44 | 48 | } 49 |
    50 | } 51 | 52 | renderErrorMessage = () => 53 | this.state.error &&
    {this.state.error}
    54 | 55 | renderProgressMessage = () => { 56 | if (!this.state.projects) { 57 | return

    Fetching projects and CI pipelines from GitLab...

    58 | } else if (this.state.projects.length === 0) { 59 | return

    No projects with CI pipelines found.

    60 | } 61 | return null 62 | } 63 | 64 | onServerStateUpdated = (state: GlobalState) => { 65 | this.setState({ 66 | ...state, 67 | ...this.args.override, 68 | projects: this.filterProjectsByTags(state.projects) 69 | }) 70 | } 71 | 72 | onDisconnect = () => this.setState({error: 'gitlab-radiator server is offline'}) 73 | 74 | filterProjectsByTags = (projects: Project[] | null) => { 75 | if (projects === null) { 76 | return null 77 | } 78 | 79 | // No tag list specified, include all projects 80 | if (!this.args.includedTags) { 81 | return projects 82 | } 83 | // Empty tag list specified, include projects without tags 84 | if (this.args.includedTags.length === 0) { 85 | return projects.filter(project => 86 | project.tags.length === 0 87 | ) 88 | } 89 | // Tag list specified, include projects which have at least one of them 90 | return projects.filter(project => 91 | project.tags.some(tag => this.args.includedTags?.includes(tag)) 92 | ) 93 | } 94 | } 95 | 96 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 97 | const root = createRoot(document.getElementById('app')!) 98 | root.render(); 99 | 100 | module.hot?.accept() 101 | -------------------------------------------------------------------------------- /src/client/info.tsx: -------------------------------------------------------------------------------- 1 | import type {Pipeline} from './gitlab-types' 2 | import React from 'react' 3 | import {Timestamp} from './timestamp' 4 | 5 | export function Info({pipeline, now}: {pipeline: Pipeline, now: number}) { 6 | return
    7 |
    8 | {pipeline.commit ? pipeline.commit.author : '-'} 9 | {pipeline.commit ? `'${pipeline.commit.title}'` : '-'} 10 |
    11 |
    12 | 13 | on {pipeline.ref} 14 |
    15 |
    16 | } 17 | -------------------------------------------------------------------------------- /src/client/jobs.tsx: -------------------------------------------------------------------------------- 1 | import groupBy from 'lodash/groupBy' 2 | import mapValues from 'lodash/mapValues' 3 | import orderBy from 'lodash/orderBy' 4 | import partition from 'lodash/partition' 5 | import React from 'react' 6 | import toPairs from 'lodash/toPairs' 7 | import type {Job, JobStatus} from './gitlab-types' 8 | 9 | const NON_BREAKING_SPACE = '\xa0' 10 | 11 | const JOB_STATES_IN_INTEREST_ORDER: JobStatus[] = [ 12 | 'failed', 13 | 'running', 14 | 'created', 15 | 'pending', 16 | 'success', 17 | 'skipped' 18 | ] 19 | 20 | export function Jobs({jobs, maxNonFailedJobsVisible}: {jobs: Job[], maxNonFailedJobsVisible: number}) { 21 | const [failedJobs, nonFailedJobs] = partition(jobs, {status: 'failed'}) 22 | const filteredJobs = sortByOriginalOrder( 23 | failedJobs.concat( 24 | orderBy(nonFailedJobs, ({status}) => JOB_STATES_IN_INTEREST_ORDER.indexOf(status)) 25 | .slice(0, Math.max(0, maxNonFailedJobsVisible - failedJobs.length)) 26 | ), 27 | jobs 28 | ) 29 | 30 | const hiddenJobs = jobs.filter(job => filteredJobs.indexOf(job) === -1) 31 | const hiddenCountsByStatus = mapValues( 32 | groupBy(hiddenJobs, 'status'), 33 | jobsForStatus => jobsForStatus.length 34 | ) 35 | 36 | const hiddenJobsText = orderBy(toPairs(hiddenCountsByStatus), ([status]) => status) 37 | .map(([status, count]) => `${count}${NON_BREAKING_SPACE}${status}`) 38 | .join(', ') 39 | 40 | return
      41 | {filteredJobs.map(job => )} 42 | { 43 | hiddenJobs.length > 0 ?
    1. + {hiddenJobsText}
    2. : null 44 | } 45 |
    46 | } 47 | 48 | function JobElement({job}: {job: Job}) { 49 | return
  • 50 | {job.name} 51 | {!job.url && job.name} 52 |
  • 53 | } 54 | 55 | function sortByOriginalOrder(filteredJobs: Job[], jobs: Job[]) { 56 | return orderBy(filteredJobs, (job: Job) => jobs.indexOf(job)) 57 | } 58 | -------------------------------------------------------------------------------- /src/client/projects.tsx: -------------------------------------------------------------------------------- 1 | import {Info} from './info' 2 | import React from 'react' 3 | import sortBy from 'lodash/sortBy' 4 | import {Stages} from './stages' 5 | import type {Project} from './gitlab-types' 6 | 7 | export function Projects({columns, now, projects, projectsOrder, screen, zoom}: {columns: number, now: number, projects: Project[], projectsOrder: string[], screen: {id: number, total: number}, zoom: number}) { 8 | return
      9 | {sortBy(projects, projectsOrder) 10 | .filter(forScreen(screen, projects.length)) 11 | .map(project => ) 12 | } 13 |
    14 | } 15 | 16 | function ProjectElement({columns, now, project}: {columns: number, now: number, project: Project}) { 17 | const [pipeline] = project.pipelines 18 | 19 | return
  • 20 |

    21 | {project.url && {project.name}} 22 | {!project.url && project.name} 23 |

    24 | 25 | 26 |
  • 27 | } 28 | 29 | function forScreen(screen: {id: number, total: number}, projectsCount: number) { 30 | const perScreen = Math.ceil(projectsCount / screen.total) 31 | const first = perScreen * (screen.id - 1) 32 | const last = perScreen * screen.id 33 | return (_project: Project, projectIndex: number) => projectIndex >= first && projectIndex < last 34 | } 35 | 36 | export function zoomStyle(zoom: number) { 37 | const widthPercentage = Math.round(100 / zoom) 38 | return { 39 | transform: `scale(${zoom})`, 40 | width: `${widthPercentage}vmax` 41 | } 42 | } 43 | 44 | export function style(columns: number) { 45 | const marginPx = 12 46 | const widthPercentage = Math.floor(100 / columns) 47 | return { 48 | margin: `${marginPx}px`, 49 | width: `calc(${widthPercentage}% - ${2 * marginPx}px)` 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/client/stages.tsx: -------------------------------------------------------------------------------- 1 | import {Jobs} from './jobs' 2 | import React from 'react' 3 | import type {Stage} from './gitlab-types' 4 | 5 | export function Stages({stages, maxNonFailedJobsVisible}: {stages: Stage[], maxNonFailedJobsVisible: number}) { 6 | return
      7 | {stages.map((stage, index) => 8 | 9 | )} 10 |
    11 | } 12 | 13 | function StageElement({stage, maxNonFailedJobsVisible}: {stage: Stage, maxNonFailedJobsVisible: number}) { 14 | return
  • 15 |
    {stage.name}
    16 | 17 |
  • 18 | } 19 | -------------------------------------------------------------------------------- /src/client/timestamp.tsx: -------------------------------------------------------------------------------- 1 | import moment from 'moment' 2 | import React from 'react' 3 | import type {Stage} from './gitlab-types' 4 | 5 | export function Timestamp({stages, now}: {stages: Stage[], now: number}) { 6 | const timestamps = getTimestamps(stages) 7 | if (timestamps.length === 0) { 8 | return Pending... 9 | } 10 | 11 | const finished = timestamps 12 | .map(t => t.finishedAt) 13 | .filter((t): t is number => t !== null) 14 | 15 | const inProgress = timestamps.length > finished.length 16 | if (inProgress) { 17 | const [{startedAt}] = timestamps.sort((a, b) => a.startedAt - b.startedAt) 18 | return Started {moment(startedAt).from(now)} 19 | } 20 | 21 | const [latestFinishedAt] = finished.sort((a, b) => b - a) 22 | return Finished {moment(latestFinishedAt).from(now)} 23 | } 24 | 25 | function getTimestamps(stages: Stage[]): {startedAt: number, finishedAt: number | null}[] { 26 | return stages 27 | .flatMap(s => s.jobs) 28 | .map(job => ({ 29 | startedAt: parseDate(job.startedAt), 30 | finishedAt: parseDate(job.finishedAt) 31 | })) 32 | .filter((t): t is {startedAt: number, finishedAt: number | null} => t.startedAt !== null) 33 | } 34 | 35 | function parseDate(value: string | null) { 36 | return value ? new Date(value).valueOf() : null 37 | } 38 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import fs from 'fs' 3 | import os from 'os' 4 | import yaml from 'js-yaml' 5 | 6 | const configFile = expandTilde(process.env.GITLAB_RADIATOR_CONFIG || '~/.gitlab-radiator.yml') 7 | const yamlContent = fs.readFileSync(configFile, 'utf8') 8 | export const config = validate(yaml.load(yamlContent)) 9 | 10 | config.interval = Number(config.interval || 10) * 1000 11 | config.port = Number(config.port || 3000) 12 | config.zoom = Number(config.zoom || 1.0) 13 | config.columns = Number(config.columns || 1) 14 | config.horizontal = config.horizontal || false 15 | config.groupSuccessfulProjects = config.groupSuccessfulProjects || false 16 | config.projectsOrder = config.projectsOrder || ['name'] 17 | config.gitlabs = config.gitlabs.map((gitlab) => { 18 | return { 19 | url: gitlab.url, 20 | ignoreArchived: gitlab.ignoreArchived === undefined ? true : gitlab.ignoreArchived, 21 | maxNonFailedJobsVisible: Number(gitlab.maxNonFailedJobsVisible || 999999), 22 | ca: gitlab.caFile && fs.existsSync(gitlab.caFile, 'utf-8') ? fs.readFileSync(gitlab.caFile) : undefined, 23 | 'access-token': gitlab['access-token'] || process.env.GITLAB_ACCESS_TOKEN, 24 | projects: { 25 | excludePipelineStatus: (gitlab.projects || {}).excludePipelineStatus || [], 26 | include: (gitlab.projects || {}).include || '', 27 | exclude: (gitlab.projects || {}).exclude || '' 28 | } 29 | } 30 | }) 31 | config.colors = config.colors || {} 32 | 33 | function expandTilde(path) { 34 | return path.replace(/^~($|\/|\\)/, `${os.homedir()}$1`) 35 | } 36 | 37 | function validate(cfg) { 38 | assert.ok(cfg.gitlabs, 'Mandatory gitlab properties missing from configuration file') 39 | cfg.gitlabs.forEach((gitlab) => { 40 | assert.ok(gitlab.url, 'Mandatory gitlab url missing from configuration file') 41 | assert.ok(gitlab['access-token'] || process.env.GITLAB_ACCESS_TOKEN, 'Mandatory gitlab access token missing from configuration (and none present at GITLAB_ACCESS_TOKEN env variable)') 42 | }) 43 | return cfg 44 | } 45 | -------------------------------------------------------------------------------- /src/dev-assets.js: -------------------------------------------------------------------------------- 1 | import config from '../webpack.dev.cjs' 2 | import webpack from 'webpack' 3 | import webpackDevMiddleware from 'webpack-dev-middleware' 4 | import webpackHotMiddleware from 'webpack-hot-middleware' 5 | 6 | export function bindDevAssets(app) { 7 | const compiler = webpack(config) 8 | app.use(webpackDevMiddleware(compiler)) 9 | app.use(webpackHotMiddleware(compiler)) 10 | } 11 | -------------------------------------------------------------------------------- /src/gitlab/client.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import https from 'https' 3 | import url from 'url' 4 | 5 | export function gitlabRequest(path, params, gitlab) { 6 | return lazyClient(gitlab).get(path, {params}) 7 | } 8 | 9 | const clients = new Map() 10 | 11 | function lazyClient(gitlab) { 12 | const gitlabUrl = gitlab.url 13 | if (gitlabUrl === undefined) { 14 | 15 | console.log('Got undefined url for ' + JSON.stringify(gitlab)) 16 | } 17 | if (!clients.get(gitlabUrl)) { 18 | const client = axios.create({ 19 | baseURL: url.resolve(gitlabUrl, '/api/v4/'), 20 | headers: {'PRIVATE-TOKEN': gitlab['access-token']}, 21 | httpsAgent: new https.Agent({keepAlive: true, ca: gitlab.ca}), 22 | timeout: 30 * 1000 23 | }) 24 | clients.set(gitlabUrl, client) 25 | } 26 | return clients.get(gitlabUrl) 27 | } 28 | -------------------------------------------------------------------------------- /src/gitlab/index.js: -------------------------------------------------------------------------------- 1 | import {fetchLatestPipelines} from './pipelines.js' 2 | import {fetchProjects} from './projects.js' 3 | 4 | export async function update(config) { 5 | const projectsWithPipelines = await loadProjectsWithPipelines(config) 6 | return projectsWithPipelines 7 | .filter(project => project.pipelines.length > 0) 8 | } 9 | 10 | async function loadProjectsWithPipelines(config) { 11 | const allProjectsWithPipelines = [] 12 | await Promise.all(config.gitlabs.map(async (gitlab) => { 13 | const projects = (await fetchProjects(gitlab)) 14 | .map(project => ({ 15 | ...project, 16 | maxNonFailedJobsVisible: gitlab.maxNonFailedJobsVisible 17 | })) 18 | 19 | for (const project of projects) { 20 | allProjectsWithPipelines.push(await projectWithPipelines(project, gitlab)) 21 | } 22 | })) 23 | return allProjectsWithPipelines 24 | } 25 | 26 | async function projectWithPipelines(project, config) { 27 | const pipelines = filterOutEmpty(await fetchLatestPipelines(project.id, config)) 28 | .filter(excludePipelineStatusFilter(config)) 29 | const status = defaultBranchStatus(project, pipelines) 30 | return { 31 | ...project, 32 | pipelines, 33 | status 34 | } 35 | } 36 | 37 | function defaultBranchStatus(project, pipelines) { 38 | const [head] = pipelines 39 | .filter(({ref}) => ref === project.default_branch) 40 | .map(({status}) => status) 41 | return head 42 | } 43 | 44 | function filterOutEmpty(pipelines) { 45 | return pipelines.filter(pipeline => pipeline.stages) 46 | } 47 | 48 | function excludePipelineStatusFilter(config) { 49 | return pipeline => { 50 | if (config.projects && config.projects.excludePipelineStatus) { 51 | return !config.projects.excludePipelineStatus.includes(pipeline.status) 52 | } 53 | return true 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/gitlab/pipelines.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import {gitlabRequest} from './client.js' 3 | 4 | export async function fetchLatestPipelines(projectId, gitlab) { 5 | const pipelines = await fetchLatestAndMasterPipeline(projectId, gitlab) 6 | 7 | const pipelinesWithStages = [] 8 | for (const {id, ref, status} of pipelines) { 9 | const {commit, stages} = await fetchJobs(projectId, id, gitlab) 10 | const downstreamStages = await fetchDownstreamJobs(projectId, id, gitlab) 11 | pipelinesWithStages.push({ 12 | id, 13 | ref, 14 | status, 15 | commit, 16 | stages: stages.concat(downstreamStages) 17 | }) 18 | } 19 | return pipelinesWithStages 20 | } 21 | 22 | 23 | async function fetchLatestAndMasterPipeline(projectId, config) { 24 | const pipelines = await fetchPipelines(projectId, config, {per_page: 100}) 25 | if (pipelines.length === 0) { 26 | return [] 27 | } 28 | const latestPipeline = _.take(pipelines, 1) 29 | if (latestPipeline[0].ref === 'master') { 30 | return latestPipeline 31 | } 32 | const latestMasterPipeline = _(pipelines).filter({ref: 'master'}).take(1).value() 33 | if (latestMasterPipeline.length > 0) { 34 | return latestPipeline.concat(latestMasterPipeline) 35 | } 36 | const masterPipelines = await fetchPipelines(projectId, config, {per_page: 50, ref: 'master'}) 37 | return latestPipeline.concat(_.take(masterPipelines, 1)) 38 | } 39 | 40 | async function fetchPipelines(projectId, config, options) { 41 | const {data: pipelines} = await gitlabRequest(`/projects/${projectId}/pipelines`, options, config) 42 | return pipelines.filter(pipeline => pipeline.status !== 'skipped') 43 | } 44 | 45 | async function fetchDownstreamJobs(projectId, pipelineId, config) { 46 | const {data: gitlabBridgeJobs} = await gitlabRequest(`/projects/${projectId}/pipelines/${pipelineId}/bridges`, {per_page: 100}, config) 47 | const childPipelines = gitlabBridgeJobs.filter(bridge => bridge.downstream_pipeline !== null && bridge.downstream_pipeline.status !== 'skipped') 48 | 49 | const downstreamStages = [] 50 | for(const childPipeline of childPipelines) { 51 | const {stages} = await fetchJobs(childPipeline.downstream_pipeline.project_id, childPipeline.downstream_pipeline.id, config) 52 | downstreamStages.push(stages.map(stage => ({ 53 | ...stage, 54 | name: `${childPipeline.stage}:${stage.name}` 55 | }))) 56 | } 57 | return downstreamStages.flat() 58 | } 59 | 60 | async function fetchJobs(projectId, pipelineId, config) { 61 | const {data: gitlabJobs} = await gitlabRequest(`/projects/${projectId}/pipelines/${pipelineId}/jobs?include_retried=true`, {per_page: 100}, config) 62 | if (gitlabJobs.length === 0) { 63 | return {commit: undefined, stages: []} 64 | } 65 | 66 | const commit = findCommit(gitlabJobs) 67 | const stages = _(gitlabJobs) 68 | .map(job => ({ 69 | id: job.id, 70 | status: job.status, 71 | stage: job.stage, 72 | name: job.name, 73 | startedAt: job.started_at, 74 | finishedAt: job.finished_at, 75 | url: job.web_url 76 | })) 77 | .orderBy('id') 78 | .groupBy('stage') 79 | .mapValues(mergeRetriedJobs) 80 | .mapValues(cleanup) 81 | .toPairs() 82 | .map(([name, jobs]) => ({name, jobs: _.sortBy(jobs, 'name')})) 83 | .value() 84 | 85 | return { 86 | commit, 87 | stages 88 | } 89 | } 90 | 91 | function findCommit(jobs) { 92 | const [job] = jobs.filter(j => j.commit) 93 | if (!job) { 94 | return null 95 | } 96 | return { 97 | title: job.commit.title, 98 | author: job.commit.author_name 99 | } 100 | } 101 | 102 | function mergeRetriedJobs(jobs) { 103 | return jobs.reduce((mergedJobs, job) => { 104 | const index = mergedJobs.findIndex(mergedJob => mergedJob.name === job.name) 105 | if (index >= 0) { 106 | mergedJobs[index] = job 107 | } else { 108 | mergedJobs.push(job) 109 | } 110 | return mergedJobs 111 | }, []) 112 | } 113 | 114 | function cleanup(jobs) { 115 | return _(jobs) 116 | .map(job => _.omitBy(job, _.isNull)) 117 | .map(job => _.omit(job, 'stage')) 118 | .value() 119 | } 120 | -------------------------------------------------------------------------------- /src/gitlab/projects.js: -------------------------------------------------------------------------------- 1 | import {gitlabRequest} from './client.js' 2 | 3 | export async function fetchProjects(gitlab) { 4 | const projects = await fetchOwnProjects(gitlab) 5 | return projects 6 | // Ignore projects for which CI/CD is not enabled 7 | .filter(project => project.jobs_enabled) 8 | .map(projectMapper) 9 | .filter(includeRegexFilter(gitlab)) 10 | .filter(excludeRegexFilter(gitlab)) 11 | .filter(archivedFilter(gitlab)) 12 | } 13 | 14 | async function fetchOwnProjects(gitlab) { 15 | const projects = [] 16 | const SAFETY_MAX_PAGE = 10 17 | for (let page = 1; page <= SAFETY_MAX_PAGE; page += 1) { 18 | 19 | const {data, headers} = await gitlabRequest('/projects', {page, per_page: 100, membership: true}, gitlab) 20 | projects.push(data) 21 | if (data.length === 0 || !headers['x-next-page']) { 22 | break 23 | } 24 | } 25 | return projects.flat() 26 | } 27 | 28 | function projectMapper(project) { 29 | return { 30 | id: project.id, 31 | name: project.path_with_namespace, 32 | nameWithoutNamespace: project.path, 33 | group: getGroupName(project), 34 | archived: project.archived, 35 | default_branch: project.default_branch || 'master', 36 | url: project.web_url, 37 | tags: (project.tag_list || []).map(t => t.toLowerCase()) 38 | } 39 | } 40 | 41 | function getGroupName(project) { 42 | const pathWithNameSpace = project.path_with_namespace 43 | return pathWithNameSpace.split('/')[0] 44 | } 45 | 46 | function includeRegexFilter(config) { 47 | return project => { 48 | if (config.projects && config.projects.include) { 49 | const includeRegex = new RegExp(config.projects.include, "i") 50 | return includeRegex.test(project.name) 51 | } 52 | return true 53 | } 54 | } 55 | 56 | function excludeRegexFilter(config) { 57 | return project => { 58 | if (config.projects && config.projects.exclude) { 59 | const excludeRegex = new RegExp(config.projects.exclude, "i") 60 | return !excludeRegex.test(project.name) 61 | } 62 | return true 63 | } 64 | } 65 | 66 | function archivedFilter(config) { 67 | return project => { 68 | if (config.ignoreArchived) { 69 | return !project.archived 70 | } 71 | return true 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/gitlab/runners.js: -------------------------------------------------------------------------------- 1 | import {gitlabRequest} from './client.js' 2 | 3 | export async function fetchOfflineRunners(gitlab) { 4 | const runners = await fetchRunners(gitlab) 5 | const offline = runners.filter(r => r.status === 'offline') 6 | return { 7 | offline, 8 | totalCount: runners.length 9 | } 10 | } 11 | 12 | async function fetchRunners(gitlab) { 13 | const {data: runners} = await gitlabRequest('/runners', {}, gitlab) 14 | return runners.map(r => ({ 15 | name: r.description || r.id, 16 | status: r.status 17 | })) 18 | } 19 | -------------------------------------------------------------------------------- /src/less.js: -------------------------------------------------------------------------------- 1 | import {config} from './config.js' 2 | import fs from 'fs' 3 | import less from 'less' 4 | import path from 'path' 5 | 6 | const filename = path.join('public', 'client.less') 7 | 8 | export async function serveLessAsCss(req, res) { 9 | try { 10 | const source = await fs.promises.readFile(filename, 'utf-8') 11 | const {css} = await less.render(withColorOverrides(source), {filename}) 12 | res.setHeader('content-type', 'text/css') 13 | res.send(css) 14 | } catch (err) { 15 | console.error('Failed to render client.less', err) 16 | res.sendStatus(500) 17 | } 18 | } 19 | 20 | function withColorOverrides(source) { 21 | let colorLess = '' 22 | Object.keys(config.colors).forEach((stateName) => { 23 | colorLess += `@${stateName}-color:${config.colors[stateName]};` 24 | }) 25 | return source + colorLess 26 | } 27 | -------------------------------------------------------------------------------- /test/gitlab-integration.js: -------------------------------------------------------------------------------- 1 | import {expect} from 'chai' 2 | import {fetchLatestPipelines} from '../src/gitlab/pipelines.js' 3 | import {fetchProjects} from '../src/gitlab/projects.js' 4 | import {update} from '../src/gitlab/index.js' 5 | 6 | const gitlab = { 7 | url: 'https://gitlab.com', 8 | 'access-token': 'glpat-ueDVvYgWytPzCdHSmzbT', 9 | maxNonFailedJobsVisible: 10 10 | } 11 | 12 | describe('Gitlab client', () => { 13 | it('Should find five projects with no filtering ', async () => { 14 | const config = {...gitlab} 15 | const projects = await fetchProjects(config) 16 | expect(projects).to.deep.equal([ 17 | {archived: false, default_branch: 'master', group: 'gitlab-radiator-test', id: 39541352, name: 'gitlab-radiator-test/project-with-child-pipeline', nameWithoutNamespace: 'project-with-child-pipeline', tags: [], url: 'https://gitlab.com/gitlab-radiator-test/project-with-child-pipeline'}, 18 | {archived: false, default_branch: 'master', group: 'gitlab-radiator-test', id: 5385889, name: 'gitlab-radiator-test/ci-skip-test-project', nameWithoutNamespace: 'ci-skip-test-project', tags: [], url: 'https://gitlab.com/gitlab-radiator-test/ci-skip-test-project'}, 19 | {archived: false, default_branch: 'master', group: 'gitlab-radiator-test', id: 5304923, name: 'gitlab-radiator-test/empty-test', nameWithoutNamespace: 'empty-test', tags: [], url: 'https://gitlab.com/gitlab-radiator-test/empty-test'}, 20 | {archived: false, default_branch: 'master', group: 'gitlab-radiator-test', id: 5290928, name: 'gitlab-radiator-test/integration-test-project-2', nameWithoutNamespace: 'integration-test-project-2', tags: [], url: 'https://gitlab.com/gitlab-radiator-test/integration-test-project-2'}, 21 | {archived: false, default_branch: 'master', group: 'gitlab-radiator-test', id: 5290865, name: 'gitlab-radiator-test/integration-test-project-1', nameWithoutNamespace: 'integration-test-project-1', tags: ['display-1'], url: 'https://gitlab.com/gitlab-radiator-test/integration-test-project-1'} 22 | ]) 23 | }) 24 | 25 | it('Should find one project with inclusive filtering', async () => { 26 | const config = {...gitlab, projects: {include: '.*project-1'}} 27 | const projects = await fetchProjects(config) 28 | expect(projects).to.deep.equal([ 29 | {archived: false, default_branch: 'master', id: 5290865, group: 'gitlab-radiator-test', name: 'gitlab-radiator-test/integration-test-project-1', nameWithoutNamespace: 'integration-test-project-1', tags: ['display-1'], url: 'https://gitlab.com/gitlab-radiator-test/integration-test-project-1'} 30 | ]) 31 | }) 32 | 33 | it('Should find four projects with exclusive filtering', async () => { 34 | const config = {...gitlab, projects: {exclude: '.*project-1'}} 35 | const projects = await fetchProjects(config) 36 | expect(projects).to.deep.equal([ 37 | {archived: false, default_branch: 'master', group: 'gitlab-radiator-test', id: 39541352, name: 'gitlab-radiator-test/project-with-child-pipeline', nameWithoutNamespace: 'project-with-child-pipeline', tags: [], url: 'https://gitlab.com/gitlab-radiator-test/project-with-child-pipeline'}, 38 | {archived: false, default_branch: 'master', id: 5385889, group: 'gitlab-radiator-test', name: 'gitlab-radiator-test/ci-skip-test-project', nameWithoutNamespace: 'ci-skip-test-project', tags: [], url: 'https://gitlab.com/gitlab-radiator-test/ci-skip-test-project'}, 39 | {archived: false, default_branch: 'master', id: 5304923, group: 'gitlab-radiator-test', name: 'gitlab-radiator-test/empty-test', nameWithoutNamespace: 'empty-test', tags: [], url: 'https://gitlab.com/gitlab-radiator-test/empty-test'}, 40 | {archived: false, default_branch: 'master', id: 5290928, group: 'gitlab-radiator-test', name: 'gitlab-radiator-test/integration-test-project-2', nameWithoutNamespace: 'integration-test-project-2', tags: [], url: 'https://gitlab.com/gitlab-radiator-test/integration-test-project-2'} 41 | ]) 42 | }) 43 | 44 | it('Should find latest non-skipped pipeline for project', async () => { 45 | const config = {...gitlab} 46 | const pipelines = await fetchLatestPipelines(5385889, config) 47 | expect(pipelines).to.deep.equal( 48 | [{ 49 | commit: { 50 | author: 'Heikki Pora', 51 | title: '[ci skip] do nothing' 52 | }, 53 | id: 1261086942, 54 | ref: 'master', 55 | stages: [{ 56 | jobs: [{ 57 | finishedAt: '2024-04-20T08:24:58.581Z', 58 | id: 6674648871, 59 | name: 'test', 60 | startedAt: '2024-04-20T08:24:31.697Z', 61 | status: 'success', 62 | url: 'https://gitlab.com/gitlab-radiator-test/ci-skip-test-project/-/jobs/6674648871' 63 | }], 64 | name: 'test' 65 | }], 66 | status: 'success' 67 | }] 68 | ) 69 | }) 70 | 71 | it('Should find latest pipelines for project (feature branch + master) with stages and retried jobs merged to one entry', async () => { 72 | const config = {...gitlab} 73 | const pipelines = await fetchLatestPipelines(5290928, config) 74 | expect(pipelines).to.deep.equal( 75 | [{ 76 | id: 234613306, 77 | status: 'success', 78 | commit: { 79 | title: 'Fail more', 80 | author: 'Heikki Pora' 81 | }, 82 | ref: 'feature/test-branch', 83 | stages: [ 84 | { 85 | name: 'test', 86 | jobs: [ 87 | { 88 | id: 932599898, 89 | status: 'success', 90 | name: 'fail_randomly_long_name', 91 | startedAt: '2020-12-26T13:59:35.397Z', 92 | finishedAt: '2020-12-26T14:00:11.845Z', 93 | url: 'https://gitlab.com/gitlab-radiator-test/integration-test-project-2/-/jobs/932599898' 94 | } 95 | ] 96 | }, 97 | { 98 | name: 'build', 99 | jobs: [ 100 | { 101 | id: 932599715, 102 | status: 'success', 103 | name: 'build_my_stuff', 104 | startedAt: '2020-12-26T14:00:12.710Z', 105 | finishedAt: '2020-12-26T14:00:53.946Z', 106 | url: 'https://gitlab.com/gitlab-radiator-test/integration-test-project-2/-/jobs/932599715' 107 | } 108 | ] 109 | } 110 | ] 111 | }, 112 | { 113 | id: 234613296, 114 | status: 'failed', 115 | commit: { 116 | author: 'Heikki Pora', 117 | title: 'Fail more' 118 | }, 119 | ref: 'master', 120 | stages: [ 121 | { 122 | jobs: [ 123 | { 124 | finishedAt: '2020-12-26T14:01:11.815Z', 125 | id: 932600811, 126 | name: 'fail_randomly_long_name', 127 | startedAt: '2020-12-26T14:00:39.928Z', 128 | status: 'failed', 129 | url: 'https://gitlab.com/gitlab-radiator-test/integration-test-project-2/-/jobs/932600811' 130 | } 131 | ], 132 | name: 'test' 133 | }, 134 | { 135 | jobs: [ 136 | { 137 | finishedAt: '2020-12-26T13:59:28.050Z', 138 | id: 932599688, 139 | name: 'build_my_stuff', 140 | startedAt: '2020-12-26T13:58:54.325Z', 141 | status: 'success', 142 | url: 'https://gitlab.com/gitlab-radiator-test/integration-test-project-2/-/jobs/932599688' 143 | } 144 | ], 145 | name: 'build' 146 | } 147 | ] 148 | }] 149 | ) 150 | }) 151 | 152 | it('Should find two projects with two pipelines for the first and one for the second (and exclude projects without pipelines)', async() => { 153 | const config = {gitlabs: [{...gitlab, projects: {exclude: '.*-with-child-pipeline'}}]} 154 | const projects = await update(config) 155 | expect(projects).to.deep.equal( 156 | [ 157 | { 158 | archived: false, 159 | default_branch: 'master', 160 | group: 'gitlab-radiator-test', 161 | id: 5385889, 162 | maxNonFailedJobsVisible: 10, 163 | name: 'gitlab-radiator-test/ci-skip-test-project', 164 | nameWithoutNamespace: 'ci-skip-test-project', 165 | tags: [], 166 | url: 'https://gitlab.com/gitlab-radiator-test/ci-skip-test-project', 167 | pipelines: [ 168 | { 169 | id: 1261086942, 170 | status: 'success', 171 | commit: { 172 | author: 'Heikki Pora', 173 | title: '[ci skip] do nothing' 174 | }, 175 | ref: 'master', 176 | stages: [{ 177 | jobs: [{ 178 | finishedAt: '2024-04-20T08:24:58.581Z', 179 | id: 6674648871, 180 | name: 'test', 181 | startedAt: '2024-04-20T08:24:31.697Z', 182 | status: 'success', 183 | url: 'https://gitlab.com/gitlab-radiator-test/ci-skip-test-project/-/jobs/6674648871' 184 | }], 185 | name: 'test' 186 | }] 187 | } 188 | ], 189 | status: 'success' 190 | }, 191 | { 192 | archived: false, 193 | default_branch: 'master', 194 | group: 'gitlab-radiator-test', 195 | id: 5290928, 196 | maxNonFailedJobsVisible: 10, 197 | name: 'gitlab-radiator-test/integration-test-project-2', 198 | nameWithoutNamespace: 'integration-test-project-2', 199 | url: 'https://gitlab.com/gitlab-radiator-test/integration-test-project-2', 200 | tags: [], 201 | pipelines: [ 202 | { 203 | id: 234613306, 204 | status: 'success', 205 | commit: { 206 | title: 'Fail more', 207 | author: 'Heikki Pora' 208 | }, 209 | ref: 'feature/test-branch', 210 | stages: [ 211 | { 212 | name: 'test', 213 | jobs: [ 214 | { 215 | id: 932599898, 216 | status: 'success', 217 | name: 'fail_randomly_long_name', 218 | startedAt: '2020-12-26T13:59:35.397Z', 219 | finishedAt: '2020-12-26T14:00:11.845Z', 220 | url: 'https://gitlab.com/gitlab-radiator-test/integration-test-project-2/-/jobs/932599898' 221 | } 222 | ] 223 | }, 224 | { 225 | name: 'build', 226 | jobs: [ 227 | { 228 | id: 932599715, 229 | status: 'success', 230 | name: 'build_my_stuff', 231 | startedAt: '2020-12-26T14:00:12.710Z', 232 | finishedAt: '2020-12-26T14:00:53.946Z', 233 | url: 'https://gitlab.com/gitlab-radiator-test/integration-test-project-2/-/jobs/932599715' 234 | } 235 | ] 236 | } 237 | ] 238 | }, 239 | { 240 | id: 234613296, 241 | ref: 'master', 242 | status: 'failed', 243 | commit: { 244 | title: 'Fail more', 245 | author: 'Heikki Pora' 246 | }, 247 | stages: [ 248 | { 249 | name: 'test', 250 | jobs: [ 251 | { 252 | id: 932600811, 253 | status: 'failed', 254 | name: 'fail_randomly_long_name', 255 | startedAt: '2020-12-26T14:00:39.928Z', 256 | finishedAt: '2020-12-26T14:01:11.815Z', 257 | url: 'https://gitlab.com/gitlab-radiator-test/integration-test-project-2/-/jobs/932600811' 258 | } 259 | ] 260 | }, 261 | { 262 | name: 'build', 263 | jobs: [ 264 | { 265 | id: 932599688, 266 | status: 'success', 267 | name: 'build_my_stuff', 268 | startedAt: '2020-12-26T13:58:54.325Z', 269 | finishedAt: '2020-12-26T13:59:28.050Z', 270 | url: 'https://gitlab.com/gitlab-radiator-test/integration-test-project-2/-/jobs/932599688' 271 | } 272 | ] 273 | } 274 | ] 275 | } 276 | ], 277 | status: 'failed' 278 | }, 279 | { 280 | archived: false, 281 | default_branch: 'master', 282 | group: 'gitlab-radiator-test', 283 | id: 5290865, 284 | maxNonFailedJobsVisible: 10, 285 | name: 'gitlab-radiator-test/integration-test-project-1', 286 | nameWithoutNamespace: 'integration-test-project-1', 287 | url: 'https://gitlab.com/gitlab-radiator-test/integration-test-project-1', 288 | tags: ['display-1'], 289 | pipelines: [ 290 | { 291 | id: 234493901, 292 | ref: 'master', 293 | status: 'success', 294 | commit: { 295 | title: 'Fail manual step', 296 | author: 'Heikki Pora' 297 | }, 298 | stages: [ 299 | { 300 | name: 'test', 301 | jobs: [ 302 | { 303 | id: 932213321, 304 | status: 'success', 305 | name: 'api-test', 306 | startedAt: '2020-12-25T20:02:42.733Z', 307 | finishedAt: '2020-12-25T20:03:36.383Z', 308 | url: 'https://gitlab.com/gitlab-radiator-test/integration-test-project-1/-/jobs/932213321' 309 | }, 310 | { 311 | id: 932213322, 312 | status: 'success', 313 | name: 'browser-test', 314 | startedAt: '2020-12-25T20:02:43.020Z', 315 | finishedAt: '2020-12-25T20:03:28.386Z', 316 | url: 'https://gitlab.com/gitlab-radiator-test/integration-test-project-1/-/jobs/932213322' 317 | }, 318 | { 319 | id: 932213319, 320 | status: 'success', 321 | name: 'eslint', 322 | startedAt: '2020-12-25T20:02:42.444Z', 323 | finishedAt: '2020-12-25T20:03:34.931Z', 324 | url: 'https://gitlab.com/gitlab-radiator-test/integration-test-project-1/-/jobs/932213319' 325 | }, 326 | { 327 | id: 932213320, 328 | status: 'success', 329 | name: 'verify', 330 | startedAt: '2020-12-25T20:02:42.663Z', 331 | finishedAt: '2020-12-25T20:03:35.364Z', 332 | url: 'https://gitlab.com/gitlab-radiator-test/integration-test-project-1/-/jobs/932213320' 333 | } 334 | ] 335 | }, 336 | { 337 | name: 'build', 338 | jobs: [ 339 | { 340 | id: 932213323, 341 | status: 'success', 342 | name: 'package-my-stuff', 343 | startedAt: '2020-12-25T20:03:37.107Z', 344 | finishedAt: '2020-12-25T20:04:22.618Z', 345 | url: 'https://gitlab.com/gitlab-radiator-test/integration-test-project-1/-/jobs/932213323' 346 | } 347 | ] 348 | }, 349 | { 350 | name: 'deploy', 351 | jobs: [ 352 | { 353 | id: 932213324, 354 | status: 'success', 355 | name: 'deploy-my-awesome-stuff', 356 | startedAt: '2020-12-25T20:04:23.450Z', 357 | finishedAt: '2020-12-25T20:05:14.167Z', 358 | url: 'https://gitlab.com/gitlab-radiator-test/integration-test-project-1/-/jobs/932213324' 359 | } 360 | ] 361 | }, 362 | { 363 | name: 'finnish', 364 | jobs: [ 365 | { 366 | id: 932213325, 367 | status: 'manual', 368 | name: 'manual_step-1', 369 | url: 'https://gitlab.com/gitlab-radiator-test/integration-test-project-1/-/jobs/932213325' 370 | }, 371 | { 372 | id: 932213326, 373 | status: 'manual', 374 | name: 'manual_step-2', 375 | url: 'https://gitlab.com/gitlab-radiator-test/integration-test-project-1/-/jobs/932213326' 376 | } 377 | ] 378 | } 379 | ] 380 | } 381 | ], 382 | status: 'success' 383 | }] 384 | ) 385 | }) 386 | 387 | it('Should include triggered child pipeline for project', async () => { 388 | const config = {...gitlab} 389 | const pipelines = await fetchLatestPipelines(39541352, config) 390 | expect(pipelines).to.deep.equal( 391 | [{ 392 | commit: { 393 | author: 'Heikki Pora', 394 | title: 'Add job triggering another project' 395 | }, 396 | id: 1261086879, 397 | ref: 'master', 398 | stages: [{ 399 | jobs: [{ 400 | finishedAt: '2024-04-20T08:24:30.130Z', 401 | id: 6674648615, 402 | name: 'start-job', 403 | startedAt: '2024-04-20T08:24:03.377Z', 404 | status: 'success', 405 | url: 'https://gitlab.com/gitlab-radiator-test/project-with-child-pipeline/-/jobs/6674648615' 406 | }], 407 | name: 'start' 408 | }, { 409 | jobs: [{ 410 | finishedAt: '2024-04-20T08:24:58.581Z', 411 | id: 6674648871, 412 | name: 'test', 413 | startedAt: '2024-04-20T08:24:31.697Z', 414 | status: 'success', 415 | url: 'https://gitlab.com/gitlab-radiator-test/ci-skip-test-project/-/jobs/6674648871' 416 | }], 417 | name: 'run-child:test' 418 | }, { 419 | jobs: [{ 420 | finishedAt: '2024-04-20T08:24:58.344Z', 421 | id: 6674648869, 422 | name: 'build', 423 | startedAt: '2024-04-20T08:24:31.526Z', 424 | status: 'success', 425 | url: 'https://gitlab.com/gitlab-radiator-test/project-with-child-pipeline/-/jobs/6674648869' 426 | }], 427 | name: 'run-child:build' 428 | },{ 429 | jobs: [{ 430 | finishedAt: '2024-04-20T08:25:26.496Z', 431 | id: 6674648870, 432 | name: 'test', 433 | startedAt: '2024-04-20T08:24:58.597Z', 434 | status: 'success', 435 | url: 'https://gitlab.com/gitlab-radiator-test/project-with-child-pipeline/-/jobs/6674648870' 436 | }], 437 | name: 'run-child:test' 438 | }], 439 | status: 'success' 440 | }] 441 | ) 442 | }) 443 | 444 | }) 445 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist/", 4 | "target": "es5", 5 | "lib": [ 6 | "dom" 7 | ], 8 | "allowJs": true, 9 | "skipLibCheck": true, 10 | "esModuleInterop": true, 11 | "allowSyntheticDefaultImports": true, 12 | "strict": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "module": "esnext", 15 | "moduleResolution": "node", 16 | "isolatedModules": true, 17 | "resolveJsonModule": true, 18 | "jsx": "react", 19 | "sourceMap": true, 20 | "declaration": false, 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | "incremental": true, 24 | "noFallthroughCasesInSwitch": true 25 | } 26 | } -------------------------------------------------------------------------------- /webpack.common.cjs: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const webpack = require('webpack') 3 | 4 | module.exports = { 5 | module: { 6 | rules: [ 7 | { 8 | test: /\.(js|jsx|ts|tsx)$/, 9 | use: 'ts-loader', 10 | exclude: /node_modules/, 11 | }, 12 | { 13 | test: /\.(ttf|html)$/i, 14 | type: 'asset/resource' 15 | } 16 | ] 17 | }, 18 | output: { 19 | filename: 'client.js', 20 | path: path.resolve(__dirname, 'build/public') 21 | }, 22 | resolve: { 23 | extensions: ['.js', '.ts', '.tsx'] 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /webpack.dev.cjs: -------------------------------------------------------------------------------- 1 | const common = require('./webpack.common.cjs') 2 | const {merge} = require('webpack-merge') 3 | const webpack = require('webpack') 4 | 5 | module.exports = merge(common, { 6 | mode: 'development', 7 | entry: [ 8 | './src/client/index.tsx', 9 | 'webpack-hot-middleware/client' 10 | ], 11 | plugins: [new webpack.HotModuleReplacementPlugin()], 12 | devtool: 'inline-source-map' 13 | }) -------------------------------------------------------------------------------- /webpack.prod.cjs: -------------------------------------------------------------------------------- 1 | const common = require('./webpack.common.cjs') 2 | const {merge} = require('webpack-merge') 3 | 4 | module.exports = merge(common, { 5 | mode: 'production', 6 | entry: [ 7 | './src/client/index.tsx' 8 | ] 9 | }) 10 | --------------------------------------------------------------------------------