6 - Send ntfy alert when poll failures cross threshold
This commit is contained in:
parent
8d063a8262
commit
6ab175a466
7 changed files with 183 additions and 9 deletions
|
|
@ -63,3 +63,7 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
|
|
||||||
CRAWLER_MIN_DOMAIN_DELAY_SECONDS=10
|
CRAWLER_MIN_DOMAIN_DELAY_SECONDS=10
|
||||||
|
|
||||||
|
NTFY_URL=
|
||||||
|
NTFY_TOPIC=
|
||||||
|
NTFY_THRESHOLD=
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Http\Controllers\Admin;
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,38 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
use Lvl0\FediDiscover\Models\Instance;
|
use Lvl0\FediDiscover\Models\Instance;
|
||||||
|
|
||||||
class PollAlertService
|
class PollAlertService
|
||||||
{
|
{
|
||||||
public function recordFailure(Instance $instance): void
|
public function recordFailure(Instance $instance, string $message): void
|
||||||
{
|
{
|
||||||
$instance->increment('consecutive_poll_failures');
|
$instance->increment('consecutive_poll_failures');
|
||||||
|
$instance->refresh();
|
||||||
|
|
||||||
|
$ntfyUrl = config('services.ntfy.url');
|
||||||
|
$ntfyThreshold = (int) config('services.ntfy.threshold');
|
||||||
|
$ntfyTopic = config('services.ntfy.topic');
|
||||||
|
|
||||||
|
if ($ntfyUrl === null || $ntfyThreshold === 0 || $ntfyTopic === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($instance->consecutive_poll_failures < $ntfyThreshold) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Http::timeout(5)
|
||||||
|
->withBody($instance->url . ' - ' . $message, 'text/plain')
|
||||||
|
->post(rtrim($ntfyUrl, '/') . '/' . $ntfyTopic);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
logger()->warning('ntfy alert failed', ['instance' => $instance->url, 'error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,12 @@
|
||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
'ntfy' => [
|
||||||
|
'url' => env('NTFY_URL') ?: null,
|
||||||
|
'topic' => env('NTFY_TOPIC') ?: null,
|
||||||
|
'threshold' => env('NTFY_THRESHOLD'),
|
||||||
|
],
|
||||||
|
|
||||||
'postmark' => [
|
'postmark' => [
|
||||||
'key' => env('POSTMARK_API_KEY'),
|
'key' => env('POSTMARK_API_KEY'),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -61,5 +61,4 @@ public function pages(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(Page::class);
|
return $this->hasMany(Page::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
namespace Tests\Feature\Admin;
|
namespace Tests\Feature\Admin;
|
||||||
|
|
||||||
|
use App\Enums\PageStatusEnum;
|
||||||
use App\Models\Page;
|
use App\Models\Page;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Lvl0\FediDiscover\Config\InstanceType;
|
use Lvl0\FediDiscover\Config\InstanceType;
|
||||||
|
|
@ -63,23 +64,23 @@ public function test_admin_instances_page_shows_error_count_per_instance(): void
|
||||||
Page::factory()
|
Page::factory()
|
||||||
->count(3)
|
->count(3)
|
||||||
->sequence(fn ($s) => ['url' => "https://aardvark.example/fail-{$s->index}"])
|
->sequence(fn ($s) => ['url' => "https://aardvark.example/fail-{$s->index}"])
|
||||||
->createQuietly(['instance_id' => $first->id, 'status' => \App\Enums\PageStatusEnum::Failed]);
|
->createQuietly(['instance_id' => $first->id, 'status' => PageStatusEnum::Failed]);
|
||||||
|
|
||||||
Page::factory()
|
Page::factory()
|
||||||
->count(2)
|
->count(2)
|
||||||
->sequence(fn ($s) => ['url' => "https://aardvark.example/ok-{$s->index}"])
|
->sequence(fn ($s) => ['url' => "https://aardvark.example/ok-{$s->index}"])
|
||||||
->createQuietly(['instance_id' => $first->id, 'status' => \App\Enums\PageStatusEnum::Fetched]);
|
->createQuietly(['instance_id' => $first->id, 'status' => PageStatusEnum::Fetched]);
|
||||||
|
|
||||||
// Second instance: 1 failed + 4 non-failed pages
|
// Second instance: 1 failed + 4 non-failed pages
|
||||||
Page::factory()
|
Page::factory()
|
||||||
->count(1)
|
->count(1)
|
||||||
->sequence(fn ($s) => ['url' => "https://zebra.example/fail-{$s->index}"])
|
->sequence(fn ($s) => ['url' => "https://zebra.example/fail-{$s->index}"])
|
||||||
->createQuietly(['instance_id' => $second->id, 'status' => \App\Enums\PageStatusEnum::Failed]);
|
->createQuietly(['instance_id' => $second->id, 'status' => PageStatusEnum::Failed]);
|
||||||
|
|
||||||
Page::factory()
|
Page::factory()
|
||||||
->count(4)
|
->count(4)
|
||||||
->sequence(fn ($s) => ['url' => "https://zebra.example/ok-{$s->index}"])
|
->sequence(fn ($s) => ['url' => "https://zebra.example/ok-{$s->index}"])
|
||||||
->createQuietly(['instance_id' => $second->id, 'status' => \App\Enums\PageStatusEnum::Fetched]);
|
->createQuietly(['instance_id' => $second->id, 'status' => PageStatusEnum::Fetched]);
|
||||||
|
|
||||||
$response = $this->get('/admin/instances');
|
$response = $this->get('/admin/instances');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
|
|
||||||
use App\Services\PollAlertService;
|
use App\Services\PollAlertService;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
use Lvl0\FediDiscover\Config\InstanceType;
|
use Lvl0\FediDiscover\Config\InstanceType;
|
||||||
use Lvl0\FediDiscover\Models\Instance;
|
use Lvl0\FediDiscover\Models\Instance;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
@ -14,19 +15,157 @@ class PollAlertServiceTest extends TestCase
|
||||||
{
|
{
|
||||||
use RefreshDatabase;
|
use RefreshDatabase;
|
||||||
|
|
||||||
public function test_recordFailure_increments_consecutive_poll_failures_on_the_instance(): void
|
public function test_record_failure_increments_consecutive_poll_failures_on_the_instance(): void
|
||||||
{
|
{
|
||||||
$instance = Instance::factory()
|
$instance = Instance::factory()
|
||||||
->type(InstanceType::Mastodon)
|
->type(InstanceType::Mastodon)
|
||||||
->enabled()
|
->enabled()
|
||||||
->create(['consecutive_poll_failures' => 0]);
|
->create(['consecutive_poll_failures' => 0]);
|
||||||
|
|
||||||
$service = new PollAlertService();
|
$service = new PollAlertService;
|
||||||
$service->recordFailure($instance);
|
$service->recordFailure($instance, 'test');
|
||||||
|
|
||||||
$this->assertDatabaseHas('fedi_discover_instances', [
|
$this->assertDatabaseHas('fedi_discover_instances', [
|
||||||
'id' => $instance->id,
|
'id' => $instance->id,
|
||||||
'consecutive_poll_failures' => 1,
|
'consecutive_poll_failures' => 1,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_no_alert_sent_below_threshold(): void
|
||||||
|
{
|
||||||
|
Http::fake();
|
||||||
|
|
||||||
|
config([
|
||||||
|
'services.ntfy.url' => 'https://ntfy.example.com',
|
||||||
|
'services.ntfy.topic' => 'trove-alerts',
|
||||||
|
'services.ntfy.threshold' => 3,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$instance = Instance::factory()
|
||||||
|
->type(InstanceType::Mastodon)
|
||||||
|
->enabled()
|
||||||
|
->create(['consecutive_poll_failures' => 1]); // will become 2 after recordFailure
|
||||||
|
|
||||||
|
$service = new PollAlertService;
|
||||||
|
$service->recordFailure($instance, 'test');
|
||||||
|
|
||||||
|
Http::assertNothingSent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_alert_sent_when_threshold_is_reached(): void
|
||||||
|
{
|
||||||
|
Http::fake();
|
||||||
|
|
||||||
|
config([
|
||||||
|
'services.ntfy.url' => 'https://ntfy.example.com',
|
||||||
|
'services.ntfy.topic' => 'trove-alerts',
|
||||||
|
'services.ntfy.threshold' => 3,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$instance = Instance::factory()
|
||||||
|
->type(InstanceType::Mastodon)
|
||||||
|
->enabled()
|
||||||
|
->create(['consecutive_poll_failures' => 2]); // will become 3 after recordFailure = exactly at threshold
|
||||||
|
|
||||||
|
$service = new PollAlertService;
|
||||||
|
$service->recordFailure($instance, 'test');
|
||||||
|
|
||||||
|
Http::assertSent(function ($request) {
|
||||||
|
return $request->url() === 'https://ntfy.example.com/trove-alerts'
|
||||||
|
&& $request->method() === 'POST';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_alert_sent_when_count_exceeds_threshold(): void
|
||||||
|
{
|
||||||
|
Http::fake();
|
||||||
|
|
||||||
|
config([
|
||||||
|
'services.ntfy.url' => 'https://ntfy.example.com',
|
||||||
|
'services.ntfy.topic' => 'trove-alerts',
|
||||||
|
'services.ntfy.threshold' => 3,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$instance = Instance::factory()
|
||||||
|
->type(InstanceType::Mastodon)
|
||||||
|
->enabled()
|
||||||
|
->create(['consecutive_poll_failures' => 3]); // will become 4 after recordFailure = above threshold
|
||||||
|
|
||||||
|
$service = new PollAlertService;
|
||||||
|
$service->recordFailure($instance, 'test');
|
||||||
|
|
||||||
|
Http::assertSent(function ($request) {
|
||||||
|
return $request->url() === 'https://ntfy.example.com/trove-alerts'
|
||||||
|
&& $request->method() === 'POST';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_no_alert_sent_when_threshold_is_zero(): void
|
||||||
|
{
|
||||||
|
Http::fake();
|
||||||
|
|
||||||
|
config([
|
||||||
|
'services.ntfy.url' => 'https://ntfy.example.com',
|
||||||
|
'services.ntfy.topic' => 'trove-alerts',
|
||||||
|
'services.ntfy.threshold' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$instance = Instance::factory()
|
||||||
|
->type(InstanceType::Mastodon)
|
||||||
|
->enabled()
|
||||||
|
->create(['consecutive_poll_failures' => 5]);
|
||||||
|
|
||||||
|
$service = new PollAlertService;
|
||||||
|
$service->recordFailure($instance, 'test');
|
||||||
|
|
||||||
|
Http::assertNothingSent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_no_alert_sent_when_topic_is_null(): void
|
||||||
|
{
|
||||||
|
Http::fake();
|
||||||
|
|
||||||
|
config([
|
||||||
|
'services.ntfy.url' => 'https://ntfy.example.com',
|
||||||
|
'services.ntfy.topic' => null,
|
||||||
|
'services.ntfy.threshold' => 3,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$instance = Instance::factory()
|
||||||
|
->type(InstanceType::Mastodon)
|
||||||
|
->enabled()
|
||||||
|
->create(['consecutive_poll_failures' => 2]); // will become 3 after recordFailure = at threshold
|
||||||
|
|
||||||
|
$service = new PollAlertService;
|
||||||
|
$service->recordFailure($instance, 'test');
|
||||||
|
|
||||||
|
Http::assertNothingSent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_alert_body_contains_instance_url_and_message(): void
|
||||||
|
{
|
||||||
|
Http::fake();
|
||||||
|
|
||||||
|
config([
|
||||||
|
'services.ntfy.url' => 'https://ntfy.example.com',
|
||||||
|
'services.ntfy.topic' => 'trove-alerts',
|
||||||
|
'services.ntfy.threshold' => 3,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$instance = Instance::factory()
|
||||||
|
->type(InstanceType::Mastodon)
|
||||||
|
->enabled()
|
||||||
|
->create([
|
||||||
|
'url' => 'https://mastodon.social',
|
||||||
|
'consecutive_poll_failures' => 2, // will become 3 = at threshold
|
||||||
|
]);
|
||||||
|
|
||||||
|
$service = new PollAlertService;
|
||||||
|
$service->recordFailure($instance, 'connection refused after 3 retries');
|
||||||
|
|
||||||
|
Http::assertSent(function ($request) {
|
||||||
|
return str_contains($request->body(), 'https://mastodon.social')
|
||||||
|
&& str_contains($request->body(), 'connection refused after 3 retries');
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue