From 5bf92c56b9723c9f8f5b19a104c3084368b7b8ca Mon Sep 17 00:00:00 2001 From: Tom Schlick Date: Thu, 24 Sep 2026 14:15:15 -0400 Subject: [PATCH] Pause monitors without disabling them or letting later checks resume them. --- app/Actions/DispatchMonitorCheck.php | 5 + app/Actions/PauseMonitor.php | 42 +++++++ app/Actions/ReceiveHeartbeat.php | 5 + app/Actions/ResumeMonitor.php | 43 +++++++ .../Resources/Monitors/Pages/ViewMonitor.php | 36 +++++- app/GraphQL/Mutations/PauseMonitor.php | 25 ++++ app/GraphQL/Mutations/ResumeMonitor.php | 25 ++++ app/Http/Controllers/HeartbeatController.php | 8 ++ app/Jobs/RunCheckJob.php | 3 +- graphql/schema.graphql | 2 + .../Actions/DispatchMonitorCheckTest.php | 14 +++ tests/Feature/Actions/PauseMonitorTest.php | 111 ++++++++++++++++++ tests/Feature/Filament/PauseMonitorTest.php | 40 +++++++ tests/Feature/GraphQL/PauseMonitorTest.php | 36 ++++++ tests/Feature/HeartbeatTest.php | 18 +++ tests/Feature/Jobs/RunCheckJobTest.php | 23 ++++ 16 files changed, 432 insertions(+), 4 deletions(-) create mode 100644 app/Actions/PauseMonitor.php create mode 100644 app/Actions/ResumeMonitor.php create mode 100644 app/GraphQL/Mutations/PauseMonitor.php create mode 100644 app/GraphQL/Mutations/ResumeMonitor.php create mode 100644 tests/Feature/Actions/PauseMonitorTest.php create mode 100644 tests/Feature/Filament/PauseMonitorTest.php create mode 100644 tests/Feature/GraphQL/PauseMonitorTest.php create mode 100644 tests/Feature/Jobs/RunCheckJobTest.php diff --git a/app/Actions/DispatchMonitorCheck.php b/app/Actions/DispatchMonitorCheck.php index 379fe70..ebb2e31 100644 --- a/app/Actions/DispatchMonitorCheck.php +++ b/app/Actions/DispatchMonitorCheck.php @@ -4,6 +4,7 @@ namespace App\Actions; +use App\Enums\MonitorStatus; use App\Enums\MonitorType; use App\Jobs\RunCheckJob; use App\Models\Monitor; @@ -17,6 +18,10 @@ public function handle(Monitor $monitor, bool $saved = false): int { + if ($monitor->status === MonitorStatus::Paused) { + return 0; + } + if ($saved && (! $monitor->enabled || ! $monitor->type->usesOutboundProbe())) { return 0; } diff --git a/app/Actions/PauseMonitor.php b/app/Actions/PauseMonitor.php new file mode 100644 index 0000000..027cf01 --- /dev/null +++ b/app/Actions/PauseMonitor.php @@ -0,0 +1,42 @@ +status === MonitorStatus::Paused) { + return $monitor; + } + + $previous = $monitor->status; + $monitor->status = MonitorStatus::Paused; + $monitor->last_status_changed_at = now(); + $monitor->save(); + + $this->broadcast($monitor, $previous); + + return $monitor; + } + + private function broadcast(Monitor $monitor, MonitorStatus $previous): void + { + try { + MonitorStatusUpdated::dispatch($monitor, $previous); + } catch (BroadcastException $exception) { + report($exception); + } + } +} diff --git a/app/Actions/ReceiveHeartbeat.php b/app/Actions/ReceiveHeartbeat.php index 4b73b9a..4255c3c 100644 --- a/app/Actions/ReceiveHeartbeat.php +++ b/app/Actions/ReceiveHeartbeat.php @@ -6,6 +6,7 @@ use App\Checking\ProbeResult; use App\Enums\HeartbeatSignal; +use App\Enums\MonitorStatus; use App\Models\CheckResult; use App\Models\Monitor; use ReturnEarly\ActionsPattern\Interfaces\ActionsPatternInterface; @@ -21,6 +22,10 @@ public function __construct( public function handle(Monitor $monitor, HeartbeatSignal $signal = HeartbeatSignal::Finish, ?int $latencyMs = null): ?CheckResult { + if ($monitor->status === MonitorStatus::Paused) { + return null; + } + if ($signal === HeartbeatSignal::Start) { $monitor->heartbeat_started_at = now(); $monitor->scheduleNextCheck(); diff --git a/app/Actions/ResumeMonitor.php b/app/Actions/ResumeMonitor.php new file mode 100644 index 0000000..f67e7e1 --- /dev/null +++ b/app/Actions/ResumeMonitor.php @@ -0,0 +1,43 @@ +status !== MonitorStatus::Paused) { + return $monitor; + } + + $previous = $monitor->status; + $monitor->status = MonitorStatus::Pending; + $monitor->next_check_at = now(); + $monitor->last_status_changed_at = now(); + $monitor->save(); + + $this->broadcast($monitor, $previous); + + return $monitor; + } + + private function broadcast(Monitor $monitor, MonitorStatus $previous): void + { + try { + MonitorStatusUpdated::dispatch($monitor, $previous); + } catch (BroadcastException $exception) { + report($exception); + } + } +} diff --git a/app/Filament/Resources/Monitors/Pages/ViewMonitor.php b/app/Filament/Resources/Monitors/Pages/ViewMonitor.php index b6fbb48..9735447 100644 --- a/app/Filament/Resources/Monitors/Pages/ViewMonitor.php +++ b/app/Filament/Resources/Monitors/Pages/ViewMonitor.php @@ -6,7 +6,10 @@ use App\Actions\DispatchMonitorCheck; use App\Actions\EndMonitorMaintenance; +use App\Actions\PauseMonitor; +use App\Actions\ResumeMonitor; use App\Actions\StartMonitorMaintenance; +use App\Enums\MonitorStatus; use App\Filament\Concerns\RefreshesOnMonitorBroadcasts; use App\Filament\Resources\Monitors\MonitorResource; use App\Filament\Widgets\MonitorHistoryWidget; @@ -34,12 +37,39 @@ protected function getHeaderActions(): array ->label('Check now') ->icon(Heroicon::OutlinedPlay) ->visible(function (): bool { - /** @var Monitor $record */ - $record = $this->getRecord(); + $record = $this->monitor(); - return $record->type->usesOutboundProbe(); + return $record->type->usesOutboundProbe() + && $record->status !== MonitorStatus::Paused; }) ->action($this->queueCheck(...)), + Action::make('pause') + ->label('Pause') + ->icon(Heroicon::OutlinedPause) + ->visible(fn (): bool => $this->monitor()->status !== MonitorStatus::Paused) + ->requiresConfirmation() + ->action(function (): void { + PauseMonitor::make()->handle($this->monitor()); + $this->refreshRecord(); + + Notification::make() + ->success() + ->title('Monitor paused') + ->send(); + }), + Action::make('resume') + ->label('Resume') + ->icon(Heroicon::OutlinedPlay) + ->visible(fn (): bool => $this->monitor()->status === MonitorStatus::Paused) + ->action(function (): void { + ResumeMonitor::make()->handle($this->monitor()); + $this->refreshRecord(); + + Notification::make() + ->success() + ->title('Monitor resumed') + ->send(); + }), Action::make('startMaintenance') ->label('Start maintenance') ->icon(Heroicon::OutlinedWrenchScrewdriver) diff --git a/app/GraphQL/Mutations/PauseMonitor.php b/app/GraphQL/Mutations/PauseMonitor.php new file mode 100644 index 0000000..43b3df5 --- /dev/null +++ b/app/GraphQL/Mutations/PauseMonitor.php @@ -0,0 +1,25 @@ +findOrFail($args['monitorId']); + + return $this->pauseMonitor->handle($monitor); + } +} diff --git a/app/GraphQL/Mutations/ResumeMonitor.php b/app/GraphQL/Mutations/ResumeMonitor.php new file mode 100644 index 0000000..fe43e59 --- /dev/null +++ b/app/GraphQL/Mutations/ResumeMonitor.php @@ -0,0 +1,25 @@ +findOrFail($args['monitorId']); + + return $this->resumeMonitor->handle($monitor); + } +} diff --git a/app/Http/Controllers/HeartbeatController.php b/app/Http/Controllers/HeartbeatController.php index 34b1220..2762e75 100644 --- a/app/Http/Controllers/HeartbeatController.php +++ b/app/Http/Controllers/HeartbeatController.php @@ -6,6 +6,7 @@ use App\Actions\ReceiveHeartbeat; use App\Enums\HeartbeatSignal; +use App\Enums\MonitorStatus; use App\Enums\MonitorType; use App\Models\Monitor; use Illuminate\Http\JsonResponse; @@ -22,6 +23,13 @@ public function __invoke(Request $request, ReceiveHeartbeat $receive, string $to abort_unless($monitor->enabled, 404); + if ($monitor->status === MonitorStatus::Paused) { + return response()->json([ + 'ok' => true, + 'paused' => true, + ]); + } + $latency = $request->integer('latency'); if ($latency === 0) { diff --git a/app/Jobs/RunCheckJob.php b/app/Jobs/RunCheckJob.php index 3495c1d..f08e792 100644 --- a/app/Jobs/RunCheckJob.php +++ b/app/Jobs/RunCheckJob.php @@ -6,6 +6,7 @@ use App\Actions\CheckMonitor; use App\Actions\RecordCheckResult; +use App\Enums\MonitorStatus; use App\Models\Monitor; use App\Models\Probe; use Illuminate\Contracts\Queue\ShouldQueue; @@ -27,7 +28,7 @@ public function handle(CheckMonitor $check, RecordCheckResult $recorder): void $monitor = Monitor::query()->with('conditions')->find($this->monitorId); $probe = $this->probeId === null ? null : Probe::query()->find($this->probeId); - if ($monitor === null || ! $monitor->enabled) { + if ($monitor === null || ! $monitor->enabled || $monitor->status === MonitorStatus::Paused) { return; } diff --git a/graphql/schema.graphql b/graphql/schema.graphql index e5fbdc8..30d316c 100644 --- a/graphql/schema.graphql +++ b/graphql/schema.graphql @@ -525,6 +525,8 @@ type Mutation @guard(with: ["sanctum"]) { deleteMaintenanceWindow(id: ID!): Boolean! startMonitorMaintenance(monitorId: ID!, title: String, message: String, endsAt: DateTime): MaintenanceWindow! endMonitorMaintenance(monitorId: ID!): Monitor! + pauseMonitor(monitorId: ID!): Monitor! + resumeMonitor(monitorId: ID!): Monitor! createStatusPage(input: CreateStatusPageInput!): StatusPage! updateStatusPage(id: ID!, input: UpdateStatusPageInput!): StatusPage! deleteStatusPage(id: ID!): Boolean! diff --git a/tests/Feature/Actions/DispatchMonitorCheckTest.php b/tests/Feature/Actions/DispatchMonitorCheckTest.php index 12a495d..6a0dc35 100644 --- a/tests/Feature/Actions/DispatchMonitorCheckTest.php +++ b/tests/Feature/Actions/DispatchMonitorCheckTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Actions\DispatchMonitorCheck; +use App\Enums\MonitorStatus; use App\Jobs\RunCheckJob; use App\Models\Monitor; use App\Models\Probe; @@ -63,6 +64,19 @@ }); }); +it('does not queue a check for a paused monitor', function () { + Queue::fake(); + + $monitor = Monitor::factory()->create(['status' => MonitorStatus::Paused]); + $probe = Probe::factory()->create(['queue' => 'checks.local']); + $monitor->probes()->attach($probe); + + expect(DispatchMonitorCheck::make()->handle($monitor))->toBe(0) + ->and(DispatchMonitorCheck::make()->handle($monitor, saved: true))->toBe(0); + + Queue::assertNothingPushed(); +}); + it('does not queue a check after saving a heartbeat or disabled monitor', function () { Queue::fake(); diff --git a/tests/Feature/Actions/PauseMonitorTest.php b/tests/Feature/Actions/PauseMonitorTest.php new file mode 100644 index 0000000..2f908ef --- /dev/null +++ b/tests/Feature/Actions/PauseMonitorTest.php @@ -0,0 +1,111 @@ +freezeTime(); + + $monitor = Monitor::factory()->create([ + 'status' => MonitorStatus::Up, + 'last_status_changed_at' => now()->subHour(), + ]); + + $paused = PauseMonitor::make()->handle($monitor); + + expect($paused->status)->toBe(MonitorStatus::Paused) + ->and($paused->enabled)->toBeTrue() + ->and($paused->last_status_changed_at?->toDateTimeString())->toBe(now()->toDateTimeString()) + ->and($monitor->fresh()->status)->toBe(MonitorStatus::Paused); + + Event::assertDispatched( + MonitorStatusUpdated::class, + fn (MonitorStatusUpdated $event): bool => $event->monitor->is($monitor) + && $event->previous === MonitorStatus::Up + && $event->monitor->status === MonitorStatus::Paused, + ); +}); + +it('does not change a monitor that is already paused', function () { + Event::fake([MonitorStatusUpdated::class]); + $this->freezeTime(); + + $changedAt = now()->subDay(); + $monitor = Monitor::factory()->create([ + 'status' => MonitorStatus::Paused, + 'last_status_changed_at' => $changedAt, + ]); + + PauseMonitor::make()->handle($monitor); + + expect($monitor->fresh()->last_status_changed_at?->toDateTimeString())->toBe($changedAt->toDateTimeString()); + + Event::assertNotDispatched(MonitorStatusUpdated::class); +}); + +it('stores paused while an active maintenance window still displays as maintenance', function () { + Event::fake([MonitorStatusUpdated::class]); + + $monitor = Monitor::factory()->create(['status' => MonitorStatus::Up]); + + SaveMaintenanceWindow::make()->handle([ + 'title' => 'Database upgrade', + 'monitorIds' => [$monitor->id], + ]); + + PauseMonitor::make()->handle($monitor->fresh()); + + $fresh = $monitor->fresh(); + + expect($fresh->status)->toBe(MonitorStatus::Paused) + ->and($fresh->effectiveStatus())->toBe(MonitorStatus::Maintenance); +}); + +it('resumes a paused monitor as pending and due now', function () { + Event::fake([MonitorStatusUpdated::class]); + $this->freezeTime(); + + $monitor = Monitor::factory()->create([ + 'status' => MonitorStatus::Paused, + 'next_check_at' => now()->addHour(), + ]); + + $resumed = ResumeMonitor::make()->handle($monitor); + + expect($resumed->status)->toBe(MonitorStatus::Pending) + ->and($resumed->next_check_at?->toDateTimeString())->toBe(now()->toDateTimeString()) + ->and($resumed->last_status_changed_at?->toDateTimeString())->toBe(now()->toDateTimeString()); + + Event::assertDispatched( + MonitorStatusUpdated::class, + fn (MonitorStatusUpdated $event): bool => $event->previous === MonitorStatus::Paused + && $event->monitor->status === MonitorStatus::Pending, + ); +}); + +it('does not change a monitor that is not paused', function () { + Event::fake([MonitorStatusUpdated::class]); + $this->freezeTime(); + + $monitor = Monitor::factory()->create([ + 'status' => MonitorStatus::Down, + 'next_check_at' => now()->addMinutes(10), + ]); + + ResumeMonitor::make()->handle($monitor); + + $fresh = $monitor->fresh(); + + expect($fresh->status)->toBe(MonitorStatus::Down) + ->and($fresh->next_check_at?->toDateTimeString())->toBe(now()->addMinutes(10)->toDateTimeString()); + + Event::assertNotDispatched(MonitorStatusUpdated::class); +}); diff --git a/tests/Feature/Filament/PauseMonitorTest.php b/tests/Feature/Filament/PauseMonitorTest.php new file mode 100644 index 0000000..c59135c --- /dev/null +++ b/tests/Feature/Filament/PauseMonitorTest.php @@ -0,0 +1,40 @@ +create(); + $monitor = Monitor::factory()->create(['status' => MonitorStatus::Up]); + + Livewire::actingAs($user) + ->test(ViewMonitor::class, ['record' => $monitor->getRouteKey()]) + ->assertActionVisible('pause') + ->assertActionHidden('resume') + ->callAction('pause') + ->assertNotified('Monitor paused') + ->assertActionHidden('pause') + ->assertActionVisible('resume') + ->callAction('resume') + ->assertNotified('Monitor resumed') + ->assertActionVisible('pause') + ->assertActionHidden('resume'); + + expect($monitor->fresh()->status)->toBe(MonitorStatus::Pending); +}); + +it('hides check now while a monitor is paused', function () { + $user = User::factory()->create(); + $monitor = Monitor::factory()->create(['status' => MonitorStatus::Paused]); + + Livewire::actingAs($user) + ->test(ViewMonitor::class, ['record' => $monitor->getRouteKey()]) + ->assertActionHidden('checkNow') + ->assertActionHidden('pause') + ->assertActionVisible('resume'); +}); diff --git a/tests/Feature/GraphQL/PauseMonitorTest.php b/tests/Feature/GraphQL/PauseMonitorTest.php new file mode 100644 index 0000000..10aaae5 --- /dev/null +++ b/tests/Feature/GraphQL/PauseMonitorTest.php @@ -0,0 +1,36 @@ +create(['status' => MonitorStatus::Up]); + + $paused = graphql(' + mutation ($monitorId: ID!) { + pauseMonitor(monitorId: $monitorId) { + status + enabled + } + } + ', ['monitorId' => $monitor->id])->assertSuccessful() + ->json('data.pauseMonitor'); + + expect($paused['status'])->toBe('Paused') + ->and($paused['enabled'])->toBeTrue() + ->and($monitor->fresh()->status)->toBe(MonitorStatus::Paused); + + $resumed = graphql(' + mutation ($monitorId: ID!) { + resumeMonitor(monitorId: $monitorId) { + status + } + } + ', ['monitorId' => $monitor->id])->assertSuccessful() + ->json('data.resumeMonitor'); + + expect($resumed['status'])->toBe('Pending') + ->and($monitor->fresh()->status)->toBe(MonitorStatus::Pending); +}); diff --git a/tests/Feature/HeartbeatTest.php b/tests/Feature/HeartbeatTest.php index 195048c..afe6946 100644 --- a/tests/Feature/HeartbeatTest.php +++ b/tests/Feature/HeartbeatTest.php @@ -32,6 +32,24 @@ ->assertJson(['ok' => true]); }); +it('ignores heartbeats while the monitor is paused', function () { + $monitor = Monitor::factory()->heartbeat()->create([ + 'status' => MonitorStatus::Paused, + ]); + + $this->getJson('/api/heartbeat/'.$monitor->heartbeat_token) + ->assertOk() + ->assertJson(['ok' => true, 'paused' => true]); + + $this->postJson('/api/heartbeat/'.$monitor->heartbeat_token.'/start') + ->assertOk() + ->assertJson(['ok' => true, 'paused' => true]); + + expect($monitor->fresh()->status)->toBe(MonitorStatus::Paused) + ->and($monitor->fresh()->heartbeat_started_at)->toBeNull() + ->and($monitor->checkResults()->count())->toBe(0); +}); + it('rejects unknown heartbeat tokens', function () { $this->getJson('/api/heartbeat/notarealtoken')->assertNotFound(); }); diff --git a/tests/Feature/Jobs/RunCheckJobTest.php b/tests/Feature/Jobs/RunCheckJobTest.php new file mode 100644 index 0000000..02f2d95 --- /dev/null +++ b/tests/Feature/Jobs/RunCheckJobTest.php @@ -0,0 +1,23 @@ +heartbeat()->create([ + 'status' => MonitorStatus::Paused, + ]); + + (new RunCheckJob($monitor->id))->handle( + app(CheckMonitor::class), + app(RecordCheckResult::class), + ); + + expect($monitor->fresh()->status)->toBe(MonitorStatus::Paused) + ->and($monitor->checkResults()->count())->toBe(0); +});