gitlab-radiator 5.0.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.
- package/.github/copilot-instructions.md +55 -0
- package/.github/workflows/test.yml +22 -0
- package/.nvmrc +1 -0
- package/README.md +1 -0
- package/bin/gitlab-radiator.js +1 -1
- package/build-npm +22 -0
- package/eslint.config.mjs +31 -0
- package/package.json +4 -4
- package/public/client.less +4 -0
- package/screenshot.png +0 -0
- package/src/client/arguments.ts +61 -0
- package/src/client/groupedProjects.tsx +18 -0
- package/src/client/groups.tsx +50 -0
- package/src/client/index.tsx +79 -0
- package/src/client/info.tsx +16 -0
- package/src/client/jobs.tsx +57 -0
- package/src/client/projects.tsx +82 -0
- package/src/client/stages.tsx +18 -0
- package/src/client/timestamp.tsx +37 -0
- package/src/client/tsconfig.json +24 -0
- package/src/common/gitlab-types.d.ts +1 -0
- package/src/config.ts +24 -24
- package/src/dev-assets.ts +15 -0
- package/src/gitlab/client.ts +1 -1
- package/src/gitlab/index.ts +1 -0
- package/src/gitlab/projects.ts +1 -1
- package/test/gitlab-integration.ts +457 -0
- package/tsconfig.build.json +14 -0
- package/tsconfig.json +25 -0
- package/webpack.common.cjs +25 -0
- package/webpack.dev.cjs +13 -0
- package/webpack.prod.cjs +9 -0
- package/public/client.js +0 -2
- package/public/client.js.LICENSE.txt +0 -43
|
@@ -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/bin/gitlab-radiator.js
CHANGED
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|
package/public/client.less
CHANGED
package/screenshot.png
ADDED
|
Binary file
|
|
@@ -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">+ {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
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import {Jobs} from './jobs'
|
|
2
|
+
import React from 'react'
|
|
3
|
+
import type {Stage} from '../common/gitlab-types'
|
|
4
|
+
|
|
5
|
+
export function Stages({stages, maxNonFailedJobsVisible}: {stages: Stage[], maxNonFailedJobsVisible: number}) {
|
|
6
|
+
return <ol className="stages">
|
|
7
|
+
{stages.map((stage, index) =>
|
|
8
|
+
<StageElement stage={stage} maxNonFailedJobsVisible={maxNonFailedJobsVisible} key={`${index}-${stage.name}`}/>
|
|
9
|
+
)}
|
|
10
|
+
</ol>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function StageElement({stage, maxNonFailedJobsVisible}: {stage: Stage, maxNonFailedJobsVisible: number}) {
|
|
14
|
+
return <li className="stage">
|
|
15
|
+
<div className="name">{stage.name}</div>
|
|
16
|
+
<Jobs jobs={stage.jobs} maxNonFailedJobsVisible={maxNonFailedJobsVisible}/>
|
|
17
|
+
</li>
|
|
18
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import moment from 'moment'
|
|
2
|
+
import React from 'react'
|
|
3
|
+
import type {Stage} from '../common/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 <span>Pending...</span>
|
|
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 <span>Started {moment(startedAt).from(now)}</span>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const [latestFinishedAt] = finished.sort((a, b) => b - a)
|
|
22
|
+
return <span>Finished {moment(latestFinishedAt).from(now)}</span>
|
|
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
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"outDir": "./dist/",
|
|
4
|
+
"target": "es5",
|
|
5
|
+
"lib": ["ES2024.Object", "dom"],
|
|
6
|
+
"allowJs": true,
|
|
7
|
+
"skipLibCheck": true,
|
|
8
|
+
"esModuleInterop": true,
|
|
9
|
+
"allowSyntheticDefaultImports": true,
|
|
10
|
+
"strict": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"module": "esnext",
|
|
13
|
+
"moduleResolution": "node",
|
|
14
|
+
"isolatedModules": true,
|
|
15
|
+
"resolveJsonModule": true,
|
|
16
|
+
"jsx": "react",
|
|
17
|
+
"sourceMap": true,
|
|
18
|
+
"declaration": false,
|
|
19
|
+
"noUnusedLocals": true,
|
|
20
|
+
"noUnusedParameters": true,
|
|
21
|
+
"incremental": true,
|
|
22
|
+
"noFallthroughCasesInSwitch": true
|
|
23
|
+
}
|
|
24
|
+
}
|