feat(proxy): add Traefik version tracking with notifications and dismissible UI warnings

- Add automated Traefik version checking job running weekly on Sundays
- Implement version detection from running containers and comparison with versions.json
- Add notifications across all channels (Email, Discord, Slack, Telegram, Pushover, Webhook) for outdated versions
- Create dismissible callout component with localStorage persistence
- Display cross-branch upgrade warnings (e.g., v3.5 -> v3.6) with changelog links
- Show patch update notifications within same branch
- Add warning icon that appears when callouts are dismissed
- Prevent duplicate notifications during proxy restart by adding restarting parameter
- Fix notification spam with transition-based logic for status changes
- Enable system email settings by default in development mode
- Track last saved/applied proxy settings to detect configuration drift
This commit is contained in:
Andras Bacsai 2025-11-13 13:38:57 +01:00
parent 351d99ab60
commit 8c77c63043
48 changed files with 1488 additions and 48 deletions

View File

@ -13,7 +13,7 @@ class StartProxy
{
use AsAction;
public function handle(Server $server, bool $async = true, bool $force = false): string|Activity
public function handle(Server $server, bool $async = true, bool $force = false, bool $restarting = false): string|Activity
{
$proxyType = $server->proxyType();
if ((is_null($proxyType) || $proxyType === 'NONE' || $server->proxy->force_stop || $server->isBuildServer()) && $force === false) {
@ -22,7 +22,10 @@ class StartProxy
$server->proxy->set('status', 'starting');
$server->save();
$server->refresh();
ProxyStatusChangedUI::dispatch($server->team_id);
if (! $restarting) {
ProxyStatusChangedUI::dispatch($server->team_id);
}
$commands = collect([]);
$proxy_path = $server->proxyPath();

View File

@ -12,13 +12,16 @@ class StopProxy
{
use AsAction;
public function handle(Server $server, bool $forceStop = true, int $timeout = 30)
public function handle(Server $server, bool $forceStop = true, int $timeout = 30, bool $restarting = false)
{
try {
$containerName = $server->isSwarm() ? 'coolify-proxy_traefik' : 'coolify-proxy';
$server->proxy->status = 'stopping';
$server->save();
ProxyStatusChangedUI::dispatch($server->team_id);
if (! $restarting) {
ProxyStatusChangedUI::dispatch($server->team_id);
}
instant_remote_process(command: [
"docker stop --time=$timeout $containerName",
@ -32,7 +35,10 @@ class StopProxy
return handleError($e);
} finally {
ProxyDashboardCacheService::clearCache($server);
ProxyStatusChanged::dispatch($server->id);
if (! $restarting) {
ProxyStatusChanged::dispatch($server->id);
}
}
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Console\Commands;
use App\Jobs\CheckTraefikVersionJob;
use Illuminate\Console\Command;
class CheckTraefikVersionCommand extends Command
{
protected $signature = 'traefik:check-version';
protected $description = 'Check Traefik proxy versions on all servers and send notifications for outdated versions';
public function handle(): int
{
$this->info('Checking Traefik versions on all servers...');
try {
CheckTraefikVersionJob::dispatch();
$this->info('Traefik version check job dispatched successfully.');
$this->info('Notifications will be sent to teams with outdated Traefik versions.');
return Command::SUCCESS;
} catch (\Exception $e) {
$this->error('Failed to dispatch Traefik version check job: '.$e->getMessage());
return Command::FAILURE;
}
}
}

View File

@ -5,6 +5,7 @@ namespace App\Console;
use App\Jobs\CheckAndStartSentinelJob;
use App\Jobs\CheckForUpdatesJob;
use App\Jobs\CheckHelperImageJob;
use App\Jobs\CheckTraefikVersionJob;
use App\Jobs\CleanupInstanceStuffsJob;
use App\Jobs\PullChangelog;
use App\Jobs\PullTemplatesFromCDN;
@ -83,6 +84,8 @@ class Kernel extends ConsoleKernel
$this->scheduleInstance->job(new RegenerateSslCertJob)->twiceDaily();
$this->scheduleInstance->job(new CheckTraefikVersionJob)->weekly()->sundays()->at('00:00')->timezone($this->instanceTimezone)->onOneServer();
$this->scheduleInstance->command('cleanup:database --yes')->daily();
$this->scheduleInstance->command('uploads:clear')->everyTwoMinutes();
}

View File

@ -10,6 +10,8 @@ class ServerMetadata extends Data
{
public function __construct(
public ?ProxyTypes $type,
public ?ProxyStatus $status
public ?ProxyStatus $status,
public ?string $last_saved_settings = null,
public ?string $last_applied_settings = null
) {}
}

View File

@ -0,0 +1,225 @@
<?php
namespace App\Jobs;
use App\Enums\ProxyTypes;
use App\Models\Server;
use App\Models\Team;
use App\Notifications\Server\TraefikVersionOutdated;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
class CheckTraefikVersionJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 3;
public function handle(): void
{
try {
Log::info('CheckTraefikVersionJob: Starting Traefik version check');
// Load versions from versions.json
$versionsPath = base_path('versions.json');
if (! File::exists($versionsPath)) {
Log::warning('CheckTraefikVersionJob: versions.json not found, skipping check');
return;
}
$allVersions = json_decode(File::get($versionsPath), true);
$traefikVersions = data_get($allVersions, 'traefik');
if (empty($traefikVersions) || ! is_array($traefikVersions)) {
Log::warning('CheckTraefikVersionJob: Traefik versions not found or invalid in versions.json');
return;
}
$branches = array_keys($traefikVersions);
Log::info('CheckTraefikVersionJob: Loaded Traefik version branches', ['branches' => $branches]);
// Query all servers with Traefik proxy that are reachable
$servers = Server::whereNotNull('proxy')
->whereRelation('settings', 'is_reachable', true)
->whereRelation('settings', 'is_usable', true)
->get()
->filter(fn ($server) => $server->proxyType() === ProxyTypes::TRAEFIK->value);
$serverCount = $servers->count();
Log::info("CheckTraefikVersionJob: Found {$serverCount} server(s) with Traefik proxy");
if ($serverCount === 0) {
Log::info('CheckTraefikVersionJob: No Traefik servers found, job completed');
return;
}
$outdatedServers = collect();
// Phase 1: Scan servers and detect versions
Log::info('CheckTraefikVersionJob: Phase 1 - Scanning servers and detecting versions');
foreach ($servers as $server) {
$currentVersion = getTraefikVersionFromDockerCompose($server);
Log::info("CheckTraefikVersionJob: Server '{$server->name}' - Detected version: ".($currentVersion ?? 'unable to detect'));
// Update detected version in database
$server->update(['detected_traefik_version' => $currentVersion]);
if (! $currentVersion) {
Log::warning("CheckTraefikVersionJob: Server '{$server->name}' - Unable to detect version, skipping");
continue;
}
// Check if image tag is 'latest' by inspecting the image
$imageTag = instant_remote_process([
"docker inspect coolify-proxy --format '{{.Config.Image}}' 2>/dev/null",
], $server, false);
if (str_contains(strtolower(trim($imageTag)), ':latest')) {
Log::info("CheckTraefikVersionJob: Server '{$server->name}' uses 'latest' tag, skipping notification (UI warning only)");
continue;
}
// Parse current version to extract major.minor.patch
$current = ltrim($currentVersion, 'v');
if (! preg_match('/^(\d+\.\d+)\.(\d+)$/', $current, $matches)) {
Log::warning("CheckTraefikVersionJob: Server '{$server->name}' - Invalid version format '{$current}', skipping");
continue;
}
$currentBranch = $matches[1]; // e.g., "3.6"
$currentPatch = $matches[2]; // e.g., "0"
Log::debug("CheckTraefikVersionJob: Server '{$server->name}' - Parsed branch: {$currentBranch}, patch: {$currentPatch}");
// Find the latest version for this branch
$latestForBranch = $traefikVersions["v{$currentBranch}"] ?? null;
if (! $latestForBranch) {
// User is on a branch we don't track - check if newer branches exist
Log::debug("CheckTraefikVersionJob: Server '{$server->name}' - Branch v{$currentBranch} not tracked, checking for newer branches");
$newestBranch = null;
$newestVersion = null;
foreach ($traefikVersions as $branch => $version) {
$branchNum = ltrim($branch, 'v');
if (version_compare($branchNum, $currentBranch, '>')) {
if (! $newestVersion || version_compare($version, $newestVersion, '>')) {
$newestBranch = $branchNum;
$newestVersion = $version;
}
}
}
if ($newestVersion) {
Log::info("CheckTraefikVersionJob: Server '{$server->name}' is outdated - on {$current}, newer branch {$newestBranch} with version {$newestVersion} available");
$server->outdatedInfo = [
'current' => $current,
'latest' => $newestVersion,
'type' => 'minor_upgrade',
];
$outdatedServers->push($server);
} else {
Log::info("CheckTraefikVersionJob: Server '{$server->name}' on {$current} - no newer branches available");
}
continue;
}
// Compare patch version within the same branch
$latest = ltrim($latestForBranch, 'v');
if (version_compare($current, $latest, '<')) {
Log::info("CheckTraefikVersionJob: Server '{$server->name}' is outdated - current: {$current}, latest for branch: {$latest}");
$server->outdatedInfo = [
'current' => $current,
'latest' => $latest,
'type' => 'patch_update',
];
$outdatedServers->push($server);
} else {
// Check if newer branches exist (user is up to date on their branch, but branch might be old)
$newestBranch = null;
$newestVersion = null;
foreach ($traefikVersions as $branch => $version) {
$branchNum = ltrim($branch, 'v');
if (version_compare($branchNum, $currentBranch, '>')) {
if (! $newestVersion || version_compare($version, $newestVersion, '>')) {
$newestBranch = $branchNum;
$newestVersion = $version;
}
}
}
if ($newestVersion) {
Log::info("CheckTraefikVersionJob: Server '{$server->name}' up to date on branch {$currentBranch} ({$current}), but newer branch {$newestBranch} available ({$newestVersion})");
$server->outdatedInfo = [
'current' => $current,
'latest' => $newestVersion,
'type' => 'minor_upgrade',
];
$outdatedServers->push($server);
} else {
Log::info("CheckTraefikVersionJob: Server '{$server->name}' is fully up to date - version: {$current}");
}
}
}
$outdatedCount = $outdatedServers->count();
Log::info("CheckTraefikVersionJob: Phase 1 complete - Found {$outdatedCount} outdated server(s)");
if ($outdatedCount === 0) {
Log::info('CheckTraefikVersionJob: All servers are up to date, no notifications to send');
return;
}
// Phase 2: Group by team and send notifications
Log::info('CheckTraefikVersionJob: Phase 2 - Grouping by team and sending notifications');
$serversByTeam = $outdatedServers->groupBy('team_id');
$teamCount = $serversByTeam->count();
Log::info("CheckTraefikVersionJob: Grouped outdated servers into {$teamCount} team(s)");
foreach ($serversByTeam as $teamId => $teamServers) {
$team = Team::find($teamId);
if (! $team) {
Log::warning("CheckTraefikVersionJob: Team ID {$teamId} not found, skipping");
continue;
}
$serverNames = $teamServers->pluck('name')->join(', ');
Log::info("CheckTraefikVersionJob: Sending notification to team '{$team->name}' for {$teamServers->count()} server(s): {$serverNames}");
// Send one notification per team with all outdated servers (with per-server info)
$team->notify(new TraefikVersionOutdated($teamServers));
Log::info("CheckTraefikVersionJob: Notification sent to team '{$team->name}'");
}
Log::info('CheckTraefikVersionJob: Job completed successfully');
} catch (\Throwable $e) {
Log::error('CheckTraefikVersionJob: Error checking Traefik versions: '.$e->getMessage(), [
'exception' => $e,
'trace' => $e->getTraceAsString(),
]);
throw $e;
}
}
}

View File

@ -31,12 +31,12 @@ class RestartProxyJob implements ShouldBeEncrypted, ShouldQueue
public function handle()
{
try {
StopProxy::run($this->server);
StopProxy::run($this->server, restarting: true);
$this->server->proxy->force_stop = false;
$this->server->save();
StartProxy::run($this->server, force: true);
StartProxy::run($this->server, force: true, restarting: true);
} catch (\Throwable $e) {
return handleError($e);

View File

@ -347,6 +347,8 @@ class Index extends Component
}
$this->createdServer->proxy->type = $proxyType;
$this->createdServer->proxy->status = 'exited';
$this->createdServer->proxy->last_saved_settings = null;
$this->createdServer->proxy->last_applied_settings = null;
$this->createdServer->save();
$this->getProjects();
}

View File

@ -62,6 +62,9 @@ class Discord extends Component
#[Validate(['boolean'])]
public bool $serverPatchDiscordNotifications = false;
#[Validate(['boolean'])]
public bool $traefikOutdatedDiscordNotifications = true;
#[Validate(['boolean'])]
public bool $discordPingEnabled = true;
@ -98,6 +101,7 @@ class Discord extends Component
$this->settings->server_reachable_discord_notifications = $this->serverReachableDiscordNotifications;
$this->settings->server_unreachable_discord_notifications = $this->serverUnreachableDiscordNotifications;
$this->settings->server_patch_discord_notifications = $this->serverPatchDiscordNotifications;
$this->settings->traefik_outdated_discord_notifications = $this->traefikOutdatedDiscordNotifications;
$this->settings->discord_ping_enabled = $this->discordPingEnabled;
@ -120,6 +124,7 @@ class Discord extends Component
$this->serverReachableDiscordNotifications = $this->settings->server_reachable_discord_notifications;
$this->serverUnreachableDiscordNotifications = $this->settings->server_unreachable_discord_notifications;
$this->serverPatchDiscordNotifications = $this->settings->server_patch_discord_notifications;
$this->traefikOutdatedDiscordNotifications = $this->settings->traefik_outdated_discord_notifications;
$this->discordPingEnabled = $this->settings->discord_ping_enabled;
}

View File

@ -104,6 +104,9 @@ class Email extends Component
#[Validate(['boolean'])]
public bool $serverPatchEmailNotifications = false;
#[Validate(['boolean'])]
public bool $traefikOutdatedEmailNotifications = true;
#[Validate(['nullable', 'email'])]
public ?string $testEmailAddress = null;
@ -155,6 +158,7 @@ class Email extends Component
$this->settings->server_reachable_email_notifications = $this->serverReachableEmailNotifications;
$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->save();
} else {
@ -187,6 +191,7 @@ class Email extends Component
$this->serverReachableEmailNotifications = $this->settings->server_reachable_email_notifications;
$this->serverUnreachableEmailNotifications = $this->settings->server_unreachable_email_notifications;
$this->serverPatchEmailNotifications = $this->settings->server_patch_email_notifications;
$this->traefikOutdatedEmailNotifications = $this->settings->traefik_outdated_email_notifications;
}
}

View File

@ -70,6 +70,9 @@ class Pushover extends Component
#[Validate(['boolean'])]
public bool $serverPatchPushoverNotifications = false;
#[Validate(['boolean'])]
public bool $traefikOutdatedPushoverNotifications = true;
public function mount()
{
try {
@ -104,6 +107,7 @@ class Pushover extends Component
$this->settings->server_reachable_pushover_notifications = $this->serverReachablePushoverNotifications;
$this->settings->server_unreachable_pushover_notifications = $this->serverUnreachablePushoverNotifications;
$this->settings->server_patch_pushover_notifications = $this->serverPatchPushoverNotifications;
$this->settings->traefik_outdated_pushover_notifications = $this->traefikOutdatedPushoverNotifications;
$this->settings->save();
refreshSession();
@ -125,6 +129,7 @@ class Pushover extends Component
$this->serverReachablePushoverNotifications = $this->settings->server_reachable_pushover_notifications;
$this->serverUnreachablePushoverNotifications = $this->settings->server_unreachable_pushover_notifications;
$this->serverPatchPushoverNotifications = $this->settings->server_patch_pushover_notifications;
$this->traefikOutdatedPushoverNotifications = $this->settings->traefik_outdated_pushover_notifications;
}
}

View File

@ -67,6 +67,9 @@ class Slack extends Component
#[Validate(['boolean'])]
public bool $serverPatchSlackNotifications = false;
#[Validate(['boolean'])]
public bool $traefikOutdatedSlackNotifications = true;
public function mount()
{
try {
@ -100,6 +103,7 @@ class Slack extends Component
$this->settings->server_reachable_slack_notifications = $this->serverReachableSlackNotifications;
$this->settings->server_unreachable_slack_notifications = $this->serverUnreachableSlackNotifications;
$this->settings->server_patch_slack_notifications = $this->serverPatchSlackNotifications;
$this->settings->traefik_outdated_slack_notifications = $this->traefikOutdatedSlackNotifications;
$this->settings->save();
refreshSession();
@ -120,6 +124,7 @@ class Slack extends Component
$this->serverReachableSlackNotifications = $this->settings->server_reachable_slack_notifications;
$this->serverUnreachableSlackNotifications = $this->settings->server_unreachable_slack_notifications;
$this->serverPatchSlackNotifications = $this->settings->server_patch_slack_notifications;
$this->traefikOutdatedSlackNotifications = $this->settings->traefik_outdated_slack_notifications;
}
}

View File

@ -70,6 +70,9 @@ class Telegram extends Component
#[Validate(['boolean'])]
public bool $serverPatchTelegramNotifications = false;
#[Validate(['boolean'])]
public bool $traefikOutdatedTelegramNotifications = true;
#[Validate(['nullable', 'string'])]
public ?string $telegramNotificationsDeploymentSuccessThreadId = null;
@ -109,6 +112,9 @@ class Telegram extends Component
#[Validate(['nullable', 'string'])]
public ?string $telegramNotificationsServerPatchThreadId = null;
#[Validate(['nullable', 'string'])]
public ?string $telegramNotificationsTraefikOutdatedThreadId = null;
public function mount()
{
try {
@ -143,6 +149,7 @@ class Telegram extends Component
$this->settings->server_reachable_telegram_notifications = $this->serverReachableTelegramNotifications;
$this->settings->server_unreachable_telegram_notifications = $this->serverUnreachableTelegramNotifications;
$this->settings->server_patch_telegram_notifications = $this->serverPatchTelegramNotifications;
$this->settings->traefik_outdated_telegram_notifications = $this->traefikOutdatedTelegramNotifications;
$this->settings->telegram_notifications_deployment_success_thread_id = $this->telegramNotificationsDeploymentSuccessThreadId;
$this->settings->telegram_notifications_deployment_failure_thread_id = $this->telegramNotificationsDeploymentFailureThreadId;
@ -157,6 +164,7 @@ class Telegram extends Component
$this->settings->telegram_notifications_server_reachable_thread_id = $this->telegramNotificationsServerReachableThreadId;
$this->settings->telegram_notifications_server_unreachable_thread_id = $this->telegramNotificationsServerUnreachableThreadId;
$this->settings->telegram_notifications_server_patch_thread_id = $this->telegramNotificationsServerPatchThreadId;
$this->settings->telegram_notifications_traefik_outdated_thread_id = $this->telegramNotificationsTraefikOutdatedThreadId;
$this->settings->save();
} else {
@ -177,6 +185,7 @@ class Telegram extends Component
$this->serverReachableTelegramNotifications = $this->settings->server_reachable_telegram_notifications;
$this->serverUnreachableTelegramNotifications = $this->settings->server_unreachable_telegram_notifications;
$this->serverPatchTelegramNotifications = $this->settings->server_patch_telegram_notifications;
$this->traefikOutdatedTelegramNotifications = $this->settings->traefik_outdated_telegram_notifications;
$this->telegramNotificationsDeploymentSuccessThreadId = $this->settings->telegram_notifications_deployment_success_thread_id;
$this->telegramNotificationsDeploymentFailureThreadId = $this->settings->telegram_notifications_deployment_failure_thread_id;
@ -191,6 +200,7 @@ class Telegram extends Component
$this->telegramNotificationsServerReachableThreadId = $this->settings->telegram_notifications_server_reachable_thread_id;
$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;
}
}

View File

@ -62,6 +62,9 @@ class Webhook extends Component
#[Validate(['boolean'])]
public bool $serverPatchWebhookNotifications = false;
#[Validate(['boolean'])]
public bool $traefikOutdatedWebhookNotifications = true;
public function mount()
{
try {
@ -95,6 +98,7 @@ class Webhook extends Component
$this->settings->server_reachable_webhook_notifications = $this->serverReachableWebhookNotifications;
$this->settings->server_unreachable_webhook_notifications = $this->serverUnreachableWebhookNotifications;
$this->settings->server_patch_webhook_notifications = $this->serverPatchWebhookNotifications;
$this->settings->traefik_outdated_webhook_notifications = $this->traefikOutdatedWebhookNotifications;
$this->settings->save();
refreshSession();
@ -115,6 +119,7 @@ class Webhook extends Component
$this->serverReachableWebhookNotifications = $this->settings->server_reachable_webhook_notifications;
$this->serverUnreachableWebhookNotifications = $this->settings->server_unreachable_webhook_notifications;
$this->serverPatchWebhookNotifications = $this->settings->server_patch_webhook_notifications;
$this->traefikOutdatedWebhookNotifications = $this->settings->traefik_outdated_webhook_notifications;
}
}

View File

@ -5,7 +5,6 @@ namespace App\Livewire\Server;
use App\Actions\Proxy\CheckProxy;
use App\Actions\Proxy\StartProxy;
use App\Actions\Proxy\StopProxy;
use App\Jobs\RestartProxyJob;
use App\Models\Server;
use App\Services\ProxyDashboardCacheService;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
@ -61,7 +60,13 @@ class Navbar extends Component
{
try {
$this->authorize('manageProxy', $this->server);
RestartProxyJob::dispatch($this->server);
StopProxy::run($this->server, restarting: true);
$this->server->proxy->force_stop = false;
$this->server->save();
$activity = StartProxy::run($this->server, force: true, restarting: true);
$this->dispatch('activityMonitor', $activity->id);
} catch (\Throwable $e) {
return handleError($e, $this);
}
@ -118,19 +123,28 @@ class Navbar extends Component
public function showNotification()
{
$previousStatus = $this->proxyStatus;
$this->server->refresh();
$this->proxyStatus = $this->server->proxy->status ?? 'unknown';
switch ($this->proxyStatus) {
case 'running':
$this->loadProxyConfiguration();
$this->dispatch('success', 'Proxy is running.');
// Only show "Proxy is running" notification when transitioning from a stopped/error state
// Don't show during normal start/restart flows (starting, restarting, stopping)
if (in_array($previousStatus, ['exited', 'stopped', 'unknown', null])) {
$this->dispatch('success', 'Proxy is running.');
}
break;
case 'restarting':
$this->dispatch('info', 'Initiating proxy restart.');
break;
case 'exited':
$this->dispatch('info', 'Proxy has exited.');
// Only show "Proxy has exited" notification when transitioning from running state
// Don't show during normal stop/restart flows (stopping, restarting)
if (in_array($previousStatus, ['running'])) {
$this->dispatch('info', 'Proxy has exited.');
}
break;
case 'stopping':
$this->dispatch('info', 'Proxy is stopping.');

View File

@ -4,8 +4,10 @@ namespace App\Livewire\Server;
use App\Actions\Proxy\GetProxyConfiguration;
use App\Actions\Proxy\SaveProxyConfiguration;
use App\Enums\ProxyTypes;
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\File;
use Livewire\Component;
class Proxy extends Component
@ -144,4 +146,125 @@ class Proxy extends Component
return handleError($e, $this);
}
}
public function getLatestTraefikVersionProperty(): ?string
{
try {
$versionsPath = base_path('versions.json');
if (! File::exists($versionsPath)) {
return null;
}
$versions = json_decode(File::get($versionsPath), true);
$traefikVersions = data_get($versions, 'traefik');
if (! $traefikVersions) {
return null;
}
// Handle new structure (array of branches)
if (is_array($traefikVersions)) {
$currentVersion = $this->server->detected_traefik_version;
// If we have a current version, try to find matching branch
if ($currentVersion && $currentVersion !== 'latest') {
$current = ltrim($currentVersion, 'v');
if (preg_match('/^(\d+\.\d+)/', $current, $matches)) {
$branch = "v{$matches[1]}";
if (isset($traefikVersions[$branch])) {
$version = $traefikVersions[$branch];
return str_starts_with($version, 'v') ? $version : "v{$version}";
}
}
}
// Return the newest available version
$newestVersion = collect($traefikVersions)
->map(fn ($v) => ltrim($v, 'v'))
->sortBy(fn ($v) => $v, SORT_NATURAL)
->last();
return $newestVersion ? "v{$newestVersion}" : null;
}
// Handle old structure (simple string) for backward compatibility
return str_starts_with($traefikVersions, 'v') ? $traefikVersions : "v{$traefikVersions}";
} catch (\Throwable $e) {
return null;
}
}
public function getIsTraefikOutdatedProperty(): bool
{
if ($this->server->proxyType() !== ProxyTypes::TRAEFIK->value) {
return false;
}
$currentVersion = $this->server->detected_traefik_version;
if (! $currentVersion || $currentVersion === 'latest') {
return false;
}
$latestVersion = $this->latestTraefikVersion;
if (! $latestVersion) {
return false;
}
// Compare versions (strip 'v' prefix)
$current = ltrim($currentVersion, 'v');
$latest = ltrim($latestVersion, 'v');
return version_compare($current, $latest, '<');
}
public function getNewerTraefikBranchAvailableProperty(): ?string
{
try {
if ($this->server->proxyType() !== ProxyTypes::TRAEFIK->value) {
return null;
}
$currentVersion = $this->server->detected_traefik_version;
if (! $currentVersion || $currentVersion === 'latest') {
return null;
}
$versionsPath = base_path('versions.json');
if (! File::exists($versionsPath)) {
return null;
}
$versions = json_decode(File::get($versionsPath), true);
$traefikVersions = data_get($versions, 'traefik');
if (! is_array($traefikVersions)) {
return null;
}
// Extract current branch (e.g., "3.5" from "3.5.6")
$current = ltrim($currentVersion, 'v');
if (! preg_match('/^(\d+\.\d+)/', $current, $matches)) {
return null;
}
$currentBranch = $matches[1];
// Find the newest branch that's greater than current
$newestVersion = null;
foreach ($traefikVersions as $branch => $version) {
$branchNum = ltrim($branch, 'v');
if (version_compare($branchNum, $currentBranch, '>')) {
$cleanVersion = ltrim($version, 'v');
if (! $newestVersion || version_compare($cleanVersion, $newestVersion, '>')) {
$newestVersion = $cleanVersion;
}
}
}
return $newestVersion ? "v{$newestVersion}" : null;
} catch (\Throwable $e) {
return null;
}
}
}

View File

@ -29,6 +29,7 @@ class DiscordNotificationSettings extends Model
'server_reachable_discord_notifications',
'server_unreachable_discord_notifications',
'server_patch_discord_notifications',
'traefik_outdated_discord_notifications',
'discord_ping_enabled',
];
@ -48,6 +49,7 @@ class DiscordNotificationSettings extends Model
'server_reachable_discord_notifications' => 'boolean',
'server_unreachable_discord_notifications' => 'boolean',
'server_patch_discord_notifications' => 'boolean',
'traefik_outdated_discord_notifications' => 'boolean',
'discord_ping_enabled' => 'boolean',
];

View File

@ -36,6 +36,7 @@ class EmailNotificationSettings extends Model
'scheduled_task_failure_email_notifications',
'server_disk_usage_email_notifications',
'server_patch_email_notifications',
'traefik_outdated_email_notifications',
];
protected $casts = [
@ -63,6 +64,7 @@ class EmailNotificationSettings extends Model
'scheduled_task_failure_email_notifications' => 'boolean',
'server_disk_usage_email_notifications' => 'boolean',
'server_patch_email_notifications' => 'boolean',
'traefik_outdated_email_notifications' => 'boolean',
];
public function team()

View File

@ -30,6 +30,7 @@ class PushoverNotificationSettings extends Model
'server_reachable_pushover_notifications',
'server_unreachable_pushover_notifications',
'server_patch_pushover_notifications',
'traefik_outdated_pushover_notifications',
];
protected $casts = [
@ -49,6 +50,7 @@ class PushoverNotificationSettings extends Model
'server_reachable_pushover_notifications' => 'boolean',
'server_unreachable_pushover_notifications' => 'boolean',
'server_patch_pushover_notifications' => 'boolean',
'traefik_outdated_pushover_notifications' => 'boolean',
];
public function team()

View File

@ -167,6 +167,7 @@ class Server extends BaseModel
'hetzner_server_id',
'hetzner_server_status',
'is_validating',
'detected_traefik_version',
];
protected $guarded = [];

View File

@ -29,6 +29,7 @@ class SlackNotificationSettings extends Model
'server_reachable_slack_notifications',
'server_unreachable_slack_notifications',
'server_patch_slack_notifications',
'traefik_outdated_slack_notifications',
];
protected $casts = [
@ -47,6 +48,7 @@ class SlackNotificationSettings extends Model
'server_reachable_slack_notifications' => 'boolean',
'server_unreachable_slack_notifications' => 'boolean',
'server_patch_slack_notifications' => 'boolean',
'traefik_outdated_slack_notifications' => 'boolean',
];
public function team()

View File

@ -49,7 +49,9 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
protected static function booted()
{
static::created(function ($team) {
$team->emailNotificationSettings()->create();
$team->emailNotificationSettings()->create([
'use_instance_email_settings' => isDev(),
]);
$team->discordNotificationSettings()->create();
$team->slackNotificationSettings()->create();
$team->telegramNotificationSettings()->create();

View File

@ -30,6 +30,7 @@ class TelegramNotificationSettings extends Model
'server_reachable_telegram_notifications',
'server_unreachable_telegram_notifications',
'server_patch_telegram_notifications',
'traefik_outdated_telegram_notifications',
'telegram_notifications_deployment_success_thread_id',
'telegram_notifications_deployment_failure_thread_id',
@ -43,6 +44,7 @@ class TelegramNotificationSettings extends Model
'telegram_notifications_server_reachable_thread_id',
'telegram_notifications_server_unreachable_thread_id',
'telegram_notifications_server_patch_thread_id',
'telegram_notifications_traefik_outdated_thread_id',
];
protected $casts = [
@ -62,6 +64,7 @@ class TelegramNotificationSettings extends Model
'server_reachable_telegram_notifications' => 'boolean',
'server_unreachable_telegram_notifications' => 'boolean',
'server_patch_telegram_notifications' => 'boolean',
'traefik_outdated_telegram_notifications' => 'boolean',
'telegram_notifications_deployment_success_thread_id' => 'encrypted',
'telegram_notifications_deployment_failure_thread_id' => 'encrypted',
@ -75,6 +78,7 @@ class TelegramNotificationSettings extends Model
'telegram_notifications_server_reachable_thread_id' => 'encrypted',
'telegram_notifications_server_unreachable_thread_id' => 'encrypted',
'telegram_notifications_server_patch_thread_id' => 'encrypted',
'telegram_notifications_traefik_outdated_thread_id' => 'encrypted',
];
public function team()

View File

@ -29,6 +29,7 @@ class WebhookNotificationSettings extends Model
'server_reachable_webhook_notifications',
'server_unreachable_webhook_notifications',
'server_patch_webhook_notifications',
'traefik_outdated_webhook_notifications',
];
protected function casts(): array
@ -49,6 +50,7 @@ class WebhookNotificationSettings extends Model
'server_reachable_webhook_notifications' => 'boolean',
'server_unreachable_webhook_notifications' => 'boolean',
'server_patch_webhook_notifications' => 'boolean',
'traefik_outdated_webhook_notifications' => 'boolean',
];
}

View File

@ -0,0 +1,186 @@
<?php
namespace App\Notifications\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 TraefikVersionOutdated extends CustomEmailNotification
{
public function __construct(public Collection $servers)
{
$this->onQueue('high');
}
public function via(object $notifiable): array
{
return $notifiable->getEnabledChannels('traefik_outdated');
}
private function formatVersion(string $version): string
{
// Add 'v' prefix if not present for consistent display
return str_starts_with($version, 'v') ? $version : "v{$version}";
}
public function toMail($notifiable = null): MailMessage
{
$mail = new MailMessage;
$count = $this->servers->count();
$mail->subject("Coolify: Traefik proxy outdated on {$count} server(s)");
$mail->view('emails.traefik-version-outdated', [
'servers' => $this->servers,
'count' => $count,
]);
return $mail;
}
public function toDiscord(): DiscordMessage
{
$count = $this->servers->count();
$hasUpgrades = $this->servers->contains(fn ($s) => ($s->outdatedInfo['type'] ?? 'patch_update') === 'minor_upgrade');
$description = "**{$count} server(s)** running outdated Traefik proxy. Update recommended for security and features.\n\n";
$description .= "*Based on actual running container version*\n\n";
$description .= "**Affected servers:**\n";
foreach ($this->servers as $server) {
$info = $server->outdatedInfo ?? [];
$current = $this->formatVersion($info['current'] ?? 'unknown');
$latest = $this->formatVersion($info['latest'] ?? 'unknown');
$type = ($info['type'] ?? 'patch_update') === 'patch_update' ? '(patch)' : '(upgrade)';
$description .= "{$server->name}: {$current}{$latest} {$type}\n";
}
$description .= "\n⚠️ It is recommended to test before switching the production version.";
if ($hasUpgrades) {
$description .= "\n\n📖 **For major/minor upgrades**: Read the Traefik changelog before upgrading to understand breaking changes.";
}
return new DiscordMessage(
title: ':warning: Coolify: Traefik proxy outdated',
description: $description,
color: DiscordMessage::warningColor(),
);
}
public function toTelegram(): array
{
$count = $this->servers->count();
$hasUpgrades = $this->servers->contains(fn ($s) => ($s->outdatedInfo['type'] ?? 'patch_update') === 'minor_upgrade');
$message = "⚠️ Coolify: Traefik proxy outdated on {$count} server(s)!\n\n";
$message .= "Update recommended for security and features.\n";
$message .= " Based on actual running container version\n\n";
$message .= "📊 Affected servers:\n";
foreach ($this->servers as $server) {
$info = $server->outdatedInfo ?? [];
$current = $this->formatVersion($info['current'] ?? 'unknown');
$latest = $this->formatVersion($info['latest'] ?? 'unknown');
$type = ($info['type'] ?? 'patch_update') === 'patch_update' ? '(patch)' : '(upgrade)';
$message .= "{$server->name}: {$current}{$latest} {$type}\n";
}
$message .= "\n⚠️ It is recommended to test before switching the production version.";
if ($hasUpgrades) {
$message .= "\n\n📖 For major/minor upgrades: Read the Traefik changelog before upgrading to understand breaking changes.";
}
return [
'message' => $message,
'buttons' => [],
];
}
public function toPushover(): PushoverMessage
{
$count = $this->servers->count();
$hasUpgrades = $this->servers->contains(fn ($s) => ($s->outdatedInfo['type'] ?? 'patch_update') === 'minor_upgrade');
$message = "Traefik proxy outdated on {$count} server(s)!\n";
$message .= "Based on actual running container version\n\n";
$message .= "Affected servers:\n";
foreach ($this->servers as $server) {
$info = $server->outdatedInfo ?? [];
$current = $this->formatVersion($info['current'] ?? 'unknown');
$latest = $this->formatVersion($info['latest'] ?? 'unknown');
$type = ($info['type'] ?? 'patch_update') === 'patch_update' ? '(patch)' : '(upgrade)';
$message .= "{$server->name}: {$current}{$latest} {$type}\n";
}
$message .= "\nIt is recommended to test before switching the production version.";
if ($hasUpgrades) {
$message .= "\n\nFor major/minor upgrades: Read the Traefik changelog before upgrading.";
}
return new PushoverMessage(
title: 'Traefik proxy outdated',
level: 'warning',
message: $message,
);
}
public function toSlack(): SlackMessage
{
$count = $this->servers->count();
$hasUpgrades = $this->servers->contains(fn ($s) => ($s->outdatedInfo['type'] ?? 'patch_update') === 'minor_upgrade');
$description = "Traefik proxy outdated on {$count} server(s)!\n";
$description .= "_Based on actual running container version_\n\n";
$description .= "*Affected servers:*\n";
foreach ($this->servers as $server) {
$info = $server->outdatedInfo ?? [];
$current = $this->formatVersion($info['current'] ?? 'unknown');
$latest = $this->formatVersion($info['latest'] ?? 'unknown');
$type = ($info['type'] ?? 'patch_update') === 'patch_update' ? '(patch)' : '(upgrade)';
$description .= "• `{$server->name}`: {$current}{$latest} {$type}\n";
}
$description .= "\n:warning: It is recommended to test before switching the production version.";
if ($hasUpgrades) {
$description .= "\n\n:book: For major/minor upgrades: Read the Traefik changelog before upgrading to understand breaking changes.";
}
return new SlackMessage(
title: 'Coolify: Traefik proxy outdated',
description: $description,
color: SlackMessage::warningColor()
);
}
public function toWebhook(): array
{
$servers = $this->servers->map(function ($server) {
$info = $server->outdatedInfo ?? [];
return [
'name' => $server->name,
'uuid' => $server->uuid,
'current_version' => $info['current'] ?? 'unknown',
'latest_version' => $info['latest'] ?? 'unknown',
'update_type' => $info['type'] ?? 'patch_update',
];
})->toArray();
return [
'success' => false,
'message' => 'Traefik proxy outdated',
'event' => 'traefik_version_outdated',
'affected_servers_count' => $this->servers->count(),
'servers' => $servers,
];
}
}

View File

@ -334,3 +334,94 @@ function generateDefaultProxyConfiguration(Server $server, array $custom_command
return $config;
}
function getExactTraefikVersionFromContainer(Server $server): ?string
{
try {
Log::debug("getExactTraefikVersionFromContainer: Server '{$server->name}' (ID: {$server->id}) - Checking for exact version");
// Method A: Execute traefik version command (most reliable)
$versionCommand = "docker exec coolify-proxy traefik version 2>/dev/null | grep -oP 'Version:\s+\K\d+\.\d+\.\d+'";
Log::debug("getExactTraefikVersionFromContainer: Server '{$server->name}' (ID: {$server->id}) - Running: {$versionCommand}");
$output = instant_remote_process([$versionCommand], $server, false);
if (! empty(trim($output))) {
$version = trim($output);
Log::debug("getExactTraefikVersionFromContainer: Server '{$server->name}' (ID: {$server->id}) - Detected exact version from command: {$version}");
return $version;
}
// Method B: Try OCI label as fallback
$labelCommand = "docker inspect coolify-proxy --format '{{index .Config.Labels \"org.opencontainers.image.version\"}}' 2>/dev/null";
Log::debug("getExactTraefikVersionFromContainer: Server '{$server->name}' (ID: {$server->id}) - Trying OCI label");
$label = instant_remote_process([$labelCommand], $server, false);
if (! empty(trim($label))) {
// Extract version number from label (might have 'v' prefix)
if (preg_match('/(\d+\.\d+\.\d+)/', trim($label), $matches)) {
Log::debug("getExactTraefikVersionFromContainer: Server '{$server->name}' (ID: {$server->id}) - Detected from OCI label: {$matches[1]}");
return $matches[1];
}
}
Log::debug("getExactTraefikVersionFromContainer: Server '{$server->name}' (ID: {$server->id}) - Could not detect exact version");
return null;
} catch (\Exception $e) {
Log::error("getExactTraefikVersionFromContainer: Server '{$server->name}' (ID: {$server->id}) - Error: ".$e->getMessage());
return null;
}
}
function getTraefikVersionFromDockerCompose(Server $server): ?string
{
try {
Log::debug("getTraefikVersionFromDockerCompose: Server '{$server->name}' (ID: {$server->id}) - Starting version detection");
// Try to get exact version from running container (e.g., "3.6.0")
$exactVersion = getExactTraefikVersionFromContainer($server);
if ($exactVersion) {
Log::debug("getTraefikVersionFromDockerCompose: Server '{$server->name}' (ID: {$server->id}) - Using exact version: {$exactVersion}");
return $exactVersion;
}
// Fallback: Check image tag (current method)
Log::debug("getTraefikVersionFromDockerCompose: Server '{$server->name}' (ID: {$server->id}) - Falling back to image tag detection");
$containerName = 'coolify-proxy';
$inspectCommand = "docker inspect {$containerName} --format '{{.Config.Image}}' 2>/dev/null";
$image = instant_remote_process([$inspectCommand], $server, false);
if (empty(trim($image))) {
Log::debug("getTraefikVersionFromDockerCompose: Server '{$server->name}' (ID: {$server->id}) - Container '{$containerName}' not found or not running");
return null;
}
$image = trim($image);
Log::debug("getTraefikVersionFromDockerCompose: Server '{$server->name}' (ID: {$server->id}) - Running container image: {$image}");
// Extract version from image string (e.g., "traefik:v3.6" or "traefik:3.6.0" or "traefik:latest")
if (preg_match('/traefik:(v?\d+\.\d+(?:\.\d+)?|latest)/i', $image, $matches)) {
Log::debug("getTraefikVersionFromDockerCompose: Server '{$server->name}' (ID: {$server->id}) - Extracted version from image tag: {$matches[1]}");
return $matches[1];
}
Log::debug("getTraefikVersionFromDockerCompose: Server '{$server->name}' (ID: {$server->id}) - Image format doesn't match expected pattern: {$image}");
return null;
} catch (\Exception $e) {
Log::error("getTraefikVersionFromDockerCompose: Server '{$server->name}' (ID: {$server->id}) - Error: ".$e->getMessage());
ray('Error getting Traefik version from running container: '.$e->getMessage());
return null;
}
}

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('servers', function (Blueprint $table) {
$table->string('detected_traefik_version')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('servers', function (Blueprint $table) {
$table->dropColumn('detected_traefik_version');
});
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('email_notification_settings', function (Blueprint $table) {
$table->boolean('traefik_outdated_email_notifications')->default(true);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('email_notification_settings', function (Blueprint $table) {
$table->dropColumn('traefik_outdated_email_notifications');
});
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('discord_notification_settings', function (Blueprint $table) {
$table->boolean('traefik_outdated_discord_notifications')->default(true);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('discord_notification_settings', function (Blueprint $table) {
$table->dropColumn('traefik_outdated_discord_notifications');
});
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('pushover_notification_settings', function (Blueprint $table) {
$table->boolean('traefik_outdated_pushover_notifications')->default(true);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('pushover_notification_settings', function (Blueprint $table) {
$table->dropColumn('traefik_outdated_pushover_notifications');
});
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('slack_notification_settings', function (Blueprint $table) {
$table->boolean('traefik_outdated_slack_notifications')->default(true);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('slack_notification_settings', function (Blueprint $table) {
$table->dropColumn('traefik_outdated_slack_notifications');
});
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('telegram_notification_settings', function (Blueprint $table) {
$table->boolean('traefik_outdated_telegram_notifications')->default(true);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('telegram_notification_settings', function (Blueprint $table) {
$table->dropColumn('traefik_outdated_telegram_notifications');
});
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('webhook_notification_settings', function (Blueprint $table) {
$table->boolean('traefik_outdated_webhook_notifications')->default(true);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('webhook_notification_settings', function (Blueprint $table) {
$table->dropColumn('traefik_outdated_webhook_notifications');
});
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('telegram_notification_settings', function (Blueprint $table) {
$table->text('telegram_notifications_traefik_outdated_thread_id')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('telegram_notification_settings', function (Blueprint $table) {
$table->dropColumn('telegram_notifications_traefik_outdated_thread_id');
});
}
};

View File

@ -113,6 +113,8 @@ class ProductionSeeder extends Seeder
$server_details['proxy'] = ServerMetadata::from([
'type' => ProxyTypes::TRAEFIK->value,
'status' => ProxyStatus::EXITED->value,
'last_saved_settings' => null,
'last_applied_settings' => null,
]);
$server = Server::create($server_details);
$server->settings->is_reachable = true;
@ -177,6 +179,8 @@ uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
$server_details['proxy'] = ServerMetadata::from([
'type' => ProxyTypes::TRAEFIK->value,
'status' => ProxyStatus::EXITED->value,
'last_saved_settings' => null,
'last_applied_settings' => null,
]);
$server = Server::create($server_details);
$server->settings->is_reachable = true;

View File

@ -1,13 +1,13 @@
@props(['type' => 'warning', 'title' => 'Warning', 'class' => ''])
@props(['type' => 'warning', 'title' => 'Warning', 'class' => '', 'dismissible' => false, 'onDismiss' => null])
@php
$icons = [
'warning' => '<svg class="w-5 h-5 text-yellow-600 dark:text-yellow-400" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path></svg>',
'danger' => '<svg class="w-5 h-5 text-red-600 dark:text-red-400" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"></path></svg>',
'info' => '<svg class="w-5 h-5 text-blue-600 dark:text-blue-400" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path></svg>',
'success' => '<svg class="w-5 h-5 text-green-600 dark:text-green-400" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path></svg>'
];
@ -42,12 +42,12 @@
$icon = $icons[$type] ?? $icons['warning'];
@endphp
<div {{ $attributes->merge(['class' => 'p-4 border rounded-lg ' . $colorScheme['bg'] . ' ' . $colorScheme['border'] . ' ' . $class]) }}>
<div {{ $attributes->merge(['class' => 'relative p-4 border rounded-lg ' . $colorScheme['bg'] . ' ' . $colorScheme['border'] . ' ' . $class]) }}>
<div class="flex items-start">
<div class="flex-shrink-0">
{!! $icon !!}
</div>
<div class="ml-3">
<div class="ml-3 {{ $dismissible ? 'pr-8' : '' }}">
<div class="text-base font-bold {{ $colorScheme['title'] }}">
{{ $title }}
</div>
@ -55,5 +55,15 @@
{{ $slot }}
</div>
</div>
@if($dismissible && $onDismiss)
<button type="button" @click.stop="{{ $onDismiss }}"
class="absolute top-2 right-2 p-1 rounded hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
aria-label="Dismiss">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" class="w-4 h-4 {{ $colorScheme['text'] }}">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
@endif
</div>
</div>

View File

@ -0,0 +1,43 @@
<x-emails.layout>
{{ $count }} server(s) are running outdated Traefik proxy. Update recommended for security and features.
**Note:** This check is based on the actual running container version, not the configuration file.
## Affected Servers
@foreach ($servers as $server)
@php
$info = $server->outdatedInfo ?? [];
$current = $info['current'] ?? 'unknown';
$latest = $info['latest'] ?? 'unknown';
$type = ($info['type'] ?? 'patch_update') === 'patch_update' ? '(patch)' : '(upgrade)';
$hasUpgrades = $hasUpgrades ?? false;
if ($type === 'upgrade') {
$hasUpgrades = true;
}
// Add 'v' prefix for display
$current = str_starts_with($current, 'v') ? $current : "v{$current}";
$latest = str_starts_with($latest, 'v') ? $latest : "v{$latest}";
@endphp
- **{{ $server->name }}**: {{ $current }} {{ $latest }} {{ $type }}
@endforeach
## Recommendation
It is recommended to test the new Traefik version before switching it in production environments. You can update your proxy configuration through your [Coolify Dashboard]({{ config('app.url') }}).
@if ($hasUpgrades ?? false)
**Important for major/minor upgrades:** Before upgrading to a new major or minor version, please read the [Traefik changelog](https://github.com/traefik/traefik/releases) to understand breaking changes and new features.
@endif
## Next Steps
1. Review the [Traefik release notes](https://github.com/traefik/traefik/releases) for changes
2. Test the new version in a non-production environment
3. Update your proxy configuration when ready
4. Monitor services after the update
---
You can manage your server proxy settings in your Coolify Dashboard.
</x-emails.layout>

View File

@ -80,6 +80,8 @@
label="Server Unreachable" />
<x-forms.checkbox canGate="update" :canResource="$settings" instantSave="saveModel" id="serverPatchDiscordNotifications"
label="Server Patching" />
<x-forms.checkbox canGate="update" :canResource="$settings" instantSave="saveModel" id="traefikOutdatedDiscordNotifications"
label="Traefik Proxy Outdated" />
</div>
</div>
</div>

View File

@ -161,6 +161,8 @@
label="Server Unreachable" />
<x-forms.checkbox canGate="update" :canResource="$settings" instantSave="saveModel" id="serverPatchEmailNotifications"
label="Server Patching" />
<x-forms.checkbox canGate="update" :canResource="$settings" instantSave="saveModel" id="traefikOutdatedEmailNotifications"
label="Traefik Proxy Outdated" />
</div>
</div>
</div>

View File

@ -82,6 +82,8 @@
label="Server Unreachable" />
<x-forms.checkbox canGate="update" :canResource="$settings" instantSave="saveModel" id="serverPatchPushoverNotifications"
label="Server Patching" />
<x-forms.checkbox canGate="update" :canResource="$settings" instantSave="saveModel" id="traefikOutdatedPushoverNotifications"
label="Traefik Proxy Outdated" />
</div>
</div>
</div>

View File

@ -74,6 +74,7 @@
<x-forms.checkbox canGate="update" :canResource="$settings" instantSave="saveModel" id="serverUnreachableSlackNotifications"
label="Server Unreachable" />
<x-forms.checkbox canGate="update" :canResource="$settings" instantSave="saveModel" id="serverPatchSlackNotifications" label="Server Patching" />
<x-forms.checkbox canGate="update" :canResource="$settings" instantSave="saveModel" id="traefikOutdatedSlackNotifications" label="Traefik Proxy Outdated" />
</div>
</div>
</div>

View File

@ -169,6 +169,15 @@
<x-forms.input canGate="update" :canResource="$settings" type="password" placeholder="Custom Telegram Thread ID"
id="telegramNotificationsServerPatchThreadId" />
</div>
<div class="pl-1 flex gap-2">
<div class="w-96">
<x-forms.checkbox canGate="update" :canResource="$settings" instantSave="saveModel" id="traefikOutdatedTelegramNotifications"
label="Traefik Proxy Outdated" />
</div>
<x-forms.input canGate="update" :canResource="$settings" type="password" placeholder="Custom Telegram Thread ID"
id="telegramNotificationsTraefikOutdatedThreadId" />
</div>
</div>
</div>
</div>

View File

@ -83,6 +83,8 @@
id="serverUnreachableWebhookNotifications" label="Server Unreachable" />
<x-forms.checkbox canGate="update" :canResource="$settings" instantSave="saveModel"
id="serverPatchWebhookNotifications" label="Server Patching" />
<x-forms.checkbox canGate="update" :canResource="$settings" instantSave="saveModel"
id="traefikOutdatedWebhookNotifications" label="Traefik Proxy Outdated" />
</div>
</div>
</div>

View File

@ -1,5 +1,5 @@
<div class="pb-6">
<x-slide-over @startproxy.window="slideOverOpen = true" fullScreen>
<x-slide-over @startproxy.window="slideOverOpen = true" fullScreen closeWithX>
<x-slot:title>Proxy Startup Logs</x-slot:title>
<x-slot:content>
<livewire:activity-monitor header="Logs" fullHeight />
@ -97,12 +97,6 @@
<div class="order-first sm:order-last">
<div>
@if ($server->proxySet())
<x-slide-over fullScreen @startproxy.window="slideOverOpen = true">
<x-slot:title>Proxy Status</x-slot:title>
<x-slot:content>
<livewire:activity-monitor header="Logs" />
</x-slot:content>
</x-slide-over>
@if ($proxyStatus === 'running')
<div class="flex gap-2">
<div class="mt-1" wire:loading wire:target="loadProxyConfiguration">
@ -181,6 +175,7 @@
});
$wire.$on('restartEvent', () => {
$wire.$dispatch('info', 'Initiating proxy restart.');
window.dispatchEvent(new CustomEvent('startproxy'))
$wire.$call('restart');
});
$wire.$on('startProxy', () => {

View File

@ -21,7 +21,15 @@
@endif
<x-forms.button canGate="update" :canResource="$server" type="submit">Save</x-forms.button>
</div>
<div class="subtitle">Configure your proxy settings and advanced options.</div>
<div class="pb-4">Configure your proxy settings and advanced options.</div>
@if (
$server->proxy->last_applied_settings &&
$server->proxy->last_saved_settings !== $server->proxy->last_applied_settings)
<x-callout type="warning" title="Configuration Out of Sync" class="my-4">
The saved proxy configuration differs from the currently running configuration. Restart the
proxy to apply your changes.
</x-callout>
@endif
<h3>Advanced</h3>
<div class="pb-6 w-96">
<x-forms.checkbox canGate="update" :canResource="$server"
@ -43,32 +51,87 @@
: 'Caddy (Coolify Proxy)';
@endphp
@if ($server->proxyType() === ProxyTypes::TRAEFIK->value || $server->proxyType() === 'CADDY')
<div class="flex items-center gap-2">
<h3>{{ $proxyTitle }}</h3>
@if ($proxySettings)
<div @if($server->proxyType() === ProxyTypes::TRAEFIK->value) x-data="{ traefikWarningsDismissed: localStorage.getItem('callout-dismissed-traefik-warnings-{{ $server->id }}') === 'true' }" @endif>
<div class="flex items-center gap-2">
<h3>{{ $proxyTitle }}</h3>
@can('update', $server)
<x-modal-confirmation title="Reset Proxy Configuration?"
buttonTitle="Reset Configuration" submitAction="resetProxyConfiguration"
:actions="[
'Reset proxy configuration to default settings',
'All custom configurations will be lost',
'Custom ports and entrypoints will be removed',
]" confirmationText="{{ $server->name }}"
confirmationLabel="Please confirm by entering the server name below"
shortConfirmationLabel="Server Name" step2ButtonText="Reset Configuration"
:confirmWithPassword="false" :confirmWithText="true">
</x-modal-confirmation>
<div wire:loading wire:target="loadProxyConfiguration">
<x-forms.button disabled>Reset Configuration</x-forms.button>
</div>
<div wire:loading.remove wire:target="loadProxyConfiguration">
@if ($proxySettings)
<x-modal-confirmation title="Reset Proxy Configuration?"
buttonTitle="Reset Configuration" submitAction="resetProxyConfiguration"
:actions="[
'Reset proxy configuration to default settings',
'All custom configurations will be lost',
'Custom ports and entrypoints will be removed',
]" confirmationText="{{ $server->name }}"
confirmationLabel="Please confirm by entering the server name below"
shortConfirmationLabel="Server Name" step2ButtonText="Reset Configuration"
:confirmWithPassword="false" :confirmWithText="true">
</x-modal-confirmation>
@endif
</div>
@endcan
@if ($server->proxyType() === ProxyTypes::TRAEFIK->value)
<button type="button" x-show="traefikWarningsDismissed"
@click="traefikWarningsDismissed = false; localStorage.removeItem('callout-dismissed-traefik-warnings-{{ $server->id }}')"
class="p-1.5 rounded hover:bg-yellow-100 dark:hover:bg-yellow-900/30 transition-colors"
title="Show Traefik warnings">
<svg class="w-4 h-4 text-yellow-600 dark:text-yellow-400" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg">
<path fill="currentColor" d="M240.26 186.1L152.81 34.23a28.74 28.74 0 0 0-49.62 0L15.74 186.1a27.45 27.45 0 0 0 0 27.71A28.31 28.31 0 0 0 40.55 228h174.9a28.31 28.31 0 0 0 24.79-14.19a27.45 27.45 0 0 0 .02-27.71m-20.8 15.7a4.46 4.46 0 0 1-4 2.2H40.55a4.46 4.46 0 0 1-4-2.2a3.56 3.56 0 0 1 0-3.73L124 46.2a4.77 4.77 0 0 1 8 0l87.44 151.87a3.56 3.56 0 0 1 .02 3.73M116 136v-32a12 12 0 0 1 24 0v32a12 12 0 0 1-24 0m28 40a16 16 0 1 1-16-16a16 16 0 0 1 16 16"></path>
</svg>
</button>
@endif
</div>
@if ($server->proxyType() === ProxyTypes::TRAEFIK->value)
<div x-show="!traefikWarningsDismissed"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 -translate-y-2"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 -translate-y-2">
@if ($server->detected_traefik_version === 'latest')
<x-callout dismissible onDismiss="traefikWarningsDismissed = true; localStorage.setItem('callout-dismissed-traefik-warnings-{{ $server->id }}', 'true')" type="warning" title="Using 'latest' Traefik Tag" class="my-4">
Your proxy container is running the <span class="font-mono">latest</span> tag. While
this ensures you always have the newest version, it may introduce unexpected breaking
changes.
<br><br>
<strong>Recommendation:</strong> Pin to a specific version (e.g., <span
class="font-mono">traefik:{{ $this->latestTraefikVersion }}</span>) to ensure
stability and predictable updates.
</x-callout>
@elseif($this->isTraefikOutdated)
<x-callout dismissible onDismiss="traefikWarningsDismissed = true; localStorage.setItem('callout-dismissed-traefik-warnings-{{ $server->id }}', 'true')" type="warning" title="Traefik Patch Update Available" class="my-4">
Your Traefik proxy container is running version <span
class="font-mono">v{{ $server->detected_traefik_version }}</span>, but version <span
class="font-mono">{{ $this->latestTraefikVersion }}</span> is available.
<br><br>
<strong>Recommendation:</strong> Update to the latest patch version for security fixes
and
bug fixes. Please test in a non-production environment first.
</x-callout>
@endif
@if ($this->newerTraefikBranchAvailable)
<x-callout dismissible onDismiss="traefikWarningsDismissed = true; localStorage.setItem('callout-dismissed-traefik-warnings-{{ $server->id }}', 'true')" type="info" title="Newer Traefik Version Available" class="my-4">
A newer version of Traefik is available: <span
class="font-mono">{{ $this->newerTraefikBranchAvailable }}</span>
<br><br>
<strong>Important:</strong> Before upgrading to a new major or minor version, please
read
the <a href="https://github.com/traefik/traefik/releases" target="_blank"
class="underline text-white">Traefik changelog</a> to understand breaking changes
and new features.
<br><br>
<strong>Recommendation:</strong> Test the upgrade in a non-production environment first.
</x-callout>
@endif
</div>
@endif
</div>
@endif
@if (
$server->proxy->last_applied_settings &&
$server->proxy->last_saved_settings !== $server->proxy->last_applied_settings)
<div class="text-red-500 ">Configuration out of sync. Restart the proxy to apply the new
configurations.
</div>
@endif
<div wire:loading wire:target="loadProxyConfiguration" class="pt-4">
<x-loading text="Loading proxy configuration..." />
</div>

View File

@ -0,0 +1,181 @@
<?php
use App\Models\Server;
use App\Models\Team;
use App\Notifications\Server\TraefikVersionOutdated;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Notification;
uses(RefreshDatabase::class);
beforeEach(function () {
Notification::fake();
});
it('detects servers table has detected_traefik_version column', function () {
expect(\Illuminate\Support\Facades\Schema::hasColumn('servers', 'detected_traefik_version'))->toBeTrue();
});
it('server model casts detected_traefik_version as string', function () {
$server = Server::factory()->make();
expect($server->getFillable())->toContain('detected_traefik_version');
});
it('notification settings have traefik_outdated fields', function () {
$team = Team::factory()->create();
// Check Email notification settings
expect($team->emailNotificationSettings)->toHaveKey('traefik_outdated_email_notifications');
// Check Discord notification settings
expect($team->discordNotificationSettings)->toHaveKey('traefik_outdated_discord_notifications');
// Check Telegram notification settings
expect($team->telegramNotificationSettings)->toHaveKey('traefik_outdated_telegram_notifications');
expect($team->telegramNotificationSettings)->toHaveKey('telegram_notifications_traefik_outdated_thread_id');
// Check Slack notification settings
expect($team->slackNotificationSettings)->toHaveKey('traefik_outdated_slack_notifications');
// Check Pushover notification settings
expect($team->pushoverNotificationSettings)->toHaveKey('traefik_outdated_pushover_notifications');
// Check Webhook notification settings
expect($team->webhookNotificationSettings)->toHaveKey('traefik_outdated_webhook_notifications');
});
it('versions.json contains traefik branches with patch versions', function () {
$versionsPath = base_path('versions.json');
expect(File::exists($versionsPath))->toBeTrue();
$versions = json_decode(File::get($versionsPath), true);
expect($versions)->toHaveKey('traefik');
$traefikVersions = $versions['traefik'];
expect($traefikVersions)->toBeArray();
// Each branch should have format like "v3.6" => "3.6.0"
foreach ($traefikVersions as $branch => $version) {
expect($branch)->toMatch('/^v\d+\.\d+$/'); // e.g., "v3.6"
expect($version)->toMatch('/^\d+\.\d+\.\d+$/'); // e.g., "3.6.0"
}
});
it('formats version with v prefix for display', function () {
// Test the formatVersion logic from notification class
$version = '3.6';
$formatted = str_starts_with($version, 'v') ? $version : "v{$version}";
expect($formatted)->toBe('v3.6');
$versionWithPrefix = 'v3.6';
$formatted2 = str_starts_with($versionWithPrefix, 'v') ? $versionWithPrefix : "v{$versionWithPrefix}";
expect($formatted2)->toBe('v3.6');
});
it('compares semantic versions correctly', function () {
// Test version comparison logic used in job
$currentVersion = 'v3.5';
$latestVersion = 'v3.6';
$isOutdated = version_compare(ltrim($currentVersion, 'v'), ltrim($latestVersion, 'v'), '<');
expect($isOutdated)->toBeTrue();
// Test equal versions
$sameVersion = version_compare(ltrim('3.6', 'v'), ltrim('3.6', 'v'), '=');
expect($sameVersion)->toBeTrue();
// Test newer version
$newerVersion = version_compare(ltrim('3.7', 'v'), ltrim('3.6', 'v'), '>');
expect($newerVersion)->toBeTrue();
});
it('notification class accepts servers collection with outdated info', function () {
$team = Team::factory()->create();
$server1 = Server::factory()->make([
'name' => 'Server 1',
'team_id' => $team->id,
'detected_traefik_version' => 'v3.5.0',
]);
$server1->outdatedInfo = [
'current' => '3.5.0',
'latest' => '3.5.6',
'type' => 'patch_update',
];
$server2 = Server::factory()->make([
'name' => 'Server 2',
'team_id' => $team->id,
'detected_traefik_version' => 'v3.4.0',
]);
$server2->outdatedInfo = [
'current' => '3.4.0',
'latest' => '3.6.0',
'type' => 'minor_upgrade',
];
$servers = collect([$server1, $server2]);
$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');
});
it('notification channels can be retrieved', function () {
$team = Team::factory()->create();
$notification = new TraefikVersionOutdated(collect());
$channels = $notification->via($team);
expect($channels)->toBeArray();
});
it('traefik version check command exists', function () {
$commands = \Illuminate\Support\Facades\Artisan::all();
expect($commands)->toHaveKey('traefik:check-version');
});
it('job handles servers with no proxy type', function () {
$team = Team::factory()->create();
$server = Server::factory()->create([
'team_id' => $team->id,
]);
// Server without proxy configuration returns null for proxyType()
expect($server->proxyType())->toBeNull();
});
it('handles latest tag correctly', function () {
// Test that 'latest' tag is not considered for outdated comparison
$currentVersion = 'latest';
$latestVersion = '3.6';
// Job skips notification for 'latest' tag
$shouldNotify = $currentVersion !== 'latest';
expect($shouldNotify)->toBeFalse();
});
it('groups servers by team correctly', function () {
$team1 = Team::factory()->create(['name' => 'Team 1']);
$team2 = Team::factory()->create(['name' => 'Team 2']);
$servers = collect([
(object) ['team_id' => $team1->id, 'name' => 'Server 1'],
(object) ['team_id' => $team1->id, 'name' => 'Server 2'],
(object) ['team_id' => $team2->id, 'name' => 'Server 3'],
]);
$grouped = $servers->groupBy('team_id');
expect($grouped)->toHaveCount(2);
expect($grouped[$team1->id])->toHaveCount(2);
expect($grouped[$team2->id])->toHaveCount(1);
});

View File

@ -0,0 +1,155 @@
<?php
use Illuminate\Support\Facades\Log;
beforeEach(function () {
// Mock Log facade to prevent actual logging during tests
Log::shouldReceive('debug')->andReturn(null);
Log::shouldReceive('error')->andReturn(null);
});
it('parses traefik version with v prefix', function () {
$image = 'traefik:v3.6';
preg_match('/traefik:(v?\d+\.\d+(?:\.\d+)?|latest)/i', $image, $matches);
expect($matches[1])->toBe('v3.6');
});
it('parses traefik version without v prefix', function () {
$image = 'traefik:3.6.0';
preg_match('/traefik:(v?\d+\.\d+(?:\.\d+)?|latest)/i', $image, $matches);
expect($matches[1])->toBe('3.6.0');
});
it('parses traefik latest tag', function () {
$image = 'traefik:latest';
preg_match('/traefik:(v?\d+\.\d+(?:\.\d+)?|latest)/i', $image, $matches);
expect($matches[1])->toBe('latest');
});
it('parses traefik version with patch number', function () {
$image = 'traefik:v3.5.1';
preg_match('/traefik:(v?\d+\.\d+(?:\.\d+)?|latest)/i', $image, $matches);
expect($matches[1])->toBe('v3.5.1');
});
it('parses traefik version with minor only', function () {
$image = 'traefik:3.6';
preg_match('/traefik:(v?\d+\.\d+(?:\.\d+)?|latest)/i', $image, $matches);
expect($matches[1])->toBe('3.6');
});
it('returns null for invalid image format', function () {
$image = 'nginx:latest';
preg_match('/traefik:(v?\d+\.\d+(?:\.\d+)?|latest)/i', $image, $matches);
expect($matches)->toBeEmpty();
});
it('returns null for empty image string', function () {
$image = '';
preg_match('/traefik:(v?\d+\.\d+(?:\.\d+)?|latest)/i', $image, $matches);
expect($matches)->toBeEmpty();
});
it('handles case insensitive traefik image name', function () {
$image = 'TRAEFIK:v3.6';
preg_match('/traefik:(v?\d+\.\d+(?:\.\d+)?|latest)/i', $image, $matches);
expect($matches[1])->toBe('v3.6');
});
it('parses full docker image with registry', function () {
$image = 'docker.io/library/traefik:v3.6';
preg_match('/traefik:(v?\d+\.\d+(?:\.\d+)?|latest)/i', $image, $matches);
expect($matches[1])->toBe('v3.6');
});
it('compares versions correctly after stripping v prefix', function () {
$version1 = 'v3.5';
$version2 = 'v3.6';
$result = version_compare(ltrim($version1, 'v'), ltrim($version2, 'v'), '<');
expect($result)->toBeTrue();
});
it('compares same versions as equal', function () {
$version1 = 'v3.6';
$version2 = '3.6';
$result = version_compare(ltrim($version1, 'v'), ltrim($version2, 'v'), '=');
expect($result)->toBeTrue();
});
it('compares versions with patch numbers', function () {
$version1 = '3.5.1';
$version2 = '3.6.0';
$result = version_compare($version1, $version2, '<');
expect($result)->toBeTrue();
});
it('parses exact version from traefik version command output', function () {
$output = "Version: 3.6.0\nCodename: ramequin\nGo version: go1.24.10";
preg_match('/Version:\s+(\d+\.\d+\.\d+)/', $output, $matches);
expect($matches[1])->toBe('3.6.0');
});
it('parses exact version from OCI label with v prefix', function () {
$label = 'v3.6.0';
preg_match('/(\d+\.\d+\.\d+)/', $label, $matches);
expect($matches[1])->toBe('3.6.0');
});
it('parses exact version from OCI label without v prefix', function () {
$label = '3.6.0';
preg_match('/(\d+\.\d+\.\d+)/', $label, $matches);
expect($matches[1])->toBe('3.6.0');
});
it('extracts major.minor branch from full version', function () {
$version = '3.6.0';
preg_match('/^(\d+\.\d+)\.(\d+)$/', $version, $matches);
expect($matches[1])->toBe('3.6'); // branch
expect($matches[2])->toBe('0'); // patch
});
it('compares patch versions within same branch', function () {
$current = '3.6.0';
$latest = '3.6.2';
$result = version_compare($current, $latest, '<');
expect($result)->toBeTrue();
});
it('detects up-to-date patch version', function () {
$current = '3.6.2';
$latest = '3.6.2';
$result = version_compare($current, $latest, '=');
expect($result)->toBeTrue();
});
it('compares branches for minor upgrades', function () {
$currentBranch = '3.5';
$newerBranch = '3.6';
$result = version_compare($currentBranch, $newerBranch, '<');
expect($result)->toBeTrue();
});

View File

@ -15,5 +15,15 @@
"sentinel": {
"version": "0.0.16"
}
},
"traefik": {
"v3.6": "3.6.0",
"v3.5": "3.5.6",
"v3.4": "3.4.5",
"v3.3": "3.3.7",
"v3.2": "3.2.5",
"v3.1": "3.1.7",
"v3.0": "3.0.4",
"v2.11": "2.11.31"
}
}