Building a dashboard using Laravel, Vue.js and Pusher
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.3 and Vue. In this post I'd like to explain why and how we made it.
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.
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 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.
- The todo's of each member of our team. It's managed through a few markdown files in a private repo on GitHub.
- Stars and number of package downloads are pulled in via the Packagist API.
- A rain forecast (for the bikers amongst us) via buienradar.nl.
- Internet up/down status.
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 content of the actual blade view that renders the dashboard page.
@extends('layouts/master')
@section('content')
@javascript(compact('pusherKey'))
<google-calendar grid="a1:a2"></google-calendar>
<last-fm grid="b1:c1"></last-fm>
<current-time grid="d1" dateformat="ddd DD/MM"></current-time>
<packagist-statistics grid="b2"></packagist-statistics>
<rain-forecast grid="c2"></rain-forecast>
<internet-connection grid="d2"></internet-connection>
<github-file file-name="freek" grid="a3"></github-file>
<github-file file-name="rogier" grid="b3"></github-file>
<github-file file-name="seb" grid="c3"></github-file>
<github-file file-name="willem" grid="d3"></github-file>
@endsection
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 grid
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 github-file
component you see a3
, so as you see in the screenshot of the dashboard, this component will get displayed in the first column on the third row.
You can also pass a range to the grid
attribute. The last-fm
component is a good example. Setting grid
to b1:c1
causes it to be displayed in the second an third columns of the first row.
Our dashboard uses three rows and four columns. Want to change the size of your dashboard? No problem: those numbers can be adjusted in _grid.scss
:
$grid: (
cell-spacing: 1vw,
cell-padding: 1vw,
cols : 4,
rows : 3,
);
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.
The looks of the dashboard and the grid system were designed by my colleague Willem. He did an awesome job creating custom dashboard layouts as easy as possible. If you want to know more about how the grid system works internally, you should read his guest post.
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. 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
. All events are sent from the server to the client reside there. In that directory you'll 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
{
/**
* Get the channels the event should broadcast on.
*/
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/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:
namespace App\Components\InternetConnectionStatus;
use App\Events\InternetConnectionStatus\Heartbeat;
use Illuminate\Console\Command;
class SendHeartbeat extends Command
{
/**
* The console command name.
*
* @var string
*/
protected $signature = 'dashboard:heartbeat';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Send a heartbeat to help the client verify if it is connected to the internet.';
/**
* Execute the console command.
*
* @return mixed
*/
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: 'body',
components: {
CurrentTime,
GithubFile,
GoogleCalendar,
InternetConnection,
LastFm,
PackagistStatistics,
RainForecast,
},
});
The components themselves live in the resources/assets/js/components
directory. This is the code of the internet-connection.js
inside that directory:
import Grid from './grid';
import moment from 'moment';
import Echo from '../mixins/echo';
export default {
template: `
<grid :position="grid">
<section :class="online? 'up': 'down' | modify-class 'internet-connection' ">
<div class="internet-connection__icon">
</div>
</section>
</grid>
`,
components: {
Grid,
},
mixins: [Echo],
props: ['grid'],
data() {
return {
online: true,
lastHeartBeatReceivedAt: moment(),
};
},
created() {
setInterval(this.determineConnectionStatus, 1000);
},
methods: {
determineConnectionStatus() {
const lastHeartBeatReceivedSecondsAgo = moment().diff(this.lastHeartBeatReceivedAt, 'seconds');
this.online = lastHeartBeatReceivedSecondsAgo < 125;
},
getEventHandlers() {
return {
'InternetConnectionStatus.Heartbeat': () => {
this.lastHeartBeatReceivedAt = moment();
},
};
},
},
};
There's a whole lot going on in that code. Let's break it down. The template
key contains the html code that actually gets rendered. The css class that gets used depends the value of the state of the online
variable on the Vue instance. (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\InternetConnectionStatus
) but without App\Events
and with the \
replaced by a .
. So in our example that would become InternetConnectionStatus.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. Under it's hood all authentication and communication with Pusher is handled. Echo is being setup in resources/assets/js/app.js
.
window.Echo = new Echo({
broadcaster: 'pusher',
key: dashboard.pusherKey,
});
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) => {
let fullyQualifiedEventName = `.App.Events.${eventName}`;
Echo.private('dashboard')
.listen(fullyQualifiedEventName, (eventName) => {
handler(eventName);
});
});
},
};
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 execute the handler.
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/Components/Packagist/FetchTotals.php
is responsible for fetching the package statistics via the Packagist API files on GitHub, and transforming it an array. After that it'll fire off an event to inform the Vue side of things that new data is available.
namespace App\Components\Packagist;
use App\Events\Packagist\TotalsFetched;
use GuzzleHttp\Client;
use Illuminate\Console\Command;
use Spatie\Packagist\Packagist;
class FetchTotals extends Command
{
/**
* The console command name.
*
* @var string
*/
protected $signature = 'dashboard:packagist';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Fetch the total amount of downloads of packages for a vendor.';
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$client = new Client();
$packagist = new Packagist($client);
$totals = collect($packagist->getPackagesByVendor('spatie')['packageNames'])
->map(function ($packageName) use ($packagist) {
return $packagist->findPackageByName($packageName)['package'];
})
->pipe(function ($packageProperties) {
return [
'stars' => $packageProperties->sum('favers'),
'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:
<?php
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:
import Grid from './grid';
import Echo from '../mixins/echo';
import SaveState from '../mixins/save-state';
export default {
template: `
<grid :position="grid" modifiers="padded overflow">
<section class="packagist-statistics">
<h1>Package Downloads</h1>
<ul>
<li class="packagist-statistic">
<span class="packagist-statistics__stars"></span>
<span class="packagist-statistics__count">{{ stars | format-number }}</span>
</li>
<li class="packagist-statistic">
<h2 class="packagist-statistics__period">Today</h2>
<span class="packagist-statistics__count">{{ daily | format-number }}</span>
</li>
<li class="packagist-statistic">
<h2 class="packagist-statistics__period">This month</h2>
<span class="packagist-statistics__count">{{ monthly | format-number }}</span>
</li>
<li class="packagist-statistic -total">
<h2 class="packagist-statistics__period">Total Downloads</h2>
<span class="packagist-statistics__count">{{ total | format-number }}</span>
</li>
</ul>
</section>
</grid>
`,
components: {
Grid,
},
mixins: [Echo, SaveState],
props: ['grid'],
data() {
return {
stars: 0,
daily: 0,
monthly: 0,
total: 0,
};
},
methods: {
getEventHandlers() {
return {
'Packagist.TotalsFetched': response => {
this.stars = response.stars;
this.daily = response.daily;
this.monthly = response.monthly;
this.total = response.total;
},
};
},
getSavedStateId() {
return 'packagist-statistics';
},
},
};
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) 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.
The Pi used the default Raspian OS. When it is powered on it'll automatically launch Chromium 50 and display the contents of https://dashboard.spatie.be.
Reloading the dashboard
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.
export default {
watch: {
'$data': {
handler() {
this.saveState();
},
deep: true,
},
},
created() {
this.loadState();
},
methods: {
loadState() {
let savedState = this.getSavedState();
if (!savedState) {
return;
}
this.$data = savedState;
},
saveState() {
localStorage.setItem(this.getSavedStateId(), JSON.stringify(this.$data));
},
getSavedState() {
let savedState = localStorage.getItem(this.getSavedStateId());
if (savedState) {
savedState = JSON.parse(savedState);
}
return savedState;
},
},
};
The mixin watches the data
of the component it's applied to. Whenever data
changes, saveState
gets called, which serializes the data
and writes it to local storage. Afterwards, when the component is created, the mixin will restore it's state. This means that when we power on our tv the saved data will immediately be displayed.
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:
- spatie/last-fm-now-playing: Get info on a track that user is currently playing
- spatie/laravel-google-calendar: The easiest way the not only read but also write to a Google Calendar via PHP.
- graham-campbell/github: read data from GitHub.
- spatie/packagist-api: fetch statistics on PHP Packages.
- l5-very-basic-auth: a basic auth filter to protect routes.
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:
- Geckoboard
- Cyfe
- Razorflow
- Dashing (no longer maintained but still works great)
- Statusboard 2
- ...
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.
We'll be sure to keep our Dashboard updated so it uses the latest versions of frameworks and packages. When Laravel 5.3 is released the code will get updated to use the latest features of the framework, most notably Laravel Echo. A new major version of Vue is also somewhere around the corner.
Again the entire source code is available on GitHub. If you have any questions on the dashboard, feel free to open up an issue on GitHub.
EDIT on 2016-08-29: the dashboard was updated in to make use of Laravel 5.3 and Laravel Echo. This blog post was updated too to reflect those changes.
What are your thoughts on "Building a dashboard using Laravel, Vue.js and Pusher"?