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:
Andras Bacsai
2026-10-19 10:41:59 +02:00
co-authored by Claude Opus 5.5
parent 4ca0b4415c
commit 83ea5cc1f7
3 changed files with 254 additions and 6 deletions
+24
View File
@@ -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:');
});
});