mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
feat(servers): show the patch status of each server in the server list
- ServerSetting::patchStatus() summarizes the saved patch check (updates, security updates, reboot required, error). Results saved before security and reboot detection count as 0 and false. - Grid cards show "N security updates", "N updates", "Patch check failed", and "Reboot required" badges; the table gets a Patches column from lg. The badge text is searchable. - The toolbar shows a one-line team summary, for example "Patches: 2 servers with updates · 1 with security updates · 1 needs a reboot". - Badges and the summary are shown only for servers where the user can view the security pages (viewSecurity on the server), so members do not see them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
4ca0b4415c
commit
83ea5cc1f7
@@ -7,6 +7,7 @@ use App\Enums\TrafficIpMode;
|
||||
use Illuminate\Contracts\Encryption\DecryptException;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
@@ -450,4 +451,27 @@ class ServerSetting extends Model
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Summary of the saved patch check, or null when the server was never checked.
|
||||
* Results saved before security and reboot detection count as 0 and false.
|
||||
*
|
||||
* @return array{updates: int, security: int, reboot_required: bool, error: ?string, checked_at: Carbon}|null
|
||||
*/
|
||||
public function patchStatus(): ?array
|
||||
{
|
||||
if ($this->patch_checked_at === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$result = $this->patch_check_result ?? [];
|
||||
|
||||
return [
|
||||
'updates' => (int) ($result['total_updates'] ?? 0),
|
||||
'security' => (int) ($result['security_updates'] ?? 0),
|
||||
'reboot_required' => ($result['reboot_required'] ?? null) === true,
|
||||
'error' => filled($this->patch_check_error) ? $this->patch_check_error : null,
|
||||
'checked_at' => $this->patch_checked_at,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,20 @@
|
||||
default => 'error',
|
||||
};
|
||||
|
||||
$patchStatus = auth()->user()?->can('viewSecurity', $server) ? $server->settings->patchStatus() : null;
|
||||
$patchBadges = [];
|
||||
if ($patchStatus !== null) {
|
||||
$patchBadges[] = match (true) {
|
||||
$patchStatus['error'] !== null => ['label' => 'Patch check failed', 'type' => 'error'],
|
||||
$patchStatus['security'] > 0 => ['label' => $patchStatus['security'].' security '.str('update')->plural($patchStatus['security']), 'type' => 'error'],
|
||||
$patchStatus['updates'] > 0 => ['label' => $patchStatus['updates'].' '.str('update')->plural($patchStatus['updates']), 'type' => 'warning'],
|
||||
default => ['label' => 'Up to date', 'type' => 'success'],
|
||||
};
|
||||
if ($patchStatus['error'] === null && $patchStatus['reboot_required']) {
|
||||
$patchBadges[] = ['label' => 'Reboot required', 'type' => 'warning'];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'uuid' => $server->uuid,
|
||||
'name' => $server->name,
|
||||
@@ -50,8 +64,29 @@
|
||||
'status' => $status,
|
||||
'statusType' => $statusType,
|
||||
'resourceCount' => $server->definedResources()->count(),
|
||||
'patchStatus' => $patchStatus === null ? null : [
|
||||
'failed' => $patchStatus['error'] !== null,
|
||||
'updates' => $patchStatus['error'] === null ? $patchStatus['updates'] : 0,
|
||||
'security' => $patchStatus['error'] === null ? $patchStatus['security'] : 0,
|
||||
'rebootRequired' => $patchStatus['error'] === null && $patchStatus['reboot_required'],
|
||||
],
|
||||
'patchBadges' => $patchBadges,
|
||||
'patchCheckedAt' => $patchStatus === null ? null : 'Patches checked '.$patchStatus['checked_at']->diffForHumans(),
|
||||
];
|
||||
})->values();
|
||||
|
||||
$patchStatuses = $serverRows->pluck('patchStatus')->filter();
|
||||
$showPatches = $patchStatuses->isNotEmpty();
|
||||
$withUpdates = $patchStatuses->where('updates', '>', 0)->count();
|
||||
$withSecurityUpdates = $patchStatuses->where('security', '>', 0)->count();
|
||||
$needReboot = $patchStatuses->where('rebootRequired', true)->count();
|
||||
$failedChecks = $patchStatuses->where('failed', true)->count();
|
||||
$patchSummary = collect([
|
||||
$withUpdates > 0 ? $withUpdates.' '.str('server')->plural($withUpdates).' with updates' : null,
|
||||
$withSecurityUpdates > 0 ? $withSecurityUpdates.' with security updates' : null,
|
||||
$needReboot > 0 ? $needReboot.' '.($needReboot === 1 ? 'needs' : 'need').' a reboot' : null,
|
||||
$failedChecks > 0 ? $failedChecks.' '.str('check')->plural($failedChecks).' failed' : null,
|
||||
])->filter()->join(' · ');
|
||||
@endphp
|
||||
|
||||
<div x-data="{
|
||||
@@ -62,7 +97,7 @@
|
||||
const query = this.search.trim().toLowerCase();
|
||||
if (!query) return this.servers;
|
||||
return this.servers.filter(server =>
|
||||
[server.name, server.description, server.status]
|
||||
[server.name, server.description, server.status, ...server.patchBadges.map(badge => badge.label)]
|
||||
.some(value => String(value || '').toLowerCase().includes(query))
|
||||
);
|
||||
},
|
||||
@@ -89,8 +124,13 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<x-canvas-view-toggle :list-url="route('server.index')"
|
||||
:canvas-url="route('server.canvas.index')" />
|
||||
<div class="flex flex-wrap items-center gap-3 sm:justify-end">
|
||||
@if (filled($patchSummary))
|
||||
<p class="text-[12px] text-neutral-500 dark:text-fg-dim">Patches: {{ $patchSummary }}</p>
|
||||
@endif
|
||||
<x-canvas-view-toggle :list-url="route('server.index')"
|
||||
:canvas-url="route('server.canvas.index')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-cloak x-show="viewMode === 'grid'"
|
||||
@@ -135,21 +175,41 @@
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@php
|
||||
$cardPatchBadges = array_filter($serverRow['patchBadges'], fn ($badge) => $badge['type'] !== 'success');
|
||||
@endphp
|
||||
@if ($cardPatchBadges !== [])
|
||||
<div class="relative z-10 mt-auto flex flex-wrap gap-1.5 pt-3"
|
||||
data-tooltip="{{ $serverRow['patchCheckedAt'] }}">
|
||||
@foreach ($cardPatchBadges as $badge)
|
||||
<x-status-badge :status="$badge['label']" :type="$badge['type']" />
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div x-show="viewMode === 'table'"
|
||||
class="overflow-x-auto rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.05]">
|
||||
<div
|
||||
class="grid min-w-[480px] grid-cols-[minmax(0,1fr)_9.5rem] border-b border-neutral-200 bg-neutral-50 px-4 py-2.5 text-[11px] font-medium text-neutral-500 md:min-w-[560px] md:grid-cols-[minmax(0,1fr)_6rem_9.5rem] dark:border-white/[0.08] dark:bg-white/[0.05] dark:text-fg-faint">
|
||||
<div @class([
|
||||
'grid min-w-[480px] grid-cols-[minmax(0,1fr)_9.5rem] border-b border-neutral-200 bg-neutral-50 px-4 py-2.5 text-[11px] font-medium text-neutral-500 md:min-w-[560px] md:grid-cols-[minmax(0,1fr)_6rem_9.5rem] dark:border-white/[0.08] dark:bg-white/[0.05] dark:text-fg-faint',
|
||||
'lg:min-w-[760px] lg:grid-cols-[minmax(0,1fr)_6rem_12rem_9.5rem]' => $showPatches,
|
||||
])>
|
||||
<div>Server</div>
|
||||
<div class="hidden md:block">Resources</div>
|
||||
@if ($showPatches)
|
||||
<div class="hidden lg:block">Patches</div>
|
||||
@endif
|
||||
<div>Status</div>
|
||||
</div>
|
||||
<template x-for="server in filteredServers" :key="server.uuid">
|
||||
<a :href="server.href" {{ wireNavigate() }}
|
||||
class="grid min-h-14 min-w-[480px] grid-cols-[minmax(0,1fr)_9.5rem] items-center border-b border-neutral-200 px-4 py-2.5 text-[12px] transition-colors last:border-b-0 hover:bg-neutral-50 hover:no-underline md:min-w-[560px] md:grid-cols-[minmax(0,1fr)_6rem_9.5rem] dark:border-white/[0.07] dark:hover:bg-white/[0.025]">
|
||||
@class([
|
||||
'grid min-h-14 min-w-[480px] grid-cols-[minmax(0,1fr)_9.5rem] items-center border-b border-neutral-200 px-4 py-2.5 text-[12px] transition-colors last:border-b-0 hover:bg-neutral-50 hover:no-underline md:min-w-[560px] md:grid-cols-[minmax(0,1fr)_6rem_9.5rem] dark:border-white/[0.07] dark:hover:bg-white/[0.025]',
|
||||
'lg:min-w-[760px] lg:grid-cols-[minmax(0,1fr)_6rem_12rem_9.5rem]' => $showPatches,
|
||||
])>
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<div
|
||||
class="flex size-8 shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/[0.1] dark:bg-white/[0.035] dark:text-fg-dim">
|
||||
@@ -174,6 +234,21 @@
|
||||
<span x-text="server.resourceCount"></span>
|
||||
</span>
|
||||
</div>
|
||||
@if ($showPatches)
|
||||
<div class="hidden flex-wrap gap-1 lg:flex" :data-tooltip="server.patchCheckedAt">
|
||||
<template x-for="badge in server.patchBadges" :key="badge.label">
|
||||
<x-status-badge :dynamic="true">
|
||||
<span class="relative inline-flex size-1.5 shrink-0 rounded-full"
|
||||
:class="{
|
||||
'bg-emerald-500': badge.type === 'success',
|
||||
'bg-warning': badge.type === 'warning',
|
||||
'bg-red-500': badge.type === 'error',
|
||||
}"></span>
|
||||
<span class="truncate" x-text="badge.label"></span>
|
||||
</x-status-badge>
|
||||
</template>
|
||||
</div>
|
||||
@endif
|
||||
<div class="flex items-center gap-2 text-[11px] font-medium text-neutral-600 dark:text-fg-dim">
|
||||
<span x-show="server.statusType !== 'success'"
|
||||
class="hidden size-2 shrink-0 rounded-full md:inline-block"
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Server\Index;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Server::flushIdentityMap();
|
||||
Storage::fake('ssh-keys');
|
||||
$this->withoutVite();
|
||||
InstanceSettings::forceCreate(['id' => 0]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
|
||||
});
|
||||
|
||||
function serverListPatchServer(object $test, string $name, array $settings = []): Server
|
||||
{
|
||||
$server = Server::factory()->create([
|
||||
'name' => $name,
|
||||
'team_id' => $test->team->id,
|
||||
'private_key_id' => $test->privateKey->id,
|
||||
]);
|
||||
$server->settings->forceFill($settings)->saveQuietly();
|
||||
|
||||
return $server;
|
||||
}
|
||||
|
||||
function serverListPatchResult(int $updates, int $security, ?bool $rebootRequired): array
|
||||
{
|
||||
return [
|
||||
'osId' => 'ubuntu',
|
||||
'package_manager' => 'apt',
|
||||
'total_updates' => $updates,
|
||||
'security_updates' => $security,
|
||||
'reboot_required' => $rebootRequired,
|
||||
'updates' => [],
|
||||
];
|
||||
}
|
||||
|
||||
function serverListPatchActingAs(object $test, string $role): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$test->team->members()->attach($user, ['role' => $role]);
|
||||
$test->actingAs($user);
|
||||
session(['currentTeam' => $test->team]);
|
||||
}
|
||||
|
||||
describe('patchStatus()', function () {
|
||||
it('returns null when the server was never checked', function () {
|
||||
$server = serverListPatchServer($this, 'never');
|
||||
|
||||
expect($server->settings->fresh()->patchStatus())->toBeNull();
|
||||
});
|
||||
|
||||
it('returns the error of a failed check', function () {
|
||||
$server = serverListPatchServer($this, 'failed', [
|
||||
'patch_check_result' => ['osId' => 'ubuntu', 'package_manager' => 'apt'],
|
||||
'patch_check_error' => 'apt-get failed',
|
||||
'patch_checked_at' => now(),
|
||||
]);
|
||||
|
||||
expect($server->settings->fresh()->patchStatus())
|
||||
->toMatchArray(['updates' => 0, 'security' => 0, 'reboot_required' => false, 'error' => 'apt-get failed']);
|
||||
});
|
||||
|
||||
it('returns the update, security, and reboot counts', function () {
|
||||
$server = serverListPatchServer($this, 'counts', [
|
||||
'patch_check_result' => serverListPatchResult(3, 1, true),
|
||||
'patch_checked_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
$status = $server->settings->fresh()->patchStatus();
|
||||
|
||||
expect($status)->toMatchArray(['updates' => 3, 'security' => 1, 'reboot_required' => true, 'error' => null])
|
||||
->and($status['checked_at']->isSameMinute(now()->subHour()))->toBeTrue();
|
||||
});
|
||||
|
||||
it('treats an old result without security and reboot keys as none', function () {
|
||||
$server = serverListPatchServer($this, 'old', [
|
||||
'patch_check_result' => ['osId' => 'ubuntu', 'package_manager' => 'apt', 'total_updates' => 2, 'updates' => []],
|
||||
'patch_checked_at' => now(),
|
||||
]);
|
||||
|
||||
expect($server->settings->fresh()->patchStatus())
|
||||
->toMatchArray(['updates' => 2, 'security' => 0, 'reboot_required' => false, 'error' => null]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('server list', function () {
|
||||
beforeEach(function () {
|
||||
serverListPatchServer($this, 'secure-host', [
|
||||
'patch_check_result' => serverListPatchResult(3, 1, true),
|
||||
'patch_checked_at' => now(),
|
||||
]);
|
||||
serverListPatchServer($this, 'plain-host', [
|
||||
'patch_check_result' => serverListPatchResult(2, 0, false),
|
||||
'patch_checked_at' => now(),
|
||||
]);
|
||||
serverListPatchServer($this, 'broken-host', [
|
||||
'patch_check_error' => 'apt-get failed',
|
||||
'patch_checked_at' => now(),
|
||||
]);
|
||||
serverListPatchServer($this, 'unchecked-host');
|
||||
});
|
||||
|
||||
it('shows patch badges and the team summary to an admin', function () {
|
||||
serverListPatchActingAs($this, 'admin');
|
||||
|
||||
Livewire::test(Index::class)
|
||||
->assertSee('1 security update')
|
||||
->assertSee('2 updates')
|
||||
->assertSee('Reboot required')
|
||||
->assertSee('Patch check failed')
|
||||
->assertSee('Patches: 2 servers with updates · 1 with security updates · 1 needs a reboot · 1 check failed');
|
||||
});
|
||||
|
||||
it('hides patch badges and the summary from a member', function () {
|
||||
serverListPatchActingAs($this, 'member');
|
||||
|
||||
Livewire::test(Index::class)
|
||||
->assertSee('secure-host')
|
||||
->assertDontSee('security update')
|
||||
->assertDontSee('Reboot required')
|
||||
->assertDontSee('Patch check failed')
|
||||
->assertDontSee('Patches:');
|
||||
});
|
||||
|
||||
it('hides the summary when every checked server is up to date', function () {
|
||||
Server::query()->each(fn (Server $server) => $server->settings->forceFill([
|
||||
'patch_check_result' => serverListPatchResult(0, 0, false),
|
||||
'patch_check_error' => null,
|
||||
'patch_checked_at' => now(),
|
||||
])->saveQuietly());
|
||||
serverListPatchActingAs($this, 'admin');
|
||||
|
||||
Livewire::test(Index::class)
|
||||
->assertSee('Up to date')
|
||||
->assertDontSee('Patches:');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user