Health Checks for Links

This commit is contained in:
Erik Yeoh 2020-06-17 16:47:54 +08:00
parent 045ecaaf7d
commit 7d7210424f
19 changed files with 606 additions and 5 deletions

View File

@ -0,0 +1,54 @@
<?php
namespace App\Console\Commands;
use App\Link;
use App\Services\Shaark\Shaark;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Console\Command;
class LinkHealthChecks extends Command
{
protected $signature = 'shaark:link_health_check {--all}';
protected $description = 'Run health checks on links';
public function __construct()
{
parent::__construct();
}
public function handle()
{
$check_all_links = $this->option('all');
$links = Link::where('http_checked_at', '<', now()->subDays(app(Shaark::class)->getLinkHealthChecksAge()))
->orWhereNull('http_checked_at')
->orderBy('http_checked_at', 'ASC');
if (! $check_all_links) {
$links->limit(20);
}
$links->get()
->each(function (Link $link) {
try {
$response = (new Client())->request('GET', $link->getUrlAttribute(), [
'headers' => [
'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.106 Safari/537.36',
],
'http_errors' => false,
'timeout' => 5,
]);
$link->http_status = $response->getStatusCode();
} catch (RequestException $exception) {
// Might happen when the domain as expired
$link->http_status = 500;
} finally {
$link->http_checked_at = now();
$link->save();
}
});
}
}

View File

@ -30,6 +30,9 @@ class Kernel extends ConsoleKernel
// Make backup
$this->scheduleBackup($schedule);
// Link health checks
$this->scheduleLinkHealthChecks($schedule);
}
protected function scheduleBackup(Schedule $schedule): self
@ -54,4 +57,15 @@ class Kernel extends ConsoleKernel
return $this;
}
protected function scheduleLinkHealthChecks(Schedule $schedule): self
{
$shaark = app(Shaark::class);
if (false === $shaark->getLinkHealthChecksEnabled()) {
return $this;
}
$schedule->command('shaark:link_health_check')->everyTenMinutes();
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers\Manage;
use App\Http\Controllers\Controller;
use App\Link;
use App\Services\HealthCheckStats;
use App\Services\Shaark\Shaark;
class LinksController extends Controller
{
public function __construct()
{
$this->middleware('auth');
$this->middleware('demo')->except('view');
}
public function view()
{
$stats = new HealthCheckStats();
return view('manage.links')->with([
'page_title' => __('Links'),
'num_total' => $stats->countTotal(),
'health_checks_enabled' => app(Shaark::class)->getLinkHealthChecksEnabled(),
'num_pending' => $stats->countPending(),
'num_healthy' => $stats->countHealthy(),
'num_other' => $stats->countOther(),
'num_dead' => $stats->countDead(),
]);
}
public function viewDead()
{
return view('manage.links_dead')->with([
'page_title' => __('Dead Links'),
'dead_links' => Link::whereBetween('http_status', [400, 499])->orderBy('http_checked_at', 'DESC')->paginate(10),
]);
}
public function viewOther()
{
return view('manage.links_other')->with([
'page_title' => __('Other Status Links'),
'other_links' => Link::whereBetween('http_status', [300, 399])
->orWhereBetween('http_status', [500, 599])
->orderBy('http_checked_at', 'DESC')->paginate(10),
]);
}
}

View File

@ -0,0 +1,64 @@
<?php
namespace App\Services;
use App\Services\Shaark\Shaark;
class HealthCheckStats
{
/** @var \Illuminate\Support\Collection $stats */
public $stats;
public function __construct()
{
$this->stats = \DB::table('links')
->select('http_status', \DB::raw('count(id) as num_count'))
->groupBy('http_status')
->get();
}
public function get()
{
return [
'num_healthy' => $this->countHealthy(),
'num_other' => $this->countOther(),
'num_dead' => $this->countDead(),
'num_pending' => $this->countPending(),
];
}
public function countTotal()
{
return $this->stats->sum('num_count');
}
public function countHealthy()
{
return $this->stats->where('http_status', 200)->sum('num_count');
}
public function countOther()
{
$redirects = $this->stats->whereBetween('http_status', [300, 399])
->sum('num_count');
$server_errors = $this->stats->whereBetween('http_status', [500, 599])
->sum('num_count');
return $redirects + $server_errors;
}
public function countDead()
{
return $this->stats->whereBetween('http_status', [400, 499])
->sum('num_count');
}
public function countPending()
{
return \DB::table('links')
->where('http_checked_at', '<', now()->subDays(app(Shaark::class)->getLinkHealthChecksAge()))
->orWhereNull('http_checked_at')
->count();
}
}

View File

@ -27,6 +27,7 @@
"php": "^7.2",
"doctrine/dbal": "^2.9",
"fideloper/proxy": "^4.0",
"guzzlehttp/guzzle": "^6.5",
"hashids/hashids": "^2.0.4|~3.0",
"lab404/laravel-auth-checker": "^1.4",
"laravel/framework": "^6.0",

123
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "6a1c0803587a9053ccab074ee0d36977",
"content-hash": "5f8b851e33ca8323793fce95286f8db4",
"packages": [
{
"name": "clue/socket-raw",
@ -689,6 +689,124 @@
],
"time": "2019-12-12T13:22:17+00:00"
},
{
"name": "guzzlehttp/guzzle",
"version": "6.5.3",
"source": {
"type": "git",
"url": "https://github.com/guzzle/guzzle.git",
"reference": "aab4ebd862aa7d04f01a4b51849d657db56d882e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/guzzle/guzzle/zipball/aab4ebd862aa7d04f01a4b51849d657db56d882e",
"reference": "aab4ebd862aa7d04f01a4b51849d657db56d882e",
"shasum": ""
},
"require": {
"ext-json": "*",
"guzzlehttp/promises": "^1.0",
"guzzlehttp/psr7": "^1.6.1",
"php": ">=5.5",
"symfony/polyfill-intl-idn": "^1.11"
},
"require-dev": {
"ext-curl": "*",
"phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.4 || ^7.0",
"psr/log": "^1.1"
},
"suggest": {
"psr/log": "Required for using the Log middleware"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "6.5-dev"
}
},
"autoload": {
"psr-4": {
"GuzzleHttp\\": "src/"
},
"files": [
"src/functions_include.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Michael Dowling",
"email": "mtdowling@gmail.com",
"homepage": "https://github.com/mtdowling"
}
],
"description": "Guzzle is a PHP HTTP client library",
"homepage": "http://guzzlephp.org/",
"keywords": [
"client",
"curl",
"framework",
"http",
"http client",
"rest",
"web service"
],
"time": "2020-04-18T10:38:46+00:00"
},
{
"name": "guzzlehttp/promises",
"version": "v1.3.1",
"source": {
"type": "git",
"url": "https://github.com/guzzle/promises.git",
"reference": "a59da6cf61d80060647ff4d3eb2c03a2bc694646"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/guzzle/promises/zipball/a59da6cf61d80060647ff4d3eb2c03a2bc694646",
"reference": "a59da6cf61d80060647ff4d3eb2c03a2bc694646",
"shasum": ""
},
"require": {
"php": ">=5.5.0"
},
"require-dev": {
"phpunit/phpunit": "^4.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.4-dev"
}
},
"autoload": {
"psr-4": {
"GuzzleHttp\\Promise\\": "src/"
},
"files": [
"src/functions_include.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Michael Dowling",
"email": "mtdowling@gmail.com",
"homepage": "https://github.com/mtdowling"
}
],
"description": "Guzzle promises library",
"keywords": [
"promise"
],
"time": "2016-12-20T10:07:11+00:00"
},
{
"name": "guzzlehttp/psr7",
"version": "1.6.1",
@ -7594,5 +7712,6 @@
"platform": {
"php": "^7.2"
},
"platform-dev": []
"platform-dev": [],
"plugin-api-version": "1.1.0"
}

View File

@ -142,5 +142,13 @@ return [
'default' => 'whitelist',
'rules' => ['nullable', 'in:disabled,whitelist,all']
],
'link_health_checks_enabled' => [
'default' => false,
'rules' => ['nullable', 'in:on,off']
],
'link_health_checks_age' => [
'default' => '7',
'rules' => ['required', 'numeric', 'min:7', 'max:365']
],
],
];

View File

@ -5,9 +5,25 @@ use App\Link;
use Faker\Generator as Faker;
$factory->define(Link::class, function (Faker $faker) {
$http_status_codes = [
null,
200,
302,
404,
500,
];
$http_status_code = $http_status_codes[rand(0, 4)];
$http_checked_at = null;
if (! is_null($http_status_code)) {
$http_checked_at = $faker->dateTimeThisMonth;
}
return [
'title' => $faker->sentence,
'content' => $faker->paragraph,
'url' => $faker->url,
'http_status' => $http_status_code,
'http_checked_at' => $http_checked_at,
];
});

View File

@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddHealthChecksToLinksTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('links', function (Blueprint $table) {
$table->unsignedSmallInteger('http_status')->after('url')->nullable();
$table->timestamp('http_checked_at')->after('http_status')->nullable();
$table->index('http_status');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('links', function (Blueprint $table) {
$table->dropColumn([
'http_status',
'http_checked_at',
]);
});
}
}

View File

@ -0,0 +1,16 @@
<?php
use Illuminate\Database\Seeder;
class LinkSeeder extends Seeder
{
/**
* Run the database seeds.
*
* @return void
*/
public function run()
{
factory(\App\Link::class, 100)->create();
}
}

View File

@ -76,7 +76,13 @@ return [
'disabled' => 'Disabled',
'whitelist' => 'White-listing',
'all' => 'All',
]
],
'links' => [
'title' => 'Link Health Checks',
'health_checks_enabled' => 'Enable health checks',
'health_checks_age' => 'Number of days between checks for each link',
],
],
// Mails

View File

@ -25,6 +25,8 @@
"Whoops, something went wrong on our servers.": "サーバー上にエラーが発生してしまった。",
"Yes": "はい",
"No": "いいえ",
"Enabled": "有効",
"Disabled": "無効",
"Previous": "前",
"Next": "次",
@ -240,5 +242,17 @@
"Source code": "ソースコード",
"RSS Feed": "RSSフィード",
"Atom Feed": "ATOMフィード",
"All new content of :title": ":titleの最新コンテンツ"
"All new content of :title": ":titleの最新コンテンツ",
"Total": "合計",
"Health Checks": "健康チェック",
"Pending Checks": "チェック待ち",
"Healthy (200)": "健康(200)",
"Dead (4xx)": "切れxx",
"Other (3xx, 5xx)": "他xx、xx",
"Dead Links": "切れたURL",
"No dead links found": "切れたURLはありません",
"Other-status Links": "他ステータスのURL",
"No other-status links found": "他ステータスのURLはありません",
"Last Checked": "最後のチェック"
}

View File

@ -75,7 +75,13 @@ return [
'disabled' => '無効',
'whitelist' => 'ホワイトリスト',
'all' => 'すべて',
]
],
'links' => [
'title' => 'URLの健康チェック',
'health_checks_enabled' => '健康チェックを有効にしますか?',
'health_checks_age' => '各URLの次のチェックを行うまで何日待ちますか?',
],
],
// Mails

View File

@ -15,6 +15,9 @@
class="list-group-item list-group-item-action{{ request()->is('manage/settings') ? ' active' : '' }}"
><i class="fas fa-fw fa-cogs mr-1"></i> {{ __('Settings') }}
</a>
<a href="{{ route('manage.links') }}"
class="list-group-item list-group-item-action{{ request()->is('manage/links') ? ' active' : '' }}"
><i class="fas fa-fw fa-link mr-1"></i> {{ __('Links') }}</a>
<a href="{{ route('manage.walls') }}"
class="list-group-item list-group-item-action{{ request()->is('manage/walls') ? ' active' : '' }}"
><i class="fas fa-fw fa-bookmark mr-1"></i> {{ __('Walls') }}</a>

View File

@ -0,0 +1,70 @@
@extends('layouts.manage')
@section('content')
<div class="row justify-content-center">
<div class="col-12">
<div class="row text-uppercase">
<div class="col-6 col-lg-4">
<div class="card">
<div class="card-body">
<small class="card-title font-weight-bold text-muted">{{ __('Total') }}</small>
<h3 class="text-right text-primary">{{ number_format($num_total, 0) }}</h3>
</div>
</div>
</div>
<div class="col-6 col-lg-4">
<div class="card">
<div class="card-body">
<small class="card-title font-weight-bold text-muted">{{ __('Health Checks') }}</small>
@if ($health_checks_enabled)
<h3 class="text-right text-capitalize text-success">{{ __('Enabled') }}</h3>
@else
<h3 class="text-right text-capitalize text-muted">{{ __('Disabled') }}</h3>
@endif
</div>
</div>
</div>
<div class="col-6 mt-4 col-lg-4 mt-lg-0">
<div class="card">
<div class="card-body">
<small class="card-title font-weight-bold text-muted">{{ __('Pending Checks') }}</small>
<h3 class="text-right text-info">{{ number_format($num_pending, 0) }}</h3>
</div>
</div>
</div>
<div class="col-6 mt-4 col-lg-4">
<div class="card">
<div class="card-body">
<small class="card-title font-weight-bold text-muted">{{ __('Healthy (200)') }}</small>
<h3 class="text-right text-success">{{ number_format($num_healthy, 0) }}</h3>
</div>
</div>
</div>
<div class="col-6 mt-4 col-lg-4">
<div class="card">
<div class="card-body">
<small class="card-title font-weight-bold text-muted">{{ __('Dead (4xx)') }}</small>
<h3 class="text-right text-danger">{{ number_format($num_dead, 0) }}</h3>
<a href="{{ route('manage.links.dead.view') }}" class="stretched-link"></a>
</div>
</div>
</div>
<div class="col-6 mt-4 col-lg-4">
<div class="card">
<div class="card-body">
<small class="card-title font-weight-bold text-muted">{{ __('Other (3xx, 5xx)') }}</small>
<h3 class="text-right text-muted">{{ number_format($num_other, 0) }}</h3>
<a href="{{ route('manage.links.other.view') }}" class="stretched-link"></a>
</div>
</div>
</div>
</div>
</div>
</div>
@endsection

View File

@ -0,0 +1,46 @@
@extends('layouts.manage')
@section('content')
<div class="row justify-content-center">
<div class="col-12">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span>
<i class="fas fa-link mr-1"></i> {{ __('Dead Links') }}
</span>
</div>
<div class="card-body">
<div class="table-responsive">
@if ($dead_links->isEmpty())
<div class="alert alert-info">
{{ __('No dead links found') }}
</div>
@else
<table class="table table-borderless table-sm">
<thead>
<tr>
<th>{{ __('Title') }}</th>
<th>{{ __('Status') }}</th>
<th>{{ __('Last Checked') }}</th>
</tr>
</thead>
<tbody>
@foreach ($dead_links as $dead_link)
<tr>
<td><a href="{{ $dead_link->permalink }}" target="_blank">{{ $dead_link->title }}</a></td>
<td>{{ $dead_link->http_status }}</td>
<td>{{ $dead_link->http_checked_at }}</td>
</tr>
@endforeach
</tbody>
</table>
{{ $dead_links->links() }}
@endif
</div>
</div>
</div>
</div>
</div>
@endsection

View File

@ -0,0 +1,46 @@
@extends('layouts.manage')
@section('content')
<div class="row justify-content-center">
<div class="col-12">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span>
<i class="fas fa-link mr-1"></i> {{ __('Other-status Links') }}
</span>
</div>
<div class="card-body">
<div class="table-responsive">
@if ($other_links->isEmpty())
<div class="alert alert-info">
{{ __('No other-status links found') }}
</div>
@else
<table class="table table-borderless table-sm">
<thead>
<tr>
<th>{{ __('Title') }}</th>
<th>{{ __('Status') }}</th>
<th>{{ __('Last Checked') }}</th>
</tr>
</thead>
<tbody>
@foreach ($other_links as $other_link)
<tr>
<td><a href="{{ $other_link->permalink }}" target="_blank">{{ $other_link->title }}</a></td>
<td>{{ $other_link->http_status }}</td>
<td>{{ $other_link->http_checked_at }}</td>
</tr>
@endforeach
</tbody>
</table>
{{ $other_links->links() }}
@endif
</div>
</div>
</div>
</div>
</div>
@endsection

View File

@ -387,6 +387,30 @@
</div>
</div>
<div class="card mt-4" id="links">
<div class="card-header">{{ __('shaark.settings.links.title') }}</div>
<div class="card-body">
<div class="form-group">
<div class="custom-control custom-switch">
<input type="checkbox" class="custom-control-input"
name="link_health_checks_enabled" id="link_health_checks_enabled" {{ old('link_health_checks_enabled', $settings['link_health_checks_enabled']) ? ' checked' : '' }}>
<label class="custom-control-label" for="link_health_checks_enabled">{{ __('shaark.settings.links.health_checks_enabled') }}</label>
</div>
@error('link_health_checks_enabled')
<span class="text-danger" role="alert">{{ $message }}</span>
@enderror
</div>
<div class="form-group">
<label for="name">{{ __('shaark.settings.links.health_checks_age') }}</label>
<input type="number" class="form-control {{ $errors->has('link_health_checks_age') ? ' is-invalid' : '' }}" step="1" min="7" max="365"
name="link_health_checks_age" id="link_health_checks_age" value="{{ old('link_health_checks_age', $settings['link_health_checks_age']) }}">
@error('link_health_checks_age')
<span class="invalid-feedback" role="alert">{{ $message }}</span>
@enderror
</div>
</div>
</div>
<div class="card mt-4">
<div class="card-body">
<button type="submit" class="btn btn-primary">{{ __('Save') }}</button>

View File

@ -60,6 +60,11 @@ Route::group([
$router->get('walls', 'WallsController@view')->name('walls');
$router->get('links', 'LinksController@view')->name('links');
$router->post('links', 'LinksController@store');
$router->get('links/dead', 'LinksController@viewDead')->name('links.dead.view');
$router->get('links/other', 'LinksController@viewOther')->name('links.other.view');
$router->get('archives', 'ArchivesController@view')->name('archives');
$router->get('settings', 'SettingsController@form')->name('settings');