Added files for 1.10.3

This commit is contained in:
Henry Whitaker
2020-12-20 00:53:22 +00:00
parent 8dd6df08ca
commit fc924bc85e
38 changed files with 1428 additions and 1201 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) [![GitHub Workflow Status](https://img.shields.io/github/workflow/status/henrywhitaker3/Speedtest-Tracker/Stable?label=master&logo=github&style=flat-square)](https://github.com/henrywhitaker3/Speedtest-Tracker/actions) [![GitHub Workflow Status](https://img.shields.io/github/workflow/status/henrywhitaker3/Speedtest-Tracker/Dev?label=dev&logo=github&style=flat-square)](https://github.com/henrywhitaker3/Speedtest-Tracker/actions) [![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.10.1-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) [![GitHub Workflow Status](https://img.shields.io/github/workflow/status/henrywhitaker3/Speedtest-Tracker/Stable?label=master&logo=github&style=flat-square)](https://github.com/henrywhitaker3/Speedtest-Tracker/actions) [![GitHub Workflow Status](https://img.shields.io/github/workflow/status/henrywhitaker3/Speedtest-Tracker/Dev?label=dev&logo=github&style=flat-square)](https://github.com/henrywhitaker3/Speedtest-Tracker/actions) [![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.10.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 [Ookla's Speedtest cli](https://www.speedtest.net/apps/cli) 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 [Ookla's Speedtest cli](https://www.speedtest.net/apps/cli) to get the data and uses [Chart.js](https://www.chartjs.org/) to plot the results.

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) [![GitHub Workflow Status](https://img.shields.io/github/workflow/status/henrywhitaker3/Speedtest-Tracker/Stable?label=master&logo=github&style=flat-square)](https://github.com/henrywhitaker3/Speedtest-Tracker/actions) [![GitHub Workflow Status](https://img.shields.io/github/workflow/status/henrywhitaker3/Speedtest-Tracker/Dev?label=dev&logo=github&style=flat-square)](https://github.com/henrywhitaker3/Speedtest-Tracker/actions) [![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.10.1-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) [![GitHub Workflow Status](https://img.shields.io/github/workflow/status/henrywhitaker3/Speedtest-Tracker/Stable?label=master&logo=github&style=flat-square)](https://github.com/henrywhitaker3/Speedtest-Tracker/actions) [![GitHub Workflow Status](https://img.shields.io/github/workflow/status/henrywhitaker3/Speedtest-Tracker/Dev?label=dev&logo=github&style=flat-square)](https://github.com/henrywhitaker3/Speedtest-Tracker/actions) [![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.10.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

@@ -28,7 +28,9 @@ class Kernel extends ConsoleKernel
*/ */
protected function schedule(Schedule $schedule) protected function schedule(Schedule $schedule)
{ {
$schedule->job(new SpeedtestJob(true, config('integrations')))->cron(SettingsHelper::get('schedule')['value']); if ((bool)SettingsHelper::get('schedule_enabled')->value) {
$schedule->job(new SpeedtestJob(true, config('integrations')))->cron(SettingsHelper::get('schedule')['value']);
}
$schedule->command('speedtest:overview')->cron('0 ' . SettingsHelper::get('speedtest_overview_time')->value . ' * * *'); $schedule->command('speedtest:overview')->cron('0 ' . SettingsHelper::get('speedtest_overview_time')->value . ' * * *');
$schedule->command('speedtest:clear-sessions')->everyMinute(); $schedule->command('speedtest:clear-sessions')->everyMinute();
} }
@@ -40,7 +42,7 @@ class Kernel extends ConsoleKernel
*/ */
protected function commands() protected function commands()
{ {
$this->load(__DIR__.'/Commands'); $this->load(__DIR__ . '/Commands');
require base_path('routes/console.php'); require base_path('routes/console.php');
} }

View File

@@ -91,6 +91,11 @@ class SettingsHelper
*/ */
public static function settingIsEditable(string $key) public static function settingIsEditable(string $key)
{ {
// Manual override for app_name
if ($key === 'app_name') {
return true;
}
$results = []; $results = [];
// Try exact key // Try exact key
@@ -139,6 +144,7 @@ class SettingsHelper
{ {
return [ return [
'base' => SettingsHelper::getBase(), 'base' => SettingsHelper::getBase(),
'name' => SettingsHelper::get('app_name')->value,
'widgets' => [ 'widgets' => [
'show_average' => (bool)SettingsHelper::get('show_average')->value, 'show_average' => (bool)SettingsHelper::get('show_average')->value,
'show_max' => (bool)SettingsHelper::get('show_max')->value, 'show_max' => (bool)SettingsHelper::get('show_max')->value,

View File

@@ -15,9 +15,9 @@ class SettingsController extends Controller
{ {
public function __construct() public function __construct()
{ {
if((bool)SettingsHelper::get('auth')->value === true) { if ((bool)SettingsHelper::get('auth')->value === true) {
$this->middleware('auth:api') $this->middleware('auth:api')
->except([ 'config' ]); ->except(['config']);
} }
} }
@@ -51,21 +51,21 @@ class SettingsController extends Controller
public function store(Request $request) public function store(Request $request)
{ {
$rule = [ $rule = [
'name' => [ 'required', 'string', 'min:1' ], 'name' => ['required', 'string', 'min:1'],
]; ];
if($request->name == 'schedule') { if ($request->name == 'schedule') {
$rule['value'] = [ 'required', new Cron ]; $rule['value'] = ['required', new Cron];
} }
$validator = Validator::make($request->all(), $rule); $validator = Validator::make($request->all(), $rule);
if($validator->fails()) { if ($validator->fails()) {
return response()->json([ return response()->json([
'method' => 'Store a setting', 'method' => 'Store a setting',
'error' => $validator->errors() 'error' => $validator->errors()
], 422); ], 422);
} }
if(!isset($request->value)) { if (!isset($request->value)) {
$request->value = ''; $request->value = '';
} }
@@ -86,12 +86,12 @@ class SettingsController extends Controller
public function bulkStore(Request $request) public function bulkStore(Request $request)
{ {
$rule = [ $rule = [
'data' => [ 'array', 'required' ], 'data' => ['array', 'required'],
'data.*.name' => [ 'string', 'required' ], 'data.*.name' => ['string', 'required'],
]; ];
$validator = Validator::make($request->all(), $rule); $validator = Validator::make($request->all(), $rule);
if($validator->fails()) { if ($validator->fails()) {
return response()->json([ return response()->json([
'method' => 'Bulk store a setting', 'method' => 'Bulk store a setting',
'error' => $validator->errors() 'error' => $validator->errors()
@@ -99,14 +99,14 @@ class SettingsController extends Controller
} }
$settings = []; $settings = [];
foreach($request->data as $d) { foreach ($request->data as $d) {
if(!isset($d['value']) || $d['value'] == null) { if (!isset($d['value']) || $d['value'] == null) {
$d['value'] = ''; $d['value'] = '';
} }
if($d['name'] == 'speedtest_overview_time') { if ($d['name'] == 'speedtest_overview_time') {
$ok = [ '00', '01', '02', '03', '04', '05', '06', '07', '08', '09', '10', '11', '12', '13', '14', '15', '16', '17', '18', '19', '20', '21', '22', '23' ]; $ok = ['00', '01', '02', '03', '04', '05', '06', '07', '08', '09', '10', '11', '12', '13', '14', '15', '16', '17', '18', '19', '20', '21', '22', '23'];
if(!in_array($d['value'], $ok)) { if (!in_array($d['value'], $ok)) {
return response()->json([ return response()->json([
'method' => 'Bulk store a setting', 'method' => 'Bulk store a setting',
'error' => 'Invalid speedtest_overview_time value' 'error' => 'Invalid speedtest_overview_time value'
@@ -116,9 +116,9 @@ class SettingsController extends Controller
$setting = SettingsHelper::get($d['name']); $setting = SettingsHelper::get($d['name']);
if($setting == false) { if ($setting == false) {
$setting = SettingsHelper::set($d['name'], $d['value']); $setting = SettingsHelper::set($d['name'], $d['value']);
} else if(SettingsHelper::settingIsEditable($setting->name)) { } else if (SettingsHelper::settingIsEditable($setting->name)) {
$setting = SettingsHelper::set($d['name'], $d['value']); $setting = SettingsHelper::set($d['name'], $d['value']);
} else { } else {
continue; continue;

View File

@@ -52,19 +52,19 @@ class SpeedtestJob implements ShouldQueue
*/ */
public function handle() public function handle()
{ {
if($this->config['healthchecks_enabled'] === true) { if ($this->config['healthchecks_enabled'] === true) {
$this->healthcheck('start'); $this->healthcheck('start');
} }
$output = SpeedtestHelper::output(); $output = SpeedtestHelper::output();
$speedtest = SpeedtestHelper::runSpeedtest($output, $this->scheduled); $speedtest = SpeedtestHelper::runSpeedtest($output, $this->scheduled);
if($speedtest == false) { if ($speedtest == false) {
if($this->config['healthchecks_enabled'] === true) { if ($this->config['healthchecks_enabled'] === true) {
$this->healthcheck('fail'); $this->healthcheck('fail');
} }
event(new SpeedtestFailedEvent()); event(new SpeedtestFailedEvent());
} else { } else {
if($this->config['healthchecks_enabled'] === true) { if ($this->config['healthchecks_enabled'] === true) {
$this->healthcheck('success'); $this->healthcheck('success');
} }
@@ -82,19 +82,19 @@ class SpeedtestJob implements ShouldQueue
private function healthcheck(String $method) private function healthcheck(String $method)
{ {
try { try {
$hc = new Healthchecks(SettingsHelper::get('healthchecks_uuid')->value); $hc = new Healthchecks(SettingsHelper::get('healthchecks_uuid')->value, SettingsHelper::get('healthchecks_server_url')->value);
if($method === 'start') { if ($method === 'start') {
$hc->start(); $hc->start();
} }
if($method === 'success') { if ($method === 'success') {
$hc->success(); $hc->success();
} }
if($method === 'fail') { if ($method === 'fail') {
$hc->fail(); $hc->fail();
} }
} catch(Exception $e) { } catch (Exception $e) {
Log::error($e->getMessage()); Log::error($e->getMessage());
} }
} }

View File

@@ -44,7 +44,7 @@ class IntegrationsServiceProvider extends ServiceProvider
SettingsHelper::loadIntegrationConfig(); SettingsHelper::loadIntegrationConfig();
App::bind('healthcheck', function () use ($setting) { App::bind('healthcheck', function () use ($setting) {
return new Healthchecks($setting->value); return new Healthchecks($setting->value, SettingsHelper::get('healthchecks_server_url')->value);
}); });
} catch (InvalidUuidStringException $e) { } catch (InvalidUuidStringException $e) {
Log::error('Invalid healthchecks UUID'); Log::error('Invalid healthchecks UUID');

View File

@@ -1,4 +1,16 @@
{ {
"1.10.3": [
{
"description": "Moved stuff into pages.",
"link": ""
}
],
"1.10.2": [
{
"description": "Added option to disable scheduled tests.",
"link": ""
}
],
"1.10.1": [ "1.10.1": [
{ {
"description": "Fixed integrations config being empty causing healthchecks to not run on scheduled tests.", "description": "Fixed integrations config being empty causing healthchecks to not run on scheduled tests.",

513
conf/site/composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@ return [
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
*/ */
'version' => '1.10.1', 'version' => '1.10.3',
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------

View File

@@ -0,0 +1,38 @@
<?php
use App\Helpers\SettingsHelper;
use App\Setting;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddScheduleEnabledSetting extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
if (!SettingsHelper::get('schedule_enabled')) {
Setting::create([
'name' => 'schedule_enabled',
'value' => true,
'description' => 'Enable/disable the schedule worker'
]);
}
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Setting::whereIn('name', [
'schedule_enabled',
])->delete();
}
}

View File

@@ -0,0 +1,38 @@
<?php
use App\Helpers\SettingsHelper;
use App\Setting;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddAppNameSetting extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
if (!SettingsHelper::get('app_name')) {
Setting::create([
'name' => 'app_name',
'value' => 'Speedtest Tracker',
'description' => 'Set a custom app name'
]);
}
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Setting::whereIn('name', [
'app_name',
])->delete();
}
}

View File

@@ -0,0 +1,38 @@
<?php
use App\Helpers\SettingsHelper;
use App\Setting;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddCustomHealthchecksSetting extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
if (!SettingsHelper::get('healthchecks_server_url')) {
Setting::create([
'name' => 'healthchecks_server_url',
'value' => 'https://hc-ping.com/',
'description' => 'The URL of the healthchecks.io server. Change this to use a self-hosted server.'
]);
}
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Setting::whereIn('name', [
'healthchecks_server_url',
])->delete();
}
}

View File

@@ -3243,20 +3243,13 @@
"integrity": "sha512-D34BqZU4cIlMCY93rZHbrq9pjTAQJ3U8S8rfBqjwHxkGPThWFjzZDQpgMJY0QViLxth6ZKYiwFBo14RdN44U/w==" "integrity": "sha512-D34BqZU4cIlMCY93rZHbrq9pjTAQJ3U8S8rfBqjwHxkGPThWFjzZDQpgMJY0QViLxth6ZKYiwFBo14RdN44U/w=="
}, },
"csv-file-validator": { "csv-file-validator": {
"version": "1.8.0", "version": "1.10.1",
"resolved": "https://registry.npmjs.org/csv-file-validator/-/csv-file-validator-1.8.0.tgz", "resolved": "https://registry.npmjs.org/csv-file-validator/-/csv-file-validator-1.10.1.tgz",
"integrity": "sha512-+/wdJxbe9zk1KJv7GC5aCVOVrg10W7xWIypILuQsJ3ocegF/YueTarb8Dqg1snEfkPmh2aCjbhVXnu1gM3RRIA==", "integrity": "sha512-jYFl3a/ptlJIEzLM28BlApn+JthmCz/3f/WLdt2fdCmMGb+eiP9QkulFhmepzFFrMi2Iel6m4OPXrHWpOFCHqg==",
"requires": { "requires": {
"famulus": "2.1.2", "famulus": "^2.2.0",
"lodash": "4.17.15", "lodash": "^4.17.20",
"papaparse": "^5.2.0" "papaparse": "^5.3.0"
},
"dependencies": {
"lodash": {
"version": "4.17.15",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz",
"integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A=="
}
} }
}, },
"cyclist": { "cyclist": {
@@ -4184,11 +4177,11 @@
} }
}, },
"famulus": { "famulus": {
"version": "2.1.2", "version": "2.2.2",
"resolved": "https://registry.npmjs.org/famulus/-/famulus-2.1.2.tgz", "resolved": "https://registry.npmjs.org/famulus/-/famulus-2.2.2.tgz",
"integrity": "sha512-UjfF9lOEP6IFLC/DTwUe5KbCYINbuYYJS+mivlnWyK8yqt/9WYHrJ4RihZ0pa9HVxQObu8IWroJOyyt8dXCVkw==", "integrity": "sha512-tobqs8uC0OomrMN/cX7aUUj3OSJn5y2GCfcTleCrtIfjxUkl6kJFLovnyEWfD6M+cQ1ZXhE5BaTJzMoDibbodA==",
"requires": { "requires": {
"lodash": "^4.17.15" "lodash": "^4.17.20"
} }
}, },
"fast-deep-equal": { "fast-deep-equal": {
@@ -7078,9 +7071,9 @@
"dev": true "dev": true
}, },
"papaparse": { "papaparse": {
"version": "5.2.0", "version": "5.3.0",
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.2.0.tgz", "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.3.0.tgz",
"integrity": "sha512-ylq1wgUSnagU+MKQtNeVqrPhZuMYBvOSL00DHycFTCxownF95gpLAk1HiHdUW77N8yxRq1qHXLdlIPyBSG9NSA==" "integrity": "sha512-Lb7jN/4bTpiuGPrYy4tkKoUS8sTki8zacB5ke1p5zolhcSE4TlWgrlsxjrDTbG/dFVh07ck7X36hUf/b5V68pg=="
}, },
"parallel-transform": { "parallel-transform": {
"version": "1.2.0", "version": "1.2.0",

View File

@@ -27,7 +27,7 @@
"dependencies": { "dependencies": {
"@babel/plugin-proposal-class-properties": "^7.12.1", "@babel/plugin-proposal-class-properties": "^7.12.1",
"chart.js": "^2.9.4", "chart.js": "^2.9.4",
"csv-file-validator": "^1.8.0", "csv-file-validator": "^1.10.1",
"js-cookie": "^2.2.1", "js-cookie": "^2.2.1",
"react-bootstrap": "^1.4.0", "react-bootstrap": "^1.4.0",
"react-chartjs-2": "^2.11.1", "react-chartjs-2": "^2.11.1",

View File

@@ -1,6 +1,6 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import { Container, Row, Col, Collapse, Button, Modal } from 'react-bootstrap'; import { Row, Col} from 'react-bootstrap';
import SessionsTable from './SessionsTable'; import SessionsTable from './SessionsTable';
import ResetPassword from './ResetPassword'; import ResetPassword from './ResetPassword';
@@ -44,34 +44,18 @@ export default class Authentication extends Component {
if( (window.config.auth == true && window.authenticated == true)) { if( (window.config.auth == true && window.authenticated == true)) {
return ( return (
<Container className="mb-4"> <div>
<Row> <Row>
<Col sm={{ span: 12 }} className="mb-3 text-center"> <Col sm={{ span: 12 }} className="text-center">
<div className="mouse" aria-controls="testsTable" onClick={this.toggleCollapse} aria-expanded={showCollapse}> <ResetPassword />
<h4 className="d-inline mr-2">Authentication</h4>
{(showCollapse) ?
<span className="ti-angle-up"></span>
:
<span className="ti-angle-down"></span>
}
</div>
</Col> </Col>
</Row> </Row>
<Collapse in={showCollapse}> <Row>
<div> <Col sm={{ span: 12 }} className="text-center">
<Row> <SessionsTable />
<Col sm={{ span: 12 }} className="text-center"> </Col>
<ResetPassword /> </Row>
</Col> </div>
</Row>
<Row>
<Col sm={{ span: 12 }} className="text-center">
<SessionsTable />
</Col>
</Row>
</div>
</Collapse>
</Container>
); );
} else { } else {
return ( return (

View File

@@ -37,6 +37,10 @@ export default class HistoryGraph extends Component {
}); });
} }
componentWillUnmount() {
clearInterval(this.state.interval);
}
getDLULPing = (days) => { getDLULPing = (days) => {
var url = 'api/speedtest/time/' + days; var url = 'api/speedtest/time/' + days;

View File

@@ -26,6 +26,10 @@ export default class LatestResults extends Component {
}); });
} }
componentWillUnmount() {
clearInterval(this.state.interval);
}
getData = () => { getData = () => {
var url = 'api/speedtest/latest'; var url = 'api/speedtest/latest';

View File

@@ -26,6 +26,10 @@ export default class TestsTable extends Component {
}); });
} }
componentWillUnmount() {
clearInterval(this.state.interval);
}
getData = (page = this.state.page, refresh = true) => { getData = (page = this.state.page, refresh = true) => {
var url = 'api/speedtest/?page=' + page; var url = 'api/speedtest/?page=' + page;
@@ -83,59 +87,48 @@ export default class TestsTable extends Component {
if(data.length > 0) { if(data.length > 0) {
return ( return (
<Container className="mb-4 mt-4" fluid> <div>
<Row> <Container className="mb-4 mt-4 px-5">
<Col sm={{ span: 12 }} className="mb-3 text-center"> <Row>
<div className="mouse" aria-controls="testsTable" onClick={this.toggleCollapse} aria-expanded={show}> <Col sm={{ span: 12 }} className="mb-3 text-center">
<h4 className="d-inline mr-2">All tests</h4> <div>
{(show) ? <h4 className="d-inline mr-2">All tests</h4>
<span className="ti-angle-up"></span>
:
<span className="ti-angle-down"></span>
}
</div>
{(show) &&
<div className="my-1">
<span className="text-muted">Auto refresh: {(refresh) ? 'On' : 'Off'}</span> <span className="text-muted">Auto refresh: {(refresh) ? 'On' : 'Off'}</span>
</div> </div>
} </Col>
</Col> </Row>
</Row> <Row>
<Collapse in={show}> <Col sm={{ span: 12 }} id="testsTable">
<div> <Table responsive>
<thead>
<tr>
<th>ID</th>
<th>Time</th>
<th>Download (Mbit/s)</th>
<th>Upload (Mbit/s)</th>
<th>Ping (ms)</th>
<th>More</th>
</tr>
</thead>
<tbody>
{data.map((e,i) => {
return (
<TableRow key={e.id} data={e} />
);
})}
</tbody>
</Table>
</Col>
</Row>
{page < lastPage &&
<Row> <Row>
<Col sm={{ span: 12 }} id="testsTable"> <Col sm={{ span: 12 }} className="text-center">
<Table responsive> <Button variant="primary" onClick={this.getMoreData}>Show more</Button>
<thead>
<tr>
<th>ID</th>
<th>Time</th>
<th>Download (Mbit/s)</th>
<th>Upload (Mbit/s)</th>
<th>Ping (ms)</th>
<th>More</th>
</tr>
</thead>
<tbody>
{data.map((e,i) => {
return (
<TableRow key={e.id} data={e} />
);
})}
</tbody>
</Table>
</Col> </Col>
</Row> </Row>
{page < lastPage && }
<Row> </Container>
<Col sm={{ span: 12 }} className="text-center"> </div>
<Button variant="primary" onClick={this.getMoreData}>Show more</Button>
</Col>
</Row>
}
</div>
</Collapse>
</Container>
); );
} else { } else {
return ( return (

View File

@@ -5,25 +5,22 @@ import LatestResults from '../Graphics/LatestResults';
import Footer from './Footer'; import Footer from './Footer';
import DataRow from '../Data/DataRow'; import DataRow from '../Data/DataRow';
import TestsTable from '../Graphics/TestsTable'; import TestsTable from '../Graphics/TestsTable';
import Settings from '../Settings/Settings';
import Login from '../Login'; import Login from '../Login';
import Authentication from '../Authentication/Authentication'; import Authentication from '../Authentication/Authentication';
import Navbar from '../Navbar';
export default class HomePage extends Component { export default class HomePage extends Component {
render() { render() {
return ( return (
<div> <div>
<Navbar />
<div className="my-4"> <div className="my-4">
{(window.config.auth == true && window.authenticated == false) && {(window.config.auth == true && window.authenticated == false) &&
<Login /> <Login />
} }
<LatestResults /> <LatestResults />
<HistoryGraph /> <HistoryGraph />
<TestsTable />
<Settings />
<Authentication />
<DataRow />
</div> </div>
<Footer /> <Footer />
</div> </div>

View File

@@ -39,6 +39,9 @@ export default class Login extends Component {
Cookies.set('auth', token, { expires: expires }) Cookies.set('auth', token, { expires: expires })
window.location.reload(true); window.location.reload(true);
}) })
.catch((err) => {
toast.error('Something went wrong logging in.');
})
} }
toggleShow = () => { toggleShow = () => {

View File

@@ -1,19 +1,77 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import {Nav, Navbar as BootstrapNavbar, NavLink as BootstrapNavLink} from 'react-bootstrap';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import { Link, NavLink } from 'react-router-dom';
export default class Navbar extends Component { export default class Navbar extends Component {
constructor(props) { constructor(props) {
super(props) super(props)
this.state = { this.state = {
brand: {
name: window.config.name,
url: window.config.base
},
} }
} }
generatePagesArray() {
var pages = [
{
name: 'Home',
url: window.config.base,
authRequired: false
},
{
name: 'All Tests',
url: window.config.base + 'speedtests',
authRequired: false
},
{
name: 'Settings',
url: window.config.base + 'settings',
authRequired: true
},
]
return pages;
}
generateLinks = () => {
var pages = this.generatePagesArray();
return pages.map(page => {
if(
page.authRequired === false ||
(
page.authRequired === true &&
window.config.auth &&
window.authenticated
) ||
(
page.authRequired === true &&
window.config.auth === false
)
) {
return <BootstrapNavLink key={page.url} as={NavLink} to={page.url}>{page.name}</BootstrapNavLink>;
}
});
}
render() { render() {
var brand = this.state.brand;
var pages = this.generateLinks();
return ( return (
<div> <BootstrapNavbar variant="dark" bg="dark" expand="sm">
</div> <BootstrapNavbar.Brand as={Link} to={brand.url}><img style={{width: '15%'}} src={window.config.base + 'files/icons/fav/android-icon-192x192.png'} /> {brand.name}</BootstrapNavbar.Brand>
<BootstrapNavbar.Toggle aria-controls="basic-navbar-nav" />
<BootstrapNavbar.Collapse id="basic-navbar-nav">
<Nav className="ml-auto">
{pages}
</Nav>
</BootstrapNavbar.Collapse>
</BootstrapNavbar>
); );
} }
} }

View File

@@ -1,77 +0,0 @@
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import { Card, Form, Button } from 'react-bootstrap';
import Axios from 'axios';
import { toast } from 'react-toastify';
export default class Setting extends Component {
constructor(props) {
super(props)
this.state = {
name: this.props.name,
value: this.props.value,
description: this.props.description,
}
}
ucfirst(string) {
return string.charAt(0).toUpperCase() + string.slice(1);
}
update = () => {
var url = 'api/settings?token=' + window.token;
var data = {
name: this.state.name,
value: this.state.value
};
Axios.post(url, data)
.then((resp) => {
toast.success(this.ucfirst(this.state.name) + ' updated');
})
.catch((err) => {
if(err.response.status == 422) {
var errors = err.response.data.error;
for(var key in errors) {
var error = errors[key];
toast.error(error[0])
}
} else {
toast.error('Something went wrong')
}
})
}
updateValue = (e) => {
this.setState({
value: e.target.value
});
}
render() {
var name = this.state.name;
var value = this.state.value;
var description = this.state.description;
return (
<Card className="m-2 setting-card">
<Card.Body className="d-flex align-items-center">
<div>
<h4>{this.ucfirst(name)}</h4>
<div dangerouslySetInnerHTML={{ __html: description}} />
<Form.Group controlId={name}>
<Form.Label>{this.ucfirst(name)}</Form.Label>
<Form.Control type="text" label={name} defaultValue={value} onInput={this.updateValue} />
</Form.Group>
<Button variant="primary" onClick={this.update}>Save</Button>
</div>
</Card.Body>
</Card>
);
}
}
if (document.getElementById('Setting')) {
ReactDOM.render(<Setting />, document.getElementById('Setting'));
}

View File

@@ -1,296 +0,0 @@
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import { Card, Form, Button, Modal, Row, Col } from 'react-bootstrap';
import Axios from 'axios';
import { toast } from 'react-toastify';
import SettingsModalCard from '../Settings/SettingsModalCard';
export default class SettingWithModal extends Component {
constructor(props) {
super(props)
this.state = {
title: this.props.title,
description: this.props.description,
settings: this.props.settings,
show: false,
autoClose: this.props.autoClose
}
}
ucfirst(string) {
return string.charAt(0).toUpperCase() + string.slice(1);
}
update = () => {
var url = 'api/settings/bulk?token=' + window.token;
var data = [];
var settings = this.state.settings;
settings.forEach(e => {
if(e.type !== 'button-get') {
var res = {
name: e.obj.name,
value: e.obj.value
};
data.push(res);
}
});
data = {
data: data
};
Axios.post(url, data)
.then((resp) => {
toast.success(this.state.title + ' updated');
if(this.state.autoClose) {
this.toggleShow();
}
Axios.get('api/settings/config')
.then((resp) => {
window.config = resp.data;
})
})
.catch((err) => {
if(err.response.status == 422) {
toast.error('Your input was invalid');
} else {
toast.error('Something went wrong')
}
})
}
updateValue = (e) => {
var name = e.target.id;
if(e.target.type == 'checkbox') {
var val = e.target.checked;
} else {
var val = e.target.value;
}
var settings = this.state.settings;
var i = 0;
settings.forEach(ele => {
if(ele.obj.name == name) {
ele.obj.value = val;
}
settings[i] = ele;
i++;
});
this.setState({
settings: settings
});
}
toggleShow = () => {
var show = this.state.show;
if(show) {
this.setState({
show: false
});
} else {
this.setState({
show: true
});
}
}
render() {
var title = this.state.title;
var description = this.state.description;
var show = this.state.show;
var settings = this.state.settings;
return (
<>
<SettingsModalCard title={title} description={description} toggleShow={this.toggleShow} />
<Modal show={show} onHide={this.toggleShow}>
<Modal.Header closeButton>
<Modal.Title>{title}</Modal.Title>
</Modal.Header>
<Modal.Body>
{settings.map((e,i) => {
var name = e.obj.name.split('_');
name[0] = this.ucfirst(name[0]);
name = name.join(' ');
if(e.obj.description == null || e.obj.description == '') {
var sm = { span: 12 };
var md = { span: 12 };
} else {
var sm = { span: 12 };
var md = { span: 6 };
}
var readonly = false;
if(window.config.editable[e.obj.name] == false) {
readonly = true;
}
if(e.type == 'info') {
return (
<Row key={e.obj.id} className="d-flex align-items-center">
<Col md={md} sm={sm}>
<p>{e.obj.content}</p>
</Col>
</Row>
)
} else if(e.type == 'checkbox') {
return (
<Row key={e.obj.id} className="d-flex align-items-center">
<Col md={md} sm={sm}>
<Form.Group controlId={e.obj.name}>
{readonly ?
<>
<Form.Check type="checkbox" disabled label={name} defaultChecked={Boolean(Number(e.obj.value))} onInput={this.updateValue} />
<Form.Text className="text-muted">This setting is defined as an env variable and is not editable.</Form.Text>
</>
:
<Form.Check type="checkbox" label={name} defaultChecked={Boolean(Number(e.obj.value))} onInput={this.updateValue} />
}
</Form.Group>
</Col>
{e.description == null &&
<Col md={md} sm={sm}>
<p>{e.obj.description}</p>
</Col>
}
</Row>
);
} else if(e.type == 'number') {
return (
<Row key={e.obj.id}>
<Col md={md} sm={sm}>
<Form.Group controlId={e.obj.name}>
<Form.Label>{name}</Form.Label>
{readonly ?
<>
<Form.Control type="number" disabled min={e.min} max={e.max} defaultValue={e.obj.value} onInput={this.updateValue} />
<Form.Text className="text-muted">This setting is defined as an env variable and is not editable.</Form.Text>
</>
:
<Form.Control type="number" min={e.min} max={e.max} defaultValue={e.obj.value} onInput={this.updateValue} />
}
</Form.Group>
</Col>
{e.description == null &&
<Col md={md} sm={sm}>
<p>{e.obj.description}</p>
</Col>
}
</Row>
);
} else if(e.type == 'text') {
return (
<Row key={e.obj.id}>
<Col md={md} sm={sm}>
<Form.Group controlId={e.obj.name}>
<Form.Label>{name}</Form.Label>
{readonly ?
<>
<Form.Control type="text" disabled defaultValue={e.obj.value} onInput={this.updateValue} />
<Form.Text className="text-muted">This setting is defined as an env variable and is not editable.</Form.Text>
</>
:
<Form.Control type="text" defaultValue={e.obj.value} onInput={this.updateValue} />
}
</Form.Group>
</Col>
{e.description == null &&
<Col md={md} sm={sm}>
<p dangerouslySetInnerHTML={{ __html: e.obj.description}}></p>
</Col>
}
</Row>
);
} else if(e.type == 'select') {
return (
<Row key={e.obj.id}>
<Col md={md} sm={sm}>
<Form.Group controlId={e.obj.name}>
<Form.Label>{name}</Form.Label>
{readonly ?
<>
<Form.Control as="select" disabled defaultValue={e.obj.value} onInput={this.updateValue}>
{e.options.map((e,i) => {
return (
<option key={i} value={e.value}>{e.name}</option>
)
})}
</Form.Control>
<Form.Text className="text-muted">This setting is defined as an env variable and is not editable.</Form.Text>
</>
:
<Form.Control as="select" defaultValue={e.obj.value} onInput={this.updateValue}>
{e.options.map((e,i) => {
return (
<option key={i} value={e.value}>{e.name}</option>
)
})}
</Form.Control>
}
</Form.Group>
</Col>
{e.description == null &&
<Col md={md} sm={sm}>
<p>{e.obj.description}</p>
</Col>
}
</Row>
)
} else if(e.type == 'button-get') {
return (
<Row key={e.obj.id}>
<Col md={md} sm={sm}>
<p>{name}</p>
<Button onClick={() => { Axios.get(e.url) }} >{name}</Button>
</Col>
{e.description == null &&
<Col md={md} sm={sm}>
<p>{e.obj.description}</p>
</Col>
}
</Row>
)
} else if(e.type == 'group') {
return (
<div key={e.obj.id}>
<Row>
<Col md={md} sm={sm}>
<p className="mb-0">{name}</p>
</Col>
{e.description == null &&
<Col md={md} sm={sm}>
<p>{e.obj.description}</p>
</Col>
}
</Row>
<Row>
<Col sm={{ span: 12 }}>
{e.children.map((ee,ii) => {
if(ee.type == 'button-get') {
return (
<Button key={ii} variant={ee.btnType} className={'mr-2 mb-3'} onClick={() => { Axios.get(ee.url)
.then((resp) => { toast.success('Healthcheck sent') })
.catch((resp) => { resp = resp.response; toast.error(resp.data.error) })
}} >{ee.text}</Button>
)
}
})}
</Col>
</Row>
</div>
)
}
})}
<Button variant="primary" type="submit" onClick={this.update} >Save</Button>
</Modal.Body>
</Modal>
</>
);
}
}
if (document.getElementById('Setting')) {
ReactDOM.render(<Setting />, document.getElementById('Setting'));
}

View File

@@ -1,323 +0,0 @@
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import { Modal, Container, Row, Col, Collapse } from 'react-bootstrap';
import Loader from '../Loader';
import Axios from 'axios';
import Setting from './Setting';
import SettingWithModal from './SettingWithModal';
import ResetSettings from './ResetSettings';
export default class Settings extends Component {
constructor(props) {
super(props)
this.state = {
show: false,
loading: true,
data: [],
}
}
componentDidMount = () => {
if( (window.config.auth == true && window.authenticated == true) || window.config.auth == false) {
this.getData();
}
}
toggleShow = () => {
if(this.state.show) {
var show = false;
} else {
var show = true;
}
this.setState({
show: show
});
}
getData = () => {
var url = 'api/settings/?token=' + window.token;
Axios.get(url)
.then((resp) => {
this.setState({
loading: false,
data: resp.data
});
})
.catch((err) => {
if(err.response) {
}
})
}
buildSettingsCards = () => {
var e = this.state.data;
return (
<Row>
<Col lg={{ span: 4 }} md={{ span: 6 }} sm={{ span: 12 }}>
<SettingWithModal title="General settings" description="Configure general settings for the app." autoClose={true} settings={[
{
obj: e.schedule,
type: 'text'
},
{
obj: e.server,
type: 'text'
},
{
obj: e.show_average,
type: 'checkbox'
},
{
obj: e.show_max,
type: 'checkbox'
},
{
obj: e.show_min,
type: 'checkbox'
},
]} />
</Col>
<Col lg={{ span: 4 }} md={{ span: 6 }} sm={{ span: 12 }}>
<SettingWithModal title="Graph settings" description="Control settings for the graphs." autoClose={true} settings={[
{
obj: e.download_upload_graph_enabled,
type: 'checkbox'
},
{
obj: e.download_upload_graph_width,
type: 'select',
options: [
{
name: 'Full-width',
'value': 12
},
{
name: 'Half-width',
'value': 6
}
],
},
{
obj: e.ping_graph_enabled,
type: 'checkbox'
},
{
obj: e.ping_graph_width,
type: 'select',
options: [
{
name: 'Full-width',
'value': 12
},
{
name: 'Half-width',
'value': 6
}
],
},
{
obj: e.failure_graph_enabled,
type: 'checkbox'
},
{
obj: e.failure_graph_width,
type: 'select',
options: [
{
name: 'Full-width',
'value': 12
},
{
name: 'Half-width',
'value': 6
}
],
},
{
obj: e.show_failed_tests_on_graph,
type: 'checkbox'
}
]} />
</Col>
<Col lg={{ span: 4 }} md={{ span: 6 }} sm={{ span: 12 }}>
<SettingWithModal title="Notification settings" description="Control which types of notifications the server sends." autoClose={false} settings={[
{
obj: e.slack_webhook,
type: 'text'
},
{
obj: e.telegram_bot_token,
type: 'text'
},
{
obj: e.telegram_chat_id,
type: 'text'
},
{
obj: {
id: (Math.floor(Math.random() * 10000) + 1),
name: "Test notifications",
description: "After saving your updated notification settings, use this to check your settings are correct."
},
type: 'button-get',
url: 'api/settings/test-notification?token=' + window.token
},
{
obj: e.speedtest_notifications,
type: 'checkbox'
},
{
obj: e.speedtest_overview_notification,
type: 'checkbox'
},
{
obj: e.speedtest_overview_time,
type: 'number',
min: 0,
max: 23
},
{
obj: {
id: (Math.floor(Math.random() * 10000) + 1),
name: "Conditional Notifications",
description: ""
},
type: 'group',
children: [
]
},
{
obj: e.threshold_alert_percentage_notifications,
type: 'checkbox',
},
{
obj: e.threshold_alert_percentage,
type: 'number',
min: 0,
max: 100
},
{
obj: e.threshold_alert_absolute_notifications,
type: 'checkbox',
},
{
obj: e.threshold_alert_absolute_download,
type: 'number',
},
{
obj: e.threshold_alert_absolute_upload,
type: 'number',
},
{
obj: e.threshold_alert_absolute_ping,
type: 'number',
}
]} />
</Col>
<Col lg={{ span: 4 }} md={{ span: 6 }} sm={{ span: 12 }}>
<SettingWithModal title="healthchecks.io settings" description="Control settings for healthchecks.io" autoClose={false} settings={[
{
obj: e.healthchecks_uuid,
type: 'text'
},
{
obj: e.healthchecks_enabled,
type: 'checkbox'
},
{
obj: {
id: (Math.floor(Math.random() * 10000) + 1),
name: "Test healthchecks (after saving)",
description: ""
},
type: 'group',
children: [
{
type: 'button-get',
url: 'api/settings/test-healthchecks/start?token=' + window.token,
btnType: 'outline-success',
text: 'Start',
inline: true,
},
{
type: 'button-get',
url: 'api/settings/test-healthchecks/success?token=' + window.token,
btnType: 'success',
text: 'Success',
inline: true,
},
{
type: 'button-get',
url: 'api/settings/test-healthchecks/fail?token=' + window.token,
btnType: 'danger',
text: 'Fail',
inline: true,
},
]
},
]} />
</Col>
<Col lg={{ span: 4 }} md={{ span: 6 }} sm={{ span: 12 }}>
<ResetSettings />
</Col>
</Row>
)
}
render() {
var show = this.state.show;
var loading = this.state.loading;
var data = this.state.data;
if(!loading) {
var cards = this.buildSettingsCards();
}
if( (window.config.auth == true && window.authenticated == true) || window.config.auth == false) {
return (
<div>
<Container className="my-4">
<Row>
<Col sm={{ span: 12 }} className="mb-3 text-center">
<div className="mouse" onClick={this.toggleShow}>
<h4 className="mb-0 mr-2 d-inline">Settings</h4>
{(show) ?
<span className="ti-angle-up"></span>
:
<span className="ti-angle-down"></span>
}
</div>
</Col>
</Row>
<Collapse in={show}>
<div>
<Row>
<Col sm={{ span: 12 }}>
{loading ?
<Loader small />
:
cards
}
</Col>
</Row>
</div>
</Collapse>
</Container>
</div>
);
} else {
return(
<></>
)
}
}
}
if (document.getElementById('Settings')) {
ReactDOM.render(<Settings />, document.getElementById('Settings'));
}

View File

@@ -0,0 +1,282 @@
import Axios from 'axios';
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import Footer from '../Home/Footer';
import Loader from '../Loader';
import Navbar from '../Navbar';
import SettingsTabs from './SettingsTabs';
export default class SettingsIndex extends Component {
constructor(props) {
super(props)
this.state = {
data: null,
loading: true,
}
}
getData = () => {
var url = 'api/settings/?token=' + window.token;
Axios.get(url)
.then((resp) => {
this.setState({
data: this.sortSettings(resp.data),
loading: false,
});
})
.catch((err) => {
//
})
}
sortSettings = (data) => {
return {
General: [
{
obj: data.app_name,
type: 'text',
},
{
obj: data.schedule_enabled,
type: 'text',
},
{
obj: data.schedule,
type: 'text',
},
{
obj: data.server,
type: 'text',
},
{
obj: data.show_average,
type: 'checkbox',
},
{
obj: data.show_max,
type: 'checkbox',
},
{
obj: data.show_min,
type: 'checkbox',
}
],
Graphs: [
{
obj: data.download_upload_graph_enabled,
type: 'checkbox',
hideDescription: true
},
{
obj: data.download_upload_graph_width,
type: 'select',
options: [
{
name: 'Full-width',
'value': 12
},
{
name: 'Half-width',
'value': 6
}
],
},
{
obj: data.ping_graph_enabled,
type: 'checkbox',
hideDescription: true
},
{
obj: data.ping_graph_width,
type: 'select',
options: [
{
name: 'Full-width',
'value': 12
},
{
name: 'Half-width',
'value': 6
}
],
},
{
obj: data.failure_graph_enabled,
type: 'checkbox',
hideDescription: true
},
{
obj: data.failure_graph_width,
type: 'select',
options: [
{
name: 'Full-width',
'value': 12
},
{
name: 'Half-width',
'value': 6
}
],
},
{
obj: data.show_failed_tests_on_graph,
type: 'checkbox',
},
],
Notifications: [
{
obj: data.slack_webhook,
type: 'text'
},
{
obj: data.telegram_bot_token,
type: 'text'
},
{
obj: data.telegram_chat_id,
type: 'text'
},
{
type: 'btn-get',
url: 'api/settings/test-notification?token=' + window.token,
btnType: 'primary',
obj: {
id: (Math.floor(Math.random() * 10000) + 1),
name: 'Test notifications',
description: 'After saving your updated notification settings, use this to check your settings are correct.'
}
},
{
obj: data.speedtest_notifications,
type: 'checkbox'
},
{
obj: data.speedtest_overview_notification,
type: 'checkbox'
},
{
obj: data.speedtest_overview_time,
type: 'number',
min: 0,
max: 23,
},
// Add handling for title stuff
{
obj: data.threshold_alert_percentage,
type: 'number',
min: 0,
max: 100
},
{
obj: data.threshold_alert_absolute_notifications,
type: 'checkbox'
},
{
obj: data.threshold_alert_absolute_download,
type: 'number'
},
{
obj: data.threshold_alert_absolute_upload,
type: 'number'
},
{
obj: data.threshold_alert_absolute_ping,
type: 'number'
},
],
healthchecks: [
{
obj: data.healthchecks_enabled,
type: 'checkbox'
},
{
obj: data.healthchecks_server_url,
type: 'text'
},
{
obj: data.healthchecks_uuid,
type: 'text'
},
{
obj: {
id: (Math.floor(Math.random() * 10000) + 1),
name: "Test healthchecks.io integration",
description: ""
},
},
{
obj: {
id: (Math.floor(Math.random() * 10000) + 1),
name: "Start",
description: ""
},
type: 'btn-get',
url: 'api/settings/test-healthchecks/start?token=' + window.token,
btnType: 'outline-success',
inline: true,
earlyReturn: true,
classes: 'mr-2'
},
{
obj: {
id: (Math.floor(Math.random() * 10000) + 1),
name: "Success",
description: ""
},
type: 'btn-get',
url: 'api/settings/test-healthchecks/success?token=' + window.token,
btnType: 'success',
text: 'Success',
inline: true,
earlyReturn: true,
classes: 'mr-2'
},
{
obj: {
id: (Math.floor(Math.random() * 10000) + 1),
name: "Fail",
description: ""
},
type: 'btn-get',
url: 'api/settings/test-healthchecks/fail?token=' + window.token,
btnType: 'danger',
text: 'Fail',
inline: true,
earlyReturn: true,
classes: 'mr-2'
},
]
};
}
componentDidMount() {
this.getData();
}
render() {
var data = this.state.data;
var loading = this.state.loading;
return (
<div>
<Navbar />
<div className="container my-5">
{loading ?
<Loader />
:
<SettingsTabs data={data} />
}
</div>
<Footer />
</div>
);
}
}
if (document.getElementById('settingsIndex')) {
ReactDOM.render(<SettingsIndex />, document.getElementById('settingsIndex'));
}

View File

@@ -0,0 +1,185 @@
import React, { Component } from 'react';
import { Form } from 'react-bootstrap';
import ReactDOM from 'react-dom';
export default class SettingsInput extends Component {
constructor(props) {
super(props)
this.state = {
type: this.props.type,
name: this.props.name,
displayName: (this.props.name) ? this.formatName(this.props.name) : '',
value: (this.props.value) ? this.props.value : '',
classes: this.props.classes,
id: this.props.id,
label: (this.props.label) ? this.props.label : false,
readonly: true,
description: (this.props.description) ? this.props.description : false,
options: this.props.options ? this.props.options : [],
hideDescription: this.props.hideDescription ? true : false,
min: this.props.min ? this.props.min : null,
max: this.props.max ? this.props.max : null,
url: this.props.url,
inline: this.props.inline ? 'd-inline-block' : 'd-block',
btnType: this.props.btnType,
earlyReturn: this.props.earlyReturn ? true : false,
}
}
componentDidMount() {
this.setState({
readonly: this.isReadOnly()
});
}
formatName(name) {
name = name.split('_').join(' ');
return name.charAt(0).toUpperCase() + name.slice(1);
}
handleInput = (evt) => {
var val = evt.target.value;
if(this.state.type === 'checkbox') {
val = evt.target.checked;
}
this.props.handler(
this.state.name,
val
);
this.setState({
value: val
});
}
isReadOnly = () => {
if(window.config.editable[this.state.name] == false) {
return true;
}
return false;
}
generateNumberInput(disabled) {
return <Form.Control
name={this.state.name}
type={this.state.type}
defaultValue={this.state.value}
disabled={disabled}
min={this.state.min}
max={this.state.max}
onInput={this.handleInput} />
}
generateSelectInput(disabled) {
return (
<Form.Control
as="select"
name={this.state.name}
type={this.state.type}
defaultValue={this.state.value}
disabled={disabled}
onInput={this.handleInput}
>
{this.state.options.map((option,i) => {
return <option key={i} value={option.value}>{option.name}</option>
})}
</Form.Control>
);
}
generateCheckboxInput(disabled) {
return <Form.Control
custom
className="ml-2"
name={this.state.name}
type={this.state.type}
defaultChecked={this.state.value}
disabled={disabled}
onInput={this.handleInput} />
}
generateTextInput(disabled) {
return <Form.Control
name={this.state.name}
type={this.state.type}
defaultValue={this.state.value}
disabled={disabled}
onInput={this.handleInput} />
}
generateButtonGetInput() {
var url = this.state.url;
return (
<button
type="button"
className={"btn btn-" + this.state.btnType + ' ' + this.state.inline + ' ' + this.state.classes}
onClick={() => {
window.axios.get(url)
}}
>{this.state.displayName}</button>
);
}
generateInput = () => {
var disabled = (this.state.readonly) ? true : false;
var input = null;
if(this.state.type === 'number') {
input = this.generateNumberInput(disabled);
}
if(this.state.type === 'select') {
input = this.generateSelectInput(disabled);
}
if(this.state.type === 'checkbox') {
input = this.generateCheckboxInput(disabled);
}
if(this.state.type === 'text') {
input = this.generateTextInput(disabled);
}
if(this.state.type === 'btn-get') {
input = this.generateButtonGetInput();
}
if(this.state.earlyReturn) {
return input;
}
return (
<Form.Group controlId={this.state.id}>
{this.state.label &&
<Form.Label style={{fontSize: '1.25rem'}}>{this.formatName(this.state.name)}</Form.Label>
}
{input}
{this.state.description && !this.state.hideDescription &&
<p className="mt-1 text-muted" dangerouslySetInnerHTML={{ __html: this.state.description }}></p>
}
{this.state.readonly &&
<Form.Text className="text-muted">This setting is defined as an env variable and is not editable.</Form.Text>
}
</Form.Group>
);
}
render() {
var input = this.generateInput();
return input;
}
}
if (document.getElementById('SettingsInput')) {
ReactDOM.render(<SettingsInput />, document.getElementById('SettingsInput'));
}

View File

@@ -1,37 +0,0 @@
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import { Card, Button } from 'react-bootstrap';
export default class SettingsModalCard extends Component {
constructor(props) {
super(props)
this.state = {
title: this.props.title,
description: this.props.description,
toggleShow: this.props.toggleShow,
}
}
render() {
var title = this.state.title;
var description = this.state.description;
var toggleShow = this.state.toggleShow;
return (
<Card className="m-2 setting-card">
<Card.Body className="d-flex align-items-center">
<div>
<h4>{title}</h4>
<p>{description}</p>
<Button variant="primary" onClick={toggleShow}>Edit</Button>
</div>
</Card.Body>
</Card>
);
}
}
if (document.getElementById('SettingModalCard')) {
ReactDOM.render(<SettingsModalCard />, document.getElementById('SettingModalCard'));
}

View File

@@ -0,0 +1,177 @@
import Axios from 'axios';
import React, { Component } from 'react';
import { Nav, Tab, Tabs } from 'react-bootstrap';
import ReactDOM from 'react-dom';
import { toast } from 'react-toastify';
import SettingsInput from './SettingsInput';
import ResetSettings from './tabs/ResetSettings';
import BackupSettings from './tabs/BackupSettings';
import GeneralSettings from './tabs/GeneralSettings';
import GraphsSettings from './tabs/GraphsSettings';
import HealthchecksSettings from './tabs/HealthchecksSettings';
import NotificationsSettings from './tabs/NotificationsSettings';
import Authentication from '../Authentication/Authentication';
export default class SettingsTabs extends Component {
constructor(props) {
super(props)
this.state = {
tab: "General",
data: this.props.data
}
}
generateTabs = () => {
var tabs = [
'General',
'Graphs',
'Notifications',
'healthchecks.io',
'Reset',
'Backup/Restore',
];
if(window.config.auth) {
tabs.push('Authentication');
}
return tabs.map((tab) => {
return <Tab key={tab} eventKey={tab} title={tab} />
});
}
switchTab = (tab) => {
this.setState({
tab: tab
});
}
save = (settings, name) => {
var url = 'api/settings/bulk?token=' + window.token;
var data = [];
settings.forEach(e => {
if(e.type !== 'btn-get') {
var res = {
name: e.obj.name,
value: e.obj.value
};
data.push(res);
}
});
data = {
data: data
};
Axios.post(url, data)
.then((resp) => {
toast.success(name + ' settings updated');
Axios.get('api/settings/config')
.then((resp) => {
window.config = resp.data;
})
})
.catch((err) => {
if(err.response.status == 422) {
toast.error('Your input was invalid');
} else {
toast.error('Something went wrong')
}
})
}
generateInputs = (settings, handler) => {
return settings.map((setting) => {
return <SettingsInput
key={setting.obj.id}
name={setting.obj.name}
id={setting.obj.id}
type={setting.type}
value={setting.obj.value}
description={setting.obj.description}
handler={handler}
label={setting.obj.name}
description={setting.obj.description}
options={setting.type == 'select' ? setting.options : []}
hideDescription={setting.hideDescription ? setting.hideDescription : false}
min={setting.min ? setting.min : false}
min={setting.max ? setting.max : false}
btnType={setting.btnType}
inline={setting.inline}
url={setting.url}
earlyReturn={setting.earlyReturn ? true : false}
classes={setting.classes ? setting.classes : ''}
/>
})
}
getTabContent = () => {
var data = this.state.data;
switch(this.state.tab) {
case 'General':
return <GeneralSettings
data={data.General}
generateInputs={this.generateInputs}
save={this.save} />
case 'Graphs':
return <GraphsSettings
data={data.Graphs}
generateInputs={this.generateInputs}
save={this.save} />
case 'Notifications':
return <NotificationsSettings
data={data.Notifications}
generateInputs={this.generateInputs}
save={this.save} />
case 'healthchecks.io':
return <HealthchecksSettings
data={data.healthchecks}
generateInputs={this.generateInputs}
save={this.save} />
case 'Reset':
return <ResetSettings
data={data.healthchecks}
generateInputs={this.generateInputs}
save={this.save} />
case 'Backup/Restore':
return <BackupSettings
data={data.healthchecks}
generateInputs={this.generateInputs}
save={this.save} />
case 'Authentication':
return <Authentication
data={data.healthchecks}
generateInputs={this.generateInputs}
save={this.save} />
}
}
render() {
var tabs = this.generateTabs();
var activeTab = this.state.tab;
var tabContent = this.getTabContent();
return (
<div>
<Tabs
variant="tabs"
onSelect={(tab) => { this.switchTab(tab) }}
activeKey={activeTab}
>
{tabs}
</Tabs>
<div className="mt-3">
{tabContent}
</div>
</div>
);
}
}
if (document.getElementById('settingsTabs')) {
ReactDOM.render(<SettingsTabs />, document.getElementById('settingsTabs'));
}

View File

@@ -0,0 +1,26 @@
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import { Modal, Button, Tab } from 'react-bootstrap';
import Axios from 'axios';
import DataRow from '../../Data/DataRow';
export default class BackupSettings extends Component {
constructor(props) {
super(props)
this.state = {
}
}
render() {
return (
<Tab.Content>
<DataRow />
</Tab.Content>
);
}
}
if (document.getElementById('BackupSettings')) {
ReactDOM.render(<BackupSettings />, document.getElementById('BackupSettings'));
}

View File

@@ -0,0 +1,46 @@
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import { Modal, Button, Tab } from 'react-bootstrap';
import Axios from 'axios';
export default class GeneralSettings extends Component {
constructor(props) {
super(props)
this.state = {
data: this.props.data
}
}
inputHandler = (name, val) => {
var settings = this.state.data;
var i = 0;
settings.forEach(ele => {
if(ele.obj.name == name) {
ele.obj.value = val;
}
settings[i] = ele;
i++;
});
this.setState({
data: settings
});
}
render() {
var settings = this.props.generateInputs(this.state.data, this.inputHandler);
return (
<Tab.Content>
{settings}
<div className="mt-3">
<button className="btn btn-primary" onClick={() => { this.props.save(this.state.data, 'General') }}>Save</button>
</div>
</Tab.Content>
);
}
}
if (document.getElementById('GeneralSettings')) {
ReactDOM.render(<GeneralSettings />, document.getElementById('GeneralSettings'));
}

View File

@@ -0,0 +1,48 @@
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import { Modal, Button, Tab } from 'react-bootstrap';
import Axios from 'axios';
import { toast } from 'react-toastify';
import SettingsInput from '../SettingsInput';
export default class GraphsSettings extends Component {
constructor(props) {
super(props)
this.state = {
data: this.props.data
}
}
inputHandler = (name, val) => {
var settings = this.state.data;
var i = 0;
settings.forEach(ele => {
if(ele.obj.name == name) {
ele.obj.value = val;
}
settings[i] = ele;
i++;
});
this.setState({
data: settings
});
}
render() {
var settings = this.props.generateInputs(this.state.data, this.inputHandler);
return (
<Tab.Content>
{settings}
<div className="mt-3">
<button className="btn btn-primary" onClick={() => { this.props.save(this.state.data, 'General') }}>Save</button>
</div>
</Tab.Content>
);
}
}
if (document.getElementById('GraphsSettings')) {
ReactDOM.render(<GraphsSettings />, document.getElementById('GraphsSettings'));
}

View File

@@ -0,0 +1,48 @@
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import { Modal, Button, Tab } from 'react-bootstrap';
import Axios from 'axios';
import { toast } from 'react-toastify';
import SettingsInput from '../SettingsInput';
export default class HealthchecksSettings extends Component {
constructor(props) {
super(props)
this.state = {
data: this.props.data
}
}
inputHandler = (name, val) => {
var settings = this.state.data;
var i = 0;
settings.forEach(ele => {
if(ele.obj.name == name) {
ele.obj.value = val;
}
settings[i] = ele;
i++;
});
this.setState({
data: settings
});
}
render() {
var settings = this.props.generateInputs(this.state.data, this.inputHandler);
return (
<Tab.Content>
{settings}
<div className="mt-3">
<button className="btn btn-primary" onClick={() => { this.props.save(this.state.data, 'healthchecks.io') }}>Save</button>
</div>
</Tab.Content>
);
}
}
if (document.getElementById('HealthchecksSettings')) {
ReactDOM.render(<HealthchecksSettings />, document.getElementById('HealthchecksSettings'));
}

View File

@@ -0,0 +1,46 @@
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import { Modal, Button, Tab } from 'react-bootstrap';
import Axios from 'axios';
export default class NotificationsSettings extends Component {
constructor(props) {
super(props)
this.state = {
data: this.props.data
}
}
inputHandler = (name, val) => {
var settings = this.state.data;
var i = 0;
settings.forEach(ele => {
if(ele.obj.name == name) {
ele.obj.value = val;
}
settings[i] = ele;
i++;
});
this.setState({
data: settings
});
}
render() {
var settings = this.props.generateInputs(this.state.data, this.inputHandler);
return (
<Tab.Content>
{settings}
<div className="mt-3">
<button className="btn btn-primary" onClick={() => { this.props.save(this.state.data, 'Notifications') }}>Save</button>
</div>
</Tab.Content>
);
}
}
if (document.getElementById('NotificationsSettings')) {
ReactDOM.render(<NotificationsSettings />, document.getElementById('NotificationsSettings'));
}

View File

@@ -1,7 +1,6 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import { Modal, Button } from 'react-bootstrap'; import { Button } from 'react-bootstrap';
import SettingsModalCard from './SettingsModalCard';
import Axios from 'axios'; import Axios from 'axios';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -10,15 +9,6 @@ export default class ResetSettings extends Component {
super(props) super(props)
this.state = { this.state = {
show: false,
}
}
toggleShow = () => {
if(this.state.show) {
this.setState({ show: false });
} else {
this.setState({ show:true });
} }
} }
@@ -45,17 +35,9 @@ export default class ResetSettings extends Component {
return ( return (
<> <>
<SettingsModalCard title={title} description="Bulk delete speedtests from the database." toggleShow={this.toggleShow} /> <h4>Clear all speedtests</h4>
<Modal show={show} onHide={this.toggleShow}> <p className="text-muted">If using SQLite, a backup of the database will be stored in the location of the current database.</p>
<Modal.Header> <Button onClick={this.deleteAll} variant="danger">Delete all</Button>
<Modal.Title>{title}</Modal.Title>
</Modal.Header>
<Modal.Body>
<h4>Clear all speedtests</h4>
<p className="text-muted">If using SQLite, a backup of the database will be stored in the location of the current database.</p>
<Button onClick={this.deleteAll} variant="danger">Delete all</Button>
</Modal.Body>
</Modal>
</> </>
); );
} }

View File

@@ -0,0 +1,29 @@
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import TestsTable from './Graphics/TestsTable';
import Footer from './Home/Footer';
import Navbar from './Navbar';
export default class SpeedtestsPage extends Component {
constructor(props) {
super(props)
this.state = {
}
}
render() {
return (
<div>
<Navbar />
<TestsTable />
<Footer />
</div>
);
}
}
if (document.getElementById('SpeedtestsPage')) {
ReactDOM.render(<SpeedtestsPage />, document.getElementById('SpeedtestsPage'));
}

View File

@@ -8,6 +8,8 @@ import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css'; import 'react-toastify/dist/ReactToastify.css';
import HomePage from './components/Home/HomePage'; import HomePage from './components/Home/HomePage';
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import SettingsIndex from './components/Settings/SettingsIndex';
import SpeedtestsPage from './components/SpeedtestsPage';
export default class Index extends Component { export default class Index extends Component {
constructor(props) { constructor(props) {
@@ -84,6 +86,18 @@ export default class Index extends Component {
<HomePage /> <HomePage />
</div> </div>
)} /> )} />
<Route exact path={window.config.base + 'speedtests'} render={(props) => (
<div>
<SpeedtestsPage />
</div>
)} />
<Route exact path={window.config.base + 'settings'} render={(props) => (
<div>
<SettingsIndex />
</div>
)} />
<Route exact path={window.config.base + "error/:code"} render={(props) => ( <ErrorPage code={props.match.params.code} /> )} /> <Route exact path={window.config.base + "error/:code"} render={(props) => ( <ErrorPage code={props.match.params.code} /> )} />
<Route render={(props) => (<ErrorPage code="404" />)} /> <Route render={(props) => (<ErrorPage code="404" />)} />
</Switch> </Switch>

View File

@@ -16,20 +16,20 @@ use Illuminate\Support\Facades\Route;
| |
*/ */
Route::get(SettingsHelper::getBase() . 'files/{path?}', function($file) { Route::get(SettingsHelper::getBase() . 'files/{path?}', function ($file) {
$fileP = explode('?', $file)[0]; $fileP = explode('?', $file)[0];
$fileP = public_path() . '/' . $fileP; $fileP = public_path() . '/' . $fileP;
if(file_exists($fileP)) { if (file_exists($fileP)) {
$contents = File::get($fileP); $contents = File::get($fileP);
$mime = \GuzzleHttp\Psr7\mimetype_from_filename($fileP); $mime = \GuzzleHttp\Psr7\mimetype_from_filename($fileP);
return Response::make(File::get($fileP), 200, [ 'Content-type' => $mime ]); return Response::make(File::get($fileP), 200, ['Content-type' => $mime]);
} else { } else {
abort(404); abort(404);
} }
})->where('path', '.*') })->where('path', '.*')
->name('files'); ->name('files');
Route::get('/{path?}', function() { Route::get('/{path?}', function () {
return view('app', [ 'title' => 'Speedtest Tracker' ]); return view('app', ['title' => SettingsHelper::get('app_name')->value]);
})->where('path', '^((?!\/api\/).)*$') })->where('path', '^((?!\/api\/).)*$')
->name('react'); ->name('react');