fix(ui): add wire:key to list rows — stops "pending is not defined" (v0.9.53)
Lists rendered loop rows without a wire:key. On re-render Livewire's morph matched rows by position and cloned them, dropping the inline Alpine x-data (modalTrigger) scope on the buttons — the console threw "Alpine Expression Error: pending is not defined" (and a follow-on "undefined is not valid JSON" as the snapshot merge broke). Reported in the file manager; the same pattern existed across the app. Added a stable wire:key to every loop whose rows carry inline Alpine / x-modal- trigger state: files (entries + breadcrumbs), services, the release-channel selector, settings (users, webauthn keys, login-protection bans) and the server detail page (hardening, firewall rules, SSH keys, fail2ban exceptions). Keys use a stable identifier (id / fingerprint / md5 of the natural key), not the loop index. 372 tests pass (the Livewire tests render these blades, so a broken key would fail them); /system, /settings, /files load 200 with no console errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/v1-foundation v0.9.53
parent
91be170e78
commit
7a6d20cbed
10
CHANGELOG.md
10
CHANGELOG.md
|
|
@ -13,7 +13,15 @@ getaggte Releases (Kanal `stable`, optional `beta`) — niemals Entwicklungs-Bui
|
||||||
|
|
||||||
_Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `vX.Y.Z` getaggt._
|
_Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `vX.Y.Z` getaggt._
|
||||||
|
|
||||||
## [0.9.52] - 2026-06-22
|
## [0.9.53] - 2026-06-22
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
- **JavaScript-Fehler im Datei-Manager (und anderen Listen): „pending is not defined".** In Listen
|
||||||
|
ohne stabilen `wire:key` ordnete Livewires DOM-Abgleich die Zeilen beim Neu-Rendern nach Position
|
||||||
|
zu und verlor dabei den Alpine-Zustand der Buttons (Modal-Auslöser) — die Konsole warf „pending is
|
||||||
|
not defined" und Aktionen klemmten. Alle betroffenen Listen haben jetzt einen stabilen `wire:key`:
|
||||||
|
Datei-Manager (Einträge + Pfad), Dienste, Release-Kanal-Auswahl, Benutzer, Sicherheitsschlüssel,
|
||||||
|
IP-Sperren sowie die Server-Detailseite (Härtung, Firewall-Regeln, SSH-Schlüssel, fail2ban-Ausnahmen).
|
||||||
|
|
||||||
### Behoben
|
### Behoben
|
||||||
- **Audit-Panel auf dem Dashboard zeigte noch Roh-Codes.** Die „letzten Ereignisse" auf dem Dashboard
|
- **Audit-Panel auf dem Dashboard zeigte noch Roh-Codes.** Die „letzten Ereignisse" auf dem Dashboard
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
return [
|
return [
|
||||||
// First tagged release is v0.1.0 (semantic, not -dev).
|
// First tagged release is v0.1.0 (semantic, not -dev).
|
||||||
'version' => '0.9.52',
|
'version' => '0.9.53',
|
||||||
|
|
||||||
// Deployed commit + branch. install.sh bakes these into .env from the host's .git (the prod
|
// Deployed commit + branch. install.sh bakes these into .env from the host's .git (the prod
|
||||||
// image ships no .git); the Versions page prefers them and falls back to a live .git read in
|
// image ships no .git); the Versions page prefers them and falls back to a live .git read in
|
||||||
|
|
|
||||||
|
|
@ -35,9 +35,9 @@
|
||||||
<x-icon name="folder" class="mr-1 h-4 w-4 shrink-0 text-ink-3" />
|
<x-icon name="folder" class="mr-1 h-4 w-4 shrink-0 text-ink-3" />
|
||||||
@foreach ($this->crumbs as $crumb)
|
@foreach ($this->crumbs as $crumb)
|
||||||
@if (! $loop->first)
|
@if (! $loop->first)
|
||||||
<span class="font-mono text-xs text-ink-4">/</span>
|
<span wire:key="crumb-sep-{{ $loop->index }}" class="font-mono text-xs text-ink-4">/</span>
|
||||||
@endif
|
@endif
|
||||||
<button type="button" wire:click="go({{ $loop->index }})" @class([
|
<button type="button" wire:key="crumb-{{ $loop->index }}" wire:click="go({{ $loop->index }})" @class([
|
||||||
'inline-flex min-h-11 min-w-11 items-center justify-center px-2 font-mono text-xs',
|
'inline-flex min-h-11 min-w-11 items-center justify-center px-2 font-mono text-xs',
|
||||||
'text-ink-3' => $loop->last,
|
'text-ink-3' => $loop->last,
|
||||||
'text-accent-text hover:text-accent' => ! $loop->last,
|
'text-accent-text hover:text-accent' => ! $loop->last,
|
||||||
|
|
@ -78,7 +78,10 @@
|
||||||
@else
|
@else
|
||||||
@foreach ($entries as $e)
|
@foreach ($entries as $e)
|
||||||
@php $isDir = $e['type'] === 'dir'; @endphp
|
@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">
|
{{-- Stable wire:key per row: without it Livewire's morph clones nodes by position and
|
||||||
|
the inline Alpine x-data (modalTrigger) loses its scope → "pending is not defined". --}}
|
||||||
|
<div wire:key="fe-{{ md5($e['type'].'/'.$e['name']) }}"
|
||||||
|
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">
|
||||||
{{-- Name + icon (directories look clickable) --}}
|
{{-- Name + icon (directories look clickable) --}}
|
||||||
<div class="flex min-w-0 items-center gap-3">
|
<div class="flex min-w-0 items-center gap-3">
|
||||||
<span @class([
|
<span @class([
|
||||||
|
|
|
||||||
|
|
@ -187,7 +187,7 @@
|
||||||
@endphp
|
@endphp
|
||||||
{{-- One calm state signal: a lock glyph (geschlossen = sicher, offen = offen);
|
{{-- One calm state signal: a lock glyph (geschlossen = sicher, offen = offen);
|
||||||
unsupported/neutral items render muted, no warning tone. --}}
|
unsupported/neutral items render muted, no warning tone. --}}
|
||||||
<div class="flex items-center gap-3.5 px-4 py-3 transition-colors hover:bg-raised/40 sm:px-5">
|
<div wire:key="hard-{{ $check['key'] }}" class="flex items-center gap-3.5 px-4 py-3 transition-colors hover:bg-raised/40 sm:px-5">
|
||||||
<span @class([
|
<span @class([
|
||||||
'grid h-9 w-9 shrink-0 place-items-center rounded-md border',
|
'grid h-9 w-9 shrink-0 place-items-center rounded-md border',
|
||||||
'border-online/25 bg-online/10 text-online' => $supported && ! $neutral && $check['secure'],
|
'border-online/25 bg-online/10 text-online' => $supported && ! $neutral && $check['secure'],
|
||||||
|
|
@ -348,7 +348,7 @@
|
||||||
{{-- $ruleTone, NOT $tone: the page-level $tone is a closure used by the
|
{{-- $ruleTone, NOT $tone: the page-level $tone is a closure used by the
|
||||||
gauges/Volumes panel; reusing the name here would clobber it. --}}
|
gauges/Volumes panel; reusing the name here would clobber it. --}}
|
||||||
@php $ruleTone = $actTone[$rule['action'] ?? 'ALLOW'] ?? 'online'; @endphp
|
@php $ruleTone = $actTone[$rule['action'] ?? 'ALLOW'] ?? 'online'; @endphp
|
||||||
<div class="flex items-center gap-3 px-4 py-2.5 sm:px-5">
|
<div wire:key="fw-{{ md5($rule['raw'] ?? ($rule['label'] ?? $loop->index)) }}" class="flex items-center gap-3 px-4 py-2.5 sm:px-5">
|
||||||
<span @class([
|
<span @class([
|
||||||
'h-2 w-2 shrink-0 rounded-full',
|
'h-2 w-2 shrink-0 rounded-full',
|
||||||
'bg-online' => $ruleTone === 'online',
|
'bg-online' => $ruleTone === 'online',
|
||||||
|
|
@ -449,7 +449,7 @@
|
||||||
<p class="mb-2 font-mono text-[10px] uppercase tracking-wider text-ink-4">{{ __('servers.whitelist_label') }}</p>
|
<p class="mb-2 font-mono text-[10px] uppercase tracking-wider text-ink-4">{{ __('servers.whitelist_label') }}</p>
|
||||||
<div class="flex flex-wrap gap-1.5">
|
<div class="flex flex-wrap gap-1.5">
|
||||||
@foreach ($f2bIgnore as $ip)
|
@foreach ($f2bIgnore as $ip)
|
||||||
<span class="inline-flex items-center gap-1.5 rounded-sm border border-line bg-inset px-2 py-1 font-mono text-[11px] text-ink-2">
|
<span wire:key="f2bi-{{ md5($ip) }}" class="inline-flex items-center gap-1.5 rounded-sm border border-line bg-inset px-2 py-1 font-mono text-[11px] text-ink-2">
|
||||||
{{ $ip }}
|
{{ $ip }}
|
||||||
@unless (in_array($ip, ['127.0.0.1/8', '::1'], true))
|
@unless (in_array($ip, ['127.0.0.1/8', '::1'], true))
|
||||||
<button type="button" wire:click="removeIgnore({{ \Illuminate\Support\Js::from($ip) }})" class="text-ink-4 hover:text-offline" title="{{ __('common.remove') }}">
|
<button type="button" wire:click="removeIgnore({{ \Illuminate\Support\Js::from($ip) }})" class="text-ink-4 hover:text-offline" title="{{ __('common.remove') }}">
|
||||||
|
|
@ -535,7 +535,7 @@
|
||||||
</x-slot:actions>
|
</x-slot:actions>
|
||||||
<div class="divide-y divide-line">
|
<div class="divide-y divide-line">
|
||||||
@forelse ($sshKeys as $key)
|
@forelse ($sshKeys as $key)
|
||||||
<div class="flex items-center gap-3 px-4 py-3 sm:px-5">
|
<div wire:key="sshk-{{ md5($key['fingerprint'] ?? $loop->index) }}" class="flex items-center gap-3 px-4 py-3 sm:px-5">
|
||||||
<span class="grid h-7 w-7 shrink-0 place-items-center rounded-sm bg-raised text-ink-3">
|
<span class="grid h-7 w-7 shrink-0 place-items-center rounded-sm bg-raised text-ink-3">
|
||||||
<x-icon name="shield" class="h-3.5 w-3.5" />
|
<x-icon name="shield" class="h-3.5 w-3.5" />
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,9 @@
|
||||||
@endfor
|
@endfor
|
||||||
@else
|
@else
|
||||||
@forelse ($list as $svc)
|
@forelse ($list as $svc)
|
||||||
<div class="flex flex-col gap-3 px-4 py-3 sm:px-5 lg:flex-row lg:items-center">
|
{{-- wire:key: the row's x-modal-trigger buttons carry inline Alpine state; without a
|
||||||
|
stable key Livewire's morph clones by position and drops it ("pending is not defined"). --}}
|
||||||
|
<div wire:key="svc-{{ $svc['name'] }}" class="flex flex-col gap-3 px-4 py-3 sm:px-5 lg:flex-row lg:items-center">
|
||||||
{{-- identity --}}
|
{{-- identity --}}
|
||||||
<div class="flex min-w-0 flex-1 items-center gap-3">
|
<div class="flex min-w-0 flex-1 items-center gap-3">
|
||||||
<x-status-dot :status="$svc['status']" :ping="$svc['status'] === 'online'" class="mt-1 self-start lg:mt-0 lg:self-center" />
|
<x-status-dot :status="$svc['status']" :ping="$svc['status'] === 'online'" class="mt-1 self-start lg:mt-0 lg:self-center" />
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@
|
||||||
<x-panel :title="__('settings.lp_bans_title')">
|
<x-panel :title="__('settings.lp_bans_title')">
|
||||||
<div class="divide-y divide-line">
|
<div class="divide-y divide-line">
|
||||||
@forelse ($bans as $ban)
|
@forelse ($bans as $ban)
|
||||||
<div class="flex flex-wrap items-center gap-3 py-3">
|
<div wire:key="ban-{{ $ban->getKey() }}" class="flex flex-wrap items-center gap-3 py-3">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<p class="truncate font-mono text-sm text-ink">{{ $ban->ip }}</p>
|
<p class="truncate font-mono text-sm text-ink">{{ $ban->ip }}</p>
|
||||||
<p class="truncate font-mono text-[11px] text-ink-3">
|
<p class="truncate font-mono text-[11px] text-ink-3">
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
<div class="divide-y divide-line">
|
<div class="divide-y divide-line">
|
||||||
@forelse ($users as $user)
|
@forelse ($users as $user)
|
||||||
@php $isSelf = $user->getKey() === $currentId; @endphp
|
@php $isSelf = $user->getKey() === $currentId; @endphp
|
||||||
<div class="flex flex-col gap-3 py-3 sm:flex-row sm:items-center">
|
<div wire:key="user-{{ $user->getKey() }}" class="flex flex-col gap-3 py-3 sm:flex-row sm:items-center">
|
||||||
<span @class([
|
<span @class([
|
||||||
'grid h-9 w-9 shrink-0 place-items-center rounded-md border',
|
'grid h-9 w-9 shrink-0 place-items-center rounded-md border',
|
||||||
'border-accent/30 bg-accent/10 text-accent-text' => $isSelf,
|
'border-accent/30 bg-accent/10 text-accent-text' => $isSelf,
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="divide-y divide-line">
|
<div class="divide-y divide-line">
|
||||||
@forelse ($keys as $key)
|
@forelse ($keys as $key)
|
||||||
<div class="flex items-center gap-3 py-3">
|
<div wire:key="wak-{{ $key->id }}" class="flex items-center gap-3 py-3">
|
||||||
<span class="grid h-9 w-9 shrink-0 place-items-center rounded-md border border-line bg-inset text-ink-3">
|
<span class="grid h-9 w-9 shrink-0 place-items-center rounded-md border border-line bg-inset text-ink-3">
|
||||||
<x-icon name="shield" class="h-4 w-4" />
|
<x-icon name="shield" class="h-4 w-4" />
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -240,7 +240,10 @@
|
||||||
{{-- Segmented control --}}
|
{{-- Segmented control --}}
|
||||||
<div role="radiogroup" aria-label="{{ __('system.channel_radiogroup_label') }}" class="inline-flex w-full max-w-md rounded-md border border-line bg-inset p-1">
|
<div role="radiogroup" aria-label="{{ __('system.channel_radiogroup_label') }}" class="inline-flex w-full max-w-md rounded-md border border-line bg-inset p-1">
|
||||||
@foreach ($channels as $key => $desc)
|
@foreach ($channels as $key => $desc)
|
||||||
|
{{-- wire:key keeps Livewire's morph from cloning by position, which would drop the
|
||||||
|
inline Alpine x-data (modalTrigger) scope → "pending is not defined". --}}
|
||||||
<button type="button"
|
<button type="button"
|
||||||
|
wire:key="channel-{{ $key }}"
|
||||||
role="radio"
|
role="radio"
|
||||||
aria-checked="{{ $channel === $key ? 'true' : 'false' }}"
|
aria-checked="{{ $channel === $key ? 'true' : 'false' }}"
|
||||||
wire:click="confirmChannel('{{ $key }}')"
|
wire:click="confirmChannel('{{ $key }}')"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue