perf+ux: lazy-load pages (skeletons), fix modal, normalize buttons + spinners

Route switching was slow because each page did its SSH reads inside mount(),
blocking the response. Now mount() only sets cheap shell state and the SSH read
runs in load() via wire:init — the page shell + skeletons render instantly and
content streams in.

- Lazy-load Dashboard (services), Services, Files, Server-Details: SSH moved from
  mount() to load(); skeleton placeholders (new <x-skeleton>) while !$ready.
- Modal container: z-10 -> z-50 (was rendering *under* the z-40 sidebar) and a
  real max-w-lg (the dynamic modalWidth classes were never generated by Tailwind,
  so modals spanned full width). This also fixes the "flash + disappear".
- Modal/action buttons: normalize size (min-h-11 uppercase font-display -> h-9
  text-sm font-medium) and add a wire:loading spinner + disabled-while-running on
  the confirm/save actions (no double-submit, clear feedback).
- Server-Details gauges keep wire:poll (live) but no longer block initial render.

Verified: every page renders ready=false + skeleton with no SSH, then load()
populates real data; modal buttons carry the spinner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-12 23:17:02 +02:00
parent f849076418
commit 94972c78bd
13 changed files with 133 additions and 43 deletions

View File

@ -18,6 +18,18 @@ class Dashboard extends Component
{
use WithFleetContext;
public bool $ready = false;
/** @var array<int, array> notable systemd units (lazy-loaded over SSH) */
public array $svcRows = [];
/** Lazy: the SSH services read runs after the shell renders (wire:init). */
public function load(FleetService $fleet): void
{
$this->svcRows = $this->liveServices($fleet, $this->activeServer());
$this->ready = true;
}
/** Deterministic wavy series — fallback sparkline for metrics without history. */
private function series(float $base, int $n = 40, float $amp = 9): array
{
@ -111,7 +123,7 @@ class Dashboard extends Component
'memTrend' => $this->trend($memSeries),
'diskTrend' => $this->trend($diskSeries),
'loadTrend' => $this->trend($loadSeries, ''),
'services' => $this->liveServices($fleet, $active),
'services' => $this->svcRows,
'events' => AuditEvent::with('server')->latest()->limit(6)->get(),
]);
}

View File

@ -21,25 +21,24 @@ class Index extends Component
public bool $connected = false;
public bool $ready = false;
/** @var array<int, array{name:string,type:string,size:?int,perms:string,owner:string,modified:string}> */
public array $entries = [];
public function mount(): void
{
$this->load();
// listing loads lazily via wire:init -> load()
}
/** Read the active server's directory over SSH; empty + offline on failure. */
private function load(): void
public function load(): void
{
$this->entries = [];
$this->connected = false;
$active = $this->activeServer();
if (! $active || ! $active->credential_exists) {
return;
}
if ($active && $active->credential_exists) {
try {
$this->entries = app(FleetService::class)->files($active, $this->path);
$this->connected = true;
@ -48,6 +47,9 @@ class Index extends Component
}
}
$this->ready = true;
}
public function open(string $name): void
{
$this->path = rtrim($this->path, '/').'/'.$name;

View File

@ -19,6 +19,8 @@ class Show extends Component
public bool $connected = false;
public bool $ready = false;
public array $volumes = [];
public array $interfaces = [];
@ -32,7 +34,14 @@ class Show extends Component
* On any failure (no credential, unreachable) the page renders an offline
* state from the last persisted values instead of crashing.
*/
public function mount(FleetService $fleet): void
public function mount(): void
{
// identity + gauges render from the persisted row immediately;
// the live snapshot loads lazily via wire:init -> load().
}
/** Lazy: pull the full SSH snapshot after the shell renders, behind skeletons. */
public function load(FleetService $fleet): void
{
try {
$snap = $fleet->snapshot($this->server);
@ -69,15 +78,15 @@ class Show extends Component
$this->server->forceFill(['status' => 'offline'])->save();
}
}
$this->ready = true;
}
/** Refresh the live gauges from the poller-updated row (wire:poll). */
public function pollMetrics(): void
{
if ($this->connected) {
$this->server->refresh();
}
}
/**
* Revoke an SSH key (R5): opens the confirm modal, which writes the

View File

@ -21,6 +21,8 @@ class Index extends Component
public bool $connected = false;
public bool $ready = false;
/** wire:model search over service name + description */
public string $search = '';
@ -30,15 +32,17 @@ class Index extends Component
/** @var array<int, array{time:string,unit:string,level:string,text:string}> */
public array $journal = [];
public function mount(FleetService $fleet): void
public function mount(): void
{
$active = $this->activeServer();
$this->server = $active?->name ?? '—';
if (! $active || ! $active->credential_exists) {
return;
$this->server = $this->activeServer()?->name ?? '—';
}
/** Lazy: the SSH read runs after the shell renders (wire:init), behind a skeleton. */
public function load(FleetService $fleet): void
{
$active = $this->activeServer();
if ($active && $active->credential_exists) {
try {
$data = $fleet->systemd($active);
$this->services = $data['services'];
@ -49,6 +53,9 @@ class Index extends Component
}
}
$this->ready = true;
}
/**
* Service control (R5): opens the wire-elements/modal confirm dialog. The
* modal writes the AuditEvent and re-dispatches `serviceConfirmed`, which

View File

@ -0,0 +1,5 @@
sm:max-w-md
md:max-w-xl
lg:max-w-3xl
xl:max-w-5xl
2xl:max-w-7xl

View File

@ -0,0 +1,2 @@
{{-- Shimmer placeholder for lazy-loaded content (R4: animation via utility, no inline style). --}}
<div {{ $attributes->merge(['class' => 'animate-pulse rounded bg-raised']) }}></div>

View File

@ -16,7 +16,7 @@
$memP = $chart($memSeries);
@endphp
<div class="space-y-4" wire:poll.10s>
<div class="space-y-4" wire:poll.10s wire:init="load">
{{-- Header --}}
<div class="flex flex-wrap items-end justify-between gap-3">
<div>
@ -93,6 +93,16 @@
</tr>
</thead>
<tbody>
@if (! $ready)
@for ($i = 0; $i < 5; $i++)
<tr class="border-b border-line/60">
<td class="px-4 py-3 sm:px-5"><x-skeleton class="h-3 w-40 max-w-full" /><x-skeleton class="mt-1.5 h-2.5 w-56 max-w-full" /></td>
<td class="px-4 py-3"><x-skeleton class="h-5 w-16 rounded-full" /></td>
<td class="px-4 py-3 text-right"><x-skeleton class="ml-auto h-3 w-12" /></td>
<td class="hidden px-4 py-3 sm:table-cell"><x-skeleton class="ml-auto h-3 w-14" /></td>
</tr>
@endfor
@else
@forelse ($services as $svc)
<tr class="border-b border-line/60 transition-colors last:border-0 hover:bg-raised">
<td class="px-4 py-2.5 sm:px-5">
@ -106,6 +116,7 @@
@empty
<tr><td colspan="4" class="px-5 py-8 text-center font-mono text-[11px] text-ink-3">Keine Dienstdaten Server nicht verbunden.</td></tr>
@endforelse
@endif
</tbody>
</table>
</div>

View File

@ -19,7 +19,7 @@
};
@endphp
<div class="space-y-4">
<div class="space-y-4" wire:init="load">
{{-- Header --}}
<div class="flex flex-wrap items-end justify-between gap-3">
<div>
@ -63,7 +63,16 @@
<span class="sr-only">Aktionen</span>
</div>
<div class="divide-y divide-line">
<div class="divide-y divide-line" wire:loading.class="opacity-40" wire:target="load,open,go,up">
@if (! $ready)
@for ($i = 0; $i < 8; $i++)
<div class="flex items-center gap-3 px-4 py-3 sm:px-5">
<x-skeleton class="h-8 w-8 rounded-sm" />
<x-skeleton class="h-3 w-48 max-w-full" />
<x-skeleton class="ml-auto hidden h-3 w-24 lg:block" />
</div>
@endfor
@else
@foreach ($entries as $e)
@php $isDir = $e['type'] === 'dir'; @endphp
<div class="group grid grid-cols-1 gap-x-4 gap-y-2 px-4 py-3 transition-colors hover:bg-raised sm:px-5 lg:grid-cols-[minmax(0,1fr)_8rem_6rem_11rem_auto] lg:items-center">
@ -123,6 +132,7 @@
</div>
</div>
@endforeach
@endif
</div>
</x-panel>
</div>

View File

@ -27,15 +27,17 @@
<button
type="button"
wire:click="$dispatch('closeModal')"
class="inline-flex min-h-11 items-center rounded-md border border-line bg-inset px-4 text-sm text-ink-2 transition-colors hover:bg-raised hover:text-ink"
class="inline-flex h-9 items-center rounded-md border border-line bg-inset px-3.5 text-sm text-ink-2 transition-colors hover:bg-raised hover:text-ink"
>Abbrechen</button>
<button
type="button"
wire:click="save"
wire:target="save"
wire:loading.attr="disabled"
class="inline-flex min-h-11 items-center gap-2 rounded-md bg-accent px-4 font-display text-sm font-semibold uppercase tracking-wide text-void transition-colors hover:bg-accent-bright disabled:opacity-50"
class="inline-flex h-9 items-center gap-2 rounded-md bg-accent px-3.5 text-sm font-medium text-void transition-colors hover:bg-accent-bright disabled:opacity-60"
>
<x-icon name="plus" class="h-4 w-4" />
<x-icon name="plus" class="h-4 w-4" wire:loading.remove wire:target="save" />
<svg wire:loading wire:target="save" class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
Hinzufügen
</button>
</div>

View File

@ -20,19 +20,21 @@
<button
type="button"
wire:click="$dispatch('closeModal')"
class="inline-flex min-h-11 items-center rounded-md border border-line bg-inset px-4 text-sm text-ink-2 transition-colors hover:bg-raised hover:text-ink"
class="inline-flex h-9 items-center rounded-md border border-line bg-inset px-3.5 text-sm text-ink-2 transition-colors hover:bg-raised hover:text-ink"
>Abbrechen</button>
<button
type="button"
wire:click="confirm"
wire:target="confirm"
wire:loading.attr="disabled"
@class([
'inline-flex min-h-11 items-center gap-2 rounded-md px-4 font-display text-sm font-semibold uppercase tracking-wide transition-colors disabled:opacity-50',
'inline-flex h-9 items-center gap-2 rounded-md px-3.5 text-sm font-medium transition-colors disabled:opacity-60',
'bg-offline text-void hover:bg-offline/90' => $danger,
'bg-accent text-void hover:bg-accent-bright' => ! $danger,
])
>
<x-icon :name="$icon ?: ($danger ? 'alert' : 'activity')" class="h-4 w-4" />
<x-icon :name="$icon ?: ($danger ? 'alert' : 'activity')" class="h-4 w-4" wire:loading.remove wire:target="confirm" />
<svg wire:loading wire:target="confirm" class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
{{ $confirmLabel }}
</button>
</div>

View File

@ -17,7 +17,7 @@
];
@endphp
<div class="space-y-4" wire:poll.10s="pollMetrics">
<div class="space-y-4" wire:poll.10s="pollMetrics" wire:init="load">
{{-- Header --}}
<div class="space-y-3">
<a href="{{ route('servers.index') }}"
@ -53,12 +53,12 @@
</div>
</div>
@unless ($connected)
@if ($ready && ! $connected)
<div class="flex items-center gap-2.5 rounded-md border border-warning/25 bg-warning/10 px-4 py-3">
<x-icon name="alert" class="h-4 w-4 shrink-0 text-warning" />
<p class="text-sm text-ink-2">Keine Live-Verbindung gezeigt werden die zuletzt gespeicherten Werte. Credential/Erreichbarkeit prüfen.</p>
</div>
@endunless
@endif
{{-- Auslastung + Spezifikationen --}}
<div class="grid grid-cols-1 gap-3 lg:grid-cols-2">
@ -82,6 +82,7 @@
</x-panel>
</div>
@if ($ready)
{{-- Volumes + Netzwerk-Interfaces --}}
<div class="grid grid-cols-1 gap-3 lg:grid-cols-2">
<x-panel title="Volumes" :subtitle="count($volumes) . ' Mountpoints'" :padded="false">
@ -188,4 +189,22 @@
</div>
</x-panel>
</div>
@else
<div class="grid grid-cols-1 gap-3 lg:grid-cols-2">
<x-panel title="Volumes" subtitle="lädt…">
<div class="space-y-3"><x-skeleton class="h-3 w-full" /><x-skeleton class="h-3 w-2/3" /><x-skeleton class="h-3 w-5/6" /></div>
</x-panel>
<x-panel title="Netzwerk-Interfaces" subtitle="lädt…">
<div class="space-y-3"><x-skeleton class="h-3 w-full" /><x-skeleton class="h-3 w-3/4" /><x-skeleton class="h-3 w-5/6" /></div>
</x-panel>
</div>
<div class="grid grid-cols-1 gap-3 lg:grid-cols-2">
<x-panel title="Sicherheit (Hardening)" subtitle="lädt…">
<div class="space-y-3"><x-skeleton class="h-3 w-full" /><x-skeleton class="h-3 w-2/3" /><x-skeleton class="h-3 w-5/6" /></div>
</x-panel>
<x-panel title="SSH-Schlüssel" subtitle="lädt…">
<div class="space-y-3"><x-skeleton class="h-3 w-full" /><x-skeleton class="h-3 w-3/4" /></div>
</x-panel>
</div>
@endif
</div>

View File

@ -10,7 +10,7 @@
$logTone = ['info' => 'text-ink-3', 'warn' => 'text-warning', 'error' => 'text-offline'];
@endphp
<div class="space-y-4">
<div class="space-y-4" wire:init="load">
{{-- Header --}}
<div class="flex flex-wrap items-end justify-between gap-3">
<div>
@ -37,6 +37,15 @@
</x-slot:actions>
<div class="divide-y divide-line">
@if (! $ready)
@for ($i = 0; $i < 7; $i++)
<div class="flex items-center gap-3 px-4 py-3.5 sm:px-5">
<x-skeleton class="h-2.5 w-2.5 rounded-full" />
<div class="flex-1 space-y-2"><x-skeleton class="h-3 w-44 max-w-full" /><x-skeleton class="h-2.5 w-64 max-w-full" /></div>
<x-skeleton class="hidden h-6 w-24 sm:block" />
</div>
@endfor
@else
@forelse ($list as $svc)
<div class="flex flex-col gap-3 px-4 py-3 sm:px-5 lg:flex-row lg:items-center">
{{-- identity --}}
@ -88,6 +97,7 @@
<p class="mt-1 font-mono text-[11px] text-ink-4">Suche {{ $search }} ergab keine Treffer.</p>
</div>
@endforelse
@endif
</div>
</x-panel>

View File

@ -11,7 +11,7 @@
x-on:close.stop="setShowPropertyTo(false)"
x-on:keydown.escape.window="show && closeModalOnEscape()"
x-show="show"
class="fixed inset-0 z-10 overflow-y-auto"
class="fixed inset-0 z-50 overflow-y-auto"
style="display: none;"
>
<div class="flex items-end justify-center min-h-dvh px-4 pt-4 pb-10 text-center sm:block sm:p-0">
@ -39,8 +39,7 @@
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-bind:class="modalWidth"
class="inline-block w-full align-bottom bg-surface border border-line rounded-lg text-left overflow-hidden shadow-pop transform transition-all sm:my-8 sm:align-middle sm:w-full"
class="inline-block w-full max-w-lg align-bottom bg-surface border border-line rounded-lg text-left overflow-hidden shadow-pop transform transition-all sm:my-8 sm:align-middle"
id="modal-container"
x-trap.noscroll.inert="show && showActiveComponent"
aria-modal="true"