Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
7 changes: 6 additions & 1 deletion app/Enums/NotificationChannelType.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -19,6 +21,7 @@ public function label(): string
return match ($this) {
self::Email => 'Email',
self::Pushover => 'Pushover',
self::Slack => 'Slack',
};
}

Expand All @@ -30,6 +33,7 @@ public function laravelChannel(): string
return match ($this) {
self::Email => 'mail',
self::Pushover => PushoverChannel::class,
self::Slack => SlackWebhookChannel::class,
};
}

Expand All @@ -40,7 +44,8 @@ public function sortOrder(): int
{
return match ($this) {
self::Email => 0,
self::Pushover => 1,
self::Slack => 1,
self::Pushover => 2,
};
}
}
129 changes: 118 additions & 11 deletions app/Livewire/Settings/Notifications.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<int, array{label: string, webhook_url: string, is_active: bool}>
*/
public array $slackEdits = [];

public string $newSlackLabel = '';

public string $newSlackWebhookUrl = '';

public bool $newSlackActive = true;

public ?int $defaultChannelId = null;

public function mount(): void
Expand All @@ -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<string, array<int, string|int>>
*/
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();

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -139,6 +221,31 @@ public function render()

return view('livewire.settings.notifications', [
'channels' => $channels,
'slackChannels' => $channels->where('type', NotificationChannelType::Slack)->values(),
]);
}

/**
* @return array<int, string>
*/
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();
}
}
12 changes: 12 additions & 0 deletions app/Models/NotificationChannel.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ class NotificationChannel extends Model
protected $fillable = [
'user_id',
'type',
'label',
'config',
'is_active',
'is_default',
Expand Down Expand Up @@ -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.
*/
Expand All @@ -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),
};
}
}
48 changes: 48 additions & 0 deletions app/Notifications/Channels/SlackWebhookChannel.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

declare(strict_types=1);

namespace App\Notifications\Channels;

use Illuminate\Notifications\Notification;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

/**
* Posts notifications to a Slack incoming webhook URL.
*
* Each notification class provides the payload via toSlack($notifiable) and the
* notifiable resolves the webhook URL via routeNotificationFor('slack').
*/
class SlackWebhookChannel
{
/**
* Send the notification to its target Slack webhook.
*
* @param array<string, mixed>|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(),
]);
}
}
}
48 changes: 48 additions & 0 deletions app/Notifications/MonitorDown.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,54 @@ public function toPushover(object $notifiable): PushoverMessage
->url(url("/monitors/{$this->monitor->id}"), 'View Monitor');
}

/**
* Slack incoming-webhook payload.
*
* @return array<string, mixed>
*/
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<string, mixed>
*/
Expand Down
Loading
Loading