feat(proxy): enhance Traefik version notifications to show patch and minor upgrades

- Store both patch update and newer minor version information simultaneously
- Display patch update availability alongside minor version upgrades in notifications
- Add newer_branch_target and newer_branch_latest fields to traefik_outdated_info
- Update all notification channels (Discord, Telegram, Slack, Pushover, Email, Webhook)
- Show minor version in format (e.g., v3.6) for upgrade targets instead of patch version
- Enhance UI callouts with clearer messaging about available upgrades
- Remove verbose logging in favor of cleaner code structure
- Handle edge case where SSH command returns empty response

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2025-11-17 09:59:17 +01:00
parent c77eaddede
commit 6dbe58f22b
15 changed files with 618 additions and 241 deletions

View File

@@ -195,21 +195,32 @@ it('parallel processing jobs exist and have correct structure', function () {
});
it('calculates delay seconds correctly for notification job', function () {
// Test delay calculation logic
$serverCounts = [10, 100, 500, 1000, 5000];
// Test the delay calculation logic
// Values: min=120s, max=300s, scaling=0.2
$testCases = [
['servers' => 10, 'expected' => 120], // 10 * 0.2 = 2s -> uses min of 120s
['servers' => 100, 'expected' => 120], // 100 * 0.2 = 20s -> uses min of 120s
['servers' => 600, 'expected' => 120], // 600 * 0.2 = 120s (exactly at min)
['servers' => 1000, 'expected' => 200], // 1000 * 0.2 = 200s
['servers' => 1500, 'expected' => 300], // 1500 * 0.2 = 300s (at max)
['servers' => 5000, 'expected' => 300], // 5000 * 0.2 = 1000s -> uses max of 300s
];
foreach ($serverCounts as $count) {
$delaySeconds = min(300, max(60, (int) ($count / 10)));
foreach ($testCases as $case) {
$count = $case['servers'];
$expected = $case['expected'];
// Should be at least 60 seconds
expect($delaySeconds)->toBeGreaterThanOrEqual(60);
// Use the same logic as the job's calculateNotificationDelay method
$minDelay = 120;
$maxDelay = 300;
$scalingFactor = 0.2;
$calculatedDelay = (int) ($count * $scalingFactor);
$delaySeconds = min($maxDelay, max($minDelay, $calculatedDelay));
// Should not exceed 300 seconds
expect($delaySeconds)->toBeLessThanOrEqual(300);
expect($delaySeconds)->toBe($expected, "Failed for {$count} servers");
// Should always be within bounds
expect($delaySeconds)->toBeGreaterThanOrEqual($minDelay);
expect($delaySeconds)->toBeLessThanOrEqual($maxDelay);
}
// Specific test cases
expect(min(300, max(60, (int) (10 / 10))))->toBe(60); // 10 servers = 60s (minimum)
expect(min(300, max(60, (int) (1000 / 10))))->toBe(100); // 1000 servers = 100s
expect(min(300, max(60, (int) (5000 / 10))))->toBe(300); // 5000 servers = 300s (maximum)
});

View File

@@ -103,3 +103,39 @@ it('handles invalid version format gracefully', function () {
expect($result)->toBe(0);
expect($matches)->toBeEmpty();
});
it('handles empty image tag correctly', function () {
// Test that empty string after trim doesn't cause issues with str_contains
$emptyImageTag = '';
$trimmed = trim($emptyImageTag);
// This should be false, not an error
expect(empty($trimmed))->toBeTrue();
// Test with whitespace only
$whitespaceTag = " \n ";
$trimmed = trim($whitespaceTag);
expect(empty($trimmed))->toBeTrue();
});
it('detects latest tag in image name', function () {
// Test various formats where :latest appears
$testCases = [
'traefik:latest' => true,
'traefik:Latest' => true,
'traefik:LATEST' => true,
'traefik:v3.6.0' => false,
'traefik:3.6.0' => false,
'' => false,
];
foreach ($testCases as $imageTag => $expected) {
if (empty(trim($imageTag))) {
$result = false; // Should return false for empty tags
} else {
$result = str_contains(strtolower(trim($imageTag)), ':latest');
}
expect($result)->toBe($expected, "Failed for imageTag: '{$imageTag}'");
}
});

View File

@@ -0,0 +1,122 @@
<?php
// Constants used in server check delay calculations
// These match the values in config/constants.php -> server_checks
const MIN_DELAY = 120;
const MAX_DELAY = 300;
const SCALING_FACTOR = 0.2;
it('calculates notification delay correctly using formula', function () {
// Test the delay calculation formula directly
// Formula: min(max, max(min, serverCount * scaling))
$testCases = [
['servers' => 10, 'expected' => 120], // 10 * 0.2 = 2 -> uses min 120
['servers' => 600, 'expected' => 120], // 600 * 0.2 = 120 (at min)
['servers' => 1000, 'expected' => 200], // 1000 * 0.2 = 200
['servers' => 1500, 'expected' => 300], // 1500 * 0.2 = 300 (at max)
['servers' => 5000, 'expected' => 300], // 5000 * 0.2 = 1000 -> uses max 300
];
foreach ($testCases as $case) {
$count = $case['servers'];
$calculatedDelay = (int) ($count * SCALING_FACTOR);
$result = min(MAX_DELAY, max(MIN_DELAY, $calculatedDelay));
expect($result)->toBe($case['expected'], "Failed for {$count} servers");
}
});
it('respects minimum delay boundary', function () {
// Test that delays never go below minimum
$serverCounts = [1, 10, 50, 100, 500, 599];
foreach ($serverCounts as $count) {
$calculatedDelay = (int) ($count * SCALING_FACTOR);
$result = min(MAX_DELAY, max(MIN_DELAY, $calculatedDelay));
expect($result)->toBeGreaterThanOrEqual(MIN_DELAY,
"Delay for {$count} servers should be >= ".MIN_DELAY);
}
});
it('respects maximum delay boundary', function () {
// Test that delays never exceed maximum
$serverCounts = [1500, 2000, 5000, 10000];
foreach ($serverCounts as $count) {
$calculatedDelay = (int) ($count * SCALING_FACTOR);
$result = min(MAX_DELAY, max(MIN_DELAY, $calculatedDelay));
expect($result)->toBeLessThanOrEqual(MAX_DELAY,
"Delay for {$count} servers should be <= ".MAX_DELAY);
}
});
it('provides more conservative delays than old calculation', function () {
// Compare new formula with old one
// Old: min(300, max(60, count/10))
// New: min(300, max(120, count*0.2))
$testServers = [100, 500, 1000, 2000, 3000];
foreach ($testServers as $count) {
// Old calculation
$oldDelay = min(300, max(60, (int) ($count / 10)));
// New calculation
$newDelay = min(300, max(120, (int) ($count * 0.2)));
// For counts >= 600, new delay should be >= old delay
if ($count >= 600) {
expect($newDelay)->toBeGreaterThanOrEqual($oldDelay,
"New delay should be >= old delay for {$count} servers (old: {$oldDelay}s, new: {$newDelay}s)");
}
// Both should respect the 300s maximum
expect($newDelay)->toBeLessThanOrEqual(300);
expect($oldDelay)->toBeLessThanOrEqual(300);
}
});
it('scales linearly within bounds', function () {
// Test that scaling is linear between min and max thresholds
// Find threshold where calculated delay equals min: 120 / 0.2 = 600 servers
$minThreshold = (int) (MIN_DELAY / SCALING_FACTOR);
expect($minThreshold)->toBe(600);
// Find threshold where calculated delay equals max: 300 / 0.2 = 1500 servers
$maxThreshold = (int) (MAX_DELAY / SCALING_FACTOR);
expect($maxThreshold)->toBe(1500);
// Test linear scaling between thresholds
$delay700 = min(MAX_DELAY, max(MIN_DELAY, (int) (700 * SCALING_FACTOR)));
$delay900 = min(MAX_DELAY, max(MIN_DELAY, (int) (900 * SCALING_FACTOR)));
$delay1100 = min(MAX_DELAY, max(MIN_DELAY, (int) (1100 * SCALING_FACTOR)));
expect($delay700)->toBe(140); // 700 * 0.2 = 140
expect($delay900)->toBe(180); // 900 * 0.2 = 180
expect($delay1100)->toBe(220); // 1100 * 0.2 = 220
// Verify linear progression
expect($delay900 - $delay700)->toBe(40); // 200 servers * 0.2 = 40s difference
expect($delay1100 - $delay900)->toBe(40); // 200 servers * 0.2 = 40s difference
});
it('handles edge cases in formula', function () {
// Zero servers
$result = min(MAX_DELAY, max(MIN_DELAY, (int) (0 * SCALING_FACTOR)));
expect($result)->toBe(120);
// One server
$result = min(MAX_DELAY, max(MIN_DELAY, (int) (1 * SCALING_FACTOR)));
expect($result)->toBe(120);
// Exactly at boundaries
$result = min(MAX_DELAY, max(MIN_DELAY, (int) (600 * SCALING_FACTOR))); // 600 * 0.2 = 120
expect($result)->toBe(120);
$result = min(MAX_DELAY, max(MIN_DELAY, (int) (1500 * SCALING_FACTOR))); // 1500 * 0.2 = 300
expect($result)->toBe(300);
});

View File

@@ -0,0 +1,56 @@
<?php
use App\Jobs\NotifyOutdatedTraefikServersJob;
it('has correct queue and retry configuration', function () {
$job = new NotifyOutdatedTraefikServersJob;
expect($job->tries)->toBe(3);
});
it('handles servers with null traefik_outdated_info gracefully', function () {
// Create a mock server with null traefik_outdated_info
$server = \Mockery::mock('App\Models\Server')->makePartial();
$server->traefik_outdated_info = null;
// Accessing the property should not throw an error
$result = $server->traefik_outdated_info;
expect($result)->toBeNull();
});
it('handles servers with traefik_outdated_info data', function () {
$expectedInfo = [
'current' => '3.5.0',
'latest' => '3.6.2',
'type' => 'minor_upgrade',
'upgrade_target' => 'v3.6',
'checked_at' => '2025-11-14T10:00:00Z',
];
$server = \Mockery::mock('App\Models\Server')->makePartial();
$server->traefik_outdated_info = $expectedInfo;
// Should return the outdated info
$result = $server->traefik_outdated_info;
expect($result)->toBe($expectedInfo);
});
it('handles servers with patch update info without upgrade_target', function () {
$expectedInfo = [
'current' => '3.5.0',
'latest' => '3.5.2',
'type' => 'patch_update',
'checked_at' => '2025-11-14T10:00:00Z',
];
$server = \Mockery::mock('App\Models\Server')->makePartial();
$server->traefik_outdated_info = $expectedInfo;
// Should return the outdated info without upgrade_target
$result = $server->traefik_outdated_info;
expect($result)->toBe($expectedInfo);
expect($result)->not->toHaveKey('upgrade_target');
});