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 @@
+