diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index c5e12b7ee1..54a654b431 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -80,6 +80,7 @@ protected function schedule(Schedule $schedule): void $this->scheduleInstance->job(new CheckTraefikVersionJob)->weekly()->sundays()->at('00:00')->timezone($this->instanceTimezone)->onOneServer(); $this->scheduleInstance->command('cleanup:database --yes')->daily(); + $this->scheduleInstance->command('queue:prune-batches --hours=48')->daily(); $this->scheduleInstance->command('uploads:clear')->everyTwoMinutes(); // Cleanup orphaned PR preview containers daily diff --git a/app/Jobs/CheckTraefikVersionForServerJob.php b/app/Jobs/CheckTraefikVersionForServerJob.php index 91869eb12d..077b9db77d 100644 --- a/app/Jobs/CheckTraefikVersionForServerJob.php +++ b/app/Jobs/CheckTraefikVersionForServerJob.php @@ -5,6 +5,7 @@ use App\Events\ProxyStatusChangedUI; use App\Models\Server; use App\Notifications\Server\TraefikVersionOutdated; +use Illuminate\Bus\Batchable; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; @@ -14,7 +15,7 @@ class CheckTraefikVersionForServerJob implements ShouldBeEncrypted, ShouldQueue { - use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + use Batchable, Dispatchable, InteractsWithQueue, Queueable, SerializesModels; public $tries = 3; @@ -142,7 +143,7 @@ private function getNewerBranchInfo(string $currentBranch): ?array } /** - * Store outdated information in database and send immediate notification. + * Store outdated information in database. */ private function storeOutdatedInfo(string $current, string $latest, string $type, ?string $upgradeTarget = null, ?array $newerBranchInfo = null): void { @@ -166,23 +167,13 @@ private function storeOutdatedInfo(string $current, string $latest, string $type $this->server->update(['traefik_outdated_info' => $outdatedInfo]); - // Send immediate notification to the team - $this->sendNotification($outdatedInfo); - } - - /** - * Send notification to team about outdated Traefik. - */ - private function sendNotification(array $outdatedInfo): void - { - // Attach the outdated info as a dynamic property for the notification - $this->server->outdatedInfo = $outdatedInfo; - - // Get the team and send notification + // Send immediate notification to channels that have bundling disabled $team = $this->server->team()->first(); - if ($team) { - $team->notify(new TraefikVersionOutdated(collect([$this->server]))); + $unbundledChannels = $team->getEnabledChannels('traefik_outdated', unbundledOnly: true); + if (! empty($unbundledChannels)) { + $team->notify(new TraefikVersionOutdated(collect([$this->server]), unbundledOnly: true)); + } } } } diff --git a/app/Jobs/CheckTraefikVersionJob.php b/app/Jobs/CheckTraefikVersionJob.php index ac94aa23f5..b334037ec8 100644 --- a/app/Jobs/CheckTraefikVersionJob.php +++ b/app/Jobs/CheckTraefikVersionJob.php @@ -10,6 +10,7 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Facades\Bus; class CheckTraefikVersionJob implements ShouldBeEncrypted, ShouldQueue { @@ -37,10 +38,13 @@ public function handle(): void return; } - // Dispatch individual server check jobs in parallel - // Each job will send immediate notifications when outdated Traefik is detected - foreach ($servers as $server) { - CheckTraefikVersionForServerJob::dispatch($server, $traefikVersions); - } + $jobs = $servers->map(fn ($server) => new CheckTraefikVersionForServerJob($server, $traefikVersions)); + + Bus::batch($jobs) + ->allowFailures() + ->finally(fn () => SendTraefikOutdatedNotificationJob::dispatch()) + ->name('traefik-version-check') + ->onQueue('high') + ->dispatch(); } } diff --git a/app/Jobs/SendPatchCheckNotificationJob.php b/app/Jobs/SendPatchCheckNotificationJob.php new file mode 100644 index 0000000000..1dc300a609 --- /dev/null +++ b/app/Jobs/SendPatchCheckNotificationJob.php @@ -0,0 +1,57 @@ + $serverIds Server IDs from the batch that triggered this job + */ + public function __construct(public array $serverIds = []) + { + $this->onQueue('high'); + } + + public function handle(): void + { + $query = Server::whereNotNull('patch_check_data') + ->whereRelation('settings', 'is_reachable', true) + ->with('team'); + + // Scope to specific servers from the batch to avoid race conditions + if (! empty($this->serverIds)) { + $query->whereIn('id', $this->serverIds); + } + + $servers = $query->get(); + + if ($servers->isEmpty()) { + return; + } + + $servers->groupBy('team_id')->each(function ($teamServers) { + $team = $teamServers->first()->team; + if (! $team) { + return; + } + + $team->notify(new ServerPatchCheck($teamServers, bundledOnly: true)); + }); + + // Clear patch data only for the servers in this batch + Server::whereIn('id', $servers->pluck('id'))->update(['patch_check_data' => null]); + } +} diff --git a/app/Jobs/SendTraefikOutdatedNotificationJob.php b/app/Jobs/SendTraefikOutdatedNotificationJob.php new file mode 100644 index 0000000000..c94a131825 --- /dev/null +++ b/app/Jobs/SendTraefikOutdatedNotificationJob.php @@ -0,0 +1,67 @@ +onQueue('high'); + } + + public function handle(): void + { + $servers = Server::whereNotNull('traefik_outdated_info') + ->whereProxyType(ProxyTypes::TRAEFIK->value) + ->whereRelation('settings', 'is_reachable', true) + ->with('team') + ->get(); + + if ($servers->isEmpty()) { + return; + } + + // Only include servers whose check is newer than the last notification + $serversToNotify = $servers->filter(function ($server) { + $info = $server->traefik_outdated_info; + $checkedAt = $info['checked_at'] ?? null; + $notifiedAt = $info['notified_at'] ?? null; + + return $checkedAt && (! $notifiedAt || $checkedAt > $notifiedAt); + }); + + if ($serversToNotify->isEmpty()) { + return; + } + + $serversToNotify->groupBy('team_id')->each(function ($teamServers) { + $team = $teamServers->first()->team; + if (! $team) { + return; + } + + $team->notify(new TraefikVersionOutdated($teamServers, bundledOnly: true)); + }); + + // Mark servers as notified so they aren't re-notified next week + $serversToNotify->each(function ($server) { + $info = $server->traefik_outdated_info; + $info['notified_at'] = now()->toIso8601String(); + $server->update(['traefik_outdated_info' => $info]); + }); + } +} diff --git a/app/Jobs/ServerManagerJob.php b/app/Jobs/ServerManagerJob.php index 9532282cc5..059b167785 100644 --- a/app/Jobs/ServerManagerJob.php +++ b/app/Jobs/ServerManagerJob.php @@ -13,6 +13,7 @@ use Illuminate\Queue\SerializesModels; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Log; class ServerManagerJob implements ShouldBeEncrypted, ShouldQueue @@ -30,6 +31,8 @@ class ServerManagerJob implements ShouldBeEncrypted, ShouldQueue private string $checkFrequency = '* * * * *'; + private array $pendingPatchCheckJobs = []; + /** * Create a new job instance. */ @@ -60,6 +63,9 @@ public function handle(): void // Process server-specific scheduled tasks $this->processScheduledTasks($servers); + + // Dispatch collected patch check jobs as a batch (if bundling is enabled) + $this->dispatchPatchCheckJobs(); } private function getServers(): Collection @@ -160,11 +166,11 @@ private function processServerTasks(Server $server): void } } - // Dispatch ServerPatchCheckJob if due (weekly) + // Collect ServerPatchCheckJob if due (weekly) $shouldRunPatchCheck = shouldRunCronNow('0 0 * * 0', $serverTimezone, "server-patch-check:{$server->id}", $this->executionTime); - if ($shouldRunPatchCheck) { // Weekly on Sunday at midnight - ServerPatchCheckJob::dispatch($server); + if ($shouldRunPatchCheck) { + $this->pendingPatchCheckJobs[] = new ServerPatchCheckJob($server); } // Note: CheckAndStartSentinelJob is only dispatched daily (line above) for version updates. @@ -205,4 +211,20 @@ private function shouldSkipDueToBackoff(Server $server): bool return ($cycleIndex + $serverHash) % $interval !== 0; } + + private function dispatchPatchCheckJobs(): void + { + if (empty($this->pendingPatchCheckJobs)) { + return; + } + + $serverIds = collect($this->pendingPatchCheckJobs)->map(fn ($job) => $job->server->id)->all(); + + Bus::batch($this->pendingPatchCheckJobs) + ->allowFailures() + ->finally(fn () => SendPatchCheckNotificationJob::dispatch($serverIds)) + ->name('server-patch-check') + ->onQueue('high') + ->dispatch(); + } } diff --git a/app/Jobs/ServerPatchCheckJob.php b/app/Jobs/ServerPatchCheckJob.php index 18999c009e..49a4131a14 100644 --- a/app/Jobs/ServerPatchCheckJob.php +++ b/app/Jobs/ServerPatchCheckJob.php @@ -5,6 +5,7 @@ use App\Actions\Server\CheckUpdates; use App\Models\Server; use App\Notifications\Server\ServerPatchCheck; +use Illuminate\Bus\Batchable; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; @@ -12,10 +13,11 @@ use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Facades\Log; class ServerPatchCheckJob implements ShouldBeEncrypted, ShouldQueue { - use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + use Batchable, Dispatchable, InteractsWithQueue, Queueable, SerializesModels; public $tries = 3; @@ -23,7 +25,7 @@ class ServerPatchCheckJob implements ShouldBeEncrypted, ShouldQueue public function middleware(): array { - return [(new WithoutOverlapping('server-patch-check-'.$this->server->uuid))->expireAfter(600)->dontRelease()]; + return [(new WithoutOverlapping('server-patch-check-'.$this->server->uuid))->expireAfter(600)->releaseAfter(60)]; } public function __construct(public Server $server) {} @@ -43,21 +45,21 @@ public function handle(): void // Check for updates $patchData = CheckUpdates::run($this->server); - if (isset($patchData['error'])) { - $team->notify(new ServerPatchCheck($this->server, $patchData)); - - return; // Skip if there's an error checking for updates - } - $totalUpdates = $patchData['total_updates'] ?? 0; - // Only send notification if there are updates available - if ($totalUpdates > 0) { - $team->notify(new ServerPatchCheck($this->server, $patchData)); + if (isset($patchData['error']) || $totalUpdates > 0) { + $this->server->update(['patch_check_data' => $patchData]); + + // Send immediate notification to channels that have bundling disabled + $unbundledChannels = $team->getEnabledChannels('server_patch', unbundledOnly: true); + if (! empty($unbundledChannels)) { + $team->notify(new ServerPatchCheck(collect([$this->server]), unbundledOnly: true)); + } + } else { + $this->server->update(['patch_check_data' => null]); } } catch (\Throwable $e) { - // Log error but don't fail the job - \Illuminate\Support\Facades\Log::error('ServerPatchCheckJob failed: '.$e->getMessage(), [ + Log::error('ServerPatchCheckJob failed: '.$e->getMessage(), [ 'server_id' => $this->server->id, 'server_name' => $this->server->name, 'error' => $e->getMessage(), diff --git a/app/Livewire/Notifications/Discord.php b/app/Livewire/Notifications/Discord.php index ab3884320b..60aa8fedf3 100644 --- a/app/Livewire/Notifications/Discord.php +++ b/app/Livewire/Notifications/Discord.php @@ -69,6 +69,12 @@ class Discord extends Component #[Validate(['boolean'])] public bool $discordPingEnabled = true; + #[Validate(['boolean'])] + public bool $bundlePatchNotifications = false; + + #[Validate(['boolean'])] + public bool $bundleTraefikNotifications = false; + public function mount() { try { @@ -106,6 +112,9 @@ public function syncData(bool $toModel = false) $this->settings->discord_ping_enabled = $this->discordPingEnabled; + $this->settings->bundle_patch_notifications = $this->bundlePatchNotifications; + $this->settings->bundle_traefik_notifications = $this->bundleTraefikNotifications; + $this->settings->save(); refreshSession(); } else { @@ -128,6 +137,9 @@ public function syncData(bool $toModel = false) $this->traefikOutdatedDiscordNotifications = $this->settings->traefik_outdated_discord_notifications; $this->discordPingEnabled = $this->settings->discord_ping_enabled; + + $this->bundlePatchNotifications = $this->settings->bundle_patch_notifications ?? false; + $this->bundleTraefikNotifications = $this->settings->bundle_traefik_notifications ?? false; } } diff --git a/app/Livewire/Notifications/Email.php b/app/Livewire/Notifications/Email.php index 364163ff82..290b17d37d 100644 --- a/app/Livewire/Notifications/Email.php +++ b/app/Livewire/Notifications/Email.php @@ -110,6 +110,12 @@ class Email extends Component #[Validate(['nullable', 'email'])] public ?string $testEmailAddress = null; + #[Validate(['boolean'])] + public bool $bundlePatchNotifications = false; + + #[Validate(['boolean'])] + public bool $bundleTraefikNotifications = false; + public function mount() { try { @@ -159,6 +165,9 @@ public function syncData(bool $toModel = false) $this->settings->server_unreachable_email_notifications = $this->serverUnreachableEmailNotifications; $this->settings->server_patch_email_notifications = $this->serverPatchEmailNotifications; $this->settings->traefik_outdated_email_notifications = $this->traefikOutdatedEmailNotifications; + + $this->settings->bundle_patch_notifications = $this->bundlePatchNotifications; + $this->settings->bundle_traefik_notifications = $this->bundleTraefikNotifications; $this->settings->save(); } else { @@ -192,6 +201,9 @@ public function syncData(bool $toModel = false) $this->serverUnreachableEmailNotifications = $this->settings->server_unreachable_email_notifications; $this->serverPatchEmailNotifications = $this->settings->server_patch_email_notifications; $this->traefikOutdatedEmailNotifications = $this->settings->traefik_outdated_email_notifications; + + $this->bundlePatchNotifications = $this->settings->bundle_patch_notifications ?? false; + $this->bundleTraefikNotifications = $this->settings->bundle_traefik_notifications ?? false; } } diff --git a/app/Livewire/Notifications/Pushover.php b/app/Livewire/Notifications/Pushover.php index d79eea87be..37cdb44610 100644 --- a/app/Livewire/Notifications/Pushover.php +++ b/app/Livewire/Notifications/Pushover.php @@ -73,6 +73,12 @@ class Pushover extends Component #[Validate(['boolean'])] public bool $traefikOutdatedPushoverNotifications = true; + #[Validate(['boolean'])] + public bool $bundlePatchNotifications = false; + + #[Validate(['boolean'])] + public bool $bundleTraefikNotifications = false; + public function mount() { try { @@ -109,6 +115,9 @@ public function syncData(bool $toModel = false) $this->settings->server_patch_pushover_notifications = $this->serverPatchPushoverNotifications; $this->settings->traefik_outdated_pushover_notifications = $this->traefikOutdatedPushoverNotifications; + $this->settings->bundle_patch_notifications = $this->bundlePatchNotifications; + $this->settings->bundle_traefik_notifications = $this->bundleTraefikNotifications; + $this->settings->save(); refreshSession(); } else { @@ -130,6 +139,9 @@ public function syncData(bool $toModel = false) $this->serverUnreachablePushoverNotifications = $this->settings->server_unreachable_pushover_notifications; $this->serverPatchPushoverNotifications = $this->settings->server_patch_pushover_notifications; $this->traefikOutdatedPushoverNotifications = $this->settings->traefik_outdated_pushover_notifications; + + $this->bundlePatchNotifications = $this->settings->bundle_patch_notifications ?? false; + $this->bundleTraefikNotifications = $this->settings->bundle_traefik_notifications ?? false; } } diff --git a/app/Livewire/Notifications/Slack.php b/app/Livewire/Notifications/Slack.php index f870b39868..7701e7fae4 100644 --- a/app/Livewire/Notifications/Slack.php +++ b/app/Livewire/Notifications/Slack.php @@ -71,6 +71,12 @@ class Slack extends Component #[Validate(['boolean'])] public bool $traefikOutdatedSlackNotifications = true; + #[Validate(['boolean'])] + public bool $bundlePatchNotifications = false; + + #[Validate(['boolean'])] + public bool $bundleTraefikNotifications = false; + public function mount() { try { @@ -106,6 +112,9 @@ public function syncData(bool $toModel = false) $this->settings->server_patch_slack_notifications = $this->serverPatchSlackNotifications; $this->settings->traefik_outdated_slack_notifications = $this->traefikOutdatedSlackNotifications; + $this->settings->bundle_patch_notifications = $this->bundlePatchNotifications; + $this->settings->bundle_traefik_notifications = $this->bundleTraefikNotifications; + $this->settings->save(); refreshSession(); } else { @@ -126,6 +135,9 @@ public function syncData(bool $toModel = false) $this->serverUnreachableSlackNotifications = $this->settings->server_unreachable_slack_notifications; $this->serverPatchSlackNotifications = $this->settings->server_patch_slack_notifications; $this->traefikOutdatedSlackNotifications = $this->settings->traefik_outdated_slack_notifications; + + $this->bundlePatchNotifications = $this->settings->bundle_patch_notifications ?? false; + $this->bundleTraefikNotifications = $this->settings->bundle_traefik_notifications ?? false; } } diff --git a/app/Livewire/Notifications/Telegram.php b/app/Livewire/Notifications/Telegram.php index fc3966cf6c..223f449a16 100644 --- a/app/Livewire/Notifications/Telegram.php +++ b/app/Livewire/Notifications/Telegram.php @@ -115,6 +115,12 @@ class Telegram extends Component #[Validate(['nullable', 'string'])] public ?string $telegramNotificationsTraefikOutdatedThreadId = null; + #[Validate(['boolean'])] + public bool $bundlePatchNotifications = false; + + #[Validate(['boolean'])] + public bool $bundleTraefikNotifications = false; + public function mount() { try { @@ -166,6 +172,9 @@ public function syncData(bool $toModel = false) $this->settings->telegram_notifications_server_patch_thread_id = $this->telegramNotificationsServerPatchThreadId; $this->settings->telegram_notifications_traefik_outdated_thread_id = $this->telegramNotificationsTraefikOutdatedThreadId; + $this->settings->bundle_patch_notifications = $this->bundlePatchNotifications; + $this->settings->bundle_traefik_notifications = $this->bundleTraefikNotifications; + $this->settings->save(); } else { $this->telegramEnabled = $this->settings->telegram_enabled; @@ -201,6 +210,9 @@ public function syncData(bool $toModel = false) $this->telegramNotificationsServerUnreachableThreadId = $this->settings->telegram_notifications_server_unreachable_thread_id; $this->telegramNotificationsServerPatchThreadId = $this->settings->telegram_notifications_server_patch_thread_id; $this->telegramNotificationsTraefikOutdatedThreadId = $this->settings->telegram_notifications_traefik_outdated_thread_id; + + $this->bundlePatchNotifications = $this->settings->bundle_patch_notifications ?? false; + $this->bundleTraefikNotifications = $this->settings->bundle_traefik_notifications ?? false; } } diff --git a/app/Livewire/Notifications/Webhook.php b/app/Livewire/Notifications/Webhook.php index 630d422a9b..5060789c2a 100644 --- a/app/Livewire/Notifications/Webhook.php +++ b/app/Livewire/Notifications/Webhook.php @@ -66,6 +66,12 @@ class Webhook extends Component #[Validate(['boolean'])] public bool $traefikOutdatedWebhookNotifications = true; + #[Validate(['boolean'])] + public bool $bundlePatchNotifications = false; + + #[Validate(['boolean'])] + public bool $bundleTraefikNotifications = false; + public function mount() { try { @@ -101,6 +107,9 @@ public function syncData(bool $toModel = false) $this->settings->server_patch_webhook_notifications = $this->serverPatchWebhookNotifications; $this->settings->traefik_outdated_webhook_notifications = $this->traefikOutdatedWebhookNotifications; + $this->settings->bundle_patch_notifications = $this->bundlePatchNotifications; + $this->settings->bundle_traefik_notifications = $this->bundleTraefikNotifications; + $this->settings->save(); refreshSession(); } else { @@ -121,6 +130,9 @@ public function syncData(bool $toModel = false) $this->serverUnreachableWebhookNotifications = $this->settings->server_unreachable_webhook_notifications; $this->serverPatchWebhookNotifications = $this->settings->server_patch_webhook_notifications; $this->traefikOutdatedWebhookNotifications = $this->settings->traefik_outdated_webhook_notifications; + + $this->bundlePatchNotifications = $this->settings->bundle_patch_notifications ?? false; + $this->bundleTraefikNotifications = $this->settings->bundle_traefik_notifications ?? false; } } diff --git a/app/Models/DiscordNotificationSettings.php b/app/Models/DiscordNotificationSettings.php index e86598126e..2b5de69019 100644 --- a/app/Models/DiscordNotificationSettings.php +++ b/app/Models/DiscordNotificationSettings.php @@ -32,6 +32,8 @@ class DiscordNotificationSettings extends Model 'server_patch_discord_notifications', 'traefik_outdated_discord_notifications', 'discord_ping_enabled', + 'bundle_patch_notifications', + 'bundle_traefik_notifications', ]; protected $casts = [ @@ -52,6 +54,8 @@ class DiscordNotificationSettings extends Model 'server_patch_discord_notifications' => 'boolean', 'traefik_outdated_discord_notifications' => 'boolean', 'discord_ping_enabled' => 'boolean', + 'bundle_patch_notifications' => 'boolean', + 'bundle_traefik_notifications' => 'boolean', ]; public function team() diff --git a/app/Models/EmailNotificationSettings.php b/app/Models/EmailNotificationSettings.php index 1277e45d91..c3acfe675d 100644 --- a/app/Models/EmailNotificationSettings.php +++ b/app/Models/EmailNotificationSettings.php @@ -41,6 +41,8 @@ class EmailNotificationSettings extends Model 'server_unreachable_email_notifications', 'server_patch_email_notifications', 'traefik_outdated_email_notifications', + 'bundle_patch_notifications', + 'bundle_traefik_notifications', ]; protected $casts = [ @@ -69,6 +71,8 @@ class EmailNotificationSettings extends Model 'server_disk_usage_email_notifications' => 'boolean', 'server_patch_email_notifications' => 'boolean', 'traefik_outdated_email_notifications' => 'boolean', + 'bundle_patch_notifications' => 'boolean', + 'bundle_traefik_notifications' => 'boolean', ]; public function team() diff --git a/app/Models/PushoverNotificationSettings.php b/app/Models/PushoverNotificationSettings.php index 5ad617ad69..b9ca7c6f13 100644 --- a/app/Models/PushoverNotificationSettings.php +++ b/app/Models/PushoverNotificationSettings.php @@ -32,6 +32,8 @@ class PushoverNotificationSettings extends Model 'server_unreachable_pushover_notifications', 'server_patch_pushover_notifications', 'traefik_outdated_pushover_notifications', + 'bundle_patch_notifications', + 'bundle_traefik_notifications', ]; protected $casts = [ @@ -52,6 +54,8 @@ class PushoverNotificationSettings extends Model 'server_unreachable_pushover_notifications' => 'boolean', 'server_patch_pushover_notifications' => 'boolean', 'traefik_outdated_pushover_notifications' => 'boolean', + 'bundle_patch_notifications' => 'boolean', + 'bundle_traefik_notifications' => 'boolean', ]; public function team() diff --git a/app/Models/Server.php b/app/Models/Server.php index 06426f2119..10886524e8 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -244,6 +244,7 @@ public static function flushIdentityMap(): void protected $casts = [ 'proxy' => SchemalessAttributes::class, 'traefik_outdated_info' => 'array', + 'patch_check_data' => 'array', 'server_metadata' => 'array', 'logdrain_axiom_api_key' => 'encrypted', 'logdrain_newrelic_license_key' => 'encrypted', @@ -272,6 +273,7 @@ public static function flushIdentityMap(): void 'is_validating', 'detected_traefik_version', 'traefik_outdated_info', + 'patch_check_data', 'server_metadata', 'ip_previous', ]; diff --git a/app/Models/SlackNotificationSettings.php b/app/Models/SlackNotificationSettings.php index d4f125fb5d..68b7bc0b15 100644 --- a/app/Models/SlackNotificationSettings.php +++ b/app/Models/SlackNotificationSettings.php @@ -31,6 +31,8 @@ class SlackNotificationSettings extends Model 'server_unreachable_slack_notifications', 'server_patch_slack_notifications', 'traefik_outdated_slack_notifications', + 'bundle_patch_notifications', + 'bundle_traefik_notifications', ]; protected $casts = [ @@ -50,6 +52,8 @@ class SlackNotificationSettings extends Model 'server_unreachable_slack_notifications' => 'boolean', 'server_patch_slack_notifications' => 'boolean', 'traefik_outdated_slack_notifications' => 'boolean', + 'bundle_patch_notifications' => 'boolean', + 'bundle_traefik_notifications' => 'boolean', ]; public function team() diff --git a/app/Models/TelegramNotificationSettings.php b/app/Models/TelegramNotificationSettings.php index 4930f45d43..e425c7e8d1 100644 --- a/app/Models/TelegramNotificationSettings.php +++ b/app/Models/TelegramNotificationSettings.php @@ -47,6 +47,8 @@ class TelegramNotificationSettings extends Model 'telegram_notifications_server_unreachable_thread_id', 'telegram_notifications_server_patch_thread_id', 'telegram_notifications_traefik_outdated_thread_id', + 'bundle_patch_notifications', + 'bundle_traefik_notifications', ]; protected $casts = [ @@ -81,6 +83,8 @@ class TelegramNotificationSettings extends Model 'telegram_notifications_server_unreachable_thread_id' => 'encrypted', 'telegram_notifications_server_patch_thread_id' => 'encrypted', 'telegram_notifications_traefik_outdated_thread_id' => 'encrypted', + 'bundle_patch_notifications' => 'boolean', + 'bundle_traefik_notifications' => 'boolean', ]; public function team() diff --git a/app/Models/WebhookNotificationSettings.php b/app/Models/WebhookNotificationSettings.php index 7310061816..42b92cf900 100644 --- a/app/Models/WebhookNotificationSettings.php +++ b/app/Models/WebhookNotificationSettings.php @@ -31,6 +31,8 @@ class WebhookNotificationSettings extends Model 'server_unreachable_webhook_notifications', 'server_patch_webhook_notifications', 'traefik_outdated_webhook_notifications', + 'bundle_patch_notifications', + 'bundle_traefik_notifications', ]; protected function casts(): array @@ -53,6 +55,8 @@ protected function casts(): array 'server_unreachable_webhook_notifications' => 'boolean', 'server_patch_webhook_notifications' => 'boolean', 'traefik_outdated_webhook_notifications' => 'boolean', + 'bundle_patch_notifications' => 'boolean', + 'bundle_traefik_notifications' => 'boolean', ]; } diff --git a/app/Notifications/Server/ServerPatchCheck.php b/app/Notifications/Server/ServerPatchCheck.php index ba6cd49829..ad9dfa0ac7 100644 --- a/app/Notifications/Server/ServerPatchCheck.php +++ b/app/Notifications/Server/ServerPatchCheck.php @@ -2,55 +2,66 @@ namespace App\Notifications\Server; -use App\Models\Server; use App\Notifications\CustomEmailNotification; use App\Notifications\Dto\DiscordMessage; use App\Notifications\Dto\PushoverMessage; use App\Notifications\Dto\SlackMessage; use Illuminate\Notifications\Messages\MailMessage; +use Illuminate\Support\Collection; class ServerPatchCheck extends CustomEmailNotification { - public string $serverUrl; - - public function __construct(public Server $server, public array $patchData) - { + public function __construct( + public Collection $servers, + public bool $bundledOnly = false, + public bool $unbundledOnly = false, + ) { $this->onQueue('high'); - $this->serverUrl = base_url().'/server/'.$this->server->uuid.'/security/patches'; } public function via(object $notifiable): array { - return $notifiable->getEnabledChannels('server_patch'); + return $notifiable->getEnabledChannels('server_patch', bundledOnly: $this->bundledOnly, unbundledOnly: $this->unbundledOnly); + } + + private function serverUrl(object $server): string + { + return base_url().'/server/'.$server->uuid.'/security/patches'; + } + + private function hasErrors(): bool + { + return $this->servers->contains(fn ($s) => isset($s->patch_check_data['error'])); + } + + private function errorServers(): Collection + { + return $this->servers->filter(fn ($s) => isset($s->patch_check_data['error'])); + } + + private function updateServers(): Collection + { + return $this->servers->filter(fn ($s) => ! isset($s->patch_check_data['error'])); } public function toMail($notifiable = null): MailMessage { $mail = new MailMessage; + $count = $this->servers->count(); - // Handle error case - if (isset($this->patchData['error'])) { - $mail->subject("Coolify: [ERROR] Failed to check patches on {$this->server->name}"); - $mail->view('emails.server-patches-error', [ - 'name' => $this->server->name, - 'error' => $this->patchData['error'], - 'osId' => $this->patchData['osId'] ?? 'unknown', - 'package_manager' => $this->patchData['package_manager'] ?? 'unknown', - 'server_url' => $this->serverUrl, - ]); - - return $mail; - } + $serversWithUrls = $this->servers->map(function ($server) { + return [ + 'name' => $server->name, + 'uuid' => $server->uuid, + 'url' => $this->serverUrl($server), + 'patchData' => $server->patch_check_data, + ]; + }); - $totalUpdates = $this->patchData['total_updates'] ?? 0; - $mail->subject("Coolify: [ACTION REQUIRED] {$totalUpdates} server patches available on {$this->server->name}"); - $mail->view('emails.server-patches', [ - 'name' => $this->server->name, - 'total_updates' => $totalUpdates, - 'updates' => $this->patchData['updates'] ?? [], - 'osId' => $this->patchData['osId'] ?? 'unknown', - 'package_manager' => $this->patchData['package_manager'] ?? 'unknown', - 'server_url' => $this->serverUrl, + $mail->subject("Coolify: Server patches available on {$count} server(s)"); + $mail->view('emails.server-patches-bundled', [ + 'servers' => $serversWithUrls, + 'count' => $count, ]); return $mail; @@ -58,284 +69,124 @@ public function toMail($notifiable = null): MailMessage public function toDiscord(): DiscordMessage { - // Handle error case - if (isset($this->patchData['error'])) { - $osId = $this->patchData['osId'] ?? 'unknown'; - $packageManager = $this->patchData['package_manager'] ?? 'unknown'; - $error = $this->patchData['error']; - - $description = "**Failed to check for updates** on server {$this->server->name}\n\n"; - $description .= "**Error Details:**\n"; - $description .= '• OS: '.ucfirst($osId)."\n"; - $description .= "• Package Manager: {$packageManager}\n"; - $description .= "• Error: {$error}\n\n"; - $description .= "[Manage Server]($this->serverUrl)"; - - return new DiscordMessage( - title: ':x: Coolify: [ERROR] Failed to check patches on '.$this->server->name, - description: $description, - color: DiscordMessage::errorColor(), - ); + $count = $this->servers->count(); + $description = "**{$count} server(s)** have package updates or errors.\n\n"; + + foreach ($this->errorServers() as $server) { + $data = $server->patch_check_data; + $description .= ":x: **{$server->name}** — failed to check updates\n"; + $description .= " Error: {$data['error']}\n"; } - $totalUpdates = $this->patchData['total_updates'] ?? 0; - $updates = $this->patchData['updates'] ?? []; - $osId = $this->patchData['osId'] ?? 'unknown'; - $packageManager = $this->patchData['package_manager'] ?? 'unknown'; - - $description = "**{$totalUpdates} package updates** available for server {$this->server->name}\n\n"; - $description .= "**Summary:**\n"; - $description .= '• OS: '.ucfirst($osId)."\n"; - $description .= "• Package Manager: {$packageManager}\n"; - $description .= "• Total Updates: {$totalUpdates}\n\n"; - - // Show first few packages - if (count($updates) > 0) { - $description .= "**Sample Updates:**\n"; - $sampleUpdates = array_slice($updates, 0, 5); - foreach ($sampleUpdates as $update) { - $description .= "• {$update['package']}: {$update['current_version']} → {$update['new_version']}\n"; - } - if (count($updates) > 5) { - $description .= '• ... and '.(count($updates) - 5)." more packages\n"; - } + foreach ($this->updateServers() as $server) { + $data = $server->patch_check_data; + $total = $data['total_updates'] ?? 0; + $description .= ":warning: **{$server->name}** — {$total} updates available\n"; - // Check for critical packages - $criticalPackages = collect($updates)->filter(function ($update) { - return str_contains(strtolower($update['package']), 'docker') || - str_contains(strtolower($update['package']), 'kernel') || - str_contains(strtolower($update['package']), 'openssh') || - str_contains(strtolower($update['package']), 'ssl'); - }); + $updates = $data['updates'] ?? []; + $criticalPackages = collect($updates)->filter(fn ($u) => str_contains(strtolower($u['package']), 'docker') || + str_contains(strtolower($u['package']), 'kernel') || + str_contains(strtolower($u['package']), 'openssh') || + str_contains(strtolower($u['package']), 'ssl') + ); - if ($criticalPackages->count() > 0) { - $description .= "\n **Critical packages detected** ({$criticalPackages->count()} packages may require restarts)"; + if ($criticalPackages->isNotEmpty()) { + $description .= " ⚠ {$criticalPackages->count()} critical package(s)\n"; } - $description .= "\n [Manage Server Patches]($this->serverUrl)"; } return new DiscordMessage( - title: ':warning: Coolify: [ACTION REQUIRED] Server patches available on '.$this->server->name, + title: ':warning: Coolify: [ACTION REQUIRED] Server patches available', description: $description, color: DiscordMessage::errorColor(), ); - } public function toTelegram(): array { - // Handle error case - if (isset($this->patchData['error'])) { - $osId = $this->patchData['osId'] ?? 'unknown'; - $packageManager = $this->patchData['package_manager'] ?? 'unknown'; - $error = $this->patchData['error']; - - $message = "❌ Coolify: [ERROR] Failed to check patches on {$this->server->name}!\n\n"; - $message .= "📊 Error Details:\n"; - $message .= '• OS: '.ucfirst($osId)."\n"; - $message .= "• Package Manager: {$packageManager}\n"; - $message .= "• Error: {$error}\n\n"; + $count = $this->servers->count(); + $message = "🔧 Coolify: [ACTION REQUIRED] Server patches available on {$count} server(s)!\n\n"; - return [ - 'message' => $message, - 'buttons' => [ - [ - 'text' => 'Manage Server', - 'url' => $this->serverUrl, - ], - ], - ]; + foreach ($this->errorServers() as $server) { + $data = $server->patch_check_data; + $message .= "❌ {$server->name} — failed to check updates\n"; + $message .= " Error: {$data['error']}\n"; } - $totalUpdates = $this->patchData['total_updates'] ?? 0; - $updates = $this->patchData['updates'] ?? []; - $osId = $this->patchData['osId'] ?? 'unknown'; - $packageManager = $this->patchData['package_manager'] ?? 'unknown'; - - $message = "🔧 Coolify: [ACTION REQUIRED] {$totalUpdates} server patches available on {$this->server->name}!\n\n"; - $message .= "📊 Summary:\n"; - $message .= '• OS: '.ucfirst($osId)."\n"; - $message .= "• Package Manager: {$packageManager}\n"; - $message .= "• Total Updates: {$totalUpdates}\n\n"; - - if (count($updates) > 0) { - $message .= "📦 Sample Updates:\n"; - $sampleUpdates = array_slice($updates, 0, 5); - foreach ($sampleUpdates as $update) { - $message .= "• {$update['package']}: {$update['current_version']} → {$update['new_version']}\n"; - } - if (count($updates) > 5) { - $message .= '• ... and '.(count($updates) - 5)." more packages\n"; - } + foreach ($this->updateServers() as $server) { + $data = $server->patch_check_data; + $total = $data['total_updates'] ?? 0; + $message .= "📦 {$server->name} — {$total} updates available\n"; + + $updates = $data['updates'] ?? []; + $criticalPackages = collect($updates)->filter(fn ($u) => str_contains(strtolower($u['package']), 'docker') || + str_contains(strtolower($u['package']), 'kernel') || + str_contains(strtolower($u['package']), 'openssh') || + str_contains(strtolower($u['package']), 'ssl') + ); - // Check for critical packages - $criticalPackages = collect($updates)->filter(function ($update) { - return str_contains(strtolower($update['package']), 'docker') || - str_contains(strtolower($update['package']), 'kernel') || - str_contains(strtolower($update['package']), 'openssh') || - str_contains(strtolower($update['package']), 'ssl'); - }); - - if ($criticalPackages->count() > 0) { - $message .= "\n⚠️ Critical packages detected: {$criticalPackages->count()} packages may require restarts\n"; - foreach ($criticalPackages->take(3) as $package) { - $message .= "• {$package['package']}: {$package['current_version']} → {$package['new_version']}\n"; - } - if ($criticalPackages->count() > 3) { - $message .= '• ... and '.($criticalPackages->count() - 3)." more critical packages\n"; - } + if ($criticalPackages->isNotEmpty()) { + $message .= " ⚠️ {$criticalPackages->count()} critical package(s)\n"; } } return [ 'message' => $message, - 'buttons' => [ - [ - 'text' => 'Manage Server Patches', - 'url' => $this->serverUrl, - ], - ], + 'buttons' => [], ]; } public function toPushover(): PushoverMessage { - // Handle error case - if (isset($this->patchData['error'])) { - $osId = $this->patchData['osId'] ?? 'unknown'; - $packageManager = $this->patchData['package_manager'] ?? 'unknown'; - $error = $this->patchData['error']; - - $message = "[ERROR] Failed to check patches on {$this->server->name}!\n\n"; - $message .= "Error Details:\n"; - $message .= '• OS: '.ucfirst($osId)."\n"; - $message .= "• Package Manager: {$packageManager}\n"; - $message .= "• Error: {$error}\n\n"; - - return new PushoverMessage( - title: 'Server patch check failed', - level: 'error', - message: $message, - buttons: [ - [ - 'text' => 'Manage Server', - 'url' => $this->serverUrl, - ], - ], - ); - } + $count = $this->servers->count(); + $message = "Server patches available on {$count} server(s)!\n\n"; - $totalUpdates = $this->patchData['total_updates'] ?? 0; - $updates = $this->patchData['updates'] ?? []; - $osId = $this->patchData['osId'] ?? 'unknown'; - $packageManager = $this->patchData['package_manager'] ?? 'unknown'; - - $message = "[ACTION REQUIRED] {$totalUpdates} server patches available on {$this->server->name}!\n\n"; - $message .= "Summary:\n"; - $message .= '• OS: '.ucfirst($osId)."\n"; - $message .= "• Package Manager: {$packageManager}\n"; - $message .= "• Total Updates: {$totalUpdates}\n\n"; - - if (count($updates) > 0) { - $message .= "Sample Updates:\n"; - $sampleUpdates = array_slice($updates, 0, 3); - foreach ($sampleUpdates as $update) { - $message .= "• {$update['package']}: {$update['current_version']} → {$update['new_version']}\n"; - } - if (count($updates) > 3) { - $message .= '• ... and '.(count($updates) - 3)." more packages\n"; - } - - // Check for critical packages - $criticalPackages = collect($updates)->filter(function ($update) { - return str_contains(strtolower($update['package']), 'docker') || - str_contains(strtolower($update['package']), 'kernel') || - str_contains(strtolower($update['package']), 'openssh') || - str_contains(strtolower($update['package']), 'ssl'); - }); + foreach ($this->errorServers() as $server) { + $data = $server->patch_check_data; + $message .= "[ERROR] {$server->name} — {$data['error']}\n"; + } - if ($criticalPackages->count() > 0) { - $message .= "\nCritical packages detected: {$criticalPackages->count()} may require restarts"; - } + foreach ($this->updateServers() as $server) { + $data = $server->patch_check_data; + $total = $data['total_updates'] ?? 0; + $message .= "{$server->name} — {$total} updates\n"; } return new PushoverMessage( title: 'Server patches available', level: 'error', message: $message, - buttons: [ - [ - 'text' => 'Manage Server Patches', - 'url' => $this->serverUrl, - ], - ], ); } public function toSlack(): SlackMessage { - // Handle error case - if (isset($this->patchData['error'])) { - $osId = $this->patchData['osId'] ?? 'unknown'; - $packageManager = $this->patchData['package_manager'] ?? 'unknown'; - $error = $this->patchData['error']; - - $description = "Failed to check patches on '{$this->server->name}'!\n\n"; - $description .= "*Error Details:*\n"; - $description .= '• OS: '.ucfirst($osId)."\n"; - $description .= "• Package Manager: {$packageManager}\n"; - $description .= "• Error: `{$error}`\n\n"; - $description .= "\n:link: <{$this->serverUrl}|Manage Server>"; - - return new SlackMessage( - title: 'Coolify: [ERROR] Server patch check failed', - description: $description, - color: SlackMessage::errorColor() - ); + $count = $this->servers->count(); + $description = "Server patches available on {$count} server(s)!\n\n"; + + foreach ($this->errorServers() as $server) { + $data = $server->patch_check_data; + $description .= ":x: *{$server->name}* — failed to check updates\n"; + $description .= " Error: `{$data['error']}`\n"; } - $totalUpdates = $this->patchData['total_updates'] ?? 0; - $updates = $this->patchData['updates'] ?? []; - $osId = $this->patchData['osId'] ?? 'unknown'; - $packageManager = $this->patchData['package_manager'] ?? 'unknown'; - - $description = "{$totalUpdates} server patches available on '{$this->server->name}'!\n\n"; - $description .= "*Summary:*\n"; - $description .= '• OS: '.ucfirst($osId)."\n"; - $description .= "• Package Manager: {$packageManager}\n"; - $description .= "• Total Updates: {$totalUpdates}\n\n"; - - if (count($updates) > 0) { - $description .= "*Sample Updates:*\n"; - $sampleUpdates = array_slice($updates, 0, 5); - foreach ($sampleUpdates as $update) { - $description .= "• `{$update['package']}`: {$update['current_version']} → {$update['new_version']}\n"; - } - if (count($updates) > 5) { - $description .= '• ... and '.(count($updates) - 5)." more packages\n"; - } + foreach ($this->updateServers() as $server) { + $data = $server->patch_check_data; + $total = $data['total_updates'] ?? 0; + $description .= ":warning: *{$server->name}* — {$total} updates available\n"; + + $updates = $data['updates'] ?? []; + $criticalPackages = collect($updates)->filter(fn ($u) => str_contains(strtolower($u['package']), 'docker') || + str_contains(strtolower($u['package']), 'kernel') || + str_contains(strtolower($u['package']), 'openssh') || + str_contains(strtolower($u['package']), 'ssl') + ); - // Check for critical packages - $criticalPackages = collect($updates)->filter(function ($update) { - return str_contains(strtolower($update['package']), 'docker') || - str_contains(strtolower($update['package']), 'kernel') || - str_contains(strtolower($update['package']), 'openssh') || - str_contains(strtolower($update['package']), 'ssl'); - }); - - if ($criticalPackages->count() > 0) { - $description .= "\n:warning: *Critical packages detected:* {$criticalPackages->count()} packages may require restarts\n"; - foreach ($criticalPackages->take(3) as $package) { - $description .= "• `{$package['package']}`: {$package['current_version']} → {$package['new_version']}\n"; - } - if ($criticalPackages->count() > 3) { - $description .= '• ... and '.($criticalPackages->count() - 3)." more critical packages\n"; - } + if ($criticalPackages->isNotEmpty()) { + $description .= " :warning: {$criticalPackages->count()} critical package(s)\n"; } } - $description .= "\n:link: <{$this->serverUrl}|Manage Server Patches>"; - return new SlackMessage( title: 'Coolify: [ACTION REQUIRED] Server patches available', description: $description, @@ -345,44 +196,45 @@ public function toSlack(): SlackMessage public function toWebhook(): array { - // Handle error case - if (isset($this->patchData['error'])) { - return [ - 'success' => false, - 'message' => 'Failed to check patches', - 'event' => 'server_patch_check_error', - 'server_name' => $this->server->name, - 'server_uuid' => $this->server->uuid, - 'os_id' => $this->patchData['osId'] ?? 'unknown', - 'package_manager' => $this->patchData['package_manager'] ?? 'unknown', - 'error' => $this->patchData['error'], - 'url' => $this->serverUrl, - ]; - } + $servers = $this->servers->map(function ($server) { + $data = $server->patch_check_data; + + if (isset($data['error'])) { + return [ + 'server_name' => $server->name, + 'server_uuid' => $server->uuid, + 'event' => 'server_patch_check_error', + 'error' => $data['error'], + 'os_id' => $data['osId'] ?? 'unknown', + 'package_manager' => $data['package_manager'] ?? 'unknown', + ]; + } - $totalUpdates = $this->patchData['total_updates'] ?? 0; - $updates = $this->patchData['updates'] ?? []; + $updates = $data['updates'] ?? []; + $criticalPackages = collect($updates)->filter(fn ($u) => str_contains(strtolower($u['package']), 'docker') || + str_contains(strtolower($u['package']), 'kernel') || + str_contains(strtolower($u['package']), 'openssh') || + str_contains(strtolower($u['package']), 'ssl') + ); - // Check for critical packages - $criticalPackages = collect($updates)->filter(function ($update) { - return str_contains(strtolower($update['package']), 'docker') || - str_contains(strtolower($update['package']), 'kernel') || - str_contains(strtolower($update['package']), 'openssh') || - str_contains(strtolower($update['package']), 'ssl'); - }); + return [ + 'server_name' => $server->name, + 'server_uuid' => $server->uuid, + 'event' => 'server_patch_check', + 'total_updates' => $data['total_updates'] ?? 0, + 'os_id' => $data['osId'] ?? 'unknown', + 'package_manager' => $data['package_manager'] ?? 'unknown', + 'updates' => $updates, + 'critical_packages_count' => $criticalPackages->count(), + ]; + })->toArray(); return [ 'success' => false, 'message' => 'Server patches available', 'event' => 'server_patch_check', - 'server_name' => $this->server->name, - 'server_uuid' => $this->server->uuid, - 'total_updates' => $totalUpdates, - 'os_id' => $this->patchData['osId'] ?? 'unknown', - 'package_manager' => $this->patchData['package_manager'] ?? 'unknown', - 'updates' => $updates, - 'critical_packages_count' => $criticalPackages->count(), - 'url' => $this->serverUrl, + 'affected_servers_count' => $this->servers->count(), + 'servers' => $servers, ]; } } diff --git a/app/Notifications/Server/TraefikVersionOutdated.php b/app/Notifications/Server/TraefikVersionOutdated.php index c94cc1732d..dbe209ef6c 100644 --- a/app/Notifications/Server/TraefikVersionOutdated.php +++ b/app/Notifications/Server/TraefikVersionOutdated.php @@ -11,14 +11,17 @@ class TraefikVersionOutdated extends CustomEmailNotification { - public function __construct(public Collection $servers) - { + public function __construct( + public Collection $servers, + public bool $bundledOnly = false, + public bool $unbundledOnly = false, + ) { $this->onQueue('high'); } public function via(object $notifiable): array { - return $notifiable->getEnabledChannels('traefik_outdated'); + return $notifiable->getEnabledChannels('traefik_outdated', bundledOnly: $this->bundledOnly, unbundledOnly: $this->unbundledOnly); } private function formatVersion(string $version): string @@ -49,7 +52,7 @@ public function toMail($notifiable = null): MailMessage 'name' => $server->name, 'uuid' => $server->uuid, 'url' => base_url().'/server/'.$server->uuid.'/proxy', - 'outdatedInfo' => $server->outdatedInfo ?? [], + 'outdatedInfo' => $server->traefik_outdated_info ?? [], ]; }); @@ -65,15 +68,15 @@ public function toMail($notifiable = null): MailMessage public function toDiscord(): DiscordMessage { $count = $this->servers->count(); - $hasUpgrades = $this->servers->contains(fn ($s) => ($s->outdatedInfo['type'] ?? 'patch_update') === 'minor_upgrade' || - isset($s->outdatedInfo['newer_branch_target']) + $hasUpgrades = $this->servers->contains(fn ($s) => ($s->traefik_outdated_info['type'] ?? 'patch_update') === 'minor_upgrade' || + isset($s->traefik_outdated_info['newer_branch_target']) ); $description = "**{$count} server(s)** running outdated Traefik proxy. Update recommended for security and features.\n\n"; $description .= "**Affected servers:**\n"; foreach ($this->servers as $server) { - $info = $server->outdatedInfo ?? []; + $info = $server->traefik_outdated_info ?? []; $current = $this->formatVersion($info['current'] ?? 'unknown'); $latest = $this->formatVersion($info['latest'] ?? 'unknown'); $upgradeTarget = $this->getUpgradeTarget($info); @@ -108,8 +111,8 @@ public function toDiscord(): DiscordMessage public function toTelegram(): array { $count = $this->servers->count(); - $hasUpgrades = $this->servers->contains(fn ($s) => ($s->outdatedInfo['type'] ?? 'patch_update') === 'minor_upgrade' || - isset($s->outdatedInfo['newer_branch_target']) + $hasUpgrades = $this->servers->contains(fn ($s) => ($s->traefik_outdated_info['type'] ?? 'patch_update') === 'minor_upgrade' || + isset($s->traefik_outdated_info['newer_branch_target']) ); $message = "⚠️ Coolify: Traefik proxy outdated on {$count} server(s)!\n\n"; @@ -117,7 +120,7 @@ public function toTelegram(): array $message .= "📊 Affected servers:\n"; foreach ($this->servers as $server) { - $info = $server->outdatedInfo ?? []; + $info = $server->traefik_outdated_info ?? []; $current = $this->formatVersion($info['current'] ?? 'unknown'); $latest = $this->formatVersion($info['latest'] ?? 'unknown'); $upgradeTarget = $this->getUpgradeTarget($info); @@ -151,15 +154,15 @@ public function toTelegram(): array public function toPushover(): PushoverMessage { $count = $this->servers->count(); - $hasUpgrades = $this->servers->contains(fn ($s) => ($s->outdatedInfo['type'] ?? 'patch_update') === 'minor_upgrade' || - isset($s->outdatedInfo['newer_branch_target']) + $hasUpgrades = $this->servers->contains(fn ($s) => ($s->traefik_outdated_info['type'] ?? 'patch_update') === 'minor_upgrade' || + isset($s->traefik_outdated_info['newer_branch_target']) ); $message = "Traefik proxy outdated on {$count} server(s)!\n"; $message .= "Affected servers:\n"; foreach ($this->servers as $server) { - $info = $server->outdatedInfo ?? []; + $info = $server->traefik_outdated_info ?? []; $current = $this->formatVersion($info['current'] ?? 'unknown'); $latest = $this->formatVersion($info['latest'] ?? 'unknown'); $upgradeTarget = $this->getUpgradeTarget($info); @@ -194,15 +197,15 @@ public function toPushover(): PushoverMessage public function toSlack(): SlackMessage { $count = $this->servers->count(); - $hasUpgrades = $this->servers->contains(fn ($s) => ($s->outdatedInfo['type'] ?? 'patch_update') === 'minor_upgrade' || - isset($s->outdatedInfo['newer_branch_target']) + $hasUpgrades = $this->servers->contains(fn ($s) => ($s->traefik_outdated_info['type'] ?? 'patch_update') === 'minor_upgrade' || + isset($s->traefik_outdated_info['newer_branch_target']) ); $description = "Traefik proxy outdated on {$count} server(s)!\n"; $description .= "*Affected servers:*\n"; foreach ($this->servers as $server) { - $info = $server->outdatedInfo ?? []; + $info = $server->traefik_outdated_info ?? []; $current = $this->formatVersion($info['current'] ?? 'unknown'); $latest = $this->formatVersion($info['latest'] ?? 'unknown'); $upgradeTarget = $this->getUpgradeTarget($info); @@ -237,7 +240,7 @@ public function toSlack(): SlackMessage public function toWebhook(): array { $servers = $this->servers->map(function ($server) { - $info = $server->outdatedInfo ?? []; + $info = $server->traefik_outdated_info ?? []; $webhookData = [ 'name' => $server->name, diff --git a/app/Traits/HasNotificationSettings.php b/app/Traits/HasNotificationSettings.php index fded435fdb..09099b25ae 100644 --- a/app/Traits/HasNotificationSettings.php +++ b/app/Traits/HasNotificationSettings.php @@ -68,9 +68,21 @@ public function isNotificationTypeEnabled(string $channel, string $event): bool } /** - * Get all enabled notification channels for an event + * Map event types to their bundle setting column name. */ - public function getEnabledChannels(string $event): array + protected static array $bundleSettingMap = [ + 'server_patch' => 'bundle_patch_notifications', + 'traefik_outdated' => 'bundle_traefik_notifications', + ]; + + /** + * Get all enabled notification channels for an event. + * + * @param string $event The event type (e.g. 'server_patch', 'traefik_outdated') + * @param bool $bundledOnly Only return channels that have bundling enabled for this event + * @param bool $unbundledOnly Only return channels that have bundling disabled for this event + */ + public function getEnabledChannels(string $event, bool $bundledOnly = false, bool $unbundledOnly = false): array { $channels = []; @@ -87,8 +99,22 @@ public function getEnabledChannels(string $event): array unset($channelMap['email']); } + $bundleColumn = static::$bundleSettingMap[$event] ?? null; + foreach ($channelMap as $channel => $channelClass) { if ($this->isNotificationEnabled($channel) && $this->isNotificationTypeEnabled($channel, $event)) { + if ($bundleColumn && ($bundledOnly || $unbundledOnly)) { + $settings = $this->getNotificationSettings($channel); + $isBundled = (bool) ($settings->$bundleColumn ?? false); + + if ($bundledOnly && ! $isBundled) { + continue; + } + if ($unbundledOnly && $isBundled) { + continue; + } + } + $channels[] = $channelClass; } } diff --git a/database/migrations/2026_04_06_000000_add_bundled_notifications_support.php b/database/migrations/2026_04_06_000000_add_bundled_notifications_support.php new file mode 100644 index 0000000000..3ad3ac8675 --- /dev/null +++ b/database/migrations/2026_04_06_000000_add_bundled_notifications_support.php @@ -0,0 +1,66 @@ +json('patch_check_data')->nullable(); + }); + } + + $notificationTables = [ + 'email_notification_settings', + 'discord_notification_settings', + 'slack_notification_settings', + 'telegram_notification_settings', + 'pushover_notification_settings', + 'webhook_notification_settings', + ]; + + foreach ($notificationTables as $tableName) { + Schema::table($tableName, function (Blueprint $table) use ($tableName) { + if (! Schema::hasColumn($tableName, 'bundle_patch_notifications')) { + $table->boolean('bundle_patch_notifications')->default(false); + } + if (! Schema::hasColumn($tableName, 'bundle_traefik_notifications')) { + $table->boolean('bundle_traefik_notifications')->default(false); + } + }); + } + } + + public function down(): void + { + if (Schema::hasColumn('servers', 'patch_check_data')) { + Schema::table('servers', function (Blueprint $table) { + $table->dropColumn('patch_check_data'); + }); + } + + $notificationTables = [ + 'email_notification_settings', + 'discord_notification_settings', + 'slack_notification_settings', + 'telegram_notification_settings', + 'pushover_notification_settings', + 'webhook_notification_settings', + ]; + + foreach ($notificationTables as $tableName) { + Schema::table($tableName, function (Blueprint $table) use ($tableName) { + if (Schema::hasColumn($tableName, 'bundle_patch_notifications')) { + $table->dropColumn('bundle_patch_notifications'); + } + if (Schema::hasColumn($tableName, 'bundle_traefik_notifications')) { + $table->dropColumn('bundle_traefik_notifications'); + } + }); + } + } +}; diff --git a/database/migrations/2026_04_06_213353_create_job_batches_table.php b/database/migrations/2026_04_06_213353_create_job_batches_table.php new file mode 100644 index 0000000000..50e38c20f2 --- /dev/null +++ b/database/migrations/2026_04_06_213353_create_job_batches_table.php @@ -0,0 +1,35 @@ +string('id')->primary(); + $table->string('name'); + $table->integer('total_jobs'); + $table->integer('pending_jobs'); + $table->integer('failed_jobs'); + $table->longText('failed_job_ids'); + $table->mediumText('options')->nullable(); + $table->integer('cancelled_at')->nullable(); + $table->integer('created_at'); + $table->integer('finished_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('job_batches'); + } +}; diff --git a/resources/views/emails/server-patches-bundled.blade.php b/resources/views/emails/server-patches-bundled.blade.php new file mode 100644 index 0000000000..43923ecf28 --- /dev/null +++ b/resources/views/emails/server-patches-bundled.blade.php @@ -0,0 +1,69 @@ + +{{ $count }} server(s) have package updates available. We recommend reviewing and applying these updates promptly. + +## Affected Servers + +@foreach ($servers as $server) +@php + $serverName = data_get($server, 'name', 'Unknown Server'); + $serverUrl = data_get($server, 'url', '#'); + $patchData = data_get($server, 'patchData', []); + $hasError = isset($patchData['error']); + $totalUpdates = data_get($patchData, 'total_updates', 0); + $updates = data_get($patchData, 'updates', []); + $osId = data_get($patchData, 'osId', 'unknown'); + + $criticalPackages = collect($updates)->filter(function ($update) { + return str_contains(strtolower($update['package']), 'docker') || + str_contains(strtolower($update['package']), 'kernel') || + str_contains(strtolower($update['package']), 'openssh') || + str_contains(strtolower($update['package']), 'ssl'); + }); +@endphp +@if ($hasError) +- [**{{ $serverName }}**]({{ $serverUrl }}): Failed to check updates ({{ ucfirst($osId) }}) — {{ $patchData['error'] }} +@elseif ($criticalPackages->count() > 0) +- [**{{ $serverName }}**]({{ $serverUrl }}): {{ $totalUpdates }} updates available ({{ ucfirst($osId) }}) | {{ $criticalPackages->count() }} critical package(s) may require restarts +@else +- [**{{ $serverName }}**]({{ $serverUrl }}): {{ $totalUpdates }} updates available ({{ ucfirst($osId) }}) +@endif +@endforeach + +## Security Considerations + +Some of these updates may include important security patches. + +@php +$allCritical = collect($servers)->flatMap(function ($server) { + $updates = data_get(data_get($server, 'patchData', []), 'updates', []); + $serverName = data_get($server, 'name', 'Unknown'); + return collect($updates)->filter(function ($update) { + return str_contains(strtolower($update['package']), 'docker') || + str_contains(strtolower($update['package']), 'kernel') || + str_contains(strtolower($update['package']), 'openssh') || + str_contains(strtolower($update['package']), 'ssl'); + })->map(fn ($u) => array_merge($u, ['server' => $serverName])); +}); +@endphp + +### Critical packages that may require container/server/service restarts: + +@if ($allCritical->count() > 0) +@foreach ($allCritical as $package) +- {{ $package['server'] }} — {{ $package['package'] }}: {{ $package['current_version'] }} → {{ $package['new_version'] }} +@endforeach +@else +No critical packages requiring container restarts detected. +@endif + +## Next Steps + +1. Review the available updates for each server +2. Plan maintenance windows if critical packages are involved +3. Apply updates through the Coolify dashboard +4. Monitor services after updates are applied + +--- + +Click on any server name above to manage its patches. + diff --git a/resources/views/livewire/notifications/discord.blade.php b/resources/views/livewire/notifications/discord.blade.php index 0e5406c78e..1c48433bd8 100644 --- a/resources/views/livewire/notifications/discord.blade.php +++ b/resources/views/livewire/notifications/discord.blade.php @@ -80,8 +80,18 @@ label="Server Unreachable" /> +
+ +
+
+ +
diff --git a/resources/views/livewire/notifications/email.blade.php b/resources/views/livewire/notifications/email.blade.php index 4107030104..69652606c5 100644 --- a/resources/views/livewire/notifications/email.blade.php +++ b/resources/views/livewire/notifications/email.blade.php @@ -161,8 +161,18 @@ class="p-4 border dark:border-coolgray-300 border-neutral-200 rounded-lg flex fl label="Server Unreachable" /> +
+ +
+
+ +
diff --git a/resources/views/livewire/notifications/pushover.blade.php b/resources/views/livewire/notifications/pushover.blade.php index 74cd9e8d24..a6fa2d8d37 100644 --- a/resources/views/livewire/notifications/pushover.blade.php +++ b/resources/views/livewire/notifications/pushover.blade.php @@ -82,8 +82,18 @@ label="Server Unreachable" /> +
+ +
+
+ +
diff --git a/resources/views/livewire/notifications/slack.blade.php b/resources/views/livewire/notifications/slack.blade.php index 14c7b35085..081cdcac02 100644 --- a/resources/views/livewire/notifications/slack.blade.php +++ b/resources/views/livewire/notifications/slack.blade.php @@ -74,7 +74,17 @@ +
+ +
+
+ +
diff --git a/resources/views/livewire/notifications/telegram.blade.php b/resources/views/livewire/notifications/telegram.blade.php index 1c83caf70d..cf62e73c00 100644 --- a/resources/views/livewire/notifications/telegram.blade.php +++ b/resources/views/livewire/notifications/telegram.blade.php @@ -169,6 +169,11 @@ +
+ +
@@ -178,6 +183,11 @@
+
+ +
diff --git a/resources/views/livewire/notifications/webhook.blade.php b/resources/views/livewire/notifications/webhook.blade.php index 7c32311bfd..ef00836abc 100644 --- a/resources/views/livewire/notifications/webhook.blade.php +++ b/resources/views/livewire/notifications/webhook.blade.php @@ -83,8 +83,18 @@ class="normal-case dark:text-white btn btn-xs no-animation btn-primary"> id="serverUnreachableWebhookNotifications" label="Server Unreachable" /> +
+ +
+
+ +
diff --git a/tests/Feature/BundledNotificationsTest.php b/tests/Feature/BundledNotificationsTest.php new file mode 100644 index 0000000000..1981d461a9 --- /dev/null +++ b/tests/Feature/BundledNotificationsTest.php @@ -0,0 +1,442 @@ + 0], + ['fqdn' => 'https://coolify.test'] + ); + }); + Once::flush(); +}); + +function enableAllChannels(Team $team, string $event = 'server_patch'): void +{ + $team->emailNotificationSettings->update([ + 'use_instance_email_settings' => true, + "{$event}_email_notifications" => true, + ]); + $team->discordNotificationSettings->update([ + 'discord_enabled' => true, + 'discord_webhook_url' => 'https://discord.com/api/webhooks/test', + "{$event}_discord_notifications" => true, + ]); + $team->slackNotificationSettings->update([ + 'slack_enabled' => true, + 'slack_webhook_url' => 'https://hooks.slack.com/test', + "{$event}_slack_notifications" => true, + ]); + $team->telegramNotificationSettings->update([ + 'telegram_enabled' => true, + 'telegram_token' => 'test-token', + 'telegram_chat_id' => '123', + "{$event}_telegram_notifications" => true, + ]); + $team->pushoverNotificationSettings->update([ + 'pushover_enabled' => true, + 'pushover_user_key' => 'test-key', + 'pushover_api_token' => 'test-token', + "{$event}_pushover_notifications" => true, + ]); + $team->webhookNotificationSettings->update([ + 'webhook_enabled' => true, + 'webhook_url' => 'https://example.com/webhook', + "{$event}_webhook_notifications" => true, + ]); + $team->refresh(); +} + +function enableBundling(Team $team, string $column = 'bundle_patch_notifications'): void +{ + $team->emailNotificationSettings->update([$column => true]); + $team->discordNotificationSettings->update([$column => true]); + $team->slackNotificationSettings->update([$column => true]); + $team->telegramNotificationSettings->update([$column => true]); + $team->pushoverNotificationSettings->update([$column => true]); + $team->webhookNotificationSettings->update([$column => true]); + $team->refresh(); +} + +// ────────────────────────────────────────────────────────────────────── +// Settings +// ────────────────────────────────────────────────────────────────────── + +it('bundle settings default to false and can be toggled per event and channel', function () { + $team = Team::factory()->create(); + + // Defaults + expect($team->emailNotificationSettings->bundle_patch_notifications)->toBeFalse(); + expect($team->emailNotificationSettings->bundle_traefik_notifications)->toBeFalse(); + + // Toggle independently + $team->emailNotificationSettings->update(['bundle_patch_notifications' => true, 'bundle_traefik_notifications' => false]); + $team->discordNotificationSettings->update(['bundle_patch_notifications' => false, 'bundle_traefik_notifications' => true]); + $team->refresh(); + + expect($team->emailNotificationSettings->bundle_patch_notifications)->toBeTrue(); + expect($team->emailNotificationSettings->bundle_traefik_notifications)->toBeFalse(); + expect($team->discordNotificationSettings->bundle_patch_notifications)->toBeFalse(); + expect($team->discordNotificationSettings->bundle_traefik_notifications)->toBeTrue(); +}); + +// ────────────────────────────────────────────────────────────────────── +// Channel filtering — mixed settings split correctly +// ────────────────────────────────────────────────────────────────────── + +it('splits channels into bundled and unbundled based on per-channel setting', function () { + $team = Team::factory()->create(); + enableAllChannels($team); + // Bundle email + slack, leave the rest unbundled (default false) + $team->emailNotificationSettings->update(['bundle_patch_notifications' => true]); + $team->slackNotificationSettings->update(['bundle_patch_notifications' => true]); + $team->refresh(); + + $bundled = (new ServerPatchCheck(collect(), bundledOnly: true))->via($team); + $unbundled = (new ServerPatchCheck(collect(), unbundledOnly: true))->via($team); + $all = (new ServerPatchCheck(collect()))->via($team); + + expect($bundled)->toHaveCount(2); + expect($bundled)->toContain(EmailChannel::class); + expect($bundled)->toContain(SlackChannel::class); + expect($unbundled)->toHaveCount(4); + expect($unbundled)->toContain(DiscordChannel::class); + // Normal via() returns all regardless of bundle setting + expect($all)->toHaveCount(6); +}); + +it('traefik uses bundle_traefik_notifications column independently', function () { + $team = Team::factory()->create(); + enableAllChannels($team, 'traefik_outdated'); + enableBundling($team, 'bundle_traefik_notifications'); + $team->discordNotificationSettings->update(['bundle_traefik_notifications' => false]); + $team->refresh(); + + $bundled = (new TraefikVersionOutdated(collect(), bundledOnly: true))->via($team); + $unbundled = (new TraefikVersionOutdated(collect(), unbundledOnly: true))->via($team); + + expect($bundled)->toHaveCount(5); + expect($bundled)->not->toContain(DiscordChannel::class); + expect($unbundled)->toHaveCount(1); + expect($unbundled)->toContain(DiscordChannel::class); +}); + +it('disabled channel or event type is excluded regardless of bundle setting', function () { + $team = Team::factory()->create(); + enableAllChannels($team); + enableBundling($team); + // Disable discord entirely + $team->discordNotificationSettings->update(['discord_enabled' => false]); + // Disable server_patch event for slack + $team->slackNotificationSettings->update(['server_patch_slack_notifications' => false]); + $team->refresh(); + + $bundled = (new ServerPatchCheck(collect(), bundledOnly: true))->via($team); + + expect($bundled)->not->toContain(DiscordChannel::class); + expect($bundled)->not->toContain(SlackChannel::class); + expect($bundled)->toHaveCount(4); +}); + +// ────────────────────────────────────────────────────────────────────── +// Patch summary job — data handling +// ────────────────────────────────────────────────────────────────────── + +it('clears only batch-scoped servers and skips unreachable', function () { + $team = Team::factory()->create(); + + $reachable = Server::factory()->create([ + 'team_id' => $team->id, + 'patch_check_data' => ['total_updates' => 5, 'updates' => [], 'osId' => 'ubuntu', 'package_manager' => 'apt'], + ]); + $reachable->settings()->update(['is_reachable' => true]); + + $unreachable = Server::factory()->create([ + 'team_id' => $team->id, + 'patch_check_data' => ['total_updates' => 3, 'updates' => [], 'osId' => 'debian', 'package_manager' => 'apt'], + ]); + $unreachable->settings()->update(['is_reachable' => false]); + + $otherBatch = Server::factory()->create([ + 'team_id' => $team->id, + 'patch_check_data' => ['total_updates' => 1, 'updates' => [], 'osId' => 'ubuntu', 'package_manager' => 'apt'], + ]); + $otherBatch->settings()->update(['is_reachable' => true]); + + // Only pass reachable server's ID (simulating batch scope) + (new SendPatchCheckNotificationJob([$reachable->id]))->handle(); + + $reachable->refresh(); + $unreachable->refresh(); + $otherBatch->refresh(); + + expect($reachable->patch_check_data)->toBeNull(); + expect($unreachable->patch_check_data)->not->toBeNull(); // unreachable skipped + expect($otherBatch->patch_check_data)->not->toBeNull(); // different batch untouched +}); + +// ────────────────────────────────────────────────────────────────────── +// Traefik summary job — deduplication +// ────────────────────────────────────────────────────────────────────── + +it('only notifies servers with new checks and writes notified_at', function () { + $team = Team::factory()->create(); + + // Already notified — should be skipped + $old = Server::factory()->create([ + 'team_id' => $team->id, + 'proxy' => ['type' => ProxyTypes::TRAEFIK->value], + 'traefik_outdated_info' => [ + 'current' => '3.5.0', 'latest' => '3.5.6', 'type' => 'patch_update', + 'checked_at' => now()->subDay()->toIso8601String(), + 'notified_at' => now()->toIso8601String(), + ], + ]); + $old->settings()->update(['is_reachable' => true]); + + // New check — should be notified + $new = Server::factory()->create([ + 'team_id' => $team->id, + 'proxy' => ['type' => ProxyTypes::TRAEFIK->value], + 'traefik_outdated_info' => [ + 'current' => '3.4.0', 'latest' => '3.5.6', 'type' => 'patch_update', + 'checked_at' => now()->toIso8601String(), + ], + ]); + $new->settings()->update(['is_reachable' => true]); + + (new SendTraefikOutdatedNotificationJob)->handle(); + + $old->refresh(); + $new->refresh(); + + // Old server's notified_at unchanged (skipped) + expect($old->traefik_outdated_info['notified_at'])->not->toBeNull(); + // New server now has notified_at set + expect($new->traefik_outdated_info)->toHaveKey('notified_at'); + expect($new->traefik_outdated_info['notified_at'])->not->toBeNull(); +}); + +// ────────────────────────────────────────────────────────────────────── +// Team isolation +// ────────────────────────────────────────────────────────────────────── + +it('groups servers by team and does not leak across teams', function () { + $team1 = Team::factory()->create(); + $team2 = Team::factory()->create(); + + Server::factory()->create([ + 'name' => 'Team1 Server', + 'team_id' => $team1->id, + 'patch_check_data' => ['total_updates' => 5, 'updates' => [], 'osId' => 'ubuntu', 'package_manager' => 'apt'], + ])->settings()->update(['is_reachable' => true]); + + Server::factory()->create([ + 'name' => 'Team2 Server', + 'team_id' => $team2->id, + 'patch_check_data' => ['total_updates' => 3, 'updates' => [], 'osId' => 'debian', 'package_manager' => 'apt'], + ])->settings()->update(['is_reachable' => true]); + + $servers = Server::whereNotNull('patch_check_data') + ->whereRelation('settings', 'is_reachable', true) + ->with('team') + ->get(); + + $grouped = $servers->groupBy('team_id'); + + expect($grouped)->toHaveCount(2); + expect($grouped[$team1->id]->first()->name)->toBe('Team1 Server'); + expect($grouped[$team2->id]->first()->name)->toBe('Team2 Server'); +}); + +// ────────────────────────────────────────────────────────────────────── +// Notification formatting +// ────────────────────────────────────────────────────────────────────── + +it('patch notification formats all channels with multiple servers including errors', function () { + $team = Team::factory()->create(); + $updateServer = Server::factory()->create([ + 'name' => 'Web Server', + 'team_id' => $team->id, + 'uuid' => 'web-uuid', + 'patch_check_data' => [ + 'total_updates' => 3, + 'updates' => [ + ['package' => 'docker-ce', 'current_version' => '24.0', 'new_version' => '25.0', 'architecture' => 'amd64', 'repository' => 'main'], + ['package' => 'nginx', 'current_version' => '1.18', 'new_version' => '1.24', 'architecture' => 'amd64', 'repository' => 'main'], + ], + 'osId' => 'ubuntu', + 'package_manager' => 'apt', + ], + ]); + $errorServer = Server::factory()->create([ + 'name' => 'DB Server', + 'team_id' => $team->id, + 'uuid' => 'db-uuid', + 'patch_check_data' => ['error' => 'Connection refused', 'osId' => 'debian', 'package_manager' => 'apt'], + ]); + + $notification = new ServerPatchCheck(collect([$updateServer, $errorServer])); + + // Discord + $discord = $notification->toDiscord(); + expect($discord->description) + ->toContain('2 server(s)') + ->toContain('Web Server') + ->toContain('3 updates available') + ->toContain('1 critical package(s)') + ->toContain('DB Server') + ->toContain('failed to check updates'); + + // Mail + $mail = $notification->toMail($team); + expect($mail->viewData['count'])->toBe(2); + expect($mail->viewData['servers'][0]['url'])->toContain('web-uuid'); + + // Webhook + $webhook = $notification->toWebhook(); + expect($webhook['affected_servers_count'])->toBe(2); + expect($webhook['servers'][0]['event'])->toBe('server_patch_check'); + expect($webhook['servers'][1]['event'])->toBe('server_patch_check_error'); +}); + +// ────────────────────────────────────────────────────────────────────── +// Edge cases +// ────────────────────────────────────────────────────────────────────── + +it('handles deleted server and deleted team gracefully', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'patch_check_data' => ['total_updates' => 5, 'updates' => [], 'osId' => 'ubuntu', 'package_manager' => 'apt'], + ]); + $server->settings()->update(['is_reachable' => true]); + $serverId = $server->id; + + // Delete server + $server->settings()->delete(); + $server->delete(); + + // Should not throw + (new SendPatchCheckNotificationJob([$serverId]))->handle(); + expect(Server::find($serverId))->toBeNull(); + + // Orphaned server (team deleted) + $team2 = Team::factory()->create(); + $server2 = Server::factory()->create([ + 'team_id' => $team2->id, + 'patch_check_data' => ['total_updates' => 3, 'updates' => [], 'osId' => 'debian', 'package_manager' => 'apt'], + ]); + $server2->settings()->update(['is_reachable' => true]); + $team2->delete(); + + // Should not throw + (new SendPatchCheckNotificationJob([$server2->id]))->handle(); + $server2->refresh(); + expect($server2->patch_check_data)->toBeNull(); +}); + +// ────────────────────────────────────────────────────────────────────── +// End-to-end: summary jobs send notifications via Notification::fake +// ────────────────────────────────────────────────────────────────────── + +it('patch summary job sends bundled notification per team', function () { + $team1 = Team::factory()->create(); + $team2 = Team::factory()->create(); + enableAllChannels($team1); + enableAllChannels($team2); + enableBundling($team1); + enableBundling($team2); + + Notification::fake(); + + Server::factory()->create([ + 'name' => 'Team1 Server', + 'team_id' => $team1->id, + 'patch_check_data' => ['total_updates' => 5, 'updates' => [], 'osId' => 'ubuntu', 'package_manager' => 'apt'], + ])->settings()->update(['is_reachable' => true]); + + Server::factory()->create([ + 'name' => 'Team2 Server', + 'team_id' => $team2->id, + 'patch_check_data' => ['total_updates' => 3, 'updates' => [], 'osId' => 'debian', 'package_manager' => 'apt'], + ])->settings()->update(['is_reachable' => true]); + + (new SendPatchCheckNotificationJob)->handle(); + + Notification::assertSentTo($team1, ServerPatchCheck::class, function ($notification) { + return $notification->servers->count() === 1 + && $notification->servers->first()->name === 'Team1 Server' + && $notification->bundledOnly === true; + }); + Notification::assertSentTo($team2, ServerPatchCheck::class); +}); + +it('traefik summary job sends bundled notification per team', function () { + $team = Team::factory()->create(); + enableAllChannels($team, 'traefik_outdated'); + enableBundling($team, 'bundle_traefik_notifications'); + + Notification::fake(); + + Server::factory()->create([ + 'team_id' => $team->id, + 'proxy' => ['type' => ProxyTypes::TRAEFIK->value], + 'traefik_outdated_info' => [ + 'current' => '3.5.0', 'latest' => '3.5.6', 'type' => 'patch_update', + 'checked_at' => now()->toIso8601String(), + ], + ])->settings()->update(['is_reachable' => true]); + + (new SendTraefikOutdatedNotificationJob)->handle(); + + Notification::assertSentTo($team, TraefikVersionOutdated::class, function ($notification) { + return $notification->servers->count() === 1 && $notification->bundledOnly === true; + }); +}); + +it('summary jobs do not send when no channels have bundling enabled', function () { + $team = Team::factory()->create(); + enableAllChannels($team); + enableAllChannels($team, 'traefik_outdated'); + // Bundling off by default — don't enable it + + Notification::fake(); + + Server::factory()->create([ + 'team_id' => $team->id, + 'patch_check_data' => ['total_updates' => 5, 'updates' => [], 'osId' => 'ubuntu', 'package_manager' => 'apt'], + ])->settings()->update(['is_reachable' => true]); + + $traefikServer = Server::factory()->create([ + 'team_id' => $team->id, + 'proxy' => ['type' => ProxyTypes::TRAEFIK->value], + 'traefik_outdated_info' => [ + 'current' => '3.5.0', 'latest' => '3.5.6', 'type' => 'patch_update', + 'checked_at' => now()->toIso8601String(), + ], + ]); + $traefikServer->settings()->update(['is_reachable' => true]); + + (new SendPatchCheckNotificationJob)->handle(); + (new SendTraefikOutdatedNotificationJob)->handle(); + + Notification::assertNotSentTo($team, ServerPatchCheck::class); + Notification::assertNotSentTo($team, TraefikVersionOutdated::class); +}); diff --git a/tests/Feature/CheckTraefikVersionJobTest.php b/tests/Feature/CheckTraefikVersionJobTest.php index cee1564855..c69d6a036b 100644 --- a/tests/Feature/CheckTraefikVersionJobTest.php +++ b/tests/Feature/CheckTraefikVersionJobTest.php @@ -1,20 +1,31 @@ 0], + ['fqdn' => 'https://coolify.test'] + ); + }); + Once::flush(); }); it('detects servers table has detected_traefik_version column', function () { - expect(\Illuminate\Support\Facades\Schema::hasColumn('servers', 'detected_traefik_version'))->toBeTrue(); + expect(Schema::hasColumn('servers', 'detected_traefik_version'))->toBeTrue(); }); it('server model casts detected_traefik_version as string', function () { @@ -101,7 +112,7 @@ 'team_id' => $team->id, 'detected_traefik_version' => 'v3.5.0', ]); - $server1->outdatedInfo = [ + $server1->traefik_outdated_info = [ 'current' => '3.5.0', 'latest' => '3.5.6', 'type' => 'patch_update', @@ -112,7 +123,7 @@ 'team_id' => $team->id, 'detected_traefik_version' => 'v3.4.0', ]); - $server2->outdatedInfo = [ + $server2->traefik_outdated_info = [ 'current' => '3.4.0', 'latest' => '3.6.0', 'type' => 'minor_upgrade', @@ -123,8 +134,8 @@ $notification = new TraefikVersionOutdated($servers); expect($notification->servers)->toHaveCount(2); - expect($notification->servers->first()->outdatedInfo['type'])->toBe('patch_update'); - expect($notification->servers->last()->outdatedInfo['type'])->toBe('minor_upgrade'); + expect($notification->servers->first()->traefik_outdated_info['type'])->toBe('patch_update'); + expect($notification->servers->last()->traefik_outdated_info['type'])->toBe('minor_upgrade'); }); it('notification channels can be retrieved', function () { @@ -137,7 +148,7 @@ }); it('traefik version check command exists', function () { - $commands = \Illuminate\Support\Facades\Artisan::all(); + $commands = Artisan::all(); expect($commands)->toHaveKey('traefik:check-version'); }); @@ -181,38 +192,47 @@ }); it('server check job exists and has correct structure', function () { - expect(class_exists(\App\Jobs\CheckTraefikVersionForServerJob::class))->toBeTrue(); + expect(class_exists(CheckTraefikVersionForServerJob::class))->toBeTrue(); // Verify CheckTraefikVersionForServerJob has required properties - $reflection = new \ReflectionClass(\App\Jobs\CheckTraefikVersionForServerJob::class); + $reflection = new ReflectionClass(CheckTraefikVersionForServerJob::class); expect($reflection->hasProperty('tries'))->toBeTrue(); expect($reflection->hasProperty('timeout'))->toBeTrue(); // Verify it implements ShouldQueue - $interfaces = class_implements(\App\Jobs\CheckTraefikVersionForServerJob::class); - expect($interfaces)->toContain(\Illuminate\Contracts\Queue\ShouldQueue::class); + $interfaces = class_implements(CheckTraefikVersionForServerJob::class); + expect($interfaces)->toContain(ShouldQueue::class); }); -it('sends immediate notifications when outdated traefik is detected', function () { - // Notifications are now sent immediately from CheckTraefikVersionForServerJob - // when outdated Traefik is detected, rather than being aggregated and delayed +it('sends bundled notifications after all server checks complete', function () { + // Notifications are sent by SendTraefikOutdatedNotificationJob after all per-server checks, + // bundling all outdated servers into a single notification per team $team = Team::factory()->create(); - $server = Server::factory()->make([ + $server1 = Server::factory()->make([ 'name' => 'Server 1', 'team_id' => $team->id, ]); - - $server->outdatedInfo = [ + $server1->traefik_outdated_info = [ 'current' => '3.5.0', 'latest' => '3.5.6', 'type' => 'patch_update', ]; - // Each server triggers its own notification immediately - $notification = new TraefikVersionOutdated(collect([$server])); + $server2 = Server::factory()->make([ + 'name' => 'Server 2', + 'team_id' => $team->id, + ]); + $server2->traefik_outdated_info = [ + 'current' => '3.4.0', + 'latest' => '3.6.0', + 'type' => 'minor_upgrade', + ]; + + $notification = new TraefikVersionOutdated(collect([$server1, $server2])); - expect($notification->servers)->toHaveCount(1); - expect($notification->servers->first()->outdatedInfo['type'])->toBe('patch_update'); + expect($notification->servers)->toHaveCount(2); + expect($notification->servers->first()->traefik_outdated_info['type'])->toBe('patch_update'); + expect($notification->servers->last()->traefik_outdated_info['type'])->toBe('minor_upgrade'); }); it('notification generates correct server proxy URLs', function () { @@ -223,7 +243,7 @@ 'uuid' => 'test-uuid-123', ]); - $server->outdatedInfo = [ + $server->traefik_outdated_info = [ 'current' => '3.5.0', 'latest' => '3.5.6', 'type' => 'patch_update', @@ -247,7 +267,7 @@ 'team_id' => $team->id, 'uuid' => 'uuid-1', ]); - $server1->outdatedInfo = [ + $server1->traefik_outdated_info = [ 'current' => '3.5.0', 'latest' => '3.5.6', 'type' => 'patch_update', @@ -258,7 +278,7 @@ 'team_id' => $team->id, 'uuid' => 'uuid-2', ]); - $server2->outdatedInfo = [ + $server2->traefik_outdated_info = [ 'current' => '3.4.0', 'latest' => '3.6.0', 'type' => 'minor_upgrade', @@ -287,7 +307,7 @@ 'uuid' => 'test-uuid', ]); - $server->outdatedInfo = [ + $server->traefik_outdated_info = [ 'current' => '3.5.0', 'latest' => '3.5.6', 'type' => 'patch_update', diff --git a/tests/Feature/ServerPatchCheckNotificationTest.php b/tests/Feature/ServerPatchCheckNotificationTest.php index dd8901e822..439bdf801c 100644 --- a/tests/Feature/ServerPatchCheckNotificationTest.php +++ b/tests/Feature/ServerPatchCheckNotificationTest.php @@ -2,145 +2,138 @@ use App\Models\InstanceSettings; use App\Models\Server; +use App\Models\Team; use App\Notifications\Server\ServerPatchCheck; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Once; uses(RefreshDatabase::class); beforeEach(function () { - // Create a real InstanceSettings record in the test database - // This avoids Mockery alias/overload issues that pollute global state - $this->setInstanceSettings = function ($fqdn = null, $publicIpv4 = null, $publicIpv6 = null) { - InstanceSettings::query()->delete(); - InstanceSettings::create([ - 'id' => 0, - 'fqdn' => $fqdn, - 'public_ipv4' => $publicIpv4, - 'public_ipv6' => $publicIpv6, - ]); - }; - - $this->createMockServer = function ($uuid, $name = 'Test Server') { - $mockServer = Mockery::mock(Server::class); - $mockServer->shouldReceive('getAttribute') - ->with('uuid') - ->andReturn($uuid); - $mockServer->shouldReceive('getAttribute') - ->with('name') - ->andReturn($name); - $mockServer->shouldReceive('setAttribute')->andReturnSelf(); - $mockServer->shouldReceive('getSchemalessAttributes')->andReturn([]); - $mockServer->uuid = $uuid; - $mockServer->name = $name; - - return $mockServer; - }; + InstanceSettings::unguarded(function () { + InstanceSettings::updateOrCreate( + ['id' => 0], + ['fqdn' => 'https://coolify.test'] + ); + }); + Once::flush(); }); -afterEach(function () { - Mockery::close(); -}); - -it('generates url using base_url instead of APP_URL', function () { - // Set InstanceSettings to return a specific FQDN - ($this->setInstanceSettings)('https://coolify.example.com'); - - $mockServer = ($this->createMockServer)('test-server-uuid'); +it('accepts a collection of servers', function () { + $team = Team::factory()->create(); + $server1 = Server::factory()->create([ + 'team_id' => $team->id, + 'patch_check_data' => ['total_updates' => 5, 'updates' => [], 'osId' => 'ubuntu', 'package_manager' => 'apt'], + ]); + $server2 = Server::factory()->create([ + 'team_id' => $team->id, + 'patch_check_data' => ['total_updates' => 3, 'updates' => [], 'osId' => 'debian', 'package_manager' => 'apt'], + ]); - $patchData = [ - 'total_updates' => 5, - 'updates' => [], - 'osId' => 'ubuntu', - 'package_manager' => 'apt', - ]; + $notification = new ServerPatchCheck(collect([$server1, $server2])); - $notification = new ServerPatchCheck($mockServer, $patchData); - - // The URL should use the FQDN from InstanceSettings, not APP_URL - expect($notification->serverUrl)->toBe('https://coolify.example.com/server/test-server-uuid/security/patches'); + expect($notification->servers)->toHaveCount(2); }); -it('falls back to public_ipv4 with port when fqdn is not set', function () { - // Set InstanceSettings to return public IPv4 - ($this->setInstanceSettings)(null, '192.168.1.100'); - - $mockServer = ($this->createMockServer)('test-server-uuid'); - - $patchData = [ - 'total_updates' => 3, - 'updates' => [], - 'osId' => 'debian', - 'package_manager' => 'apt', - ]; +it('generates correct mail with multiple servers', function () { + $team = Team::factory()->create(); + $server1 = Server::factory()->create([ + 'name' => 'Server 1', + 'team_id' => $team->id, + 'uuid' => 'uuid-1', + 'patch_check_data' => ['total_updates' => 5, 'updates' => [], 'osId' => 'ubuntu', 'package_manager' => 'apt'], + ]); + $server2 = Server::factory()->create([ + 'name' => 'Server 2', + 'team_id' => $team->id, + 'uuid' => 'uuid-2', + 'patch_check_data' => ['error' => 'Connection failed', 'osId' => 'debian', 'package_manager' => 'apt'], + ]); + + $notification = new ServerPatchCheck(collect([$server1, $server2])); + $mail = $notification->toMail($team); + + expect($mail->viewData['count'])->toBe(2); + expect($mail->viewData['servers'])->toHaveCount(2); + expect($mail->viewData['servers'][0]['url'])->toBe('https://coolify.test/server/uuid-1/security/patches'); + expect($mail->viewData['servers'][1]['url'])->toBe('https://coolify.test/server/uuid-2/security/patches'); +}); - $notification = new ServerPatchCheck($mockServer, $patchData); +it('generates correct discord message with updates and errors', function () { + $team = Team::factory()->create(); + $server1 = Server::factory()->create([ + 'name' => 'Server 1', + 'team_id' => $team->id, + 'patch_check_data' => ['total_updates' => 10, 'updates' => [], 'osId' => 'ubuntu', 'package_manager' => 'apt'], + ]); + $server2 = Server::factory()->create([ + 'name' => 'Server 2', + 'team_id' => $team->id, + 'patch_check_data' => ['error' => 'Timeout', 'osId' => 'debian', 'package_manager' => 'apt'], + ]); + + $notification = new ServerPatchCheck(collect([$server1, $server2])); + $discord = $notification->toDiscord(); - // The URL should use public IPv4 with default port 8000 - expect($notification->serverUrl)->toBe('http://192.168.1.100:8000/server/test-server-uuid/security/patches'); + expect($discord->description)->toContain('Server 1') + ->and($discord->description)->toContain('10 updates available') + ->and($discord->description)->toContain('Server 2') + ->and($discord->description)->toContain('failed to check updates'); }); -it('includes server url in all notification channels', function () { - ($this->setInstanceSettings)('https://coolify.test'); +it('generates correct webhook payload for multiple servers', function () { + $team = Team::factory()->create(); + $server1 = Server::factory()->create([ + 'name' => 'Server 1', + 'team_id' => $team->id, + 'uuid' => 'uuid-1', + 'patch_check_data' => ['total_updates' => 5, 'updates' => [], 'osId' => 'ubuntu', 'package_manager' => 'apt'], + ]); + $server2 = Server::factory()->create([ + 'name' => 'Server 2', + 'team_id' => $team->id, + 'uuid' => 'uuid-2', + 'patch_check_data' => ['error' => 'Connection failed', 'osId' => 'debian', 'package_manager' => 'apt'], + ]); + + $notification = new ServerPatchCheck(collect([$server1, $server2])); + $webhook = $notification->toWebhook(); - $mockServer = ($this->createMockServer)('abc-123', 'Test Server'); + expect($webhook['affected_servers_count'])->toBe(2); + expect($webhook['servers'])->toHaveCount(2); + expect($webhook['servers'][0]['server_name'])->toBe('Server 1'); + expect($webhook['servers'][0]['event'])->toBe('server_patch_check'); + expect($webhook['servers'][1]['server_name'])->toBe('Server 2'); + expect($webhook['servers'][1]['event'])->toBe('server_patch_check_error'); +}); - $patchData = [ - 'total_updates' => 10, - 'updates' => [ - [ - 'package' => 'nginx', - 'current_version' => '1.18', - 'new_version' => '1.20', - 'architecture' => 'amd64', - 'repository' => 'main', +it('detects critical packages across multiple servers', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'name' => 'Server 1', + 'team_id' => $team->id, + 'patch_check_data' => [ + 'total_updates' => 2, + 'updates' => [ + ['package' => 'docker-ce', 'current_version' => '24.0', 'new_version' => '25.0', 'architecture' => 'amd64', 'repository' => 'main'], + ['package' => 'nginx', 'current_version' => '1.18', 'new_version' => '1.20', 'architecture' => 'amd64', 'repository' => 'main'], ], + 'osId' => 'ubuntu', + 'package_manager' => 'apt', ], - 'osId' => 'ubuntu', - 'package_manager' => 'apt', - ]; + ]); - $notification = new ServerPatchCheck($mockServer, $patchData); - - // Check Discord + $notification = new ServerPatchCheck(collect([$server])); $discord = $notification->toDiscord(); - expect($discord->description)->toContain('https://coolify.test/server/abc-123/security/patches'); - - // Check Telegram - $telegram = $notification->toTelegram(); - expect($telegram['buttons'][0]['url'])->toBe('https://coolify.test/server/abc-123/security/patches'); - - // Check Pushover - $pushover = $notification->toPushover(); - expect($pushover->buttons[0]['url'])->toBe('https://coolify.test/server/abc-123/security/patches'); - // Check Slack - $slack = $notification->toSlack(); - expect($slack->description)->toContain('https://coolify.test/server/abc-123/security/patches'); - - // Check Webhook - $webhook = $notification->toWebhook(); - expect($webhook['url'])->toBe('https://coolify.test/server/abc-123/security/patches'); + expect($discord->description)->toContain('1 critical package(s)'); }); -it('uses correct url in error notifications', function () { - ($this->setInstanceSettings)('https://coolify.production.com'); +it('notification channels can be retrieved', function () { + $team = Team::factory()->create(); - $mockServer = ($this->createMockServer)('error-server-uuid', 'Error Server'); + $notification = new ServerPatchCheck(collect()); + $channels = $notification->via($team); - $patchData = [ - 'error' => 'Failed to connect to package manager', - 'osId' => 'ubuntu', - 'package_manager' => 'apt', - ]; - - $notification = new ServerPatchCheck($mockServer, $patchData); - - // Check error Discord notification - $discord = $notification->toDiscord(); - expect($discord->description)->toContain('https://coolify.production.com/server/error-server-uuid/security/patches'); - - // Check error webhook - $webhook = $notification->toWebhook(); - expect($webhook['url'])->toBe('https://coolify.production.com/server/error-server-uuid/security/patches') - ->and($webhook['event'])->toBe('server_patch_check_error'); + expect($channels)->toBeArray(); });