TutorialsJan 6, 20226 min read

Building a React dashboard to visualize workflow and job events

Olususi Oluyemi

Fullstack Developer and Tech Author

Developer C sits at a desk working on an intermediate-level project.

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:

  1. An up-to-date installation of node.js, and a package manager like npm or yarn
  2. Your preferred code editor
  3. An API communicating with your CircleCI webhook. You can read about setting one up here.
  4. 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:

  1. A pie chart showing the distribution of events by status: success, failed, error, canceled, or unauthorized.
  2. A bar chart showing the distribution of events by type: either workflow or job
  3. 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

Dashboard displaying table view

Chart view

Dashboard displaying 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.


Oluyemi is a tech enthusiast with a background in Telecommunication Engineering. With a keen interest in solving day-to-day problems encountered by users, he ventured into programming and has since directed his problem solving skills at building software for both web and mobile. A full stack software engineer with a passion for sharing knowledge, Oluyemi has published a good number of technical articles and blog posts on several blogs around the world. Being tech savvy, his hobbies include trying out new programming languages and frameworks.

Copy to clipboard