From 83ea5cc1f70c842c42e74ea4c380aa64eeb4cbdd Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:41:59 +0200 Subject: [PATCH] feat(servers): show the patch status of each server in the server list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/Models/ServerSetting.php | 24 +++ .../views/livewire/server/index.blade.php | 87 +++++++++- .../Server/ServerListPatchStatusTest.php | 149 ++++++++++++++++++ 3 files changed, 254 insertions(+), 6 deletions(-) create mode 100644 tests/Feature/Server/ServerListPatchStatusTest.php diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index 53a63cc6ee..6085320080 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -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, + ]; + } } diff --git a/resources/views/livewire/server/index.blade.php b/resources/views/livewire/server/index.blade.php index efa6a365ee..c6905cdde2 100644 --- a/resources/views/livewire/server/index.blade.php +++ b/resources/views/livewire/server/index.blade.php @@ -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
+
+ @if (filled($patchSummary)) +

Patches: {{ $patchSummary }}

+ @endif + +
@endif
+ + @php + $cardPatchBadges = array_filter($serverRow['patchBadges'], fn ($badge) => $badge['type'] !== 'success'); + @endphp + @if ($cardPatchBadges !== []) +
+ @foreach ($cardPatchBadges as $badge) + + @endforeach +
+ @endif @endforeach
-
+
$showPatches, + ])>
Server
+ @if ($showPatches) + + @endif
Status