diff --git a/README.md b/README.md index 17d5dd5..6cfb421 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ EasyMonitor is a full-stack monitoring platform for your websites and APIs. Add - **HTTP and ICMP checks** — every 30 seconds to 1 hour per monitor - **Multi-region probes** — lightweight Go binaries (~10 MB) you can deploy anywhere - **Consecutive-failure threshold** — configurable per monitor; no alerts on flaky single failures -- **Multi-channel alerts** — email and Pushover (per-user, per-monitor selection) on down and recovery +- **Multi-channel alerts** — email, Slack, and Pushover (per-user, per-monitor selection) on down and recovery - **Projects** — group related monitors (e.g. main site + APIs) - **Teams** — share monitors and projects with collaborators with role-based access - **Status pages** — public, unlisted (secret link), or private @@ -148,6 +148,7 @@ Supported channels: | Channel | Setup | Per-user config | |---------|-------|-----------------| | Email | Configured by the admin via `MAIL_MAILER` (log, SES, SMTP) | Uses the account email | +| Slack | No admin setup — Slack-side only | User adds one or more [incoming webhooks](https://api.slack.com/messaging/webhooks), each labelled (e.g. `#alerts-api`, `#alerts-frontend`) — pick which ones to alert per monitor | | Pushover | Admin sets `PUSHOVER_APP_TOKEN` once (from [pushover.net/apps/build](https://pushover.net/apps/build)) | User pastes their user key (and optional device) | Send-test buttons on the Notifications page let each user verify their configuration end-to-end. diff --git a/app/Enums/NotificationChannelType.php b/app/Enums/NotificationChannelType.php index 595d432..8e7ff1a 100644 --- a/app/Enums/NotificationChannelType.php +++ b/app/Enums/NotificationChannelType.php @@ -4,12 +4,14 @@ namespace App\Enums; +use App\Notifications\Channels\SlackWebhookChannel; use NotificationChannels\Pushover\PushoverChannel; enum NotificationChannelType: string { case Email = 'email'; case Pushover = 'pushover'; + case Slack = 'slack'; /** * Human-readable label for the channel. @@ -19,6 +21,7 @@ public function label(): string return match ($this) { self::Email => 'Email', self::Pushover => 'Pushover', + self::Slack => 'Slack', }; } @@ -30,6 +33,7 @@ public function laravelChannel(): string return match ($this) { self::Email => 'mail', self::Pushover => PushoverChannel::class, + self::Slack => SlackWebhookChannel::class, }; } @@ -40,7 +44,8 @@ public function sortOrder(): int { return match ($this) { self::Email => 0, - self::Pushover => 1, + self::Slack => 1, + self::Pushover => 2, }; } } diff --git a/app/Livewire/Settings/Notifications.php b/app/Livewire/Settings/Notifications.php index 96c0b15..6e7cae3 100644 --- a/app/Livewire/Settings/Notifications.php +++ b/app/Livewire/Settings/Notifications.php @@ -21,6 +21,20 @@ class Notifications extends Component public bool $pushoverActive = true; + /** + * Editable in-place state for each existing Slack channel, + * keyed by NotificationChannel id. + * + * @var array + */ + public array $slackEdits = []; + + public string $newSlackLabel = ''; + + public string $newSlackWebhookUrl = ''; + + public bool $newSlackActive = true; + public ?int $defaultChannelId = null; public function mount(): void @@ -37,26 +51,20 @@ public function mount(): void $this->pushoverActive = (bool) $pushover->is_active; } + $this->refreshSlackEdits(); + $this->defaultChannelId = $user->notificationChannels() ->where('is_default', true) ->value('id'); } - /** - * @return array> - */ - public function rules(): array + public function savePushover(): void { - return [ + $this->validate([ 'pushoverUserKey' => ['nullable', 'string', 'size:30'], 'pushoverDevice' => ['nullable', 'string', 'max:50'], 'pushoverActive' => ['boolean'], - ]; - } - - public function savePushover(): void - { - $this->validate(); + ]); $user = Auth::user(); @@ -88,6 +96,80 @@ public function savePushover(): void $this->dispatch('notifications-saved'); } + public function addSlackChannel(): void + { + $this->validate( + [ + 'newSlackLabel' => ['required', 'string', 'max:50'], + 'newSlackWebhookUrl' => $this->slackWebhookRules(), + 'newSlackActive' => ['boolean'], + ], + [ + 'newSlackWebhookUrl.starts_with' => __('Paste a Slack incoming webhook URL (starts with https://hooks.slack.com/).'), + ], + ); + + Auth::user()->notificationChannels()->create([ + 'type' => NotificationChannelType::Slack, + 'label' => $this->newSlackLabel, + 'config' => ['webhook_url' => $this->newSlackWebhookUrl], + 'is_active' => $this->newSlackActive, + 'is_default' => false, + ]); + + $this->newSlackLabel = ''; + $this->newSlackWebhookUrl = ''; + $this->newSlackActive = true; + + $this->refreshSlackEdits(); + + $this->dispatch('notifications-saved'); + } + + public function saveSlackChannel(int $channelId): void + { + $prefix = "slackEdits.{$channelId}"; + + $this->validate( + [ + "{$prefix}.label" => ['required', 'string', 'max:50'], + "{$prefix}.webhook_url" => $this->slackWebhookRules(), + "{$prefix}.is_active" => ['boolean'], + ], + [ + "{$prefix}.webhook_url.starts_with" => __('Paste a Slack incoming webhook URL (starts with https://hooks.slack.com/).'), + ], + ); + + $row = $this->slackEdits[$channelId]; + + $channel = Auth::user() + ->notificationChannels() + ->where('type', NotificationChannelType::Slack->value) + ->findOrFail($channelId); + + $channel->update([ + 'label' => $row['label'], + 'config' => ['webhook_url' => $row['webhook_url']], + 'is_active' => (bool) ($row['is_active'] ?? true), + ]); + + $this->dispatch('notifications-saved'); + } + + public function deleteSlackChannel(int $channelId): void + { + Auth::user() + ->notificationChannels() + ->where('type', NotificationChannelType::Slack->value) + ->whereKey($channelId) + ->delete(); + + unset($this->slackEdits[$channelId]); + + $this->dispatch('notifications-saved'); + } + public function setDefault(int $channelId): void { $user = Auth::user(); @@ -139,6 +221,31 @@ public function render() return view('livewire.settings.notifications', [ 'channels' => $channels, + 'slackChannels' => $channels->where('type', NotificationChannelType::Slack)->values(), ]); } + + /** + * @return array + */ + protected function slackWebhookRules(): array + { + return ['required', 'string', 'url', 'starts_with:https://hooks.slack.com/', 'max:500']; + } + + protected function refreshSlackEdits(): void + { + $this->slackEdits = Auth::user() + ->notificationChannels() + ->where('type', NotificationChannelType::Slack->value) + ->get() + ->mapWithKeys(fn (NotificationChannel $channel) => [ + $channel->id => [ + 'label' => (string) $channel->label, + 'webhook_url' => (string) ($channel->config['webhook_url'] ?? ''), + 'is_active' => (bool) $channel->is_active, + ], + ]) + ->all(); + } } diff --git a/app/Models/NotificationChannel.php b/app/Models/NotificationChannel.php index 3ebe49b..894ab72 100644 --- a/app/Models/NotificationChannel.php +++ b/app/Models/NotificationChannel.php @@ -23,6 +23,7 @@ class NotificationChannel extends Model protected $fillable = [ 'user_id', 'type', + 'label', 'config', 'is_active', 'is_default', @@ -82,6 +83,16 @@ public function routeNotificationForPushover(): ?PushoverReceiver return $receiver; } + /** + * Route Slack notifications to the incoming webhook URL stored in config. + */ + public function routeNotificationForSlack(): ?string + { + $webhookUrl = $this->config['webhook_url'] ?? null; + + return is_string($webhookUrl) && $webhookUrl !== '' ? $webhookUrl : null; + } + /** * Whether the channel has the configuration it needs to send. */ @@ -90,6 +101,7 @@ public function isConfigured(): bool return match ($this->type) { NotificationChannelType::Email => filled($this->user?->email), NotificationChannelType::Pushover => filled($this->config['user_key'] ?? null), + NotificationChannelType::Slack => filled($this->config['webhook_url'] ?? null), }; } } diff --git a/app/Notifications/Channels/SlackWebhookChannel.php b/app/Notifications/Channels/SlackWebhookChannel.php new file mode 100644 index 0000000..4c87541 --- /dev/null +++ b/app/Notifications/Channels/SlackWebhookChannel.php @@ -0,0 +1,48 @@ +|null $payload + */ + public function send(object $notifiable, Notification $notification): void + { + if (! method_exists($notification, 'toSlack')) { + return; + } + + $webhookUrl = $notifiable->routeNotificationFor('slack', $notification); + $payload = $notification->toSlack($notifiable); + + if (! is_string($webhookUrl) || $webhookUrl === '' || ! is_array($payload)) { + return; + } + + $response = Http::asJson() + ->timeout(10) + ->post($webhookUrl, $payload); + + if ($response->failed()) { + Log::warning('Slack webhook delivery failed', [ + 'status' => $response->status(), + 'body' => $response->body(), + ]); + } + } +} diff --git a/app/Notifications/MonitorDown.php b/app/Notifications/MonitorDown.php index 94251b2..a116ec4 100644 --- a/app/Notifications/MonitorDown.php +++ b/app/Notifications/MonitorDown.php @@ -61,6 +61,54 @@ public function toPushover(object $notifiable): PushoverMessage ->url(url("/monitors/{$this->monitor->id}"), 'View Monitor'); } + /** + * Slack incoming-webhook payload. + * + * @return array + */ + public function toSlack(object $notifiable): array + { + $monitorUrl = url("/monitors/{$this->monitor->id}"); + $fallback = "🔴 {$this->monitor->name} is DOWN — {$this->monitor->url}"; + + $contextElements = []; + if ($this->errorMessage) { + $contextElements[] = ['type' => 'mrkdwn', 'text' => "*Error:* {$this->errorMessage}"]; + } + if ($this->monitor->last_checked_at) { + $contextElements[] = [ + 'type' => 'mrkdwn', + 'text' => '*Detected:* '.$this->monitor->last_checked_at->format('M d, Y H:i:s T'), + ]; + } + + $blocks = [ + [ + 'type' => 'section', + 'text' => [ + 'type' => 'mrkdwn', + 'text' => "🔴 *{$this->monitor->name}* is *DOWN*\n<{$this->monitor->url}|{$this->monitor->url}>", + ], + ], + ]; + + if ($contextElements !== []) { + $blocks[] = ['type' => 'context', 'elements' => $contextElements]; + } + + $blocks[] = [ + 'type' => 'actions', + 'elements' => [[ + 'type' => 'button', + 'text' => ['type' => 'plain_text', 'text' => 'View Monitor'], + 'url' => $monitorUrl, + 'style' => 'danger', + ]], + ]; + + return ['text' => $fallback, 'blocks' => $blocks]; + } + /** * @return array */ diff --git a/app/Notifications/MonitorRecovered.php b/app/Notifications/MonitorRecovered.php index c17ddd9..d043e10 100644 --- a/app/Notifications/MonitorRecovered.php +++ b/app/Notifications/MonitorRecovered.php @@ -51,6 +51,49 @@ public function toPushover(object $notifiable): PushoverMessage ->url(url("/monitors/{$this->monitor->id}"), 'View Monitor'); } + /** + * Slack incoming-webhook payload. + * + * @return array + */ + public function toSlack(object $notifiable): array + { + $monitorUrl = url("/monitors/{$this->monitor->id}"); + $fallback = "🟢 {$this->monitor->name} has recovered — {$this->monitor->url}"; + + $blocks = [ + [ + 'type' => 'section', + 'text' => [ + 'type' => 'mrkdwn', + 'text' => "🟢 *{$this->monitor->name}* has *recovered*\n<{$this->monitor->url}|{$this->monitor->url}>", + ], + ], + ]; + + if ($this->monitor->last_checked_at) { + $blocks[] = [ + 'type' => 'context', + 'elements' => [[ + 'type' => 'mrkdwn', + 'text' => '*Recovered:* '.$this->monitor->last_checked_at->format('M d, Y H:i:s T'), + ]], + ]; + } + + $blocks[] = [ + 'type' => 'actions', + 'elements' => [[ + 'type' => 'button', + 'text' => ['type' => 'plain_text', 'text' => 'View Monitor'], + 'url' => $monitorUrl, + 'style' => 'primary', + ]], + ]; + + return ['text' => $fallback, 'blocks' => $blocks]; + } + /** * @return array */ diff --git a/database/factories/NotificationChannelFactory.php b/database/factories/NotificationChannelFactory.php index d5d5ff5..34bfa42 100644 --- a/database/factories/NotificationChannelFactory.php +++ b/database/factories/NotificationChannelFactory.php @@ -44,6 +44,17 @@ public function pushover(?string $userKey = null, ?string $device = null): stati ]); } + public function slack(?string $webhookUrl = null, ?string $label = null): static + { + return $this->state(fn () => [ + 'type' => NotificationChannelType::Slack, + 'label' => $label ?? '#alerts', + 'config' => [ + 'webhook_url' => $webhookUrl ?? 'https://hooks.slack.com/services/T000/B000/XXXXXXXXXXXXXXXXXXXXXXXX', + ], + ]); + } + public function default(): static { return $this->state(fn () => ['is_default' => true]); diff --git a/database/migrations/2026_05_14_130722_allow_multiple_notification_channels_per_type.php b/database/migrations/2026_05_14_130722_allow_multiple_notification_channels_per_type.php new file mode 100644 index 0000000..6d19125 --- /dev/null +++ b/database/migrations/2026_05_14_130722_allow_multiple_notification_channels_per_type.php @@ -0,0 +1,32 @@ +dropUnique(['user_id', 'type']); + $table->string('label', 100)->nullable()->after('type'); + $table->index(['user_id', 'type']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('notification_channels', function (Blueprint $table) { + $table->dropIndex(['user_id', 'type']); + $table->dropColumn('label'); + $table->unique(['user_id', 'type']); + }); + } +}; diff --git a/resources/views/livewire/monitors/create.blade.php b/resources/views/livewire/monitors/create.blade.php index 7701965..ea5a1ef 100644 --- a/resources/views/livewire/monitors/create.blade.php +++ b/resources/views/livewire/monitors/create.blade.php @@ -153,7 +153,9 @@ class="range range-sm range-primary flex-1" value="{{ $channel->id }}" wire:model="notificationChannelIds" class="checkbox checkbox-primary checkbox-sm" /> - {{ $channel->type->label() }} + + {{ $channel->type->label() }}@if ($channel->label) — {{ $channel->label }}@endif + @if ($channel->is_default) {{ __('Default') }} @endif diff --git a/resources/views/livewire/monitors/edit.blade.php b/resources/views/livewire/monitors/edit.blade.php index e0fbb59..49f63ba 100644 --- a/resources/views/livewire/monitors/edit.blade.php +++ b/resources/views/livewire/monitors/edit.blade.php @@ -166,7 +166,9 @@ class="range range-sm range-primary flex-1" value="{{ $channel->id }}" wire:model="notificationChannelIds" class="checkbox checkbox-primary checkbox-sm" /> - {{ $channel->type->label() }} + + {{ $channel->type->label() }}@if ($channel->label) — {{ $channel->label }}@endif + @if ($channel->is_default) {{ __('Default') }} @endif diff --git a/resources/views/livewire/settings/notifications.blade.php b/resources/views/livewire/settings/notifications.blade.php index b5ff0d3..d866c8c 100644 --- a/resources/views/livewire/settings/notifications.blade.php +++ b/resources/views/livewire/settings/notifications.blade.php @@ -13,8 +13,11 @@ class="card bg-base-100 border border-base-300">
-
+
{{ $channel->type->label() }} + @if ($channel->label) + — {{ $channel->label }} + @endif @if ($channel->is_default) {{ __('Default') }} @endif @@ -32,6 +35,9 @@ class="card bg-base-100 border border-base-300"> @case (\App\Enums\NotificationChannelType::Pushover) {{ __('User key set') }}{{ ($channel->config['device'] ?? null) ? ' · '.$channel->config['device'] : '' }} @break + @case (\App\Enums\NotificationChannelType::Slack) + {{ __('Webhook configured') }} + @break @endswitch

@@ -72,6 +78,124 @@ class="btn btn-outline btn-sm"> @enderror
+ +
+
+
+

{{ __('Slack') }}

+

+ {{ __('Create an') }} + {{ __('incoming webhook') }} + {{ __('for each Slack channel you want alerts delivered to. Add as many as you need — when creating a monitor you can pick which ones to alert.') }} +

+
+ + @foreach ($slackChannels as $existing) +
+
+
+ + + @error('slackEdits.'.$existing->id.'.label') + {{ $message }} + @enderror +
+
+ + + @error('slackEdits.'.$existing->id.'.webhook_url') + {{ $message }} + @enderror +
+
+ +
+ + +
+ + +
+
+
+ @endforeach + +
+
+ {{ $slackChannels->isEmpty() ? __('Add your first Slack channel') : __('Add another Slack channel') }} +
+
+
+ + + @error('newSlackLabel') + {{ $message }} + @enderror +
+
+ + + @error('newSlackWebhookUrl') + {{ $message }} + @enderror +
+
+ +
+ + +
+ + {{ __('Saved.') }} + + +
+
+
+
+
+
diff --git a/tests/Feature/Feature/Settings/NotificationsSettingsTest.php b/tests/Feature/Feature/Settings/NotificationsSettingsTest.php index 6841764..d1de4ff 100644 --- a/tests/Feature/Feature/Settings/NotificationsSettingsTest.php +++ b/tests/Feature/Feature/Settings/NotificationsSettingsTest.php @@ -90,6 +90,109 @@ Notification::assertSentTo($channel, MonitorRecovered::class); }); +test('adding a slack channel creates a new notification channel', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $url = 'https://hooks.slack.com/services/T0/B0/abc123'; + + Livewire::test(Notifications::class) + ->set('newSlackLabel', '#alerts') + ->set('newSlackWebhookUrl', $url) + ->call('addSlackChannel') + ->assertHasNoErrors(); + + $slack = $user->notificationChannels() + ->where('type', NotificationChannelType::Slack->value) + ->first(); + + expect($slack)->not->toBeNull(); + expect($slack->label)->toBe('#alerts'); + expect($slack->config['webhook_url'])->toBe($url); + expect($slack->is_active)->toBeTrue(); +}); + +test('multiple slack channels can be added per user', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $component = Livewire::test(Notifications::class) + ->set('newSlackLabel', '#alerts-api') + ->set('newSlackWebhookUrl', 'https://hooks.slack.com/services/T0/B0/api') + ->call('addSlackChannel'); + + $component->set('newSlackLabel', '#alerts-frontend') + ->set('newSlackWebhookUrl', 'https://hooks.slack.com/services/T0/B0/fe') + ->call('addSlackChannel'); + + $slacks = $user->notificationChannels() + ->where('type', NotificationChannelType::Slack->value) + ->orderBy('id') + ->get(); + + expect($slacks)->toHaveCount(2); + expect($slacks->pluck('label')->all())->toBe(['#alerts-api', '#alerts-frontend']); +}); + +test('slack channel requires a label', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + Livewire::test(Notifications::class) + ->set('newSlackWebhookUrl', 'https://hooks.slack.com/services/T0/B0/x') + ->call('addSlackChannel') + ->assertHasErrors(['newSlackLabel']); +}); + +test('slack webhook url must come from hooks.slack.com', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + Livewire::test(Notifications::class) + ->set('newSlackLabel', '#alerts') + ->set('newSlackWebhookUrl', 'https://example.com/webhook') + ->call('addSlackChannel') + ->assertHasErrors(['newSlackWebhookUrl']); +}); + +test('slack webhook url must be a valid url', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + Livewire::test(Notifications::class) + ->set('newSlackLabel', '#alerts') + ->set('newSlackWebhookUrl', 'not-a-url') + ->call('addSlackChannel') + ->assertHasErrors(['newSlackWebhookUrl']); +}); + +test('a slack channel can be updated in place', function () { + $user = User::factory()->create(); + $existing = NotificationChannel::factory()->for($user)->slack(label: '#alerts')->create(); + $this->actingAs($user); + + Livewire::test(Notifications::class) + ->set("slackEdits.{$existing->id}.label", '#alerts-renamed') + ->set("slackEdits.{$existing->id}.webhook_url", 'https://hooks.slack.com/services/T0/B0/new') + ->call('saveSlackChannel', $existing->id) + ->assertHasNoErrors(); + + $existing->refresh(); + expect($existing->label)->toBe('#alerts-renamed'); + expect($existing->config['webhook_url'])->toBe('https://hooks.slack.com/services/T0/B0/new'); +}); + +test('a slack channel can be deleted', function () { + $user = User::factory()->create(); + $existing = NotificationChannel::factory()->for($user)->slack()->create(); + $this->actingAs($user); + + Livewire::test(Notifications::class) + ->call('deleteSlackChannel', $existing->id); + + expect($user->notificationChannels()->whereKey($existing->id)->exists())->toBeFalse(); +}); + test('a user cannot target another user\'s channel', function () { $user = User::factory()->create(); $other = User::factory()->create(); diff --git a/tests/Unit/Notifications/SlackWebhookChannelTest.php b/tests/Unit/Notifications/SlackWebhookChannelTest.php new file mode 100644 index 0000000..60cf7b8 --- /dev/null +++ b/tests/Unit/Notifications/SlackWebhookChannelTest.php @@ -0,0 +1,74 @@ +create(); + $channel = NotificationChannel::factory() + ->for($user) + ->slack('https://hooks.slack.com/services/T0/B0/secret') + ->create(); + $monitor = Monitor::factory()->for($user)->create(['name' => 'API', 'url' => 'https://api.example.com']); + + NotificationFacade::sendNow([$channel], new MonitorDown($monitor, 'connection refused')); + + Http::assertSent(function ($request) { + $body = $request->data(); + + return $request->url() === 'https://hooks.slack.com/services/T0/B0/secret' + && str_contains($body['text'] ?? '', 'API') + && str_contains($body['text'] ?? '', 'DOWN') + && is_array($body['blocks'] ?? null) + && count($body['blocks']) >= 2; + }); +}); + +test('MonitorRecovered is posted to the slack webhook', function () { + Http::fake(); + + $user = User::factory()->create(); + $channel = NotificationChannel::factory() + ->for($user) + ->slack('https://hooks.slack.com/services/T0/B0/secret') + ->create(); + $monitor = Monitor::factory()->for($user)->create(['name' => 'API', 'url' => 'https://api.example.com']); + + NotificationFacade::sendNow([$channel], new MonitorRecovered($monitor)); + + Http::assertSent(function ($request) { + $body = $request->data(); + + return str_contains($body['text'] ?? '', 'recovered'); + }); +}); + +test('a slack channel with no webhook url is skipped (no http call)', function () { + Http::fake(); + + $user = User::factory()->create(); + $channel = NotificationChannel::factory() + ->for($user) + ->slack() + ->create(); + $channel->update(['config' => []]); + $channel->refresh(); + + $monitor = Monitor::factory()->for($user)->create(); + + NotificationFacade::sendNow([$channel], new MonitorRecovered($monitor)); + + Http::assertNothingSent(); +});