A long time ago, for supervision purpose, I needed to display some real time KPIs retrieved from our Jenkins IC Platform on a large screen in our team’s open-space.
After several weeks of research, I didn’t find a suitable solution (wallboard or radiator plugins or apps). So I decided to code my own tool.
This project shows how I did it with React, Node and the Jenkins Json API.
Source code
The whole source code of this article is in my GitLab repository.
Architecture
We want to build an application which gets data from a Jenkins server.
This application will be built with React and will run in a browser displayed on a TV screen.
To avoid any XSS problem, we need a proxy (node server) between the webapp and the Jenkins server which is in another domain (IC Platform).
The proxy role is the following:
rendering
the React application which will display our development KPIsproxying
the Jenkins server by offering a REST API which is redirected on the Jenkins REST API
General
Start-up
Running
Our project structure
Our project directory tree reflects exactly the previous architecture.
Indeed we create:
- a
client
directory which will contain the React sources of our webapp - a
server
directory which will contain the Node sources of our webproxy
Prerequisites
To be able to use node
and npm
we must have installed
Modules
For server side:
For client side:
Our repository
First we create a git repository in a directory named node-react-jenkins-wallboard
.
Then we create two directories, one for the server
and one another for the client
.
C:\>dir node-react-jenkins-wallboard
...
19/04/2018 08:37 <DIR> client
19/04/2018 08:44 <DIR> server
...
Node Proxy Web Server (1)
Create our proxy skeleton
Initialisation
In the server
directory:
- we do a
npm init
to initialize our directory (we define our main file asserver.js
) - we install
express
withnpm install express --save
- we create a
src
directory in which we will put our sources
The package.json
created by the init
command must look like this:
{
"name": "nrjw-server",
"version": "1.0.0",
"description": "Simple React Wallboard for Jenkins engined by a Node Server - Node Server Implementation",
"main": "server.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "Ent Wickler",
"license": "ISC",
"dependencies": {
"express": "^4.16.3"
}
}
Define a configuration file
We need to set parameters to run our server. So, in the src
folder, we create now a config.js
file with the very short following content:
'use strict';
/* */
module.exports = {
'proxyPort': '3001'
}
The directory tree at this point
└── _node-react-jenkins-wallboard
├── _client
└── _server
├── _node_modules
├── _src
| └── config.js
└── package.json
Define a main file
Now let’s create our server.js
file in the server
folder.
For that moment we don’t use the config file yet. We hard code the proxy port to 3000.
'use strict';
/* globals */
global.__BASE = __dirname ;
/* */
var express = require('express') ;
var app = express() ;
/* */
app.listen(3000, function () {
console.log('Proxy server started on port [%d]', 3000);
});
Define a usage file
Let’s create the Usage.js
file in the src
folder like the following:
'use strict';
/* we will use path.join for our requires to be posix and windows compatible */
var path = require('path');
/* get config settings */
var myConfig = require(path.join((__BASE + '/src/config')));
/* */
class Usage {
/* */
constructor() {
this._argv;
this._proxyPort;
this._initProps();
}
/* */
_initProps() {
this._argv = process.argv;
for (let i = 2; i < this._argv.length; i++) {
if (this._argv[i] === '--p' || this._argv[i] === '--port') {
this._proxyPort = this._argv[i + 1];
}
}
}
/* */
check() {
for (let i = 2; i < this._argv.length; i++) {
if (this._argv[i] === '--h' || this._argv[i] === "--help") {
console.log('');
console.log('Usage :');
console.log('-------');
console.log('');
console.log('--h (--help) : displays this help');
console.log('--p (--port) <port number> : sets proxy port number (if not : config file value or 3000 by default)');
console.log('');
console.log('Usage examples : start server --help');
console.log(' : start server --p 3000');
process.exit(0);
}
}
console.log('Get more info with "node server --h"');
}
/* */
get proxyPort() {
if (this._proxyPort) { return this._proxyPort; }
if (myConfig.proxyPort) { return myConfig.proxyPort; }
return 3000;
}
}
/* */
exports.Usage = Usage;
Usage
class uses the config.js
file to retrieve the setting port and to give help to the user if he asks for it.
Let’s assemble the code
We modify the server.js
file to check the use of the start command and to retrieve the port value of the proxy server.
Now we have with the following content:
'use strict';
/* globals */
global.__BASE = __dirname ;
/* we will use path.join for our requires to be posix and windows compatible */
var path = require('path');
/* usage verification */
var { Usage } = require(path.join((__BASE + '/src/Usage')));
var myUsage = new Usage();
myUsage.check();
/* */
var express = require('express') ;
var app = express() ;
/* */
app.listen(myUsage.proxyPort, function () {
console.log('Proxy server started on port [%d]', myUsage.proxyPort);
});
Again modify the package.json file
Because I develop/build under WINDOWS and I run my tools under a LINUX platform, my code must be compatible with both POSIX and WINDOWS platforms.
For that reason I had to modify the content of the package.json
file like this:
{
"name": "nrjw-server",
"version": "1.0.0",
"description": "Simple React Wallboard for Jenkins engined by a Node Server - Node Server Implementation",
"main": "server.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "NODE_ENV=production node server.js",
"start_windows": "set NODE_ENV=production&&node server.js",
"start_development": "NODE_ENV=development node server.js",
"start_windows_development": "set NODE_ENV=development&&node server.js"
},
"scriptsComment": {
"start": "npm run start: start the server for PROD environment on LINUX platform",
"start_windows": "npm run start_windows: start the server for PROD environment on WINDOWS platform",
"start_development": "npm run start_development: start the server for DEVT environment on LINUX platform",
"start_windows_development": "npm run start_windows_development: start the server for DEVT environment on WINDOWS platform"
},
"author": "Ent Wickler",
"license": "ISC",
"dependencies": {
"express": "^4.16.3"
}
}
Start the server
From now on Windows, we can run the server like this:
c:\node-react-jenkins-wallboard\server>node server --port 3006
or like this (the double -- --
is not a syntax error):
c:\node-react-jenkins-wallboard\server>npm run start_windows -- --port 3006
The directory tree at this point
└── node-react-jenkins-wallboard
├── client
└── server
├── node_modules
├── src
| ├── config.js
| ├── Toolbox.js
| └── Usage.js
├── package.json
└── server.js
Add content to the proxy
Now we have our skeleton which runs well, we have to implement the Proxy REST Api which
- will be requested by our future REACT client
- will call the Jenkins REST Api to retrieve KPIs
- will forward KPIs datas to the REACT client
Add a cache
Since
- we use pull REST Api methods
- we don’t control the number of client calls
- we want to protect the Jenkins server from heavy load
we add a very simple cache feature in our proxy server.
This cache function will request the Jenkins server only one time by x seconds even if there are many client requests to the proxy.
First we add a new file named Kpi.js
int the src
folder which contains the class definition of the object which will request the REST Json Api of Jenkins.
For that moment it contains only one countdown property and a function which prints the countdown value in the console.
Here is this file:
'use strict';
/* */
class Kpi {
/* */
constructor() {
this._countdown;
this._initProps();
}
/* */
_initProps() {
this._countdown = 0;
}
/* */
refresh() {
this._countdown++;
console.log('Kpi refresh n°%i', this._countdown);
}
}
/* */
exports.Kpi = Kpi;
We also modify the server.js
file to add the call to the refresh function of the Kpi
object:
'use strict';
/* globals */
global.__BASE = __dirname;
/* we will use path.join for our requires to be posix and windows compatible */
var path = require('path');
/* usage verification */
var { Usage } = require(path.join((__BASE + '/src/Usage')));
var myUsage = new Usage();
myUsage.check();
/* object which retrieves jenkins kpis */
var { Kpi } = require(path.join((__BASE + '/src/Kpi')));
var myKpi = new Kpi();
/* run the cache update every 15 seconds */
function refreshCache() {
myKpi.refresh();
}
setInterval(refreshCache, 15000);
/* */
var express = require('express');
var app = express();
/* */
app.listen(myUsage.proxyPort, function () {
console.log('Proxy server started on port [%d]', myUsage.proxyPort);
});
Test
In the command prompt, run node server
.
The result must look like this (one refresh printed every 15 seconds):
Get more info with "node server --h"
Proxy server started on port [3001]
Kpi refresh n°1
Kpi refresh n°2
Kpi refresh n°3
Kpi refresh n°4
Kpi refresh n°5
The directory tree at this point
└── node-react-jenkins-wallboard
├── client
└── server
├── node_modules
├── src
| ├── config.js
| ├── Kpi.js
| ├── Toolbox.js
| └── Usage.js
├── package.json
└── server.js
Mocking the Jenkins server
When I was developing this tool I had several Jenkins test instances.
But for this proof of concept the easiest way to do is to implement a very trivial fake server which replaces a Jenkins instance.
This mock is simply a node server which listens on a port and sends json responses in real Jenkins format.
Create the fake server
In the jenkins
directory:
- we do a
npm init
to initialize our directory (we define our main file asjenkins.js
) - we install
express
withnpm install express --save
So we create now the jenkins.js
file with the following content (very ugly hard code):
'use strict';
/* */
var express = require('express');
var app = express();
var buildsForBigProject =
{
"_class": "com.Instancebees.hudson.plugins.folder.Folder",
"views": [
{
"_class": "hudson.model.AllView",
"jobs": [
{
"_class": "com.github.mjdetullio.jenkins.plugins.multibranch.FreeStyleMultiBranchProject",
"url": "http://my.jenkins.domain.url/job/BigProject/job/BUILD/job/InstanceDeploy/"
},
{
"_class": "com.github.mjdetullio.jenkins.plugins.multibranch.FreeStyleMultiBranchProject",
"url": "http://my.jenkins.domain.url/job/BigProject/job/BUILD/job/InstanceDispatch/"
},
{
"_class": "com.github.mjdetullio.jenkins.plugins.multibranch.FreeStyleMultiBranchProject",
"url": "http://my.jenkins.domain.url/job/BigProject/job/BUILD/job/InstanceMemory/"
}
],
"name": "All"
},
{
"_class": "hudson.model.ListView",
"jobs": [
{
"_class": "hudson.model.FreeStyleProject",
"url": "http://my.jenkins.domain.url/job/BigProject/job/BUILD/job/InstanceDeploy/job/version2_release/",
"color": "blue"
},
{
"_class": "hudson.model.FreeStyleProject",
"url": "http://my.jenkins.domain.url/job/BigProject/job/BUILD/job/InstanceDispatch/job/version2_release/",
"color": "blue"
},
{
"_class": "hudson.model.FreeStyleProject",
"url": "http://my.jenkins.domain.url/job/BigProject/job/BUILD/job/InstanceMemory/job/version2_release/",
"color": "blue"
}
],
"name": "version1"
},
{
"_class": "hudson.model.ListView",
"jobs": [
{
"_class": "hudson.model.FreeStyleProject",
"url": "http://my.jenkins.domain.url/job/BigProject/job/BUILD/job/InstanceDeploy/job/version2_release/",
"color": "blue"
},
{
"_class": "hudson.model.FreeStyleProject",
"url": "http://my.jenkins.domain.url/job/BigProject/job/BUILD/job/InstanceDispatch/job/version2_release/",
"color": "blue"
},
{
"_class": "hudson.model.FreeStyleProject",
"url": "http://my.jenkins.domain.url/job/BigProject/job/BUILD/job/InstanceMemory/job/version2_release/",
"color": "red"
}
],
"name": "version2"
},
{
"_class": "hudson.model.ListView",
"jobs": [
{
"_class": "hudson.model.FreeStyleProject",
"url": "http://my.jenkins.domain.url/job/BigProject/job/BUILD/job/InstanceDeploy/job/version3_release/",
"color": "blue_anime"
},
{
"_class": "hudson.model.FreeStyleProject",
"url": "http://my.jenkins.domain.url/job/BigProject/job/BUILD/job/InstanceDispatch/job/version3_release/",
"color": "blue"
},
{
"_class": "hudson.model.FreeStyleProject",
"url": "http://my.jenkins.domain.url/job/BigProject/job/BUILD/job/InstanceMemory/job/version3_release/",
"color": "blue"
}
],
"name": "version3"
}
]
}
var buildsForSmallProject =
{
"_class": "com.cloudbees.hudson.plugins.folder.Folder",
"views": [
{
"_class": "hudson.model.AllView",
"jobs": [
{
"_class": "hudson.maven.MavenModuleSet",
"url": "http:/my.jenkins.domain.url/job/SmallProject/job/SmallProjectWS/",
"color": "blue"
},
{
"_class": "hudson.maven.MavenModuleSet",
"url": "http:/my.jenkins.domain.url/job/SmallProject/job/SmallProjectEngine/",
"color": "blue"
},
{
"_class": "hudson.maven.MavenModuleSet",
"url": "http:/my.jenkins.domain.url/job/SmallProject/job/SmallProjectData/",
"color": "blue"
},
{
"_class": "hudson.maven.MavenModuleSet",
"url": "http:/my.jenkins.domain.url/job/SmallProject/job/SmallProjectDispatcher/",
"color": "blue"
}
],
"name": "All"
}
]
}
var metricsForBigProject =
{
"_class": "hudson.model.FreeStyleProject",
"builds": [
{
"_class": "hudson.model.FreeStyleBuild",
"actions": [
{
"_class": "hudson.model.CauseAction"
},
{
"_class": "hudson.tasks.junit.TestResultAction",
"failCount": 0,
"skipCount": 8,
"totalCount": 2590
}
],
"number": 14356
},
{
"_class": "hudson.model.FreeStyleBuild",
"actions": [
{
"_class": "hudson.model.CauseAction"
},
{
"_class": "hudson.tasks.junit.TestResultAction",
"failCount": 0,
"skipCount": 8,
"totalCount": 2590
}
],
"number": 14355
},
{
"_class": "hudson.model.FreeStyleBuild",
"actions": [
{
"_class": "hudson.model.CauseAction"
},
{
"_class": "hudson.tasks.junit.TestResultAction",
"failCount": 0,
"skipCount": 8,
"totalCount": 2590
}
],
"number": 14354
}
]
}
var metricsForSmallProject =
{
"_class": "hudson.model.FreeStyleProject",
"builds": [
{
"_class": "hudson.model.FreeStyleBuild",
"actions": [
{
"_class": "hudson.model.CauseAction"
},
{
"_class": "hudson.tasks.junit.TestResultAction",
"failCount": 4,
"skipCount": 1,
"totalCount": 5545
}
],
"number": 2222
},
{
"_class": "hudson.model.FreeStyleBuild",
"actions": [
{
"_class": "hudson.model.CauseAction"
},
{
"_class": "hudson.tasks.junit.TestResultAction",
"failCount": 0,
"skipCount": 1,
"totalCount": 5545
}
],
"number": 2221
}
]
}
app.get('/jenkins/builds/:projectname', function (req, res) {
console.log("the jenkins url to get the list of builds should look like => ");
console.log("http://my.jenkins.domain.url/job/my_project_name/api/json?pretty=true&tree=views[name,jobs[url,color]]")
if (req.params.projectname === 'BigProject') {
res.status(200).json(buildsForBigProject);
return;
}
res.status(200).json(buildsForSmallProject);
return;
});
app.get('/jenkins/metrics/:projectname', function (req, res) {
console.log("the jenkins url to get the list of metrics (test results for instance) should look like => ");
console.log("http://my.jenkins.domain.url/job/my_project_name/job/my_job_name/api/json?&pretty=true&tree=builds[number,actions[failCount,skipCount,totalCount]]")
if (req.params.projectname === 'BigProject') {
res.status(200).json(metricsForBigProject);
return;
}
res.status(200).json(metricsForSmallProject);
return;
});
/* */
app.listen(3999, function () {
console.log('Fake Jenkins server started on port [%d]', 3999);
});
Now, we have 2 Rest Jenkins methods, one for listing builds and on another to list metrics.
The directory tree at this point
└── node-react-jenkins-wallboard
├── client
└── server
├── node_modules
├── src
| ├── config.js
| ├── Kpi.js
| ├── Toolbox.js
| └── Usage.js
├── package.json
└── server.js
└── jenkins
├── node_modules
├── package.json
└── jenkins.js
Node Proxy Web Server (2)
We are going to modify our node proxy to get KPIs from the mocked Jenkins server.
Promises
Because promises are cool, in the server
directory, we install request-promise
with npm install request request-promise --save
.
The package.json
file should look like this:
{
"name": "nrjw-server",
"version": "1.0.0",
"description": "Simple React Wallboard for Jenkins engined by a Node Server - Node Server Implementation",
"main": "server.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "NODE_ENV=production node server.js",
"start_windows": "set NODE_ENV=production&&node server.js",
"start_development": "NODE_ENV=development node server.js",
"start_windows_development": "set NODE_ENV=development&&node server.js"
},
"scriptsComment": {
"start": "npm run start: start the server for PROD environment on LINUX platform",
"start_windows": "npm run start_windows: start the server for PROD environment on WINDOWS platform",
"start_development": "npm run start_development: start the server for DEVT environment on LINUX platform",
"start_windows_development": "npm run start_windows_development: start the server for DEVT environment on WINDOWS platform"
},
"author": "Ent Wickler",
"license": "ISC",
"dependencies": {
"express": "^4.16.3",
"request": "^2.85.0",
"request-promise": "^4.2.2"
}
}
Setting jenkins url
We modify config.js
file to add all our Jenkins URL. So it looks like the following content:
'use strict';
/* */
module.exports = {
'proxyPort': '3001',
'urlJenkinsMetricsList': [
{ 'project': 'BigProject', 'job': 'BigProject-Metrics', 'url': 'http://localhost:3999/jenkins/metrics/bigproject' },
{ 'project': 'SmallProject', 'job': 'SmallProject-Metrics', 'url': 'http://localhost:3999/jenkins/metrics/smallproject' }
],
'urlJenkinsBuildsList': [
{ 'project': 'BigProject', 'type': 'BUILD', 'url': 'http://localhost:3999/jenkins/builds/bigproject' },
{ 'project': 'SmallProject', 'type': 'BUILD', 'url': 'http://localhost:3999/jenkins/builds/smallproject' }
]
}
Server modification
We add the REST routing like this:
'use strict';
/* globals */
global.__BASE = __dirname;
/* we will use path.join for our requires to be posix and windows compatible */
var path = require('path');
/* usage verification */
var { Usage } = require(path.join((__BASE + '/src/Usage')));
var myUsage = new Usage();
myUsage.check();
/* object which retrieves jenkins kpis */
var { Kpi } = require(path.join((__BASE + '/src/Kpi')));
var myKpi = new Kpi();
/* run the cache update every 15 seconds */
function refreshCache() {
myKpi.refresh();
}
setInterval(refreshCache, 15000);
/* */
var express = require('express');
var app = express();
/* get jenkins kpis */
app.get('/kpis', function (req, res) {
res.status(200).json(myKpi);
return;
});
/* */
app.listen(myUsage.proxyPort, function () {
console.log('Proxy server started on port [%d]', myUsage.proxyPort);
});
Kpi class content
'use strict';
/* */
var request = require('request-promise');
/* we will use path.join for our requires to be posix and windows compatible */
var path = require('path');
/* get config settings */
var myConfig = require(path.join((__BASE + '/src/config')));
/* */
class Kpi {
/* */
constructor() {
this.metrics; // tests kpis
this.builds; // builds kpis
this._initProps();
}
/* */
_initProps(pMoment) {
this.metrics = [];
this.builds = [];
}
/* the jenkins anonymous account must have the read role */
async refresh() {
let metricsByProject = [];
let buildsByProject = [];
/* metrics */
let metricsRequests = myConfig.urlJenkinsMetricsList.map(async function (mapRow) {
let options = { url: mapRow.url };
let body;
try {
body = await request.get(options);
} catch (error) {
console.error(error);
return;
}
let json = JSON.parse(body);
// The last build is builds[0]
let actions = json.builds[0].actions;
let metrics = {};
for (let i = 0; i < actions.length; i++) {
if (actions[i].totalCount) {
metrics.failCount = actions[i].failCount;
metrics.skipCount = actions[i].skipCount;
metrics.totalCount = actions[i].totalCount;
}
}
metricsByProject.push({ 'project': mapRow.project, 'job': mapRow.job, 'metrics': metrics });
});
try {
await Promise.all(metricsRequests);
} catch (error) {
console.error(error);
return;
}
this.metrics = metricsByProject.sort(function (metricA, metricB) {
let projectA = metricA.project.toLowerCase();
let projectB = metricB.project.toLowerCase();
if (projectA < projectB) { return -1; }
if (projectA > projectB) { return 1; }
return 0;
});
/* builds */
let buildsRequests = myConfig.urlJenkinsBuildsList.map(async function (mapRow) {
let options = { url: mapRow.url };
let body;
try {
body = await request.get(options);
} catch (error) {
console.error(error);
return;
}
let json = JSON.parse(body);
let views = json.views;
let builds = [];
let status;
// we analyse all the views
for (let i = 0; i < views.length; i++) {
let error = false;
let running = false;
let buildsSuccess = [];
let buildsError = [];
let buildsRunning = [];
let buildsList = [];
// Builds in error ?
for (let j = 0; j < views[i].jobs.length; j++) {
if (views[i].jobs[j].color) {
if (views[i].jobs[j].color === 'blue') {
buildsSuccess.push(views[i].jobs[j].url);
}
if (views[i].jobs[j].color === 'red') {
error = true;
buildsError.push(views[i].jobs[j].url);
}
if (views[i].jobs[j].color.indexOf("anime") != -1) {
running = true;
buildsRunning.push(views[i].jobs[j].url);
}
}
}
// by priority order
// 1 - running
// 2 - error
// 3 - success
if (!error && !running) {
status = 'success';
buildsList = buildsSuccess;
}
if (error) {
status = 'error';
buildsList = buildsError;
}
if (running) {
status = 'running';
buildsList = buildsRunning;
}
builds.push({ 'view': views[i].name, 'status': status, 'builds': buildsList });
}
buildsByProject.push({ 'project': mapRow.project, 'type': mapRow.type, 'builds': builds });
});
try {
await Promise.all(buildsRequests);
} catch (error) {
console.error(error);
return;
}
this.builds = buildsByProject.sort(function (buildA, buildB) {
let projectA = buildA.project.toLowerCase() + buildA.type.toLowerCase();
let projectB = buildB.project.toLowerCase() + buildB.type.toLowerCase();;
if (projectA < projectB) { return -1; }
if (projectA > projectB) { return 1; }
return 0;
});
}
}
exports.Kpi = Kpi;
Testing
We start
- in the
jenkins
directory, run the jenkins servernode jenkins
(listening on port 3999) - in the
server
directory, run the jenkins servernode server
(listening on port 3001)
In your brower, type http://localhost:3001/kpis
then you get:
{
"metrics": [
{
"project": "BigProject",
"job": "BigProject-Metrics",
"metrics": {
"failCount": 0,
"skipCount": 8,
"totalCount": 2590
}
},
{
"project": "SmallProject",
"job": "SmallProject-Metrics",
"metrics": {
"failCount": 4,
"skipCount": 1,
"totalCount": 5545
}
}
],
"builds": [
{
"project": "BigProject",
"type": "BUILD",
"builds": [
{
"view": "All",
"status": "success",
"builds": []
},
{
"view": "version1",
"status": "success",
"builds": [
"http://my.jenkins.domain.url/job/BigProject/job/BUILD/job/InstanceDeploy/job/version2_release/",
"http://my.jenkins.domain.url/job/BigProject/job/BUILD/job/InstanceDispatch/job/version2_release/",
"http://my.jenkins.domain.url/job/BigProject/job/BUILD/job/InstanceMemory/job/version2_release/"
]
},
{
"view": "version2",
"status": "error",
"builds": [
"http://my.jenkins.domain.url/job/BigProject/job/BUILD/job/InstanceMemory/job/version2_release/"
]
},
{
"view": "version3",
"status": "running",
"builds": [
"http://my.jenkins.domain.url/job/BigProject/job/BUILD/job/InstanceDeploy/job/version3_release/"
]
}
]
},
{
"project": "SmallProject",
"type": "BUILD",
"builds": [
{
"view": "All",
"status": "success",
"builds": [
"http:/my.jenkins.domain.url/job/SmallProject/job/SmallProjectWS/",
"http:/my.jenkins.domain.url/job/SmallProject/job/SmallProjectEngine/",
"http:/my.jenkins.domain.url/job/SmallProject/job/SmallProjectData/",
"http:/my.jenkins.domain.url/job/SmallProject/job/SmallProjectDispatcher/"
]
}
]
}
]
}
For the REACT coding you have to read the second part of this tutorial.