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
boban 2026-07-05 17:10:19 +02:00
parent 6927987a0d
commit 46924135e6
10 changed files with 98 additions and 7 deletions

View File

@ -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

View File

@ -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'));
}

View File

@ -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

View File

@ -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',
];

View File

@ -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

View File

@ -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',
];

View File

@ -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 '

View File

@ -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>

View File

@ -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 = [

View File

@ -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));
}
}