Added error handling of failed speedtests

re issue #143
This commit is contained in:
Henry Whitaker
2020-07-03 11:59:26 +01:00
parent 5ac9478799
commit 04d8f729b9
16 changed files with 318 additions and 37 deletions

View File

@@ -1,6 +1,6 @@
# Speedtest Tracker # Speedtest Tracker
[![Docker pulls](https://img.shields.io/docker/pulls/henrywhitaker3/speedtest-tracker?style=flat-square)](https://hub.docker.com/r/henrywhitaker3/speedtest-tracker) [![last_commit](https://img.shields.io/github/last-commit/henrywhitaker3/Speedtest-Tracker?style=flat-square)](https://github.com/henrywhitaker3/Speedtest-Tracker/commits) [![issues](https://img.shields.io/github/issues/henrywhitaker3/Speedtest-Tracker?style=flat-square)](https://github.com/henrywhitaker3/Speedtest-Tracker/issues) [![commit_freq](https://img.shields.io/github/commit-activity/m/henrywhitaker3/Speedtest-Tracker?style=flat-square)](https://github.com/henrywhitaker3/Speedtest-Tracker/commits) ![version](https://img.shields.io/badge/version-v1.7.2-success?style=flat-square) [![license](https://img.shields.io/github/license/henrywhitaker3/Speedtest-Tracker?style=flat-square)](https://github.com/henrywhitaker3/Speedtest-Tracker/blob/master/LICENSE) [![Docker pulls](https://img.shields.io/docker/pulls/henrywhitaker3/speedtest-tracker?style=flat-square)](https://hub.docker.com/r/henrywhitaker3/speedtest-tracker) [![last_commit](https://img.shields.io/github/last-commit/henrywhitaker3/Speedtest-Tracker?style=flat-square)](https://github.com/henrywhitaker3/Speedtest-Tracker/commits) [![issues](https://img.shields.io/github/issues/henrywhitaker3/Speedtest-Tracker?style=flat-square)](https://github.com/henrywhitaker3/Speedtest-Tracker/issues) [![commit_freq](https://img.shields.io/github/commit-activity/m/henrywhitaker3/Speedtest-Tracker?style=flat-square)](https://github.com/henrywhitaker3/Speedtest-Tracker/commits) ![version](https://img.shields.io/badge/version-v1.7.3-success?style=flat-square) [![license](https://img.shields.io/github/license/henrywhitaker3/Speedtest-Tracker?style=flat-square)](https://github.com/henrywhitaker3/Speedtest-Tracker/blob/master/LICENSE)
This program runs a speedtest check every hour and graphs the results. The back-end is written in [Laravel](https://laravel.com/) and the front-end uses [React](https://reactjs.org/). It uses the [Ookla's speedtest cli](https://www.speedtest.net/apps/cli) package to get the data and uses [Chart.js](https://www.chartjs.org/) to plot the results. This program runs a speedtest check every hour and graphs the results. The back-end is written in [Laravel](https://laravel.com/) and the front-end uses [React](https://reactjs.org/). It uses the [Ookla's speedtest cli](https://www.speedtest.net/apps/cli) package to get the data and uses [Chart.js](https://www.chartjs.org/) to plot the results.

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class SpeedtestFailedEvent
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* Create a new event instance.
*
* @return void
*/
public function __construct()
{
//
}
/**
* Get the channels the event should broadcast on.
*
* @return \Illuminate\Broadcasting\Channel|array
*/
public function broadcastOn()
{
return new PrivateChannel('channel-name');
}
}

View File

@@ -27,7 +27,7 @@ class SpeedtestHelper {
$output = json_decode($output, true, 512, JSON_THROW_ON_ERROR); $output = json_decode($output, true, 512, JSON_THROW_ON_ERROR);
if(!SpeedtestHelper::checkOutputIsComplete($output)) { if(!SpeedtestHelper::checkOutputIsComplete($output)) {
return false; $test = false;
} }
$test = Speedtest::create([ $test = Speedtest::create([
@@ -43,11 +43,27 @@ class SpeedtestHelper {
} catch(JsonException $e) { } catch(JsonException $e) {
Log::error('Failed to parse speedtest JSON'); Log::error('Failed to parse speedtest JSON');
Log::error($output); Log::error($output);
$test = false;
} catch(Exception $e) { } catch(Exception $e) {
Log::error($e->getMessage()); Log::error($e->getMessage());
$test = false;
} }
return (isset($test)) ? $test : false; if(!$test) {
Speedtest::create([
'ping' => 0,
'upload' => 0,
'download' => 0,
'failed' => true,
'scheduled' => $scheduled,
]);
}
if(!isset($test) || $test == false) {
return false;
}
return $test;
} }
/** /**
@@ -83,6 +99,7 @@ class SpeedtestHelper {
$t = Carbon::now()->subDay(); $t = Carbon::now()->subDay();
$s = Speedtest::select(DB::raw('AVG(ping) as ping, AVG(download) as download, AVG(upload) as upload')) $s = Speedtest::select(DB::raw('AVG(ping) as ping, AVG(download) as download, AVG(upload) as upload'))
->where('created_at', '>=', $t) ->where('created_at', '>=', $t)
->where('failed', false)
->first() ->first()
->toArray(); ->toArray();

View File

@@ -3,12 +3,14 @@
namespace App\Jobs; namespace App\Jobs;
use App\Events\SpeedtestCompleteEvent; use App\Events\SpeedtestCompleteEvent;
use App\Events\SpeedtestFailedEvent;
use App\Helpers\SpeedtestHelper; use App\Helpers\SpeedtestHelper;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class SpeedtestJob implements ShouldQueue class SpeedtestJob implements ShouldQueue
{ {
@@ -35,7 +37,13 @@ class SpeedtestJob implements ShouldQueue
{ {
$output = SpeedtestHelper::output(); $output = SpeedtestHelper::output();
$speedtest = SpeedtestHelper::runSpeedtest($output, $this->scheduled); $speedtest = SpeedtestHelper::runSpeedtest($output, $this->scheduled);
event(new SpeedtestCompleteEvent($speedtest)); Log::info($speedtest);
if($speedtest == false) {
Log::info('speedtest == false');
event(new SpeedtestFailedEvent());
} else {
event(new SpeedtestCompleteEvent($speedtest));
}
return $speedtest; return $speedtest;
} }
} }

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Listeners;
use App\Helpers\SettingsHelper;
use App\Notifications\SpeedtestFailedSlack;
use App\Notifications\SpeedtestFailedTelegram;
use Exception;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Notification;
class SpeedtestFailedListener
{
/**
* Create the event listener.
*
* @return void
*/
public function __construct()
{
//
}
/**
* Handle the event.
*
* @param object $event
* @return void
*/
public function handle($event)
{
if(env('SLACK_WEBHOOK')) {
try {
Notification::route('slack', env('SLACK_WEBHOOK'))
->notify(new SpeedtestFailedSlack());
} catch(Exception $e) {
Log::notice('Your sleck webhook is invalid');
Log::notice($e);
}
}
if(env('TELEGRAM_BOT_TOKEN') && env('TELEGRAM_CHAT_ID')) {
try {
Notification::route(TelegramChannel::class, env('TELEGRAM_CHAT_ID'))
->notify(new SpeedtestFailedTelegram());
} catch(Exception $e) {
Log::notice('Your telegram settings are invalid');
Log::notice($e);
}
}
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Messages\SlackMessage;
use Illuminate\Notifications\Notification;
class SpeedtestFailedSlack extends Notification implements ShouldQueue
{
use Queueable;
/**
* Create a new notification instance.
*
* @return void
*/
public function __construct()
{
//
}
/**
* Get the notification's delivery channels.
*
* @param mixed $notifiable
* @return array
*/
public function via($notifiable)
{
return [
'slack',
];
}
/**
* Format slack notification
*
* @param mixed $notifiable
* @return SlackMessage
*/
public function toSlack($notifiable)
{
return (new SlackMessage)
->error()
->attachment(function ($attachment) {
$attachment->title('Failed speedtest')
->content('Something went wrong running your speedtest');
});
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use NotificationChannels\Telegram\TelegramChannel;
use NotificationChannels\Telegram\TelegramMessage;
class SpeedtestFailedTelegram extends Notification implements ShouldQueue
{
use Queueable;
/**
* Create a new notification instance.
*
* @return void
*/
public function __construct()
{
//
}
public function via($notifiable)
{
return [
TelegramChannel::class
];
}
/**
* Format tekegram notification
*
* @param mixed $notifiable
* @return TelegramMessage
*/
public function toTelegram($notifiable)
{
$msg = "Error: something went wrong running your speedtest";
return TelegramMessage::create()
->to(env('TELEGRAM_CHAT_ID'))
->content($msg)
->options(['parse_mode' => 'Markdown']);
}
}

View File

@@ -3,8 +3,10 @@
namespace App\Providers; namespace App\Providers;
use App\Events\SpeedtestCompleteEvent; use App\Events\SpeedtestCompleteEvent;
use App\Events\SpeedtestFailedEvent;
use App\Events\SpeedtestOverviewEvent; use App\Events\SpeedtestOverviewEvent;
use App\Listeners\SpeedtestCompleteListener; use App\Listeners\SpeedtestCompleteListener;
use App\Listeners\SpeedtestFailedListener;
use App\Listeners\SpeedtestOverviewListener; use App\Listeners\SpeedtestOverviewListener;
use Illuminate\Auth\Events\Registered; use Illuminate\Auth\Events\Registered;
use Illuminate\Auth\Listeners\SendEmailVerificationNotification; use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
@@ -28,6 +30,9 @@ class EventServiceProvider extends ServiceProvider
SpeedtestOverviewEvent::class => [ SpeedtestOverviewEvent::class => [
SpeedtestOverviewListener::class SpeedtestOverviewListener::class
], ],
SpeedtestFailedEvent::class => [
SpeedtestFailedListener::class
],
]; ];
/** /**

View File

@@ -21,6 +21,7 @@ class Speedtest extends Model
'server_host', 'server_host',
'url', 'url',
'scheduled', 'scheduled',
'failed',
]; ];
protected $table = 'speedtests'; protected $table = 'speedtests';

View File

@@ -1,4 +1,14 @@
{ {
"1.7.3": [
{
"description": "Updated dependencies",
"link": ""
},
{
"description": "Added notifications and logging of failed tests",
"link": ""
}
],
"1.7.2": [ "1.7.2": [
{ {
"description": "Updated UI for speedtest info", "description": "Updated UI for speedtest info",

View File

@@ -7,7 +7,7 @@ return [
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
*/ */
'version' => '1.7.2', 'version' => '1.7.3',
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class UpdateSpeedtestAddFailedColumn extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('speedtests', function($table) {
$table->boolean('failed')->default(false);
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('speedtests', function($table) {
$table->dropColumn('failed');
});
}
}

2
public/js/app.js vendored

File diff suppressed because one or more lines are too long

View File

@@ -112,6 +112,7 @@ export default class LatestResults extends Component {
value={parseFloat(data.data.ping).toFixed(1)} value={parseFloat(data.data.ping).toFixed(1)}
avg={parseFloat(data.average.ping).toFixed(1)} avg={parseFloat(data.average.ping).toFixed(1)}
max={parseFloat(data.max.ping).toFixed(1)} max={parseFloat(data.max.ping).toFixed(1)}
failed={data.data.failed}
unit="ms" unit="ms"
icon="ping" icon="ping"
/> />
@@ -127,6 +128,7 @@ export default class LatestResults extends Component {
value={parseFloat(data.data.download).toFixed(1)} value={parseFloat(data.data.download).toFixed(1)}
avg={parseFloat(data.average.download).toFixed(1)} avg={parseFloat(data.average.download).toFixed(1)}
max={parseFloat(data.max.download).toFixed(1)} max={parseFloat(data.max.download).toFixed(1)}
failed={data.data.failed}
unit="Mbit/s" unit="Mbit/s"
icon="dl" icon="dl"
/> />
@@ -142,6 +144,7 @@ export default class LatestResults extends Component {
value={parseFloat(data.data.upload).toFixed(1)} value={parseFloat(data.data.upload).toFixed(1)}
avg={parseFloat(data.average.upload).toFixed(1)} avg={parseFloat(data.average.upload).toFixed(1)}
max={parseFloat(data.max.upload).toFixed(1)} max={parseFloat(data.max.upload).toFixed(1)}
failed={data.data.failed}
unit="Mbit/s" unit="Mbit/s"
icon="ul" icon="ul"
/> />

View File

@@ -29,36 +29,49 @@ export default class TableRow extends Component {
var e = this.state.data; var e = this.state.data;
var show = this.state.show; var show = this.state.show;
return ( if(e.failed != true) {
<tr> return (
<td>{e.id}</td> <tr>
<td>{new Date(e.created_at).toLocaleString()}</td> <td>{e.id}</td>
<td>{e.download}</td> <td>{new Date(e.created_at).toLocaleString()}</td>
<td>{e.upload}</td> <td>{e.download}</td>
<td>{e.ping}</td> <td>{e.upload}</td>
{e.server_host != null ? <td>{e.ping}</td>
<td> {e.server_host != null ?
<span onClick={this.toggleShow} className="ti-arrow-top-right mouse"></span> <td>
<Modal show={show} onHide={this.toggleShow}> <span onClick={this.toggleShow} className="ti-arrow-top-right mouse"></span>
<Modal.Header> <Modal show={show} onHide={this.toggleShow}>
<Modal.Title>More info</Modal.Title> <Modal.Header>
</Modal.Header> <Modal.Title>More info</Modal.Title>
<Modal.Body className="text-center"> </Modal.Header>
<p>Server ID: {e.server_id}</p> <Modal.Body className="text-center">
<p>Name: {e.server_name}</p> <p>Server ID: {e.server_id}</p>
<p>Host: {e.server_host}</p> <p>Name: {e.server_name}</p>
<p>URL: <a href={e.url} target="_blank" rel="noopener noreferer">Speedtest.net</a></p> <p>Host: {e.server_host}</p>
{e.scheduled != undefined && <p>URL: <a href={e.url} target="_blank" rel="noopener noreferer">Speedtest.net</a></p>
<p>Type: {e.scheduled == true ? 'scheduled' : 'manual'}</p> {e.scheduled != undefined &&
} <p>Type: {e.scheduled == true ? 'scheduled' : 'manual'}</p>
</Modal.Body> }
</Modal> </Modal.Body>
</td> </Modal>
: </td>
:
<td></td>
}
</tr>
);
} else {
return (
<tr>
<td>{e.id}</td>
<td>{new Date(e.created_at).toLocaleString()}</td>
<td><span className="ti-close text-danger"></span></td>
<td><span className="ti-close text-danger"></span></td>
<td><span className="ti-close text-danger"></span></td>
<td></td> <td></td>
} </tr>
</tr> );
); }
} }
} }

View File

@@ -12,7 +12,8 @@ export default class Widget extends Component {
unit: this.props.unit, unit: this.props.unit,
icon: this.props.icon, icon: this.props.icon,
avg: this.props.avg, avg: this.props.avg,
max: this.props.max max: this.props.max,
failed: this.props.failed,
} }
} }
@@ -36,6 +37,7 @@ export default class Widget extends Component {
var icon = this.state.icon; var icon = this.state.icon;
var max = this.state.max; var max = this.state.max;
var avg = this.state.avg; var avg = this.state.avg;
var failed = Boolean(Number(this.state.failed));
switch(icon) { switch(icon) {
case 'ping': case 'ping':
@@ -60,7 +62,7 @@ export default class Widget extends Component {
</div> </div>
<div className="text-truncate"> <div className="text-truncate">
<h3 className="d-inline">{value}</h3> <h3 className="d-inline">{(!failed) ? value : <span className="ti-close text-danger"></span> }</h3>
<p className="d-inline ml-2">{unit} (current)</p> <p className="d-inline ml-2">{unit} (current)</p>
</div> </div>
<div className="text-muted text-truncate"> <div className="text-muted text-truncate">