gitlab-radiator 5.2.0 → 5.4.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/package.json +6 -6
- package/public/client.js +2 -0
- package/public/client.js.LICENSE.txt +673 -0
- package/src/app.js +77 -0
- package/src/auth.js +18 -0
- package/src/common/browser-error.js +1 -0
- package/src/config.js +82 -0
- package/src/dev-assets.js +11 -0
- package/src/gitlab/client.js +20 -0
- package/src/gitlab/index.js +50 -0
- package/src/gitlab/pipelines.js +125 -0
- package/src/gitlab/projects.js +65 -0
- package/src/gitlab/runners.js +23 -0
- package/src/less.js +28 -0
- package/src/tsconfig.build.tsbuildinfo +1 -0
- package/.github/copilot-instructions.md +0 -55
- package/.github/workflows/test.yml +0 -22
- package/.nvmrc +0 -1
- package/build-npm +0 -22
- package/eslint.config.mjs +0 -31
- package/screenshot.png +0 -0
- package/src/app.ts +0 -85
- package/src/auth.ts +0 -20
- package/src/client/arguments.ts +0 -61
- package/src/client/groupedProjects.tsx +0 -18
- package/src/client/groups.tsx +0 -50
- package/src/client/index.tsx +0 -79
- package/src/client/info.tsx +0 -16
- package/src/client/jobs.tsx +0 -57
- package/src/client/projects.tsx +0 -82
- package/src/client/stages.tsx +0 -18
- package/src/client/timestamp.tsx +0 -37
- package/src/client/tsconfig.json +0 -24
- package/src/common/gitlab-types.d.ts +0 -58
- package/src/config.ts +0 -95
- package/src/dev-assets.ts +0 -15
- package/src/gitlab/client.ts +0 -34
- package/src/gitlab/index.ts +0 -60
- package/src/gitlab/pipelines.ts +0 -178
- package/src/gitlab/projects.ts +0 -87
- package/src/gitlab/runners.ts +0 -35
- package/src/less.ts +0 -33
- package/test/gitlab-integration.ts +0 -457
- package/tsconfig.build.json +0 -14
- package/tsconfig.json +0 -25
- package/webpack.common.cjs +0 -25
- package/webpack.dev.cjs +0 -13
- package/webpack.prod.cjs +0 -9
package/src/app.ts
DELETED
|
@@ -1,85 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
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
|
-
}
|
package/src/client/arguments.ts
DELETED
|
@@ -1,61 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,18 +0,0 @@
|
|
|
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
|
-
}
|
package/src/client/groups.tsx
DELETED
|
@@ -1,50 +0,0 @@
|
|
|
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
|
-
|
package/src/client/index.tsx
DELETED
|
@@ -1,79 +0,0 @@
|
|
|
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()
|
package/src/client/info.tsx
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
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
|
-
}
|
package/src/client/jobs.tsx
DELETED
|
@@ -1,57 +0,0 @@
|
|
|
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
|
-
}
|
package/src/client/projects.tsx
DELETED
|
@@ -1,82 +0,0 @@
|
|
|
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
|
-
}
|
package/src/client/stages.tsx
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
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
|
-
}
|
package/src/client/timestamp.tsx
DELETED
|
@@ -1,37 +0,0 @@
|
|
|
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
|
-
}
|
package/src/client/tsconfig.json
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
export interface GlobalState {
|
|
2
|
-
columns: number
|
|
3
|
-
error: string | null
|
|
4
|
-
groupSuccessfulProjects: boolean
|
|
5
|
-
horizontal: boolean
|
|
6
|
-
rotateRunningPipelines: number
|
|
7
|
-
projects: Project[] | null
|
|
8
|
-
projectsOrder: Array<ProjectsOrder>
|
|
9
|
-
zoom: number
|
|
10
|
-
now: number
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export interface Project {
|
|
14
|
-
archived: boolean
|
|
15
|
-
group: string
|
|
16
|
-
id: number
|
|
17
|
-
name: string
|
|
18
|
-
nameWithoutNamespace: string
|
|
19
|
-
topics: string[]
|
|
20
|
-
url: string
|
|
21
|
-
default_branch: string
|
|
22
|
-
commitAsTitle: boolean
|
|
23
|
-
pipelines: Pipeline[]
|
|
24
|
-
maxNonFailedJobsVisible: number
|
|
25
|
-
status: JobStatus
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
// Keys that represent either string or number values, and can be compared with < and >
|
|
29
|
-
export type ProjectsOrder = keyof Pick<Project, 'status' | 'name' | 'id' | 'nameWithoutNamespace' | 'group'>
|
|
30
|
-
|
|
31
|
-
export interface Pipeline {
|
|
32
|
-
commit: Commit | null
|
|
33
|
-
id: number
|
|
34
|
-
ref: string
|
|
35
|
-
stages: Stage[]
|
|
36
|
-
status: JobStatus
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export interface Commit {
|
|
40
|
-
title: string
|
|
41
|
-
author: string
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export interface Stage {
|
|
45
|
-
jobs: Job[]
|
|
46
|
-
name: string
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export interface Job {
|
|
50
|
-
finishedAt: string | null
|
|
51
|
-
id: number
|
|
52
|
-
name: string
|
|
53
|
-
startedAt: string | null
|
|
54
|
-
status: JobStatus
|
|
55
|
-
url: string
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export type JobStatus = 'canceled' | 'created' | 'failed' | 'manual' | 'pending' | 'running' | 'skipped' | 'success'
|