Oh Dear is the all-in-one monitoring tool for your entire website. We monitor uptime, SSL certificates, broken links, scheduled tasks and more. You'll get a notifications for us when something's wrong. All that paired with a developer friendly API and kick-ass documentation. O, and you'll also be able to create a public status page under a minute. Start monitoring using our free trial now.

Building a realtime dashboard powered by Laravel and Vue (2017 edition)

Original – by Freek Van der Herten – 15 minute read

At Spatie we have a tv screen against the wall that displays a dashboard. This dashboard displays the tasks our team should be working on, important events in the near future, which music is playing at our office, and so on. Here's what it looks like:

We've opensourced our dashboard, so you can view the entire source code on GitHub. It is built with Laravel 5.4 and Vue.

This dashboard is not new, we orginally published it last year. But at our company we like to tinker with new stuff. That's why we did a big refactor of the source code and updated all the dependencies used. The css used got a big update as well, it now uses CSS grid and CSS vars. In this blogpost, which is an update to the previous one, I like to explain how this dashboard is built.

You'll need to be familiar with both Laravel and Vue to get the most out of this post. If you need to brush up your Vue knowledge, I can highly recommend Laracast's lessons on Vue.

Video tutorial

Alternatively to reading this blogpost you can watch this talk I did at Laracon EU 2017 where I explain in depth how the dashboard works.

History

We've had a dashboard at Spatie for quite some time now. Before our current Laravel-based one we used Dashing, a framework to quickly build dashboards. The framework was created by the folks at Shopify and uses Ruby under the hood.

The time I first built our dashboard, a few years ago, we were at a crossroads at Spatie. There wasn't much momentum in the PHP world and we were toying with the idea of switching over to Ruby. The idea was that by playing with Dashing we would get some experience with the language. Then Composer and Laravel happened and we decided to stick with PHP (and given the current state of PHP ecosystem we don't regret that choice at all).

Now that support for Dashing has officially stopped, I thought it was a good time to completely rebuild the dashboard using Laravel and Vue.

High level overview

Let's take a closer look at what the dashboard is displaying. The configured dashboard from the above screenshot has the following tiles:

  • A twitter tile that show all mentions of quotes of `@spatie_be`
  • A team calendar that pulls in it's events of a Google Calendar.
  • The music that is currently playing in our office, fetched from Last.fm's API.
  • A clock with the current date, and some weather conditions, retrieve from the Yahoo Weather API.
  • The todo's of each member of our team. It's managed through a few markdown files in a private repo on GitHub.
  • Number of package downloads are pulled in via the Packagist API.
  • Same statistics of our numerous public repositories on GitHub

After the browser displays the dashboard for the first time we'll never refresh the page again. WebSockets and Vue are being leveraged to update the tiles. Doing it this way will avoid having to refresh the page and in turn avoid flashing screens.

Each tile is it's own Vue component. Laravel's default scheduler is used to periodically fetch some data from the API's of Google Calendar, Last.fm, etc... When Laravel receives a response from any of those services a broadcast event is fired to Pusher. This powerful service leverages websockets to carry over server events to clients in realtime. On the client side we'll use Laravel Echo. That JavaScript library makes handling those Pusher events very easy. Still on the client side each Vue component will listen for incoming events to update the displayed tiles.

The grid

Before diving into the Laravel and Vue code I'd like to explain how the grid system works. The grid system allows you to easily specify where a tile should be positioned on the dashboard and how big it should be.

This is the html of the actual blade view that renders the dashboard page.

<dashboard id="dashboard" columns="5" rows="3">
    <twitter :initial-tweets="{{ json_encode($initialTweets) }}" position="a1:a3"></twitter>
    <calendar position="b1:b2"></calendar>
    <music position="c1:d1"></music>
    <uptime position="b3"></uptime>

    <tasks team-member="alex" position="c2"></tasks>
    <tasks team-member="freek" position="d2"></tasks>
    <tasks team-member="seb" position="c3"></tasks>
    <tasks team-member="willem" position="d3"></tasks>

    <time-weather position="e1" date-format="ddd DD/MM"></time-weather>
    <packagist position="e2"></packagist>
    <github position="e3"></github>

    <internet-connection></internet-connection>
</dashboard>

Grid columns are named with a letter and rows are named with a number, like a spreadsheet. The size and positioning of a tile is determined in a position property per component that accepts a column name and a row number. a1 will render the component on the first row in the first column. If you look at the first tasks component you see c2, so as you see in the screenshot of the dashboard, this component will get displayed in the third column on the second row.

You can also pass a range to the position attribute. The music component is a good example. Setting grid to c1:d1 causes it to be displayed in the second an third column of the first row.

Our dashboard uses five columns and three rows. Want to change the size of your dashboard? No problem: those numbers can be adjusted by passing your prefered number of columns and rows to the columns and rows properties of the dashboard component.

Most modern tv's use a 16:9 ratio, but we've gone the extra mile to make the layout fully responsive so it still works on tv's and monitors with a different ratio.

My colleague Willem did an awesome job in making the dashboard look very pretty. To position the tiles Willem leveraged CSS grid which was recently introduced in most modern browsers.

The internet connection component

Let's take a deeper look at a component to grasp the general flow. A simple one is the internet-connection tile which notifies us when the internet connection is down.

By default is it is not shown. When there's no internet connection a small overlay will be displayed. Here's how that looks:

It works by listening to an event, called Heartbeat, that is sent out every minute by the server. When it doesn't get an event within a couple of minutes it'll determine that our internet connection is down (although it could also mean that the server where the dashboard is running on is having problems).

Server side

In the Laravel app you'll see a directory app/Events that containts all events. It also contains subdirectories for each component of the dashboard. All events are sent from the server to the client reside there. In the app/Events directory you'll also see a file named DashboardEvent which is used to transfer data from the server to the client through events.

namespace App\Events;

use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;

abstract class DashboardEvent implements ShouldBroadcast
{
    public function broadcastOn()
    {
        return new PrivateChannel('dashboard');
    }
}

That ShouldBroadcast interface is provided by Laravel. All events will get broadcasted on the private channel named dashboard. The client will listen to all events on that channel. Using the PrivateChannel class will ensure that all data will be sent in a secure way so nobody can listen in. More on that later.

Let's take a look at the app/Console/Components directory. Almost all logic that the server needs to do to fetch data for the dashboard has a home here. If you open up that directory you'll see that each component has it's own subdirectory. In each subdirectory you'll find an Artisan command that can be scheduled. In our example the App\Components\InternetConnectionStatus class contains this code:

<br />namespace App\Console\Components\InternetConnection;

use Illuminate\Console\Command;
use App\Events\InternetConnection\Heartbeat;

class SendHeartbeat extends Command
{
    protected $signature = 'dashboard:send-heartbeat';

    protected $description = 'Send a heartbeat to the internet connection tile';

    public function handle()
    {
        event(new Heartbeat());
    }
}

The only thing that this code does is send out a HeartBeat-event. This command is scheduled to run every minute in the Console kernel.

Client side

All JavaScript code used by the dashboard lives in the resources/assets/js directory. In resources/assets/js/app.js you see that the main Vue instance is being initialized on the body element:

new Vue({

    el: '#dashboard',

    components: {
        Dashboard,
        Calendar,
        Github,
        InternetConnection,
        Music,
        Packagist,
        Tasks,
        TimeWeather,
        Twitter,
        Uptime,
    },

    created() {
        broadcaster: 'pusher',
        key: window.dashboard.pusherKey,
        cluster: window.dashboard.pusherCluster,

        this.echo = new Echo(options);
    },
});

The components themselves live in the resources/assets/js/components directory. This is the code of the InternetConnection.vue inside that directory:

<template>
    <section v-if="offline" class="internet-connection">
        <div class="internet-connection__alert">
            <span class="internet-connection__icon h-icon"></span>
            <span class="internet-connection__text">Internet connection</span>
        </div>
    </section>
</template>

<script>
    import echo from '../mixins/echo';
    import { addClassModifiers } from '../helpers';
    import moment from 'moment';

    export default {

        mixins: [echo],

        data() {
            return {
                offline: false,
                lastHeartBeatReceivedAt: moment(),
            };
        },

        created() {
            setInterval(this.determineConnectionStatus, 1000);
        },

        methods: {
            addClassModifiers,

            determineConnectionStatus() {
                const lastHeartBeatReceivedSecondsAgo = moment().diff(this.lastHeartBeatReceivedAt, 'seconds');

                this.offline = lastHeartBeatReceivedSecondsAgo > 125;
            },

            getEventHandlers() {
                return {
                    'InternetConnection.Heartbeat': () => {
                        this.lastHeartBeatReceivedAt = moment();
                    },
                };
            },
        },
    };
</script>

There's a whole lot going on in that code. Let's break it down. Everything between the <template>-tags is the html code that actually gets rendered. That v-if directive makes sure that the section is onlyrendered when the state variable offline is true. Again, if you're having trouble following this, check the series on Vue on Laracasts). In the created method, which is fired as soon as the Vue component is created, we'll make sure that a method on the Vue instance called determineConnectionStatus is fired every second. That function is responsible for determining the value of online. If the last received heartbeat is less than 125 seconds ago, online will be true, otherwise it will be false.

Let's review how we can listen for events. In the code above you'll see a method called getEventHandlers. It expects an object of which the property names are the the events names. The event name is the fully qualified class name of the event that get sent out by the server (App\Events\InternetConnection\Heartbeat) but without App\Events and with the \ replaced by a .. So in our example that would become InternetConnection.Heartbeat. The value of the of a property on that object should be a function that will get executed whenever that event comes in from the server. So in our case whenever the InternetConnectionStatus.Heartbeat.HeartBeat event comes in we're going to set the state of lastHeartBeatReceivedAt to the current time. So if this event comes in the determineConnectionStatus function will determine that we're online for the next 125 seconds.

Did you notice that the component uses a Echo mixin? A mixin can be compared to a trait in the PHP-world. A mixin contains some functions. Every component that uses the mixin will get those functions. So, like a trait, a mixin is a way to bundle reusable code.

The Echo mixin is responsible for adding the power of Laravel Echo to the component. Laravel Echo is a JavaScript library that makes it easy to handle websockets. It will handle all authentication and communication with Pusher. Echo is being set up in app.js

Laravel Echo can handle multiple broadcasters, we're going to use Pusher here. That key is a public value that's needed to communicate with Pusher.

Let's go back take a look at the code of the Echo mixin.

import { forIn } from 'lodash';

export default {
    created() {
        forIn(this.getEventHandlers(), (handler, eventName) => {
            this.$root.echo
                .private('dashboard')
                .listen(`.App.Events.${eventName}`, response => handler(response));
        });
    },
};

Whenever a component that uses the mixin is created the created function will be executed. It will process the output of getEventHandlers function from the component itself. First, we'll build up the fully qualified event name. Then we'll let Echo listen for events with that name on the private dashboard channel. Whenever an event with the right name comes in we're going to pass the response to the handler and execute it.

The Package statistics component

Let's take a look at another component. In the screenshot of the dashboard you can see that there are some statistics displayed regarding how many times our packages get downloaded.

The FetchTotals class, located in app/Console/Components/Packagist/FetchTotals.php is responsible for fetching the package statistics via the Packagist API files on GitHub, and transforming it to an array. After that it'll fire off an event to inform the Vue side of things that new data is available.

namespace App\Console\Components\Packagist;

use GuzzleHttp\Client;
use Illuminate\Console\Command;
use Spatie\Packagist\Packagist;
use App\Events\Packagist\TotalsFetched;

class FetchTotals extends Command
{
    protected $signature = 'dashboard:fetch-packagist-totals';

    protected $description = 'Fetch totals for all our PHP packages';

    public function handle()
    {
        $packagist = new Packagist(new Client());

        $totals = collect($packagist->getPackagesByVendor(config('services.packagist.vendor'))['packageNames'])
                ->map(function ($packageName) use ($packagist) {
                    return $packagist->findPackageByName($packageName)['package'];
                })
                ->pipe(function ($packageProperties) {
                    return [
                        'daily' => $packageProperties->sum('downloads.daily'),
                        'monthly' => $packageProperties->sum('downloads.monthly'),
                        'total' => $packageProperties->sum('downloads.total'),
                    ];
                });

        event(new TotalsFetched($totals));
    }
}


Most of this code should be self-explanatory. It's also scheduled to run periodically. Let's take a look at the TotalsFetched event that's being sent out:

namespace App\Events\Packagist;

use App\Events\DashboardEvent;

class TotalsFetched extends DashboardEvent
{
    public $daily;

    public $monthly;

    public $total;

    public $stars;

    public function __construct($totals)
    {
        foreach ($totals as $sumName => $total) {
            $this->$sumName = $total;
        }
    }
}

When broadcasting events in Laravel, all public properties of an event are being broadcasted as well. So using this code the Vue component can easily get to the values of $daily, $monthly, $total and $stars.

Here's the Vue Component that renders the tile on the dashboard:

<template>
    <tile :position="position" modifiers="overflow">
        <section class="statistics">
            <h1>Package Downloads</h1>
            <ul>
                <li class="statistic">
                    <span class="statistic__label">24 hours</span>
                    <span class="statistic__count">{{ formatNumber(daily) }}</span>
                </li>
                <li class="statistic">
                    <span class="statistic__label">30 days</span>
                    <span class="statistic__count">{{ formatNumber(monthly) }}</span>
                </li>
                <li class="statistic">
                    <span class="statistic__label">Total</span>
                    <span class="statistic__count">{{ formatNumber(total) }}</span>
                </li>
            </ul>
        </section>
    </tile>
</template>

<script>
    import { formatNumber } from '../helpers';
    import echo from '../mixins/echo';
    import Tile from './atoms/Tile';
    import saveState from 'vue-save-state';

    export default {

        components: {
            Tile,
        },

        mixins: [echo, saveState],

        props: ['position'],

        data() {
            return {
                daily: 0,
                monthly: 0,
                total: 0,
            };
        },

        methods: {
            formatNumber,

            getEventHandlers() {
                return {
                    'Packagist.TotalsFetched': response => {
                        this.daily = response.daily;
                        this.monthly = response.monthly;
                        this.total = response.total;
                    },
                };
            },

            getSaveStateConfig() {
                return {
                    cacheKey: 'packagist',
                };
            },
        },
    };

</script>

Notice that in the getEventHandlers function we'll update the state variables stars, daily, monthly, total to the values that we get from the TotalsFetched-event.

Security

Because there is some semi-sensitive info being displayed (the tasks of our team members and the events on the calendar) we've added some security to the dashboard. That's why you can't just visit https://dashboard.spatie.be.

The url itself is protected by a basic auth filter on the routes. The filter is native to laravel package. Relying on basic auth can be a bit insecure. So if you are going to fork our dashboard be sure to pick a long password and do some rate limiting server side to prevent brute force attacks.

The data sent through the websockets is secured as well. In the Echo mixin you might have noticed that a private method is called. This will ensure that, under the hood, a private Pusher channel is used, so nobody can listen in to what is sent via the websockets.

Displaying the dashboard on a TV

Behind our tv there is a Raspberry Pi 2 that displays the dashboard. It is powered by a USB port in the tv and it has a small Wifi dongle to connect to the internet, so cables aren't necessary at all.

IMG_0938 (1)

The Pi used the default Raspian OS. When it is powered on it'll automatically launch Chromium 56 and display the contents of https://dashboard.spatie.be.

IMG_0939 (1)

Reloading the dashboard

For most components, the communication between the server and client is one-way. The client will receive data solely through the events sent out by the server. The client will never make a request for data itself.

When our last team member leaves the office he will switch off the wall mounted TV. This will cause the Pi to be powered down as well. The next time when the tv is powered back on the dashboard will be empty, waiting for events sent out by the server. We don't want to stare at an empty dashboard the first hour after the tv is powered on, let's fix that.

Every Vue component preserves it's own state in data. Wouldn't it be great to save that data whenever it is changed? Then it could be reloaded whenever the dashboard gets powered on. The SaveState-mixin, which is used on almost every component, does exactly that.

The mixin watches the data of the component it's applied to. Whenever data changes, it will serialize the data and write it to local storage. Afterwards, when the component is created, the mixin will restore it's state with the values in local storage. This means that when we power on our tv the saved data will immediately be displayed.

We've extracted that mixin to it's own package, so you can use it in your projects: https://github.com/spatie/vue-save-state

Packages used

The dashboard is fetching data from various sources: Google Calendar, Packagist, Lastfm,... Here's the list of packages used to pull in the data:

Alternatives

We mainly built our own custom Dashboard to toy around with Vue and also just because it's fun.

There are many alternatives available to display dashboards:

Choose a solution that feels good to you.

Closing notes

I hope that you've enjoyed this little tour of the code behind our dashboard. I don't have the intention of creating full documentation for the dashboard and make it monkey-proof as that would require just too much time. On the other hand I do think that if you have some experience with Laravel and Vue it's not that hard to make your own dashboard with our code.

The entire source code is available on GitHub. If you have any questions on the dashboard, feel free to open up an issue on the issue tracker.

Stay up to date with all things Laravel, PHP, and JavaScript.

You can follow me on these platforms:

On all these platforms, regularly share programming tips, and what I myself have learned in ongoing projects.

Every month I send out a newsletter containing lots of interesting stuff for the modern PHP developer.

Expect quick tips & tricks, interesting tutorials, opinions and packages. Because I work with Laravel every day there is an emphasis on that framework.

Rest assured that I will only use your email address to send you the newsletter and will not use it for any other purposes.

Comments

What are your thoughts on "Building a realtime dashboard powered by Laravel and Vue (2017 edition)"?

Comments powered by Laravel Comments
Want to join the conversation? Log in or create an account to post a comment.