Building a React dashboard to visualize workflow and job events
Fullstack Developer and Tech Author
Data visualization is the process of translating large data sets and metrics into charts, graphs, and other visuals. The resulting visual representation of data makes it easier to identify and share real-time trends, outliers, and new insights about the information represented in the data. Using CircleCI webhooks, we can gather data on workflow and job events. In this tutorial, I will lead you through the steps to create a React-based dashboard to visualize this data.
Prerequisites
To follow along with this tutorial, you will need a good grasp of JavaScript ES6. You will also need to have an understanding of some basic React concepts like hooks and functional components.
You will need to have these installed on your workstation:
- An up-to-date installation of node.js, and a package manager like npm or yarn
- Your preferred code editor
- An API communicating with your CircleCI webhook. You can read about setting one up here.
- Ensure that the Laravel API CircleCI webhook covered in this tutorial is up and running.
Our tutorials are platform-agnostic, but use CircleCI as an example. If you don’t have a CircleCI account, sign up for a free one here.
Getting started
Create a new React application using this command:
yarn create react-app circleci_workflow_dashboard
cd circleci_workflow_dashboard
For this project, we will be using Ant Design for laying out the UI and react-chartjs-2 to display our charts.
Add the project dependencies using yarn:
yarn add antd react-chartjs-2@3.3.0 chart.js
The next step is to import the styling for ant design. Update src/App.css
with this:
@import '~antd/dist/antd.css';
Adding utility functionality
Next, we need a means of communicating with our API. In the src
folder, create a new folder called utility
. In the utility
folder, create a new file named API.js
. In the src/utility/API.js
file, add this:
export const makeGETRequest = (url) => {
return fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
}).then((response) => response.json());
};
Using this function, we can make a GET
request to the API. The function returns the JSON response from the API.
For the sake of simplicity, error handling is not included in the function.
To represent dates in a readable format, we can create a utility function. In the utility
folder, create a new file called Date.js
and add this:
export const formatDate = (date) =>
new Date(date).toLocaleString("en-GB", {
month: "long",
weekday: "long",
day: "numeric",
year: "numeric",
});
Building dashboard components
Our dashboard will display a table of events you can filter on status and event type. You will also be able to toggle the dashboard between a tabular display and a chart display.
In the src
folder, create a new folder called components
. This folder will hold all the custom components we will create.
In the components
directory, create a new file called StatusTag.jsx
. This component will be used to display a tag with a color determined by the event’s status. Add this to StatusTag.jsx
:
import React from "react";
import { Tag } from "antd";
const StatusTag = ({ status }) => {
const statusColours = {
success: "green",
failed: "volcano",
error: "red",
canceled: "orange",
unauthorized: "magenta",
};
return <Tag color={statusColours[status]}>{status}</Tag>;
};
export default StatusTag;
Next, create a new file in the components
directory named TableView.jsx
. This component will take the notifications as a prop and render them in a table with the ability to filter based on type and status. Add this code to TableView.jsx
:
import React from "react";
import { Table } from "antd";
import { formatDate } from "../utility/Date";
import StatusTag from "./StatusTag";
const TableView = ({ notifications }) => {
const filterHandler = (value, record, key) => record[key] === value;
const columns = [
{
title: "Subject",
dataIndex: "commit_subject",
},
{
title: "Commit Author",
dataIndex: "commit_author",
},
{
title: "Happened At",
dataIndex: "happened_at",
render: (text) => formatDate(text),
},
{
title: "Event Type",
dataIndex: "type",
filters: [
{ text: "Job", value: "job-completed" },
{ text: "Workflow", value: "workflow-completed" },
],
onFilter: (value, record) => filterHandler(value, record, "type"),
},
{
title: "Event Status",
dataIndex: "event_status",
filters: [
{ text: "Success", value: "success" },
{ text: "Failed", value: "failed" },
{ text: "Error", value: "error" },
{ text: "Canceled", value: "canceled" },
{ text: "Unauthorized", value: "unauthorized" },
],
render: (text) => <StatusTag status={text} />,
onFilter: (value, record) => filterHandler(value, record, "event_status"),
},
{
title: "Notification ID",
dataIndex: "notification_id",
render: (text, record) => (
<a href={record["workflow_url"]} target="_blank" rel="noreferrer">
{text}
</a>
),
},
];
return (
<Table dataSource={notifications} columns={columns} rowKey="id" bordered />
);
};
export default TableView;
The table has six columns: Subject, Commit Author, Happened At, Event Type, Event Status, and Notification ID.
Each column is represented by an object in the columns
constant. Where no special rendering is required, the title
and dataIndex
are sufficient for the column declaration. The title
is used as the title of the column while the dataIndex
entry lets antd know which property the column is populated with.
To specify the component or JSX element to be rendered in a column, we add a render
key to the column’s object representation. We use this to render the StatusTag
component we created earlier. We also use this to render a well-formatted date for the Happened At
column and the Notification ID as a link to the workflow.
Building chart components
For this tutorial, we will render the data in three charts:
- A pie chart showing the distribution of events by status: success, failed, error, canceled, or unauthorized.
- A bar chart showing the distribution of events by type: either workflow or job
- A line chart showing the timeline of events
To build the pie chart, create a new file in the components
folder called StatusDistribution.jsx
and add this code to it:
import React from "react";
import { Pie } from "react-chartjs-2";
const StatusDistribution = ({ notifications }) => {
const sortedNotifications = notifications.reduce(
(sortedNotifications, notification) => {
sortedNotifications[notification["event_status"]]++;
return sortedNotifications;
},
{ error: 0, failed: 0, success: 0, unauthorized: 0, canceled: 0 }
);
const data = {
labels: ["Error", "Failed", "Success", "Unauthorized", "Canceled"],
datasets: [
{
data: Object.values(sortedNotifications),
backgroundColor: [
"rgba(255, 99, 132, 0.2)",
"rgba(255, 206, 86, 0.2)",
"rgba(75, 192, 192, 0.2)",
"rgba(153, 102, 255, 0.2)",
"rgba(255, 159, 64, 0.2)",
],
borderColor: [
"rgba(255, 99, 132, 1)",
"rgba(255, 206, 86, 1)",
"rgba(75, 192, 192, 1)",
"rgba(153, 102, 255, 1)",
"rgba(255, 159, 64, 1)",
],
borderWidth: 1,
},
],
};
return (
<>
<div className="header">
<h1 className="title">Status Distribution</h1>
</div>
<Pie data={data} height={50} />
</>
);
};
export default StatusDistribution;
Using the reduce
function on the notifications array, the notifications are sorted and counted according to the status. The values are then passed to the data
key in the datasets
configuration, which is passed to the Pie
component.
To build the bar chart, create a new file in the components
directory called TypeDistribution.jsx
and add this code to it.
import React from "react";
import { Bar } from "react-chartjs-2";
const TypeDistribution = ({ notifications }) => {
const sortedNotifications = notifications.reduce(
(sortedNotifications, notification) => {
sortedNotifications[notification["type"]]++;
return sortedNotifications;
},
{ "job-completed": 0, "workflow-completed": 0 }
);
const data = {
labels: ["Job", "Workflow"],
datasets: [
{
label: "Event Type",
data: Object.values(sortedNotifications),
backgroundColor: ["rgba(54, 162, 235, 0.2)", "rgba(75, 192, 192, 0.2)"],
borderColor: ["rgba(54, 162, 235, 1)", "rgba(75, 192, 192, 1)"],
borderWidth: 1,
},
],
};
const options = {
scales: {
y: {
beginAtZero: true,
},
},
};
return (
<>
<div className="header">
<h1 className="title">Type Distribution</h1>
</div>
<Bar data={data} options={options} height={500} />
</>
);
};
export default TypeDistribution;
Just as we did for the status distribution chart, we sort and count the notifications based on the notification type. The values are then passed to the data
key in the datasets
configuration, which is passed to the Bar
component.
To build the timeline of notifications, create a new file in the components
directory called Timeline.jsx
and add this code to it:
import React from "react";
import { Line } from "react-chartjs-2";
import { formatDate } from "../utility/Date";
const Timeline = ({ notifications }) => {
const sortedNotifications = notifications.reduce(
(sortedNotifications, notification) => {
const notificationDate = formatDate(notification["happened_at"]);
if (notificationDate in sortedNotifications) {
sortedNotifications[notificationDate]++;
} else {
sortedNotifications[notificationDate] = 1;
}
return sortedNotifications;
},
{}
);
const data = {
labels: Object.keys(sortedNotifications),
datasets: [
{
label: "Number of events",
data: Object.values(sortedNotifications),
fill: false,
backgroundColor: "rgb(255, 99, 132)",
borderColor: "rgba(255, 99, 132, 0.2)",
},
],
};
const options = {
scales: {
y: {
beginAtZero: true,
},
},
};
return (
<>
<div className="header">
<h1 className="title">Event Timeline</h1>
</div>
<Line data={data} options={options} height={500} width={1500} />
</>
);
};
export default Timeline;
The sorting functionality for this component is slightly different. Because we cannot specify all the possible dates in an initial object, we start with an empty object. Then, for each notification, we check if a key already exists for that date. If it does, we increase the count, if not we add the date with a value of 1.
Next, we need to build a component that renders all the charts in a grid. Create a new file in the components
directory called ChartView.jsx
and add this code to it:
import React from "react";
import StatusDistribution from "./StatusDistribution";
import { Col, Row } from "antd";
import TypeDistribution from "./TypeDistribution";
import Timeline from "./Timeline";
const ChartView = ({ notifications }) => {
return (
<>
<Timeline notifications={notifications} />
<Row style={{ marginTop: "30px" }} gutter={96}>
<Col>
<StatusDistribution notifications={notifications} />
</Col>
<Col offset={6}>
<TypeDistribution notifications={notifications} />
</Col>
</Row>
</>
);
};
export default ChartView;
In this component, we render the status distribution and type distribution side-by-side with the timeline above.
Passing the notifications as we have done here is known as prop drilling. Although it is generally discouraged. we did it here for the sake of simplifying the tutorial. In production apps, you should consider a proper state management implementation.
Putting it all together
With all the child components in place, update src/App.js
to match this:
import "./App.css";
import { makeGETRequest } from "./utility/Api";
import { useEffect, useState } from "react";
import { Card, Col, Row, Switch } from "antd";
import TableView from "./components/TableView";
import ChartView from "./components/ChartView";
const App = () => {
const [notifications, setNotifications] = useState([]);
const [showTableView, setShowTableView] = useState(false);
useEffect(() => {
makeGETRequest("http://127.0.0.1:8000/api/circleci").then((response) => {
setNotifications(response);
console.log(response);
});
}, []);
const handleSwitchValueChange = () => {
setShowTableView((showTableView) => !showTableView);
};
return (
<Card style={{ margin: "2%" }}>
<Row style={{ marginBottom: "10px" }}>
<Col span={6} offset={18}>
Show Data as Table
<Switch checked={showTableView} onChange={handleSwitchValueChange} />
</Col>
</Row>
{showTableView ? (
<TableView notifications={notifications} />
) : (
<ChartView notifications={notifications} />
)}
</Card>
);
};
export default App;
In this component, we retrieve the notifications from our API in the useEffect
hook and save it to state using the setNotifications
function. We then declare a function to handle the toggling of the showTableView
state variable, which determines whether the data is displayed in a table or chart.
To toggle between views, we render a Switch
and pass it the showTableView
and handleSwitchValueChange
values as props.
Finally, depending on the value of showTableView
, either the TableView
or ChartView
component is rendered.
Table view
Chart view
Conclusion
In this tutorial, we examined how we can build a React dashboard to visualize pipeline events using an API. By visualizing data in charts, we can get a high-level understanding of our pipeline and also make sense of the dataset, no matter how large. While we did not implement this feature, you can also export the data into spreadsheets for further analysis. Share this sample project with your team and extend your learning!
The code for this article is available on GitHub and the complete documentation for CircleCI webhooks is available here.