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
parent
f849076418
commit
94972c78bd
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
sm:max-w-md
|
||||
md:max-w-xl
|
||||
lg:max-w-3xl
|
||||
xl:max-w-5xl
|
||||
2xl:max-w-7xl
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue