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, Slack, generic webhooks (HMAC-signed), and Pushover (per-user, per-monitor selection) on down and recovery
- **Multi-channel alerts** — email, Slack, Discord, generic webhooks (HMAC-signed), 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 @@ -149,6 +149,7 @@ Supported channels:
|---------|-------|-----------------|
| 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 |
| Discord | No admin setup — Discord-side only | User adds one or more [channel webhooks](https://support.discord.com/hc/en-us/articles/228383668) (Server Settings → Integrations → Webhooks), each labelled — pick which ones to alert per monitor |
| Webhook | No admin setup | User adds one or more HTTP endpoints (any URL) — each gets an auto-generated HMAC-SHA256 secret. Payloads are signed with `X-EasyMonitor-Signature: sha256=…` and tagged with `X-EasyMonitor-Event: monitor.down\|monitor.recovered`. Pipe to PagerDuty, Zapier, n8n, custom services |
| 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) |

Expand Down
9 changes: 7 additions & 2 deletions app/Enums/NotificationChannelType.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace App\Enums;

use App\Notifications\Channels\DiscordWebhookChannel;
use App\Notifications\Channels\SlackWebhookChannel;
use App\Notifications\Channels\WebhookChannel;
use NotificationChannels\Pushover\PushoverChannel;
Expand All @@ -13,6 +14,7 @@ enum NotificationChannelType: string
case Email = 'email';
case Pushover = 'pushover';
case Slack = 'slack';
case Discord = 'discord';
case Webhook = 'webhook';

/**
Expand All @@ -24,6 +26,7 @@ public function label(): string
self::Email => 'Email',
self::Pushover => 'Pushover',
self::Slack => 'Slack',
self::Discord => 'Discord',
self::Webhook => 'Webhook',
};
}
Expand All @@ -37,6 +40,7 @@ public function laravelChannel(): string
self::Email => 'mail',
self::Pushover => PushoverChannel::class,
self::Slack => SlackWebhookChannel::class,
self::Discord => DiscordWebhookChannel::class,
self::Webhook => WebhookChannel::class,
};
}
Expand All @@ -49,8 +53,9 @@ public function sortOrder(): int
return match ($this) {
self::Email => 0,
self::Slack => 1,
self::Webhook => 2,
self::Pushover => 3,
self::Discord => 2,
self::Webhook => 3,
self::Pushover => 4,
};
}
}
120 changes: 120 additions & 0 deletions app/Livewire/Settings/Notifications.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,20 @@ class Notifications extends Component

public bool $newSlackActive = true;

/**
* Editable in-place state for each existing Discord channel,
* keyed by NotificationChannel id.
*
* @var array<int, array{label: string, webhook_url: string, is_active: bool}>
*/
public array $discordEdits = [];

public string $newDiscordLabel = '';

public string $newDiscordWebhookUrl = '';

public bool $newDiscordActive = true;

/**
* Editable in-place state for each existing Webhook channel,
* keyed by NotificationChannel id.
Expand Down Expand Up @@ -67,6 +81,7 @@ public function mount(): void
}

$this->refreshSlackEdits();
$this->refreshDiscordEdits();
$this->refreshWebhookEdits();

$this->defaultChannelId = $user->notificationChannels()
Expand Down Expand Up @@ -186,6 +201,80 @@ public function deleteSlackChannel(int $channelId): void
$this->dispatch('notifications-saved');
}

public function addDiscordChannel(): void
{
$this->validate(
[
'newDiscordLabel' => ['required', 'string', 'max:50'],
'newDiscordWebhookUrl' => $this->discordWebhookRules(),
'newDiscordActive' => ['boolean'],
],
[
'newDiscordWebhookUrl.regex' => __('Paste a Discord webhook URL (https://discord.com/api/webhooks/... or https://discordapp.com/api/webhooks/...).'),
],
);

Auth::user()->notificationChannels()->create([
'type' => NotificationChannelType::Discord,
'label' => $this->newDiscordLabel,
'config' => ['webhook_url' => $this->newDiscordWebhookUrl],
'is_active' => $this->newDiscordActive,
'is_default' => false,
]);

$this->newDiscordLabel = '';
$this->newDiscordWebhookUrl = '';
$this->newDiscordActive = true;

$this->refreshDiscordEdits();

$this->dispatch('notifications-saved');
}

public function saveDiscordChannel(int $channelId): void
{
$prefix = "discordEdits.{$channelId}";

$this->validate(
[
"{$prefix}.label" => ['required', 'string', 'max:50'],
"{$prefix}.webhook_url" => $this->discordWebhookRules(),
"{$prefix}.is_active" => ['boolean'],
],
[
"{$prefix}.webhook_url.regex" => __('Paste a Discord webhook URL (https://discord.com/api/webhooks/... or https://discordapp.com/api/webhooks/...).'),
],
);

$row = $this->discordEdits[$channelId];

$channel = Auth::user()
->notificationChannels()
->where('type', NotificationChannelType::Discord->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 deleteDiscordChannel(int $channelId): void
{
Auth::user()
->notificationChannels()
->where('type', NotificationChannelType::Discord->value)
->whereKey($channelId)
->delete();

unset($this->discordEdits[$channelId]);

$this->dispatch('notifications-saved');
}

public function addWebhookChannel(): void
{
$this->validate([
Expand Down Expand Up @@ -325,6 +414,7 @@ public function render()
return view('livewire.settings.notifications', [
'channels' => $channels,
'slackChannels' => $channels->where('type', NotificationChannelType::Slack)->values(),
'discordChannels' => $channels->where('type', NotificationChannelType::Discord)->values(),
'webhookChannels' => $channels->where('type', NotificationChannelType::Webhook)->values(),
]);
}
Expand Down Expand Up @@ -353,6 +443,36 @@ protected function refreshSlackEdits(): void
->all();
}

/**
* @return array<int, string>
*/
protected function discordWebhookRules(): array
{
return [
'required',
'string',
'url',
'max:500',
'regex:#^https://(discord\.com|discordapp\.com)/api/webhooks/#',
];
}

protected function refreshDiscordEdits(): void
{
$this->discordEdits = Auth::user()
->notificationChannels()
->where('type', NotificationChannelType::Discord->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();
}

/**
* @return array<int, string>
*/
Expand Down
11 changes: 11 additions & 0 deletions app/Models/NotificationChannel.php
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,16 @@ public function routeNotificationForSlack(): ?string
return is_string($webhookUrl) && $webhookUrl !== '' ? $webhookUrl : null;
}

/**
* Route Discord notifications to the incoming webhook URL stored in config.
*/
public function routeNotificationForDiscord(): ?string
{
$webhookUrl = $this->config['webhook_url'] ?? null;

return is_string($webhookUrl) && $webhookUrl !== '' ? $webhookUrl : null;
}

/**
* Route generic webhook notifications to the URL stored in config.
*/
Expand Down Expand Up @@ -122,6 +132,7 @@ public function isConfigured(): bool
NotificationChannelType::Email => filled($this->user?->email),
NotificationChannelType::Pushover => filled($this->config['user_key'] ?? null),
NotificationChannelType::Slack => filled($this->config['webhook_url'] ?? null),
NotificationChannelType::Discord => filled($this->config['webhook_url'] ?? null),
NotificationChannelType::Webhook => filled($this->config['url'] ?? null) && filled($this->config['secret'] ?? null),
};
}
Expand Down
43 changes: 43 additions & 0 deletions app/Notifications/Channels/DiscordWebhookChannel.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?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 Discord incoming webhook URL.
*
* Each notification class provides the payload via toDiscord($notifiable) and
* the notifiable resolves the webhook URL via routeNotificationFor('discord').
*/
class DiscordWebhookChannel
{
public function send(object $notifiable, Notification $notification): void
{
if (! method_exists($notification, 'toDiscord')) {
return;
}

$webhookUrl = $notifiable->routeNotificationFor('discord', $notification);
$payload = $notification->toDiscord($notifiable);

if (! is_string($webhookUrl) || $webhookUrl === '' || ! is_array($payload)) {
return;
}

$response = Http::asJson()
->timeout(10)
->post($webhookUrl, $payload);

if ($response->failed()) {
Log::warning('Discord webhook delivery failed', [
'status' => $response->status(),
'body' => $response->body(),
]);
}
}
}
36 changes: 36 additions & 0 deletions app/Notifications/MonitorDown.php
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,42 @@ public function toSlack(object $notifiable): array
return ['text' => $fallback, 'blocks' => $blocks];
}

/**
* Discord incoming-webhook payload.
*
* @return array<string, mixed>
*/
public function toDiscord(object $notifiable): array
{
$dashboardUrl = url("/monitors/{$this->monitor->id}");

$fields = [];
if ($this->errorMessage) {
$fields[] = [
'name' => 'Error',
'value' => mb_substr($this->errorMessage, 0, 1024),
'inline' => false,
];
}

$embed = [
'title' => "🔴 {$this->monitor->name} is DOWN",
'url' => $dashboardUrl,
'description' => $this->monitor->url,
'color' => 0xED4245, // Discord red
'timestamp' => $this->monitor->last_checked_at?->toIso8601String(),
];

if ($fields !== []) {
$embed['fields'] = $fields;
}

return [
'username' => 'EasyMonitor',
'embeds' => [$embed],
];
}

/**
* Generic webhook payload — signed and POSTed by WebhookChannel.
*
Expand Down
19 changes: 19 additions & 0 deletions app/Notifications/MonitorRecovered.php
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,25 @@ public function toSlack(object $notifiable): array
return ['text' => $fallback, 'blocks' => $blocks];
}

/**
* Discord incoming-webhook payload.
*
* @return array<string, mixed>
*/
public function toDiscord(object $notifiable): array
{
return [
'username' => 'EasyMonitor',
'embeds' => [[
'title' => "🟢 {$this->monitor->name} has recovered",
'url' => url("/monitors/{$this->monitor->id}"),
'description' => $this->monitor->url,
'color' => 0x57F287, // Discord green
'timestamp' => $this->monitor->last_checked_at?->toIso8601String(),
]],
];
}

/**
* Generic webhook payload — signed and POSTed by WebhookChannel.
*
Expand Down
11 changes: 11 additions & 0 deletions database/factories/NotificationChannelFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,17 @@ public function slack(?string $webhookUrl = null, ?string $label = null): static
]);
}

public function discord(?string $webhookUrl = null, ?string $label = null): static
{
return $this->state(fn () => [
'type' => NotificationChannelType::Discord,
'label' => $label ?? '#alerts',
'config' => [
'webhook_url' => $webhookUrl ?? 'https://discord.com/api/webhooks/0/aaaaaaaaaaaa',
],
]);
}

public function webhook(?string $url = null, ?string $label = null, ?string $secret = null): static
{
return $this->state(fn () => [
Expand Down
Loading
Loading