gitlab-radiator 5.1.0 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/.github/copilot-instructions.md +55 -0
  2. package/.github/workflows/test.yml +22 -0
  3. package/.nvmrc +1 -0
  4. package/README.md +1 -0
  5. package/build-npm +22 -0
  6. package/eslint.config.mjs +31 -0
  7. package/package.json +4 -4
  8. package/public/client.less +4 -0
  9. package/screenshot.png +0 -0
  10. package/src/app.ts +85 -0
  11. package/src/auth.ts +20 -0
  12. package/src/client/arguments.ts +61 -0
  13. package/src/client/groupedProjects.tsx +18 -0
  14. package/src/client/groups.tsx +50 -0
  15. package/src/client/index.tsx +79 -0
  16. package/src/client/info.tsx +16 -0
  17. package/src/client/jobs.tsx +57 -0
  18. package/src/client/projects.tsx +82 -0
  19. package/src/client/stages.tsx +18 -0
  20. package/src/client/timestamp.tsx +37 -0
  21. package/src/client/tsconfig.json +24 -0
  22. package/src/common/gitlab-types.d.ts +58 -0
  23. package/src/config.ts +95 -0
  24. package/src/dev-assets.ts +15 -0
  25. package/src/gitlab/client.ts +34 -0
  26. package/src/gitlab/index.ts +60 -0
  27. package/src/gitlab/pipelines.ts +178 -0
  28. package/src/gitlab/projects.ts +87 -0
  29. package/src/gitlab/runners.ts +35 -0
  30. package/src/less.ts +33 -0
  31. package/test/gitlab-integration.ts +457 -0
  32. package/tsconfig.build.json +14 -0
  33. package/tsconfig.json +25 -0
  34. package/webpack.common.cjs +25 -0
  35. package/webpack.dev.cjs +13 -0
  36. package/webpack.prod.cjs +9 -0
  37. package/public/client.js +0 -2
  38. package/public/client.js.LICENSE.txt +0 -43
  39. package/src/app.js +0 -74
  40. package/src/auth.js +0 -18
  41. package/src/config.js +0 -80
  42. package/src/dev-assets.js +0 -11
  43. package/src/gitlab/client.js +0 -20
  44. package/src/gitlab/index.js +0 -49
  45. package/src/gitlab/pipelines.js +0 -125
  46. package/src/gitlab/projects.js +0 -65
  47. package/src/gitlab/runners.js +0 -23
  48. package/src/less.js +0 -28
  49. package/src/tsconfig.build.tsbuildinfo +0 -1
@@ -0,0 +1,55 @@
1
+ This repository is a small Node.js + React radiator that fetches CI pipelines from GitLab and serves a live dashboard.
2
+
3
+ Purpose for an AI coding agent
4
+ - Help maintain and extend the server-side polling & GitLab API logic in `src/gitlab/*`.
5
+ - Help evolve the client UI in `src/client/*` while preserving the Socket.IO state contract.
6
+ - Be conservative: prefer minimal, focused changes that keep the current runtime behavior and configuration semantics.
7
+
8
+ Big picture
9
+ - The server (`src/app.js`) loads a YAML config (`~/.gitlab-radiator.yml` or `GITLAB_RADIATOR_CONFIG`) and repeatedly calls `update(config)` from `src/gitlab/index.js` to produce a `globalState` object.
10
+ - The server emits `state` via Socket.IO to clients. The client entry is `src/client/index.tsx` which listens for the `state` event and renders via React components such as `groupedProjects.tsx`.
11
+ - GitLab API interactions are implemented under `src/gitlab` (`client.js`, `projects.js`, `pipelines.js`, `runners.js`). `client.js` uses axios with a cached client per GitLab URL and passes `ca` into the https.Agent when configured.
12
+
13
+ Key files (examples)
14
+ - Server start / main loop: `src/app.js` (socket gating, `runUpdate()`, error handling)
15
+ - Config loading & normalization: `src/config.js` (YAML file loading, env overrides, interval/port normalization)
16
+ - GitLab API client: `src/gitlab/client.js` (lazy client cache, `gitlabRequest()`)
17
+ - GitLab update orchestration: `src/gitlab/index.js` (fetch projects + pipelines, filtering)
18
+ - Frontend entry and Socket.IO usage: `src/client/index.tsx` (listens to `state` and applies `argumentsFromDocumentUrl()`)
19
+ - CLI / installable binary: `bin/gitlab-radiator.js` (installed via npm `bin` in `package.json`)
20
+
21
+ Build / run / test commands (exact)
22
+ - Use `nvm use` always first to select correct Node.js version as per `.nvmrc`.
23
+ - Start server in dev/prod: `npm start` (runs `node src/app.js`).
24
+ - Build distribution: `npm run build` (invokes `./build-npm` wrapper in the repo root).
25
+ - Lint and auto-fix: `npm run eslint`.
26
+ - Typecheck: `npm run typecheck`.
27
+ - Run tests: `npm test` (Mocha, timeout 20s). Tests live under `test/*.js`.
28
+ - When talking to an on-prem GitLab with self-signed certs, either set `gitlabs[].caFile` in config or run: `NODE_TLS_REJECT_UNAUTHORIZED=0 gitlab-radiator`.
29
+
30
+ Project conventions and patterns
31
+ - ESM modules: `package.json` has `"type": "module"` — use `import`/`export` style and avoid CommonJS `require` unless very deliberate.
32
+ - All code is in TypeScript (`.ts`/`.tsx`), but runtime type checks are not enforced; treat types as documentation and IDE assistance. No need to transpile or use ts-node for backend code.
33
+ - Config-first: almost all behavior is driven by `~/.gitlab-radiator.yml`. `src/config.ts` maps YAML values to normalized runtime values (note: `interval` is converted to milliseconds).
34
+ - Socket contract is stable: server emits `state` (object with `projects`, `now`, `error`, `zoom`, `columns`, `projectsOrder`, `horizontal`, `groupSuccessfulProjects`); the client expects that shape. Changing the contract requires coordinated server+client changes.
35
+ - GitLab clients are cached by URL in `src/gitlab/client.ts`; create clients via `lazyClient(gitlab)` to reuse keep-alive connections.
36
+ - CA handling: `config.gitlabs[].caFile` is read in `src/config.ts` and passed as `ca` to axios `https.Agent` in `client.js`.
37
+
38
+ Safe edit guidance for agents
39
+ - Small, isolated changes preferred. When changing data shapes emitted as `state`, update `src/client/*` in the same PR to keep runtime compatibility.
40
+ - Preserve environment-driven behavior: `GITLAB_ACCESS_TOKEN`, `GITLAB_RADIATOR_CONFIG`, and `NODE_TLS_REJECT_UNAUTHORIZED` are intentionally supported; prefer config changes over hardcoding tokens.
41
+ - When adding new dependencies, ensure they are added to `package.json` and usage fits ESM. Use exact versions as per existing dependencies.
42
+ - Keep `package.json` sorted alphabetically in `dependencies` and `devDependencies`.
43
+ - Follow existing style: minimal new inline comments, keep utility functions small, and do not rework CI/test harnesses unless requested.
44
+
45
+ Debugging notes
46
+ - To reproduce the runtime locally, create a `~/.gitlab-radiator.yml` with at least one `gitlabs` entry with `url` and `access-token` (or set `GITLAB_ACCESS_TOKEN`).
47
+ - Logs and errors are printed on the server console; network timeouts are 30s in `src/gitlab/client.ts`.
48
+ - Dev mode: `src/dev-assets.ts` is loaded when `NODE_ENV !== 'production'` and `./src/dev-assets.ts` exists — use it to bind webpack dev middleware for frontend HMR.
49
+
50
+ When to ask for human guidance
51
+ - Any change that modifies the `state` emitted to clients, the YAML config schema, or the HTTP endpoints should be flagged for review.
52
+ - Changes touching authentication flows or handling of GitLab tokens/CA files should be reviewed for security implications.
53
+
54
+ References
55
+ - See `src/app.ts`, `src/config.ts`, `src/gitlab/*`, and `src/client/*` as primary examples of the runtime and data flow.
@@ -0,0 +1,22 @@
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: [22.x, 24.x]
11
+ steps:
12
+ - uses: actions/checkout@v5
13
+ - name: Use Node.js ${{ matrix.node-version }}
14
+ uses: actions/setup-node@v5
15
+ with:
16
+ node-version: ${{ matrix.node-version }}
17
+ - run: npm ci
18
+ - run: npm audit
19
+ - run: npm run eslint
20
+ - run: npm run typecheck
21
+ - run: npm test
22
+ - run: npm run build
package/.nvmrc ADDED
@@ -0,0 +1 @@
1
+ 22.18.0
package/README.md CHANGED
@@ -84,6 +84,7 @@ Optional configuration properties:
84
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
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
86
  - `gitlabs / branch` - Explicitly select the git branch to show pipelines for. Default value is empty, meaning pipelines for any branch are shown.
87
+ - `gitlabs / commitAsTitle` - If set to `true` commit title used as block title instead of repository name. Default value is `false`.
87
88
  - `gitlabs / caFile` - CA file location to be passed to the request library when accessing the gitlab instance.
88
89
  - `gitlabs / ignoreArchived` - Ignore archived projects. Default value is `true`
89
90
  - `gitlabs / offlineRunners` - Report any offline CI runners. Set to `all` to include shared runners (requires administrator or auditor access), or `none` to ignore runner status completely. Set to `default` or leave out to report only on group / project runners available to the user.
package/build-npm ADDED
@@ -0,0 +1,22 @@
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
+ # Transpile server
18
+ tsc --build tsconfig.build.json
19
+ rm -f build/src/dev-assets.ts
20
+
21
+ # Bundle and minify client JS
22
+ npx webpack --config webpack.prod.cjs
@@ -0,0 +1,31 @@
1
+ import globals from 'globals'
2
+ import mocha from 'eslint-plugin-mocha'
3
+ import react from 'eslint-plugin-react'
4
+ import reactHooks from 'eslint-plugin-react-hooks'
5
+ import tseslint from 'typescript-eslint'
6
+
7
+ export default [
8
+ ...tseslint.configs.recommended,
9
+ mocha.configs.recommended,
10
+ react.configs.flat.recommended,
11
+ reactHooks.configs.flat.recommended,
12
+ {
13
+ rules: {
14
+ 'mocha/no-mocha-arrows': 'off'
15
+ }
16
+ },
17
+ {
18
+ languageOptions: {
19
+ globals: {
20
+ ...globals.node,
21
+ ...globals.browser,
22
+ ...globals.mocha
23
+ }
24
+ },
25
+ settings: {
26
+ react: {
27
+ version: 'detect'
28
+ }
29
+ }
30
+ }
31
+ ]
package/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "type": "module",
8
8
  "description": "The missing GitLab build radiator view",
9
- "version": "5.1.0",
9
+ "version": "5.2.0",
10
10
  "license": "MIT",
11
11
  "bin": {
12
12
  "gitlab-radiator": "bin/gitlab-radiator.js"
@@ -51,7 +51,7 @@
51
51
  "moment": "2.30.1",
52
52
  "regenerator-runtime": "0.14.1",
53
53
  "socket.io": "4.8.3",
54
- "zod": "4.3.4"
54
+ "zod": "4.3.5"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/basic-auth": "1.1.8",
@@ -61,7 +61,7 @@
61
61
  "@types/js-yaml": "4.0.9",
62
62
  "@types/less": "3.0.8",
63
63
  "@types/mocha": "10.0.10",
64
- "@types/react": "19.2.7",
64
+ "@types/react": "19.2.8",
65
65
  "@types/react-dom": "19.2.3",
66
66
  "@types/webpack-env": "1.18.8",
67
67
  "@types/webpack-hot-middleware": "2.25.12",
@@ -82,7 +82,7 @@
82
82
  "style-loader": "4.0.0",
83
83
  "ts-loader": "9.5.4",
84
84
  "typescript": "5.9.3",
85
- "typescript-eslint": "8.51.0",
85
+ "typescript-eslint": "8.52.0",
86
86
  "webpack": "5.104.1",
87
87
  "webpack-cli": "6.0.1",
88
88
  "webpack-dev-middleware": "7.4.5",
@@ -120,6 +120,10 @@ ol.projects {
120
120
  text-overflow: ellipsis;
121
121
  white-space: nowrap;
122
122
  line-height: 1.2em;
123
+
124
+ &.commit_title {
125
+ text-transform: none;
126
+ }
123
127
  }
124
128
 
125
129
  h4,
package/screenshot.png ADDED
Binary file
package/src/app.ts ADDED
@@ -0,0 +1,85 @@
1
+ import {basicAuth} from './auth.ts'
2
+ import compression from 'compression'
3
+ import {config} from './config.ts'
4
+ import express from 'express'
5
+ import fs from 'fs'
6
+ import {fetchOfflineRunners} from './gitlab/runners.ts'
7
+ import http from 'http'
8
+ import {serveLessAsCss} from './less.ts'
9
+ import {Server} from 'socket.io'
10
+ import {update} from './gitlab/index.ts'
11
+ import type {GlobalState} from './common/gitlab-types.d.ts'
12
+
13
+ const app = express()
14
+ const httpServer = new http.Server(app)
15
+ const socketIoServer = new Server(httpServer)
16
+
17
+ if (process.env.NODE_ENV !== 'production' && fs.existsSync('./src/dev-assets.ts')) {
18
+ const {bindDevAssets} = await import('./dev-assets.ts')
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
+ console.log(`Listening on port *:${config.port}`)
30
+ })
31
+
32
+ const globalState: Omit<GlobalState, 'now'> = {
33
+ projects: null,
34
+ error: null,
35
+ zoom: config.zoom,
36
+ projectsOrder: config.projectsOrder,
37
+ columns: config.columns,
38
+ horizontal: config.horizontal,
39
+ rotateRunningPipelines: config.rotateRunningPipelines,
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.gitlabs, config.rotateRunningPipelines > 0)
50
+ globalState.error = await errorIfRunnerOffline()
51
+ socketIoServer.emit('state', withDate(globalState))
52
+ } catch (error: unknown) {
53
+ const message = error instanceof Error ? error.message : String(error)
54
+ console.error(message)
55
+ globalState.error = `Failed to communicate with GitLab API: ${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: Omit<GlobalState, 'now'>): GlobalState {
81
+ return {
82
+ ...state,
83
+ now: Date.now()
84
+ }
85
+ }
package/src/auth.ts ADDED
@@ -0,0 +1,20 @@
1
+ import authenticate from 'basic-auth'
2
+ import type {NextFunction, Request, Response} from 'express'
3
+
4
+ export function basicAuth(auth: {username: string; password: string } | undefined) {
5
+ if (!auth) {
6
+ console.log('No authentication configured')
7
+ return (req: Request, res: Response, next: NextFunction) => next()
8
+ }
9
+
10
+ console.log('HTTP basic auth enabled')
11
+ return (req: Request, res: Response, next: NextFunction) => {
12
+ const {name, pass} = authenticate(req) || ({} as {name?: string; pass?: string})
13
+ if (auth.username === name && auth.password === pass) {
14
+ next()
15
+ } else {
16
+ res.setHeader('WWW-Authenticate', 'Basic realm="gitlab-radiator"')
17
+ res.status(401).end()
18
+ }
19
+ }
20
+ }
@@ -0,0 +1,61 @@
1
+ export function argumentsFromDocumentUrl(): {override: {columns?: number, zoom?: number}, includedTopics: string[] | null, screen: {id: number, total: number}} {
2
+ const params = new URLSearchParams(document.location.search)
3
+ return {
4
+ override: overrideArguments(params),
5
+ includedTopics: topicArguments(params),
6
+ screen: screenArguments(params)
7
+ }
8
+ }
9
+
10
+ function topicArguments(params: URLSearchParams): string[] | null {
11
+ const topics = params.get('topics')
12
+ if (topics === null) {
13
+ return null
14
+ }
15
+ return topics
16
+ .split(',')
17
+ .map(t => t.toLowerCase().trim())
18
+ .filter(t => t)
19
+ }
20
+
21
+ function overrideArguments(params: URLSearchParams): {columns?: number, zoom?: number} {
22
+ return {
23
+ ...parseColumns(params),
24
+ ...parseZoom(params)
25
+ }
26
+ }
27
+
28
+ function parseColumns(params: URLSearchParams) {
29
+ const columnsStr = params.get('columns')
30
+ if (columnsStr) {
31
+ const columns = Number(columnsStr)
32
+ if (columns > 0 && columns <= 10) {
33
+ return {columns}
34
+ }
35
+ }
36
+ return {}
37
+ }
38
+
39
+ function parseZoom(params: URLSearchParams) {
40
+ const zoomStr = params.get('zoom')
41
+ if (zoomStr) {
42
+ const zoom = Number(zoomStr)
43
+ if (zoom > 0 && zoom <= 2) {
44
+ return {zoom}
45
+ }
46
+ }
47
+ return {}
48
+ }
49
+
50
+ function screenArguments(params: URLSearchParams): {id: number, total: number} {
51
+ const matches = (/(\d)of(\d)/).exec(params.get('screen') || '')
52
+ let id = matches ? Number(matches[1]) : 1
53
+ const total = matches ? Number(matches[2]) : 1
54
+ if (id > total) {
55
+ id = total
56
+ }
57
+ return {
58
+ id,
59
+ total
60
+ }
61
+ }
@@ -0,0 +1,18 @@
1
+ import {Groups} from './groups'
2
+ import {Projects} from './projects'
3
+ import React from 'react'
4
+ import type {Project, ProjectsOrder} from '../common/gitlab-types'
5
+
6
+ export function GroupedProjects({projects, projectsOrder, groupSuccessfulProjects, zoom, columns, now, screen, rotateRunningPipelines}: {projects: Project[], projectsOrder: ProjectsOrder[], groupSuccessfulProjects: boolean, zoom: number, columns: number, now: number, screen: {id: number, total: number}, rotateRunningPipelines: number}) {
7
+ if (groupSuccessfulProjects) {
8
+ const successfullProjects = projects.filter(p => p.status === 'success')
9
+ const otherProjects= projects.filter(p => p.status !== 'success')
10
+ const groupedProjects = Object.groupBy(successfullProjects, p => p.group)
11
+
12
+ return <React.Fragment>
13
+ <Projects now={now} zoom={zoom} columns={columns} projects={otherProjects} projectsOrder={projectsOrder} screen={screen} rotateRunningPipelines={rotateRunningPipelines}/>
14
+ <Groups now={now} zoom={zoom} columns={columns} groupedProjects={groupedProjects}/>
15
+ </React.Fragment>
16
+ }
17
+ return <Projects now={now} zoom={zoom} columns={columns} projects={projects} projectsOrder={projectsOrder} screen={screen} rotateRunningPipelines={rotateRunningPipelines}/>
18
+ }
@@ -0,0 +1,50 @@
1
+ import React from 'react'
2
+ import {Timestamp} from './timestamp'
3
+ import {style, zoomStyle} from './projects'
4
+ import type {Pipeline, Project} from '../common/gitlab-types'
5
+
6
+ export function Groups({groupedProjects, now, zoom, columns}: {groupedProjects: Partial<Record<string, Project[]>>, now: number, zoom: number, columns: number}) {
7
+ return <ol className="groups" style={zoomStyle(zoom)}>
8
+ {Object
9
+ .entries(groupedProjects)
10
+ .sort(([groupName1], [groupName2]) => groupName1.localeCompare(groupName2))
11
+ .map(([groupName, projects]) => <GroupElement columns={columns} groupName={groupName} key={groupName} projects={projects} now={now}/>)
12
+ }
13
+ </ol>
14
+ }
15
+
16
+ function GroupElement({groupName, projects, now, columns}: {groupName: string, projects: Project[] | undefined, now: number, columns: number}) {
17
+ if (!projects) {
18
+ return null
19
+ }
20
+
21
+ const pipelines: (Pipeline & {project: string})[] = []
22
+ projects.forEach((project) => {
23
+ project.pipelines.forEach((pipeline) => {
24
+ pipelines.push({
25
+ ...pipeline,
26
+ project: project.nameWithoutNamespace
27
+ })
28
+ })
29
+ })
30
+
31
+ return <li className={'group'} style={style(columns)}>
32
+ <h2>{groupName}</h2>
33
+ <div className={'group-info'}>{projects.length} Project{projects.length > 1 ? 's' : ''}</div>
34
+ <GroupInfoElement now={now} pipeline={pipelines[0]}/>
35
+ </li>
36
+ }
37
+
38
+ function GroupInfoElement({now, pipeline}: {now: number, pipeline: (Pipeline & {project: string})}) {
39
+ return <div className="pipeline-info">
40
+ <div>
41
+ <span>{pipeline.commit ? pipeline.commit.author : '-'}</span>
42
+ <span>{pipeline.commit ? pipeline.project : '-'}</span>
43
+ </div>
44
+ <div>
45
+ <Timestamp stages={pipeline.stages} now={now}/>
46
+ <span>on {pipeline.ref}</span>
47
+ </div>
48
+ </div>
49
+ }
50
+
@@ -0,0 +1,79 @@
1
+ import 'core-js/stable'
2
+ import 'regenerator-runtime/runtime'
3
+
4
+ import {argumentsFromDocumentUrl} from './arguments'
5
+ import {createRoot} from 'react-dom/client'
6
+ import {GroupedProjects} from './groupedProjects'
7
+ import React, {useCallback, useEffect, useMemo, useState} from 'react'
8
+ import {io, Socket} from 'socket.io-client'
9
+ import type {GlobalState, Project} from '../common/gitlab-types'
10
+
11
+ function RadiatorApp() {
12
+ const args = useMemo(() => argumentsFromDocumentUrl(), [])
13
+ const [state, setState] = useState<GlobalState>({
14
+ columns: 1,
15
+ error: null,
16
+ groupSuccessfulProjects: false,
17
+ horizontal: false,
18
+ rotateRunningPipelines: 0,
19
+ projects: null,
20
+ projectsOrder: [],
21
+ now: 0,
22
+ zoom: 1
23
+ })
24
+ const {now, zoom, columns, projects, projectsOrder, groupSuccessfulProjects, horizontal, rotateRunningPipelines} = state
25
+ const projectsByTags = filterProjectsByTopics(projects, args.includedTopics)
26
+
27
+ const onServerStateUpdated = useCallback((serverState: GlobalState) => {
28
+ setState(() => ({
29
+ ...serverState,
30
+ ...args.override
31
+ }))
32
+ }, [args.override])
33
+
34
+ const onDisconnect = useCallback(() => setState(prev => ({...prev, error: 'gitlab-radiator server is offline'})), [])
35
+
36
+ useEffect(() => {
37
+ const socket: Socket = io()
38
+ socket.on('state', onServerStateUpdated)
39
+ socket.on('disconnect', onDisconnect)
40
+ return () => {
41
+ socket.off('state', onServerStateUpdated)
42
+ socket.off('disconnect', onDisconnect)
43
+ socket.close()
44
+ }
45
+ }, [onServerStateUpdated, onDisconnect])
46
+
47
+ return <div className={horizontal ? 'horizontal' : ''}>
48
+ {state.error && <div className="error">{state.error}</div>}
49
+ {!state.projects && <h2 className="loading">Fetching projects and CI pipelines from GitLab...</h2>}
50
+ {state.projects?.length === 0 && <h2 className="loading">No projects with CI pipelines found.</h2>}
51
+
52
+ {projectsByTags &&
53
+ <GroupedProjects now={now} zoom={zoom} columns={columns}
54
+ projects={projectsByTags} projectsOrder={projectsOrder}
55
+ groupSuccessfulProjects={groupSuccessfulProjects}
56
+ screen={args.screen}
57
+ rotateRunningPipelines={rotateRunningPipelines}/>
58
+ }
59
+ </div>
60
+ }
61
+
62
+ function filterProjectsByTopics(projects: Project[] | null, includedTopics: string[] | null) {
63
+ if (projects === null) {
64
+ return null
65
+ }
66
+ if (!includedTopics) {
67
+ return projects
68
+ }
69
+ if (includedTopics.length === 0) {
70
+ return projects.filter(p => p.topics.length === 0)
71
+ }
72
+ return projects.filter(project => project.topics.some(tag => includedTopics?.includes(tag)))
73
+ }
74
+
75
+
76
+ const root = createRoot(document.getElementById('app')!)
77
+ root.render(<RadiatorApp/>)
78
+
79
+ module.hot?.accept()
@@ -0,0 +1,16 @@
1
+ import React from 'react'
2
+ import {Timestamp} from './timestamp'
3
+ import type {Pipeline} from '../common/gitlab-types'
4
+
5
+ export function Info({pipeline, now, commitAsTitle}: {pipeline: Pipeline, now: number, commitAsTitle: boolean}) {
6
+ return <div className="pipeline-info">
7
+ <div>
8
+ <span>{pipeline.commit ? pipeline.commit.author : '-'}</span>
9
+ <span>{commitAsTitle ? `Pipeline id: ${pipeline.id}` : (pipeline.commit ? `'${pipeline.commit.title}'` : '-')}</span>
10
+ </div>
11
+ <div>
12
+ <Timestamp stages={pipeline.stages} now={now}/>
13
+ <span>on {pipeline.ref}</span>
14
+ </div>
15
+ </div>
16
+ }
@@ -0,0 +1,57 @@
1
+ import React from 'react'
2
+ import type {Job, JobStatus} from '../common/gitlab-types'
3
+
4
+ const NON_BREAKING_SPACE = '\xa0'
5
+
6
+ const JOB_STATES_IN_INTEREST_ORDER: JobStatus[] = [
7
+ 'failed',
8
+ 'running',
9
+ 'created',
10
+ 'pending',
11
+ 'success',
12
+ 'skipped'
13
+ ]
14
+
15
+ function interestOrder(a: Job, b: Job) {
16
+ return JOB_STATES_IN_INTEREST_ORDER.indexOf(a.status) - JOB_STATES_IN_INTEREST_ORDER.indexOf(b.status)
17
+ }
18
+
19
+ export function Jobs({jobs, maxNonFailedJobsVisible}: {jobs: Job[], maxNonFailedJobsVisible: number}) {
20
+ const failedJobs = jobs.filter(job => job.status === 'failed')
21
+ const nonFailedJobs = jobs.filter(job => job.status !== 'failed').sort(interestOrder)
22
+ const filteredJobs = sortByOriginalOrder(
23
+ failedJobs.concat(
24
+ nonFailedJobs.slice(0, Math.max(0, maxNonFailedJobsVisible - failedJobs.length))
25
+ ),
26
+ jobs
27
+ )
28
+
29
+ const hiddenJobs = jobs.filter(job => filteredJobs.indexOf(job) === -1)
30
+ const hiddenCountsByStatus = Object.fromEntries(
31
+ Object.entries(Object.groupBy(hiddenJobs, job => job.status))
32
+ .map(([status, jobsForStatus]) => [status, jobsForStatus!.length])
33
+ )
34
+
35
+ const hiddenJobsText = Object.entries(hiddenCountsByStatus)
36
+ .sort(([statusA], [statusB]) => statusA.localeCompare(statusB))
37
+ .map(([status, count]: [string, number]) => `${count}${NON_BREAKING_SPACE}${status}`)
38
+ .join(', ')
39
+
40
+ return <ol className="jobs">
41
+ {filteredJobs.map((job: Job) => <JobElement job={job} key={job.id}/>)}
42
+ {
43
+ hiddenJobs.length > 0 ? <li className="hidden-jobs">+&nbsp;{hiddenJobsText}</li> : null
44
+ }
45
+ </ol>
46
+ }
47
+
48
+ function JobElement({job}: {job: Job}) {
49
+ return <li className={job.status}>
50
+ <a href={job.url} target="_blank" rel="noopener noreferrer">{job.name}</a>
51
+ {!job.url && job.name}
52
+ </li>
53
+ }
54
+
55
+ function sortByOriginalOrder(filteredJobs: Job[], jobs: Job[]) {
56
+ return [...filteredJobs].sort((a, b) => jobs.indexOf(a) - jobs.indexOf(b))
57
+ }
@@ -0,0 +1,82 @@
1
+ import {Info} from './info'
2
+ import React, {useState, useEffect} from 'react'
3
+ import {Stages} from './stages'
4
+ import type {Project, ProjectsOrder} from '../common/gitlab-types'
5
+
6
+ export function Projects({columns, now, projects, projectsOrder, screen, zoom, rotateRunningPipelines}: {columns: number, now: number, projects: Project[], projectsOrder: ProjectsOrder[], screen: {id: number, total: number}, zoom: number, rotateRunningPipelines: number}) {
7
+ return <ol className="projects" style={zoomStyle(zoom)}>
8
+ {sortByMultipleKeys(projects, projectsOrder)
9
+ .filter(forScreen(screen, projects.length))
10
+ .map(project => <ProjectElement now={now} columns={columns} rotateRunningPipelines={rotateRunningPipelines} project={project} key={project.id}/>)
11
+ }
12
+ </ol>
13
+ }
14
+
15
+ function sortByMultipleKeys(projects: Project[], keys: ProjectsOrder[]): Project[] {
16
+ return [...projects].sort((a, b) => {
17
+ for (const key of keys) {
18
+ const result = key === 'id'
19
+ ? a[key] - b[key]
20
+ : a[key].localeCompare(b[key])
21
+
22
+ if (result !== 0) {
23
+ return result
24
+ }
25
+ }
26
+ return 0
27
+ })
28
+ }
29
+
30
+ function ProjectElement({columns, now, project, rotateRunningPipelines}: {columns: number, now: number, rotateRunningPipelines: number, project: Project}) {
31
+ const [counter, setCounter] = useState<number>(0)
32
+ const runningCount = project.pipelines.filter(p => p.status === 'running').length
33
+ const isRotating = rotateRunningPipelines > 0 && runningCount > 1
34
+
35
+ useEffect(() => {
36
+ if (!isRotating) {
37
+ return
38
+ }
39
+
40
+ const timer = setInterval(() => setCounter(previous => previous + 1), rotateRunningPipelines)
41
+ return () => clearInterval(timer)
42
+ }, [isRotating, rotateRunningPipelines, setCounter])
43
+
44
+ const pipelineIndex = isRotating ? (counter % runningCount) : 0
45
+ const indexLabel = isRotating ? `${pipelineIndex + 1}/${runningCount} `: ''
46
+ const pipeline = project.pipelines[pipelineIndex]
47
+
48
+ const h2Class = project.commitAsTitle ? 'commit_title' : ''
49
+
50
+ return <li className={`project ${project.status}`} style={style(columns)}>
51
+ <h2 className={`${h2Class}`}>
52
+ {project.url && <a href={`${project.url}/pipelines`} target="_blank" rel="noopener noreferrer">{indexLabel}{project.commitAsTitle ? (pipeline.commit ? pipeline.commit.title : '-') : project.name}</a>}
53
+ {!project.url && project.name}
54
+ </h2>
55
+ <Stages stages={pipeline.stages} maxNonFailedJobsVisible={project.maxNonFailedJobsVisible}/>
56
+ <Info now={now} pipeline={pipeline} commitAsTitle={project.commitAsTitle}/>
57
+ </li>
58
+ }
59
+
60
+ function forScreen(screen: {id: number, total: number}, projectsCount: number) {
61
+ const perScreen = Math.ceil(projectsCount / screen.total)
62
+ const first = perScreen * (screen.id - 1)
63
+ const last = perScreen * screen.id
64
+ return (_project: Project, projectIndex: number) => projectIndex >= first && projectIndex < last
65
+ }
66
+
67
+ export function zoomStyle(zoom: number) {
68
+ const widthPercentage = Math.round(100 / zoom)
69
+ return {
70
+ transform: `scale(${zoom})`,
71
+ width: `${widthPercentage}vmax`
72
+ }
73
+ }
74
+
75
+ export function style(columns: number) {
76
+ const marginPx = 12
77
+ const widthPercentage = Math.floor(100 / columns)
78
+ return {
79
+ margin: `${marginPx}px`,
80
+ width: `calc(${widthPercentage}% - ${2 * marginPx}px)`
81
+ }
82
+ }