In this guide we will use Airbotics to build a web-based dashboard to a robot in React. The full source code is available under MIT license on GitHub.

Prerequisites

  • NodeJs installed on your development machine.
  • An Airbotics account with:
    • At least 1 robot created with:
      • Logging enabled: Robots > Robot details > Logs > Enabled
      • A data stream set up to collect pose information: Robots > Robot details > Data streams > Create > source:/turtle1/pose, type: turtlesim/msg/Pose
  • A computer running the turtlesim node and Airbotics agent.

Setting up React

We’re going to utilise the create-react-app tool to set up a new react project.

Initialise a new project called example-react-dashboard:

npx create-react-app example-react-dashboard  --template typescript

When the project finishes initialising open the example-react-dashboard folder in your favourite code editor or IDE.

You can check if the app is running as expected with:

npm run start

You should see the default landing page load in your browser.

We’ll use basic React techniques. A production app will include more error handling, design, testing, responsiveness, etc.

Adding Tailwind CSS

We’ll use Tailwind CSS to style the dashboard:

npm install -D tailwindcss
npx tailwindcss init

Open tailwind.config.js and replace with the following to configure template paths:

module.exports = {
  content: ["./src/**/*.{js,jsx,ts,tsx,html,css}"],
  theme: {
    extend: {},
  },
  plugins: [],
}

Open src/index.css and add the tailwind directives:

@tailwind base;
@tailwind components;
@tailwind utilities;

Replace src/App.tsx with:

const App = () => {
  return (
    <h1 className="text-3xl font-bold underline">
      Hello world!
    </h1>
  )
};

export default App;

And include index.css in src/index.tsx with:

import './index.css';
...

Finally, restart your app. If the app is still running from the previous npm run start, kill the process and rerun:

npm run start

You should now see the page reload with a styled hello world header!

Implementing layout

Now we’re going to code up the layout of our dashboard. Remember the goal from the initial screenshot was to have 4 cards in a 2x2 layout where the cards on left took up 1/3 of the horizontal space and the cards on the right took up the remaining 2/3. With that in mind we can stub out the desired layout and crete an empty component for each card.

Replace src/App.tsx with the following:

const GeneralCard = () => {
  return (
    <div className='border border-slate-300 rounded-md p-5 bg-white'>
      TODO: general card
    </div>
  )
}

const CommandsCard = () => {
  return (
    <div className='col-span-2 border border-slate-300 rounded-md p-5 bg-white'>
      TODO: commands card
    </div>
  )
}

const LocationCard = () => {
  return (
    <div className='border border-slate-300 rounded-md p-5 bg-white'>
      TODO: location card
    </div>
  )
}

const LogsCard = () => {
  return (
    <div className='col-span-2 border border-slate-300 rounded-md p-5 bg-white'>
      TODO: logs card
    </div>
  )
} 

const App = () => {
  return (
    <div className='w-screen bg-slate-50 p-8'>
      <h2 className='font-semibold text-slate-700 font-sans text-2xl mb-7'>Acme Robotics Dashboard</h2>
      <div className='grid grid-cols-3 grid-rows-3 gap-8'>
        <GeneralCard />
        <CommandsCard />
        <LocationCard />
        <LogsCard />
      </div>
    </div>
  )
};

export default App;

The above code creates a grid wrapper for the cards we want to build. Each card is it’s own component with an empty TODO message as a placeholder for now. You can see the grid size is 3 cols x 2 rows, with each of cards in column 2 spanning two columns to create the 1:2 width ratio.

After saving App.tsx, your dashboard should like the screenshot above.

Implementing the General card

Our App is starting to take shape but we need to replace our TODO placeholders with the logic to actually fetch information about the robot.

To do this we need to make an API call. To handle API calls we’ll make use of the react query library.

Install react query

Install the library with:

npm i react-query

To use react query we need to wrap our app in a QueryClientProvider. Open up src/index.tsx and replace it with:

import React from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClient, QueryClientProvider, } from 'react-query';
import App from './App';
import './index.css';

const queryClient = new QueryClient();

const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);

root.render(
    <React.StrictMode>
        <QueryClientProvider client={queryClient}>
            <App />
        </QueryClientProvider>
    </React.StrictMode>
);

Define constants

Heading back to src/App.tsx, let’s define some constants that we will use through the rest of the implementation. At the top of the file define the following constants:

const ROBOT_ID = 'test_bot';
const API_KEY = process.env.REACT_APP_AIR_API_KEY || '';
const REFETCH_INTERVAL = 1000; 

// We'll come to back to these later            
const LINEAR_SPEED = 2.0;
const ANGULAR_SPEED = 2.0;
const COMMAND_INTERFACE = 'topic';
const COMMAND_NAME = '/turtle1/cmd_vel';
const COMMAND_TYPE = 'geometry_msgs/msg/Twist';
const DATA_SOURCE = '/turtle1/pose';
const DIR_FORWARD = 'forward';
const DIR_BACKWARD = 'backward';
const DIR_RIGHT = 'right';
const DIR_LEFT = 'left';

As you can see above, The API key is read from an environment variable. Ensure you have set export REACT_APP_AIR_API_KEY="<your_api_key>".

Making the API call

Now that our app is setup correctly to use react query, we can implement the fist API call. All the info we need to the general call can be fetched from the GET robot details endpoint.

In the GeneralCard component define the following query:

const { status, data } = useQuery(['robot', ROBOT_ID, 'general'], async () => {

  const response = await fetch(`https://api.airbotics.io/robots/${ROBOT_ID}`, {
    method: 'GET',
    headers: {
      'air-api-key': API_KEY
    }
  });

  if (!response.ok) {
    throw new Error();
  }

  return await response.json();

}, { refetchInterval: REFETCH_INTERVAL })

Although the code is concise, there are a few things going on here.

The useQuery function takes 3 arguments:

  1. A unique query key. In our case its a composite key made up of robot, robotId and general.
  2. An async function that returns a promise that either:
    • returns the data we expected.
    • throws an error that should be handled.
  3. Some config options, in our case, how often the query should refetch (in milliseconds).

The const { status, data } part is the destructuring assignment that saves the status and data parts of the response into distinct variables.

The async function uses the JS fetch API to make the API call to the Airbotics backend that should return the details for the given robot. There is also an error check that throws an exception if the response did not complete successfully.

If the request executes successfully the data variable will contain the contents of response.json, i.e. the robot details from the server.

Using the API response in the UI

The last step is to populate the UI the data we got back from the API response to do some basic error handling.

Previously our GeneralCard returned a div with a TODO message, we’re going to update this to:

  • show a loading spinner while we’re making the API call.
  • show an error if the API call fails.
  • show the robot details if the API call is successful.

Update the return method of the GeneralCard component to the following:

return (
  <div className='border border-slate-300 rounded-md p-5 bg-white'>
    <h2 className='font-semibold text-slate-700 font-sans text-lg mb-7'>General</h2>
    {status === 'loading' && <p className='italic text-slate-400'>Loading...</p>}
    {status === 'error' && <p className='italic text-amber-700'>An error occured...</p>}
    {status === 'success' && (
      <>
        <div className='grid grid-cols-2 gap-4'>
          <div>
            <p className='text-slate-400'>ID</p>
            <p className='font-bold text-3xl text-blue-500 mb-7'>{data.id}</p>
          </div>
          <div>
            <p className='text-slate-400'>Name</p>
            <p className='font-bold text-3xl text-blue-500 mb-7'>{data.name}</p>
          </div>
        </div>
        <div className='grid grid-cols-4 gap-4'>
          <div>
            <p className='text-slate-400'>CPU</p>
            <p>
              <span className='font-bold text-3xl text-blue-500'>{data.vitals.cpu}</span>
              <span className='text-xl text-blue-500'>%</span>
            </p>
          </div>
          <div>
            <p className='text-slate-400'>Battery</p>
            <p>
              <span className='font-bold text-3xl text-blue-500'>{data.vitals.battery}</span>
              <span className='text-xl text-blue-500'>%</span>
            </p>
          </div>
          <div>
            <p className='text-slate-400'>RAM</p>
            <p>
              <span className='font-bold text-3xl text-blue-500'>{data.vitals.ram}</span>
              <span className='text-xl text-blue-500'>%</span>
            </p>
          </div>
          <div>
            <p className='text-slate-400'>Disk</p>
            <p>
              <span className='font-bold text-3xl text-blue-500'>{data.vitals.disk}</span>
              <span className='text-xl text-blue-500'>%</span>
            </p>
          </div>
          <div className='grid gap-4'>
            <div>
              <p className='text-slate-400'>Connection</p>
              {
                data.online ?
                  <span className='inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xl font-medium text-green-700 ring-1 ring-inset ring-green-600/20'>Online</span>
                  :
                  <span className='inline-flex items-center rounded-md bg-red-50 px-2 py-1 text-xl font-medium text-red-700 ring-1 ring-inset ring-red-600/10'>Offline</span>
              }
            </div>
          </div>

        </div>
      </>
    )}
  </div>
)

When you save and reload the browser again you should see the general card working as expected, if your robot is online you’ll see it’s latest vitals updating every second:

Implementing commands card

Now let’s move on to implementing the CommandsCard, this component is slightly more complex as we’re not just fetching and showing data, we also have to mutate it when the user clicks one of the 4 buttons.

Making the list API call

We need to show a table with the latest 10 commands shown and to do this we can repeat what we did for the general card, but this time we will get the list commands API. Inside the CommandsCard copy the following:

const { status, data } = useQuery(['robot', ROBOT_ID, 'commands'], async () => {

  const response = await fetch(`https://api.airbotics.io/robots/${ROBOT_ID}/commands?limit=10`, {
    method: 'GET',
    headers: {
      'air-api-key': API_KEY
    }
  });

  if (!response.ok) {
    throw new Error();
  }
  return await response.json();

}, { refetchInterval: REFETCH_INTERVAL });

As you can see the code is almost identical to the API call to fetch the robot details in the previous step, the API endpoint and query key being the only things that changed.

Making the send command API call

As well as listing the commands, the component also contains 4 buttons that need to make an API call to send a command to the robot. In React Query mutations are used for operations that create, update or delete data - in our case we will be creating commands.

Inside the CommandsCard, create the mutation function that will be used to send the command and pass this in as an argument to the useMutation hook:

const sendCommand = async (direction: string) => {

  let linearX = 0;
  let angularZ = 0;

  switch (direction) {
    case DIR_FORWARD:
      linearX = LINEAR_SPEED;
      angularZ = 0.0;
      break;

    case DIR_RIGHT:
      linearX = 0.0;
      angularZ = -ANGULAR_SPEED;
      break;

    case DIR_LEFT:
      linearX = 0.0;
      angularZ = ANGULAR_SPEED;
      break;

    case DIR_BACKWARD:
      linearX = -LINEAR_SPEED;
      angularZ = 0.0;
      break;

    default:
      linearX = 0.0;
      angularZ = 0.0;
      break;
  };

  const body = {
    interface: COMMAND_INTERFACE,
    name: COMMAND_NAME,
    type: COMMAND_TYPE,
    payload: {
      linear: {
        x: linearX,
        y: 0.0,
        z: 0.0
      },
      angular: {
        x: 0.0,
        y: 0.0,
        z: angularZ
      }
    }
  };

  const response = await fetch(`https://api.airbotics.io/robots/${ROBOT_ID}/commands`, {
    method: 'POST',
    headers: {
      'content-type': 'application/json',
      'air-api-key': API_KEY
    },
    body: JSON.stringify(body)
  });

  if (!response.ok) {
    throw new Error();
  }

};

const { mutate } = useMutation(sendCommand);

The above code defines a variable sendCommand which references a function that implements the send command logic. It does the following:

  • Creates a valid payload body for the send command API.
  • Uses fetch to make the the API call.
  • Throws an error if the API call fails.

Updating the UI

The UI for this component contains a row of 4 buttons for sending a left, forward, backward, right commands and a table below that shows the latest commands.

Update the CommandsCard component to return the following:

return (

  <div className='col-span-2 border border-slate-300 rounded-md p-5 bg-white'>
    <h2 className='font-semibold text-slate-700 font-sans text-lg mb-7'>Commands</h2>

    <div className='flex pb-7 gap-4'>
      <button onClick={() => mutate(DIR_LEFT)} className='bg-blue-500 hover:bg-blue-600 border border-blue-700 text-white shadow-sm px-4 py-1 rounded cursor-pointer'>Left</button>
      <button onClick={() => mutate(DIR_FORWARD)} className='bg-blue-500 hover:bg-blue-600 border border-blue-700 text-white shadow-sm px-4 py-1 rounded cursor-pointer'>Forward</button>
      <button onClick={() => mutate(DIR_BACKWARD)} className='bg-blue-500 hover:bg-blue-600 border border-blue-700 text-white shadow-sm px-4 py-1 rounded cursor-pointer'>Backward</button>
      <button onClick={() => mutate(DIR_RIGHT)} className='bg-blue-500 hover:bg-blue-600 border border-blue-700 text-white shadow-sm px-4 py-1 rounded cursor-pointer'>Right</button>
    </div>

    {status === 'loading' && <p className='italic text-slate-400'>Loading...</p>}
    {status === 'error' && <p className='italic text-amber-700'>An error occured...</p>}
    {status === 'success' && data.length === 0 && <p className='italic text-slate-700'>No commands sent</p>}
    {status === 'success' && data.length !== 0 && (
      <table className='border-collapse border border-slate-400'>
        <tbody>
          <tr>
            <th className='border border-slate-300 px-4 bg-slate-50 text-slate-700'>Time</th>
            <th className='border border-slate-300 px-4 bg-slate-50 text-slate-700'>Name</th>
            <th className='border border-slate-300 px-4 bg-slate-50 text-slate-700'>State</th>
            <th className='border border-slate-300 px-4 bg-slate-50 text-slate-700'>Linear X</th>
            <th className='border border-slate-300 px-4 bg-slate-50 text-slate-700'>Angular Z</th>
          </tr>
          {
            data.map((command: any) => <tr key={command.uuid}>
              <td className='border border-slate-300 px-4 text-slate-700'>{command.created_at}</td>
              <td className='border border-slate-300 px-4 text-slate-700'>{command.name}</td>
              <td className='border border-slate-300 px-4 text-slate-700'>{command.state}</td>
              <td className='border border-slate-300 px-4 text-slate-700'>{command.payload.linear.x}</td>
              <td className='border border-slate-300 px-4 text-slate-700'>{command.payload.angular.z}</td>
            </tr>)
          }
        </tbody>
      </table>
    )}
  </div>
)

Implementing location card

You already have a nice looking dashboard and are able to get and mutate data on the dashboard through the Airbotics API. However, up until now we’ve been dealing with simple UI elements like tables and buttons, for the location card we’re going to make use of the canvas element and to make our lives easier we’ll make use of the konva library.

Install react-konva

Install the library with:

npm i react-konva

Making the API call

In the LocationCard component define the following:

const [pose, setPose] = useState([0, 0, 0]);
const DIR_POINTER_LENGTH = 20;
const MAP_WIDTH = 330;
const MAP_HEIGHT = 300;
const MAP_MULTIPLIER = 30;

const { status, data } = useQuery(['robot', ROBOT_ID, 'location'], async () => {

  const source = encodeURIComponent(DATA_SOURCE);
  
  const response = await fetch(`https://api.airbotics.io/robots/${ROBOT_ID}/data?source=${source}&offset=0&limit=20`, {
    method: 'GET',
    headers: {
      'air-api-key': API_KEY
    }
  });
  
  if (!response.ok) {
    throw new Error();
  }

  const responseData = await response.json();

  setPose([responseData[0].payload.x * MAP_MULTIPLIER, MAP_HEIGHT - responseData[0].payload.y * MAP_MULTIPLIER, responseData[0].payload.theta]);

  const waypoints = [];
  for (const dataPoint of responseData) {
    waypoints.push(dataPoint.payload.x * MAP_MULTIPLIER);
    waypoints.push(MAP_HEIGHT - dataPoint.payload.y * MAP_MULTIPLIER);
  }

  return waypoints;

}, { refetchInterval: REFETCH_INTERVAL });

In the above code we are:

  • Setting up some constants for the canvas map.
  • Defining another userQuery with:
    • A new composite key comprised of robot, robotId and location.
    • An async function that:
      • Queries the latest data points from the /turtle1/pose.
      • Calls the setPose function to update the react state with the current pose of the turtle1.
      • Returns an array of the latest 10 pose waypoints.
      • Throws an error if the API call fails.

Updating the UI

Most of the UI for this component is taken care of by the components within react-konva library. Under the hood it utilises the HTML canvas API to render the pose map. Again we will put in basic checks for loading and error states.

Update the LocationCard component to return the following:

return (
  <div className='border border-slate-300 rounded-md p-5 bg-white'>
    <h2 className='font-semibold text-slate-700 font-sans text-lg mb-7'>Location</h2>
    {status === 'loading' && <p className='italic text-slate-400'>Loading...</p>}
    {status === 'error' && <p className='italic text-amber-700'>An error occurred...</p>}
    {status === 'success' && (
      <Stage className='bg-slate-200 border border-slate-400' width={MAP_WIDTH} height={MAP_HEIGHT} style={{ width: MAP_WIDTH, height: MAP_HEIGHT }}>
        <Layer>
          <Circle x={pose[0]} y={pose[1]} radius={4} fill='blue' />
          <Line
            x={0}
            y={0}
            points={data}
            stroke='gray'
          />
          <Line
            x={0}
            y={0}
            points={[pose[0], pose[1], pose[0] + DIR_POINTER_LENGTH * Math.cos(pose[2]), pose[1] + -DIR_POINTER_LENGTH * Math.sin(pose[2])]}
            stroke='blue'
          />
        </Layer>
      </Stage>
    )}
  </div>
)

When you save and reload the browser again you should see the location card with a canvas mapping the robots current pose.

The final card LogsCard is fairly trivial to implement after already mastering the first three, feel free to try and do the implementation yourself or don’t forget you can check out the full example source code on GitHub.

Wrapping up

In this product example you have built your own custom react dashboard. We covered a lot, but to summarise you have:

  • An understanding of how to set up a React project.
  • An understanding of how to style a UI using Tailwind CSS.
  • An understanding of React components and how to manage state.
  • An understanding of to install and use React libraries like react-query and react-konva.
  • An understanding of how to make API calls and handle loading, error and success states.
  • A fully working web dashboard that you can use to:
    • Fetch and view live detailed info and logs from robots in your fleet.
    • Fetch and view list of commands you’ve sent to a robot and a way to send new commands.
    • Fetch and plot data points collected from your robots.