gitlab-radiator 5.2.0 → 5.2.1

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 (47) hide show
  1. package/package.json +1 -1
  2. package/public/client.js +2 -0
  3. package/public/client.js.LICENSE.txt +43 -0
  4. package/src/app.js +74 -0
  5. package/src/auth.js +18 -0
  6. package/src/config.js +82 -0
  7. package/src/dev-assets.js +11 -0
  8. package/src/gitlab/client.js +20 -0
  9. package/src/gitlab/index.js +50 -0
  10. package/src/gitlab/pipelines.js +125 -0
  11. package/src/gitlab/projects.js +65 -0
  12. package/src/gitlab/runners.js +23 -0
  13. package/src/less.js +28 -0
  14. package/src/tsconfig.build.tsbuildinfo +1 -0
  15. package/.github/copilot-instructions.md +0 -55
  16. package/.github/workflows/test.yml +0 -22
  17. package/.nvmrc +0 -1
  18. package/build-npm +0 -22
  19. package/eslint.config.mjs +0 -31
  20. package/screenshot.png +0 -0
  21. package/src/app.ts +0 -85
  22. package/src/auth.ts +0 -20
  23. package/src/client/arguments.ts +0 -61
  24. package/src/client/groupedProjects.tsx +0 -18
  25. package/src/client/groups.tsx +0 -50
  26. package/src/client/index.tsx +0 -79
  27. package/src/client/info.tsx +0 -16
  28. package/src/client/jobs.tsx +0 -57
  29. package/src/client/projects.tsx +0 -82
  30. package/src/client/stages.tsx +0 -18
  31. package/src/client/timestamp.tsx +0 -37
  32. package/src/client/tsconfig.json +0 -24
  33. package/src/common/gitlab-types.d.ts +0 -58
  34. package/src/config.ts +0 -95
  35. package/src/dev-assets.ts +0 -15
  36. package/src/gitlab/client.ts +0 -34
  37. package/src/gitlab/index.ts +0 -60
  38. package/src/gitlab/pipelines.ts +0 -178
  39. package/src/gitlab/projects.ts +0 -87
  40. package/src/gitlab/runners.ts +0 -35
  41. package/src/less.ts +0 -33
  42. package/test/gitlab-integration.ts +0 -457
  43. package/tsconfig.build.json +0 -14
  44. package/tsconfig.json +0 -25
  45. package/webpack.common.cjs +0 -25
  46. package/webpack.dev.cjs +0 -13
  47. package/webpack.prod.cjs +0 -9
@@ -0,0 +1,43 @@
1
+ /**
2
+ * @license React
3
+ * react-dom-client.production.js
4
+ *
5
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
6
+ *
7
+ * This source code is licensed under the MIT license found in the
8
+ * LICENSE file in the root directory of this source tree.
9
+ */
10
+
11
+ /**
12
+ * @license React
13
+ * react-dom.production.js
14
+ *
15
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
16
+ *
17
+ * This source code is licensed under the MIT license found in the
18
+ * LICENSE file in the root directory of this source tree.
19
+ */
20
+
21
+ /**
22
+ * @license React
23
+ * react.production.js
24
+ *
25
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
26
+ *
27
+ * This source code is licensed under the MIT license found in the
28
+ * LICENSE file in the root directory of this source tree.
29
+ */
30
+
31
+ /**
32
+ * @license React
33
+ * scheduler.production.js
34
+ *
35
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
36
+ *
37
+ * This source code is licensed under the MIT license found in the
38
+ * LICENSE file in the root directory of this source tree.
39
+ */
40
+
41
+ //! moment.js
42
+
43
+ //! moment.js locale configuration
package/src/app.js ADDED
@@ -0,0 +1,74 @@
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
+ const app = express();
12
+ const httpServer = new http.Server(app);
13
+ const socketIoServer = new Server(httpServer);
14
+ if (process.env.NODE_ENV !== 'production' && fs.existsSync('./src/dev-assets.ts')) {
15
+ const { bindDevAssets } = await import("./dev-assets.js");
16
+ bindDevAssets(app);
17
+ }
18
+ app.disable('x-powered-by');
19
+ app.get('/client.css', serveLessAsCss);
20
+ app.use(express.static('public'));
21
+ app.use(compression());
22
+ app.use(basicAuth(config.auth));
23
+ httpServer.listen(config.port, () => {
24
+ console.log(`Listening on port *:${config.port}`);
25
+ });
26
+ const globalState = {
27
+ projects: null,
28
+ error: null,
29
+ zoom: config.zoom,
30
+ projectsOrder: config.projectsOrder,
31
+ columns: config.columns,
32
+ horizontal: config.horizontal,
33
+ rotateRunningPipelines: config.rotateRunningPipelines,
34
+ groupSuccessfulProjects: config.groupSuccessfulProjects
35
+ };
36
+ socketIoServer.on('connection', (socket) => {
37
+ socket.emit('state', withDate(globalState));
38
+ });
39
+ async function runUpdate() {
40
+ try {
41
+ globalState.projects = await update(config.gitlabs, config.rotateRunningPipelines > 0);
42
+ globalState.error = await errorIfRunnerOffline();
43
+ socketIoServer.emit('state', withDate(globalState));
44
+ }
45
+ catch (error) {
46
+ const message = error instanceof Error ? error.message : String(error);
47
+ console.error(message);
48
+ globalState.error = `Failed to communicate with GitLab API: ${message}`;
49
+ socketIoServer.emit('state', withDate(globalState));
50
+ }
51
+ setTimeout(runUpdate, config.interval);
52
+ }
53
+ async function errorIfRunnerOffline() {
54
+ const offlineRunnersPerGitlab = await Promise.all(config.gitlabs.map(fetchOfflineRunners));
55
+ const { offline, totalCount } = offlineRunnersPerGitlab.reduce((acc, runner) => {
56
+ return {
57
+ offline: acc.offline.concat(runner.offline),
58
+ totalCount: acc.totalCount + runner.totalCount
59
+ };
60
+ }, { offline: [], totalCount: 0 });
61
+ if (offline.length > 0) {
62
+ const names = offline.map(r => r.name).sort().join(', ');
63
+ const counts = offline.length === totalCount ? 'All' : `${offline.length}/${totalCount}`;
64
+ return `${counts} runners offline: ${names}`;
65
+ }
66
+ return null;
67
+ }
68
+ await runUpdate();
69
+ function withDate(state) {
70
+ return {
71
+ ...state,
72
+ now: Date.now()
73
+ };
74
+ }
package/src/auth.js ADDED
@@ -0,0 +1,18 @@
1
+ import authenticate from 'basic-auth';
2
+ export function basicAuth(auth) {
3
+ if (!auth) {
4
+ console.log('No authentication configured');
5
+ return (req, res, next) => next();
6
+ }
7
+ console.log('HTTP basic auth enabled');
8
+ return (req, res, next) => {
9
+ const { name, pass } = authenticate(req) || {};
10
+ if (auth.username === name && auth.password === pass) {
11
+ next();
12
+ }
13
+ else {
14
+ res.setHeader('WWW-Authenticate', 'Basic realm="gitlab-radiator"');
15
+ res.status(401).end();
16
+ }
17
+ };
18
+ }
package/src/config.js ADDED
@@ -0,0 +1,82 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import yaml from 'js-yaml';
4
+ import { z } from 'zod';
5
+ function expandTilde(path) {
6
+ return path.replace(/^~($|\/|\\)/, `${os.homedir()}$1`);
7
+ }
8
+ const JobStatusSchema = z.literal(['canceled', 'created', 'failed', 'manual', 'pending', 'running', 'skipped', 'success']);
9
+ const GitlabSchema = z.strictObject({
10
+ url: z.string().min(1, 'Mandatory gitlab url missing from configuration file'),
11
+ 'access-token': z.string().min(1).optional(),
12
+ ignoreArchived: z.boolean().default(true),
13
+ maxNonFailedJobsVisible: z.coerce.number().int().default(999999),
14
+ branch: z.string().min(1).optional(),
15
+ caFile: z.string().optional(),
16
+ offlineRunners: z.literal(['all', 'default', 'none']).default('default'),
17
+ commitAsTitle: z.boolean().default(false),
18
+ projects: z.strictObject({
19
+ excludePipelineStatus: z.array(JobStatusSchema).optional(),
20
+ include: z.string().min(1).optional(),
21
+ exclude: z.string().min(1).optional()
22
+ }).optional()
23
+ }).transform(gitlab => {
24
+ const accessToken = gitlab['access-token'] || process.env.GITLAB_ACCESS_TOKEN;
25
+ if (!accessToken) {
26
+ throw new Error('Mandatory gitlab access token missing from configuration (and none present at GITLAB_ACCESS_TOKEN env variable)');
27
+ }
28
+ const { url, ignoreArchived, maxNonFailedJobsVisible, caFile, branch, offlineRunners, commitAsTitle, projects } = gitlab;
29
+ const ca = caFile && fs.existsSync(caFile) ? fs.readFileSync(caFile, 'utf-8') : undefined;
30
+ return {
31
+ url,
32
+ ignoreArchived,
33
+ maxNonFailedJobsVisible,
34
+ branch,
35
+ ca,
36
+ offlineRunners,
37
+ 'access-token': accessToken,
38
+ commitAsTitle,
39
+ projects
40
+ };
41
+ });
42
+ const OrderSchema = z.literal(['status', 'name', 'id', 'nameWithoutNamespace', 'group']);
43
+ const ConfigSchema = z.strictObject({
44
+ interval: z.coerce.number().default(10).transform(sec => sec * 1000),
45
+ port: z.coerce.number().int().default(3000),
46
+ zoom: z.coerce.number().default(1.0),
47
+ columns: z.coerce.number().int().default(1),
48
+ horizontal: z.boolean().default(false),
49
+ rotateRunningPipelines: z.coerce.number().min(0).default(0).transform(sec => sec * 1000),
50
+ groupSuccessfulProjects: z.boolean().default(false),
51
+ projectsOrder: z.array(OrderSchema).default(['name']),
52
+ gitlabs: z.array(GitlabSchema).min(1, { message: 'Mandatory gitlab properties missing from configuration file' }),
53
+ colors: z.strictObject({
54
+ background: z.string(),
55
+ 'created-background': z.string(),
56
+ 'created-text': z.string(),
57
+ 'dark-text': z.string(),
58
+ 'error-message-background': z.string(),
59
+ 'error-message-text': z.string(),
60
+ 'failed-background': z.string(),
61
+ 'failed-text': z.string(),
62
+ 'group-background': z.string(),
63
+ 'light-text': z.string(),
64
+ 'pending-background': z.string(),
65
+ 'pending-text': z.string(),
66
+ 'project-background': z.string(),
67
+ 'running-background': z.string(),
68
+ 'running-text': z.string(),
69
+ 'skipped-background': z.string(),
70
+ 'skipped-text': z.string(),
71
+ 'success-background': z.string(),
72
+ 'success-text': z.string()
73
+ }).partial().optional(),
74
+ auth: z.strictObject({
75
+ username: z.string(),
76
+ password: z.string()
77
+ }).optional()
78
+ });
79
+ const configFile = expandTilde(process.env.GITLAB_RADIATOR_CONFIG || '~/.gitlab-radiator.yml');
80
+ const yamlContent = fs.readFileSync(configFile, 'utf8');
81
+ const rawConfig = yaml.load(yamlContent);
82
+ export const config = ConfigSchema.parse(rawConfig);
@@ -0,0 +1,11 @@
1
+ import { createRequire } from 'module';
2
+ import webpack from 'webpack';
3
+ import webpackDevMiddleware from 'webpack-dev-middleware';
4
+ import webpackHotMiddleware from 'webpack-hot-middleware';
5
+ const require = createRequire(import.meta.url);
6
+ export function bindDevAssets(app) {
7
+ const config = require('../webpack.dev.cjs');
8
+ const compiler = webpack(config);
9
+ app.use(webpackDevMiddleware(compiler));
10
+ app.use(webpackHotMiddleware(compiler));
11
+ }
@@ -0,0 +1,20 @@
1
+ import axios from 'axios';
2
+ import https from 'https';
3
+ import url from 'url';
4
+ export function gitlabRequest(pathStr, params, gitlab) {
5
+ return lazyClient(gitlab).get(pathStr, { params: params || {} });
6
+ }
7
+ const clients = new Map();
8
+ function lazyClient(gitlab) {
9
+ let client = clients.get(gitlab.url);
10
+ if (!client) {
11
+ client = axios.create({
12
+ baseURL: url.resolve(gitlab.url, '/api/v4/'),
13
+ headers: { 'PRIVATE-TOKEN': gitlab['access-token'] },
14
+ httpsAgent: new https.Agent({ keepAlive: true, ca: gitlab.ca }),
15
+ timeout: 30 * 1000
16
+ });
17
+ clients.set(gitlab.url, client);
18
+ }
19
+ return client;
20
+ }
@@ -0,0 +1,50 @@
1
+ import { fetchLatestPipelines } from "./pipelines.js";
2
+ import { fetchProjects } from "./projects.js";
3
+ export async function update(gitlabs, prioritizeRunningPipelines) {
4
+ const projectsWithPipelines = await loadProjectsWithPipelines(gitlabs, prioritizeRunningPipelines);
5
+ return projectsWithPipelines
6
+ .filter((project) => project.pipelines.length > 0);
7
+ }
8
+ async function loadProjectsWithPipelines(gitlabs, prioritizeRunningPipelines) {
9
+ const allProjectsWithPipelines = [];
10
+ await Promise.all(gitlabs.map(async (gitlab) => {
11
+ const projects = (await fetchProjects(gitlab))
12
+ .map(project => ({
13
+ ...project,
14
+ maxNonFailedJobsVisible: gitlab.maxNonFailedJobsVisible
15
+ }));
16
+ for (const project of projects) {
17
+ allProjectsWithPipelines.push(await projectWithPipelines(project, gitlab, prioritizeRunningPipelines));
18
+ }
19
+ }));
20
+ return allProjectsWithPipelines;
21
+ }
22
+ async function projectWithPipelines(project, gitlab, prioritizeRunningPipelines) {
23
+ const pipelines = filterOutEmpty(await fetchLatestPipelines(project.id, gitlab, prioritizeRunningPipelines))
24
+ .filter(excludePipelineStatusFilter(gitlab));
25
+ const status = defaultBranchStatus(project, pipelines);
26
+ return {
27
+ ...project,
28
+ maxNonFailedJobsVisible: gitlab.maxNonFailedJobsVisible,
29
+ commitAsTitle: gitlab.commitAsTitle,
30
+ pipelines,
31
+ status
32
+ };
33
+ }
34
+ function defaultBranchStatus(project, pipelines) {
35
+ const [head] = pipelines
36
+ .filter(({ ref }) => ref === project.default_branch)
37
+ .map(({ status }) => status);
38
+ return head;
39
+ }
40
+ function filterOutEmpty(pipelines) {
41
+ return pipelines.filter(pipeline => pipeline.stages);
42
+ }
43
+ function excludePipelineStatusFilter(gitlab) {
44
+ return (pipeline) => {
45
+ if (gitlab.projects?.excludePipelineStatus) {
46
+ return !gitlab.projects.excludePipelineStatus.includes(pipeline.status);
47
+ }
48
+ return true;
49
+ };
50
+ }
@@ -0,0 +1,125 @@
1
+ import { gitlabRequest } from "./client.js";
2
+ export async function fetchLatestPipelines(projectId, gitlab, prioritizeRunningPipelines) {
3
+ const pipelines = await fetchLatestAndMasterPipeline(projectId, gitlab, prioritizeRunningPipelines);
4
+ const pipelinesWithStages = [];
5
+ for (const { id, ref, status } of pipelines) {
6
+ const { commit, stages } = await fetchJobs(projectId, id, gitlab);
7
+ const downstreamStages = await fetchDownstreamJobs(projectId, id, gitlab);
8
+ pipelinesWithStages.push({
9
+ id,
10
+ ref,
11
+ status,
12
+ commit,
13
+ stages: stages.concat(downstreamStages)
14
+ });
15
+ }
16
+ return pipelinesWithStages;
17
+ }
18
+ async function fetchLatestAndMasterPipeline(projectId, gitlab, prioritizeRunningPipelines) {
19
+ const options = {
20
+ per_page: 100,
21
+ ...(gitlab.branch ? { ref: gitlab.branch } : {})
22
+ };
23
+ const pipelines = await fetchPipelines(projectId, gitlab, options);
24
+ if (pipelines.length === 0) {
25
+ return [];
26
+ }
27
+ const runningPipelines = pipelines.filter(pipeline => pipeline.status === 'running');
28
+ if (runningPipelines.length > 1 && prioritizeRunningPipelines) {
29
+ return runningPipelines;
30
+ }
31
+ const latestPipeline = pipelines.slice(0, 1);
32
+ if (latestPipeline[0].ref === 'master') {
33
+ return latestPipeline;
34
+ }
35
+ const latestMasterPipeline = pipelines.filter(p => p.ref === 'master').slice(0, 1);
36
+ if (latestMasterPipeline.length > 0) {
37
+ return latestPipeline.concat(latestMasterPipeline);
38
+ }
39
+ const masterPipelines = await fetchPipelines(projectId, gitlab, { per_page: 50, ref: 'master' });
40
+ return latestPipeline.concat(masterPipelines.slice(0, 1));
41
+ }
42
+ async function fetchPipelines(projectId, gitlab, params) {
43
+ const { data: pipelines } = await gitlabRequest(`/projects/${projectId}/pipelines`, params, gitlab);
44
+ return pipelines.filter(pipeline => pipeline.status !== 'skipped');
45
+ }
46
+ async function fetchDownstreamJobs(projectId, pipelineId, gitlab) {
47
+ const { data: gitlabBridgeJobs } = await gitlabRequest(`/projects/${projectId}/pipelines/${pipelineId}/bridges`, { per_page: 100 }, gitlab);
48
+ const childPipelines = gitlabBridgeJobs.filter((bridge) => bridge.downstream_pipeline !== null && bridge.downstream_pipeline.status !== 'skipped');
49
+ const downstreamStages = [];
50
+ for (const childPipeline of childPipelines) {
51
+ const { stages } = await fetchJobs(childPipeline.downstream_pipeline.project_id, childPipeline.downstream_pipeline.id, gitlab);
52
+ downstreamStages.push(stages.map((stage) => ({
53
+ ...stage,
54
+ name: `${childPipeline.stage}:${stage.name}`
55
+ })));
56
+ }
57
+ return downstreamStages.flat();
58
+ }
59
+ async function fetchJobs(projectId, pipelineId, gitlab) {
60
+ const { data: gitlabJobs } = await gitlabRequest(`/projects/${projectId}/pipelines/${pipelineId}/jobs?include_retried=true`, { per_page: 100 }, gitlab);
61
+ if (gitlabJobs.length === 0) {
62
+ return { commit: null, stages: [] };
63
+ }
64
+ const commit = findCommit(gitlabJobs);
65
+ // Map jobs and sort by id
66
+ const mappedJobs = gitlabJobs
67
+ .map(job => ({
68
+ id: job.id,
69
+ status: job.status,
70
+ stage: job.stage,
71
+ name: job.name,
72
+ startedAt: job.started_at,
73
+ finishedAt: job.finished_at,
74
+ url: job.web_url
75
+ }))
76
+ .sort((a, b) => a.id - b.id);
77
+ // Group by stage
78
+ const jobsByStage = new Map();
79
+ for (const job of mappedJobs) {
80
+ const stageJobs = jobsByStage.get(job.stage) || [];
81
+ stageJobs.push(job);
82
+ jobsByStage.set(job.stage, stageJobs);
83
+ }
84
+ // Convert to stages array
85
+ const stages = Array.from(jobsByStage.entries()).map(([name, jobs]) => ({
86
+ name,
87
+ jobs: mergeRetriedJobs(removeStageProperty(jobs)).sort(byName)
88
+ }));
89
+ return {
90
+ commit,
91
+ stages
92
+ };
93
+ }
94
+ function byName(a, b) {
95
+ return a.name.localeCompare(b.name);
96
+ }
97
+ function findCommit(jobs) {
98
+ const [job] = jobs.filter(j => j.commit);
99
+ if (!job || !job.commit) {
100
+ return null;
101
+ }
102
+ return {
103
+ title: job.commit.title,
104
+ author: job.commit.author_name
105
+ };
106
+ }
107
+ function mergeRetriedJobs(jobs) {
108
+ return jobs.reduce((mergedJobs, job) => {
109
+ const index = mergedJobs.findIndex(mergedJob => mergedJob.name === job.name);
110
+ if (index >= 0) {
111
+ mergedJobs[index] = job;
112
+ }
113
+ else {
114
+ mergedJobs.push(job);
115
+ }
116
+ return mergedJobs;
117
+ }, []);
118
+ }
119
+ function removeStageProperty(jobs) {
120
+ return jobs.map(job => {
121
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
122
+ const { stage, ...rest } = job;
123
+ return rest;
124
+ });
125
+ }
@@ -0,0 +1,65 @@
1
+ import { gitlabRequest } from "./client.js";
2
+ export async function fetchProjects(gitlab) {
3
+ const projects = await fetchOwnProjects(gitlab);
4
+ return projects
5
+ // Ignore projects for which CI/CD is not enabled
6
+ .filter(project => project.jobs_enabled)
7
+ .map(projectMapper)
8
+ .filter(includeRegexFilter(gitlab))
9
+ .filter(excludeRegexFilter(gitlab))
10
+ .filter(archivedFilter(gitlab));
11
+ }
12
+ async function fetchOwnProjects(gitlab) {
13
+ const projects = [];
14
+ const SAFETY_MAX_PAGE = 10;
15
+ for (let page = 1; page <= SAFETY_MAX_PAGE; page += 1) {
16
+ const { data, headers } = await gitlabRequest('/projects', { page, per_page: 100, membership: true }, gitlab);
17
+ projects.push(...data);
18
+ if (data.length === 0 || !headers['x-next-page']) {
19
+ break;
20
+ }
21
+ }
22
+ return projects;
23
+ }
24
+ function projectMapper(project) {
25
+ return {
26
+ id: project.id,
27
+ name: project.path_with_namespace,
28
+ nameWithoutNamespace: project.path,
29
+ group: getGroupName(project),
30
+ archived: project.archived,
31
+ default_branch: project.default_branch || 'master',
32
+ url: project.web_url,
33
+ topics: (project.topics || []).map((t) => t.toLowerCase())
34
+ };
35
+ }
36
+ function getGroupName(project) {
37
+ const pathWithNameSpace = project.path_with_namespace;
38
+ return pathWithNameSpace.split('/')[0];
39
+ }
40
+ function includeRegexFilter(gitlab) {
41
+ return (project) => {
42
+ if (gitlab.projects?.include) {
43
+ const includeRegex = new RegExp(gitlab.projects.include, "i");
44
+ return includeRegex.test(project.name);
45
+ }
46
+ return true;
47
+ };
48
+ }
49
+ function excludeRegexFilter(gitlab) {
50
+ return (project) => {
51
+ if (gitlab.projects?.exclude) {
52
+ const excludeRegex = new RegExp(gitlab.projects.exclude, "i");
53
+ return !excludeRegex.test(project.name);
54
+ }
55
+ return true;
56
+ };
57
+ }
58
+ function archivedFilter(gitlab) {
59
+ return (project) => {
60
+ if (gitlab.ignoreArchived) {
61
+ return !project.archived;
62
+ }
63
+ return true;
64
+ };
65
+ }
@@ -0,0 +1,23 @@
1
+ import { gitlabRequest } from "./client.js";
2
+ export async function fetchOfflineRunners(gitlab) {
3
+ if (gitlab.offlineRunners === 'none') {
4
+ return {
5
+ offline: [],
6
+ totalCount: 0
7
+ };
8
+ }
9
+ const runners = await fetchRunners(gitlab);
10
+ const offline = runners.filter(r => r.status === 'offline');
11
+ return {
12
+ offline,
13
+ totalCount: runners.length
14
+ };
15
+ }
16
+ async function fetchRunners(gitlab) {
17
+ const runnersApi = gitlab.offlineRunners === 'all' ? '/runners/all' : '/runners';
18
+ const { data: runners } = await gitlabRequest(runnersApi, null, gitlab);
19
+ return runners.map(r => ({
20
+ name: r.description || r.id.toString(),
21
+ status: r.status
22
+ }));
23
+ }
package/src/less.js ADDED
@@ -0,0 +1,28 @@
1
+ import fs from 'fs';
2
+ import less from 'less';
3
+ import path from 'path';
4
+ import { config } from "./config.js";
5
+ const filename = path.join('public', 'client.less');
6
+ export async function serveLessAsCss(_req, res) {
7
+ try {
8
+ const source = await fs.promises.readFile(filename, 'utf-8');
9
+ const { css } = await less.render(withColorOverrides(source), { filename });
10
+ res.setHeader('content-type', 'text/css');
11
+ res.send(css);
12
+ }
13
+ catch (err) {
14
+ console.error('Failed to render client.less', err);
15
+ res.sendStatus(500);
16
+ }
17
+ }
18
+ function withColorOverrides(source) {
19
+ const { colors } = config;
20
+ if (!colors) {
21
+ return source;
22
+ }
23
+ let colorLess = '';
24
+ Object.keys(colors).forEach((stateName) => {
25
+ colorLess += `@${stateName}-color:${colors[stateName]};`;
26
+ });
27
+ return source + colorLess;
28
+ }
@@ -0,0 +1 @@
1
+ {"root":["../../src/app.ts","../../src/auth.ts","../../src/config.ts","../../src/dev-assets.ts","../../src/less.ts","../../src/common/gitlab-types.d.ts","../../src/gitlab/client.ts","../../src/gitlab/index.ts","../../src/gitlab/pipelines.ts","../../src/gitlab/projects.ts","../../src/gitlab/runners.ts"],"version":"5.9.3"}
@@ -1,55 +0,0 @@
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.
@@ -1,22 +0,0 @@
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 DELETED
@@ -1 +0,0 @@
1
- 22.18.0
package/build-npm DELETED
@@ -1,22 +0,0 @@
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