perf(fleet,wg) + i18n(dashboard,update) + a11y(btn): re-audit design/perf cleanups
From the re-audit's design/perf pass (Codex-reviewed): - WithFleetContext::fleet() memoises per request (protected, not serialised by Livewire) — activeServer() calls it several times per render (Files ~6x, Services ~4x), so it was re-running the fleet query each time. - Wireguard: cache WgTraffic->series() (60s) keyed on the window + a data fingerprint (MAX(id) + COUNT) so a new/pruned sample busts it, but the 5s wire:poll no longer re-buckets the whole sample history every tick (samples only land once a minute). - dashboard: the systemd table headers (Unit/Status/Boot) were hard-coded English while a sibling column used __(); move all four through lang keys (de+en parity). - update-progress: localise the phase-list aria-label. - btn: the compact `sm` size grows to the 44px R7 touch target on a coarse pointer only, so dense row/toolbar buttons are tappable on mobile while desktop density is unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>feat/v1-foundation
parent
6927987a0d
commit
46924135e6
|
|
@ -12,9 +12,17 @@ use Illuminate\Support\Collection;
|
|||
*/
|
||||
trait WithFleetContext
|
||||
{
|
||||
/**
|
||||
* Per-request memo. activeServer() calls fleet() and is itself called several times per
|
||||
* render (Files ~6x, Services ~4x), so without this each call re-ran the fleet query.
|
||||
* Protected, so Livewire does not serialise it into the snapshot; it resets on every
|
||||
* request (the component is re-hydrated), and no page in scope mutates the fleet mid-request.
|
||||
*/
|
||||
protected ?Collection $fleetMemo = null;
|
||||
|
||||
public function fleet(): Collection
|
||||
{
|
||||
return Server::withExists('credential')->orderBy('name')->get();
|
||||
return $this->fleetMemo ??= Server::withExists('credential')->orderBy('name')->get();
|
||||
}
|
||||
|
||||
public function activeServer(): ?Server
|
||||
|
|
|
|||
|
|
@ -3,12 +3,14 @@
|
|||
namespace App\Livewire\Wireguard;
|
||||
|
||||
use App\Models\AuditEvent;
|
||||
use App\Models\WgTrafficSample;
|
||||
use App\Services\WgBridge;
|
||||
use App\Services\WgStatus;
|
||||
use App\Services\WgTraffic;
|
||||
use App\Support\Confirm\ConfirmToken;
|
||||
use App\Support\Confirm\InvalidConfirmToken;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\On;
|
||||
|
|
@ -507,9 +509,21 @@ class Index extends Component
|
|||
{
|
||||
$window = $this->clampWindow($this->window);
|
||||
|
||||
// Traffic samples land at most once per minute (clusev:wg-sample), but the page polls
|
||||
// every 5s (wire:poll) — so re-bucketing the whole WgTrafficSample history on every poll
|
||||
// is ~11/12 wasted work. Cache the aggregate, keyed on the window AND a data fingerprint
|
||||
// (MAX(id) busts on any insert, COUNT busts on a prune) so new/changed data invalidates it
|
||||
// immediately — MAX(sampled_at) alone could collide for two sample sets in the same second.
|
||||
$fp = WgTrafficSample::query()->selectRaw('COALESCE(MAX(id), 0) AS mid, COUNT(*) AS cnt')->first();
|
||||
$series = Cache::remember(
|
||||
"wg:traffic:series:{$window}:{$fp->mid}:{$fp->cnt}",
|
||||
60,
|
||||
fn () => $traffic->series($window),
|
||||
);
|
||||
|
||||
return view('livewire.wireguard.index', [
|
||||
'status' => $wg->read(),
|
||||
'traffic' => $traffic->series($window),
|
||||
'traffic' => $series,
|
||||
'windows' => self::WINDOWS,
|
||||
])->title(__('wireguard.title'));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,10 @@ return [
|
|||
|
||||
// systemd services panel
|
||||
'services_title' => 'systemd-Dienste',
|
||||
'col_unit' => 'Unit',
|
||||
'col_status' => 'Status',
|
||||
'col_state' => 'Zustand',
|
||||
'col_boot' => 'Autostart',
|
||||
'no_service_data' => 'Keine Dienstdaten — Server nicht verbunden.',
|
||||
|
||||
// Audit panel
|
||||
|
|
|
|||
|
|
@ -23,4 +23,5 @@ return [
|
|||
'error_heading' => 'Update fehlgeschlagen',
|
||||
'error_hint' => 'Der Stack konnte nicht aktualisiert werden — die bisherige Version läuft weiter. Ursache im Host-Log prüfen (journalctl -u clusev-update.service, oder run/update.log), beheben und das Update erneut über das Dashboard auslösen.',
|
||||
'back_button' => 'Zurück',
|
||||
'phases_aria' => 'Update-Phasen',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -19,7 +19,10 @@ return [
|
|||
|
||||
// systemd services panel
|
||||
'services_title' => 'systemd services',
|
||||
'col_unit' => 'Unit',
|
||||
'col_status' => 'Status',
|
||||
'col_state' => 'State',
|
||||
'col_boot' => 'Boot',
|
||||
'no_service_data' => 'No service data — server not connected.',
|
||||
|
||||
// Audit panel
|
||||
|
|
|
|||
|
|
@ -23,4 +23,5 @@ return [
|
|||
'error_heading' => 'Update failed',
|
||||
'error_hint' => 'The stack could not be updated — your previous version is still running. Check the host log for the cause (journalctl -u clusev-update.service, or run/update.log), fix it, then re-trigger the update from the dashboard.',
|
||||
'back_button' => 'Back',
|
||||
'phases_aria' => 'Update phases',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -12,8 +12,12 @@
|
|||
'danger-soft' => 'border border-offline/25 bg-offline/10 text-offline hover:bg-offline/15',
|
||||
];
|
||||
// sm = compact toolbar/row buttons; lg = full-height primary CTA (≥44px touch target, R7).
|
||||
// sm stays 32px visually on a fine pointer (mouse) for density, but grows to the 44px R7
|
||||
// minimum on a COARSE pointer (touch) so row actions are tappable — desktop is unaffected.
|
||||
$sizes = [
|
||||
'sm' => ($icon ? 'h-8 w-8' : 'h-8 px-3').' text-xs',
|
||||
'sm' => ($icon
|
||||
? 'h-8 w-8 [@media(pointer:coarse)]:h-11 [@media(pointer:coarse)]:w-11'
|
||||
: 'h-8 px-3 [@media(pointer:coarse)]:min-h-11').' text-xs',
|
||||
'lg' => ($icon ? 'h-11 w-11' : 'h-11 px-4').' text-sm',
|
||||
];
|
||||
$classes = 'inline-flex items-center justify-center gap-1.5 rounded-md font-medium transition-colors '
|
||||
|
|
|
|||
|
|
@ -98,10 +98,10 @@
|
|||
<table class="w-full border-collapse">
|
||||
<thead>
|
||||
<tr class="border-b border-line">
|
||||
<th class="px-4 py-2.5 text-left font-mono text-[10px] uppercase tracking-wider text-ink-3 sm:px-5">Unit</th>
|
||||
<th class="px-4 py-2.5 text-left font-mono text-[10px] uppercase tracking-wider text-ink-3">Status</th>
|
||||
<th class="px-4 py-2.5 text-left font-mono text-[10px] uppercase tracking-wider text-ink-3 sm:px-5">{{ __('dashboard.col_unit') }}</th>
|
||||
<th class="px-4 py-2.5 text-left font-mono text-[10px] uppercase tracking-wider text-ink-3">{{ __('dashboard.col_status') }}</th>
|
||||
<th class="px-4 py-2.5 text-right font-mono text-[10px] uppercase tracking-wider text-ink-3">{{ __('dashboard.col_state') }}</th>
|
||||
<th class="hidden px-4 py-2.5 text-right font-mono text-[10px] uppercase tracking-wider text-ink-3 sm:table-cell">Boot</th>
|
||||
<th class="hidden px-4 py-2.5 text-right font-mono text-[10px] uppercase tracking-wider text-ink-3 sm:table-cell">{{ __('dashboard.col_boot') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@
|
|||
|
||||
{{-- Phase checklist --}}
|
||||
<div class="mt-8">
|
||||
<ol class="space-y-3" aria-label="Update phases">
|
||||
<ol class="space-y-3" aria-label="{{ __('update.phases_aria') }}">
|
||||
@php
|
||||
// Real order of the host updater: git pull → docker build → stack up → migrate.
|
||||
$phases = [
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Livewire\Concerns\WithFleetContext;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* activeServer() calls fleet(), and pages call activeServer() several times per render, so the
|
||||
* fleet query used to run once per call. fleet() now memoises for the request — proven here by
|
||||
* counting the `servers` selects across multiple reads.
|
||||
*/
|
||||
class WithFleetContextTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_fleet_query_runs_once_across_repeated_reads(): void
|
||||
{
|
||||
$server = Server::create(['name' => 'box', 'ip' => '10.0.0.1', 'ssh_port' => 22, 'status' => 'online']);
|
||||
$server->credential()->create(['username' => 'root', 'auth_type' => 'password', 'secret' => 'x']);
|
||||
|
||||
$component = new class
|
||||
{
|
||||
use WithFleetContext;
|
||||
};
|
||||
|
||||
DB::enableQueryLog();
|
||||
$component->fleet();
|
||||
$component->fleet();
|
||||
$component->activeServer(); // internally calls fleet() again
|
||||
|
||||
$serverSelects = collect(DB::getQueryLog())
|
||||
->filter(fn (array $q): bool => str_contains($q['query'], 'from "servers"'))
|
||||
->count();
|
||||
DB::disableQueryLog();
|
||||
|
||||
$this->assertSame(1, $serverSelects); // memoised: one query despite three fleet() reads
|
||||
}
|
||||
|
||||
public function test_fleet_still_returns_the_fleet(): void
|
||||
{
|
||||
$a = Server::create(['name' => 'alpha', 'ip' => '10.0.0.1', 'ssh_port' => 22, 'status' => 'online']);
|
||||
$b = Server::create(['name' => 'bravo', 'ip' => '10.0.0.2', 'ssh_port' => 22, 'status' => 'online']);
|
||||
|
||||
$component = new class
|
||||
{
|
||||
use WithFleetContext;
|
||||
};
|
||||
|
||||
$ids = $component->fleet()->pluck('id');
|
||||
$this->assertTrue($ids->contains($a->id));
|
||||
$this->assertTrue($ids->contains($b->id));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue