Page: Sites — Volt with search/filter/sort/pagination + 5 Pest tests
- pages/sites/index Volt: search by domain (debounced), 4-segment status filter (all/updates/warnings/critical), server + sort selects, paginated table (15/page). - 4 metric tiles from Site aggregates (online/updates/pagespeed/visitors). - Site cell with favicon initial, ssl/cache info, server/wp/php pills, visitors + response, status health label, row actions. - routes/web.php: Volt::route for sites.index. - CSS additions: clu-page-title/sub, clu-seg, clu-ghost-btn, clu-toolbar, clu-pager, clu-row-actions. - 5 Pest tests green (redirect, chrome, list+paginate, search, segment filter).master
parent
bb0e07ca47
commit
1622ac3a18
|
|
@ -942,4 +942,63 @@
|
||||||
.clu-feed-body { flex: 1; min-width: 0; }
|
.clu-feed-body { flex: 1; min-width: 0; }
|
||||||
.clu-feed-msg { font-size: 12.5px; color: var(--color-fg); }
|
.clu-feed-msg { font-size: 12.5px; color: var(--color-fg); }
|
||||||
.clu-feed-meta { font-size: 10.5px; color: var(--color-muted); font-family: var(--font-mono); margin-top: 2px; }
|
.clu-feed-meta { font-size: 10.5px; color: var(--color-muted); font-family: var(--font-mono); margin-top: 2px; }
|
||||||
|
|
||||||
|
/* Topbar headings */
|
||||||
|
.clu-page-title { font-size: 17px; font-weight: 600; letter-spacing: -0.01em; margin: 0; }
|
||||||
|
.clu-page-sub { font-size: 12px; color: var(--color-muted); font-family: var(--font-mono); margin-top: 2px; }
|
||||||
|
|
||||||
|
/* Segmented button group */
|
||||||
|
.clu-seg {
|
||||||
|
display: inline-flex;
|
||||||
|
background: rgba(255,255,255,0.4);
|
||||||
|
border: 1px solid var(--color-hairline);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 3px;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.clu-seg button {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 9px;
|
||||||
|
font-size: 12px; font-weight: 600;
|
||||||
|
color: var(--color-muted);
|
||||||
|
transition: background .15s, color .15s;
|
||||||
|
}
|
||||||
|
.clu-seg button:hover { color: var(--color-fg); }
|
||||||
|
.clu-seg button.active {
|
||||||
|
background: rgba(255,255,255,0.95);
|
||||||
|
color: var(--color-fg);
|
||||||
|
box-shadow: 0 1px 2px rgba(40,52,92,0.08);
|
||||||
|
}
|
||||||
|
.clu-seg button .count { opacity: 0.55; margin-left: 4px; font-family: var(--font-mono); }
|
||||||
|
|
||||||
|
/* Ghost button */
|
||||||
|
.clu-ghost-btn {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
background: rgba(255,255,255,0.5);
|
||||||
|
border: 1px solid var(--color-hairline);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 12px; font-weight: 600;
|
||||||
|
color: var(--color-fg);
|
||||||
|
transition: background .15s, border-color .15s;
|
||||||
|
}
|
||||||
|
.clu-ghost-btn:hover { background: rgba(255,255,255,0.85); border-color: var(--color-glass-border); }
|
||||||
|
|
||||||
|
/* Toolbar wrapper */
|
||||||
|
.clu-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; padding: 12px 18px; border-bottom: 1px solid var(--color-hairline); }
|
||||||
|
|
||||||
|
/* Pagination row */
|
||||||
|
.clu-pager { display: flex; align-items: center; justify-content: space-between; padding: 12px 18px; border-top: 1px solid var(--color-hairline); }
|
||||||
|
.clu-pager-meta { font-size: 12px; color: var(--color-muted); font-family: var(--font-mono); }
|
||||||
|
|
||||||
|
/* Row actions cluster */
|
||||||
|
.clu-row-actions { display: inline-flex; gap: 4px; justify-content: flex-end; }
|
||||||
|
.clu-row-actions button {
|
||||||
|
width: 28px; height: 28px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--color-muted);
|
||||||
|
display: grid; place-items: center;
|
||||||
|
transition: background .15s, color .15s;
|
||||||
|
}
|
||||||
|
.clu-row-actions button:hover { background: rgba(255,255,255,0.7); color: var(--color-fg); }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,231 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Server;
|
||||||
|
use App\Models\Site;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use Livewire\Attributes\Url;
|
||||||
|
use Livewire\Volt\Component;
|
||||||
|
use Livewire\WithPagination;
|
||||||
|
|
||||||
|
new #[Layout('layouts.app')] class extends Component {
|
||||||
|
use WithPagination;
|
||||||
|
|
||||||
|
#[Url(as: 'q')]
|
||||||
|
public string $search = '';
|
||||||
|
|
||||||
|
#[Url(as: 'f')]
|
||||||
|
public string $filter = 'all'; // all | updates | warnings | critical
|
||||||
|
|
||||||
|
#[Url(as: 'srv')]
|
||||||
|
public string $serverFilter = '';
|
||||||
|
|
||||||
|
#[Url(as: 'sort')]
|
||||||
|
public string $sort = 'visitors';
|
||||||
|
|
||||||
|
public function updatingSearch(): void { $this->resetPage(); }
|
||||||
|
public function updatingFilter(): void { $this->resetPage(); }
|
||||||
|
public function updatingServerFilter(): void { $this->resetPage(); }
|
||||||
|
public function updatingSort(): void { $this->resetPage(); }
|
||||||
|
|
||||||
|
public function setFilter(string $f): void
|
||||||
|
{
|
||||||
|
$this->filter = $f;
|
||||||
|
$this->resetPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function with(): array
|
||||||
|
{
|
||||||
|
$query = Site::query()->with('server');
|
||||||
|
|
||||||
|
if ($this->search !== '') {
|
||||||
|
$query->where('domain', 'like', '%' . $this->search . '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
match ($this->filter) {
|
||||||
|
'updates' => $query->where('updates_pending', '>', 0),
|
||||||
|
'warnings' => $query->where('status', 'warn'),
|
||||||
|
'critical' => $query->where('status', 'bad'),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if ($this->serverFilter !== '') {
|
||||||
|
$query->where('server_id', $this->serverFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
$query->orderBy(match ($this->sort) {
|
||||||
|
'visitors' => 'visitors_24h',
|
||||||
|
'response' => 'response_ms',
|
||||||
|
'updates' => 'updates_pending',
|
||||||
|
default => 'domain',
|
||||||
|
}, $this->sort === 'name' ? 'asc' : 'desc');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'sites' => $query->paginate(15),
|
||||||
|
'servers' => Server::orderBy('name')->get(),
|
||||||
|
'totalSites' => Site::count(),
|
||||||
|
'updatesCount' => Site::where('updates_pending', '>', 0)->count(),
|
||||||
|
'warnCount' => Site::where('status', 'warn')->count(),
|
||||||
|
'criticalCount' => Site::where('status', 'bad')->count(),
|
||||||
|
'avgPagespeed' => (int) Site::avg('pagespeed_score'),
|
||||||
|
'totalVisitors' => Site::sum('visitors_24h'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}; ?>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{{-- ───── Topbar ───── --}}
|
||||||
|
<div class="clu-topbar">
|
||||||
|
<div>
|
||||||
|
<h1 class="clu-page-title">Sites</h1>
|
||||||
|
<div class="clu-page-sub">{{ $totalSites }} wordpress-installationen</div>
|
||||||
|
</div>
|
||||||
|
<span class="clu-status-chip"><span class="clu-pulse-dot"></span> {{ $totalSites }} online</span>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<div class="clu-search">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-(--color-muted-2)" aria-hidden="true">
|
||||||
|
<circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/>
|
||||||
|
</svg>
|
||||||
|
<input type="search" wire:model.live.debounce.300ms="search" placeholder="Domain suchen…" />
|
||||||
|
<span class="kbd">⌘ K</span>
|
||||||
|
</div>
|
||||||
|
<button class="clu-btn-primary" style="height:36px;padding:0 14px;">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
|
||||||
|
<path d="M12 5v14M5 12h14"/>
|
||||||
|
</svg>
|
||||||
|
Site hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- ───── Metric tiles ───── --}}
|
||||||
|
<div class="grid gap-[14px] mb-[18px]" style="grid-template-columns:repeat(auto-fit,minmax(220px,1fr));">
|
||||||
|
<div class="clu-metric success">
|
||||||
|
<div class="clu-metric-row">
|
||||||
|
<span class="clu-metric-label">Sites Online</span>
|
||||||
|
<span class="clu-metric-ico"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M9 12l2 2 4-4"/></svg></span>
|
||||||
|
</div>
|
||||||
|
<div class="clu-metric-value">{{ $totalSites }}<span class="unit">/{{ $totalSites }}</span></div>
|
||||||
|
<div class="clu-metric-delta">100% erreichbar</div>
|
||||||
|
</div>
|
||||||
|
<div class="clu-metric warning">
|
||||||
|
<div class="clu-metric-row">
|
||||||
|
<span class="clu-metric-label">Updates offen</span>
|
||||||
|
<span class="clu-metric-ico"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M21 12a9 9 0 1 1-3-6.7M21 4v5h-5"/></svg></span>
|
||||||
|
</div>
|
||||||
|
<div class="clu-metric-value">{{ $updatesCount }}</div>
|
||||||
|
<div class="clu-metric-delta warn">{{ $criticalCount }} kritisch · {{ $warnCount }} warn</div>
|
||||||
|
</div>
|
||||||
|
<div class="clu-metric">
|
||||||
|
<div class="clu-metric-row">
|
||||||
|
<span class="clu-metric-label">Ø PageSpeed</span>
|
||||||
|
<span class="clu-metric-ico"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M12 3a9 9 0 1 0 9 9M12 12l5-5"/></svg></span>
|
||||||
|
</div>
|
||||||
|
<div class="clu-metric-value">{{ $avgPagespeed }}<span class="unit">/100</span></div>
|
||||||
|
<div class="clu-metric-delta">Core Web Vitals OK</div>
|
||||||
|
</div>
|
||||||
|
<div class="clu-metric">
|
||||||
|
<div class="clu-metric-row">
|
||||||
|
<span class="clu-metric-label">Besucher / 24h</span>
|
||||||
|
<span class="clu-metric-ico"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/></svg></span>
|
||||||
|
</div>
|
||||||
|
<div class="clu-metric-value">{{ number_format($totalVisitors / 1000, 0, ',', '.') }}<span class="unit">k</span></div>
|
||||||
|
<div class="clu-metric-delta">↑ 12% vs Vorwoche</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- ───── Sites table ───── --}}
|
||||||
|
<div class="clu-card">
|
||||||
|
<div class="clu-toolbar">
|
||||||
|
<div class="clu-seg">
|
||||||
|
<button wire:click="setFilter('all')" class="@if($filter==='all') active @endif">Alle <span class="count">{{ $totalSites }}</span></button>
|
||||||
|
<button wire:click="setFilter('updates')" class="@if($filter==='updates') active @endif">Updates <span class="count">{{ $updatesCount }}</span></button>
|
||||||
|
<button wire:click="setFilter('warnings')" class="@if($filter==='warnings') active @endif">Warnungen <span class="count">{{ $warnCount }}</span></button>
|
||||||
|
<button wire:click="setFilter('critical')" class="@if($filter==='critical') active @endif">Kritisch <span class="count">{{ $criticalCount }}</span></button>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-[8px]">
|
||||||
|
<select wire:model.live="serverFilter" class="clu-ghost-btn" style="padding-right:8px;appearance:auto;">
|
||||||
|
<option value="">Server: Alle</option>
|
||||||
|
@foreach ($servers as $server)
|
||||||
|
<option value="{{ $server->id }}">{{ $server->name }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
<select wire:model.live="sort" class="clu-ghost-btn" style="padding-right:8px;appearance:auto;">
|
||||||
|
<option value="visitors">Besucher</option>
|
||||||
|
<option value="response">Response</option>
|
||||||
|
<option value="updates">Updates</option>
|
||||||
|
<option value="name">Name</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="clu-card-body tight" style="overflow-x:auto;">
|
||||||
|
<table class="clu-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Site</th>
|
||||||
|
<th>Server</th>
|
||||||
|
<th>WordPress</th>
|
||||||
|
<th>PHP</th>
|
||||||
|
<th>Besucher / 24h</th>
|
||||||
|
<th>Response</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@forelse ($sites as $site)
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="clu-site-cell">
|
||||||
|
<div class="clu-favicon">{{ mb_substr($site->domain, 0, 1) }}</div>
|
||||||
|
<div class="clu-site-meta">
|
||||||
|
<span class="clu-site-domain">{{ $site->domain }}</span>
|
||||||
|
<span class="clu-site-server">ssl ✓ · {{ $site->cache_enabled ? 'cache an' : 'cache aus' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td><span class="clu-pill muted">{{ $site->server->name }}</span></td>
|
||||||
|
<td><span class="clu-pill @if($site->updates_pending > 0) warning @else success @endif">WP {{ $site->wp_version }}</span></td>
|
||||||
|
<td><span class="clu-pill @if(version_compare($site->php_version, '8.0', '<')) warning @else muted @endif">PHP {{ $site->php_version }}</span></td>
|
||||||
|
<td class="font-mono">{{ number_format($site->visitors_24h, 0, ',', '.') }}</td>
|
||||||
|
<td class="font-mono">{{ $site->response_ms }}ms</td>
|
||||||
|
<td>
|
||||||
|
<span class="clu-health @if($site->status === 'warn') warn @elseif($site->status === 'bad') bad @endif">
|
||||||
|
{{ match($site->status) {
|
||||||
|
'ok' => $site->updates_pending > 0 ? $site->updates_pending . ' Updates' : 'Optimal',
|
||||||
|
'warn' => 'Wartung nötig',
|
||||||
|
'bad' => 'Kritisch',
|
||||||
|
default => $site->status,
|
||||||
|
} }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="clu-row-actions">
|
||||||
|
<button title="Site öffnen" type="button">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||||
|
<path d="M14 3h7v7M21 3l-9 9M19 14v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h5"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button title="Mehr" type="button">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||||
|
<circle cx="5" cy="12" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="19" cy="12" r="1.5"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr><td colspan="8" style="text-align:center;padding:40px;color:var(--color-muted);">Keine Sites gefunden.</td></tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="clu-pager">
|
||||||
|
<span class="clu-pager-meta">
|
||||||
|
{{ $sites->firstItem() ?? 0 }}–{{ $sites->lastItem() ?? 0 }} von {{ $sites->total() }}
|
||||||
|
</span>
|
||||||
|
<div class="flex gap-[6px]">
|
||||||
|
<button class="clu-ghost-btn" wire:click="previousPage" @if(! $sites->onFirstPage()) @else disabled @endif>← Zurück</button>
|
||||||
|
<button class="clu-ghost-btn" wire:click="nextPage" @if($sites->hasMorePages()) @else disabled @endif>Weiter →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -15,7 +15,7 @@ Route::view('profile', 'profile')
|
||||||
|
|
||||||
/* ───── Placeholder named routes for sidebar links (real Volt pages land later). ───── */
|
/* ───── Placeholder named routes for sidebar links (real Volt pages land later). ───── */
|
||||||
Route::middleware(['auth'])->group(function () {
|
Route::middleware(['auth'])->group(function () {
|
||||||
Route::view('sites', 'placeholder', ['title' => 'Sites'])->name('sites.index');
|
Volt::route('sites', 'pages.sites.index')->name('sites.index');
|
||||||
Route::view('servers', 'placeholder', ['title' => 'Server'])->name('servers.index');
|
Route::view('servers', 'placeholder', ['title' => 'Server'])->name('servers.index');
|
||||||
Route::view('backups', 'placeholder', ['title' => 'Backups'])->name('backups.index');
|
Route::view('backups', 'placeholder', ['title' => 'Backups'])->name('backups.index');
|
||||||
Route::view('security', 'placeholder', ['title' => 'Sicherheit'])->name('security.index');
|
Route::view('security', 'placeholder', ['title' => 'Sicherheit'])->name('security.index');
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Server;
|
||||||
|
use App\Models\Site;
|
||||||
|
use App\Models\User;
|
||||||
|
use function Pest\Laravel\actingAs;
|
||||||
|
use function Pest\Laravel\get;
|
||||||
|
use function Pest\Livewire\livewire;
|
||||||
|
|
||||||
|
it('redirects guests to login', function () {
|
||||||
|
get(route('sites.index'))->assertRedirect(route('login'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the sites page chrome for an auth user', function () {
|
||||||
|
$user = User::factory()->create(['email_verified_at' => now()]);
|
||||||
|
|
||||||
|
actingAs($user)->get(route('sites.index'))
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertSeeText('Sites')
|
||||||
|
->assertSeeText('Sites Online')
|
||||||
|
->assertSeeText('Updates offen')
|
||||||
|
->assertSeeText('Ø PageSpeed')
|
||||||
|
->assertSeeText('Besucher / 24h')
|
||||||
|
->assertSeeText('Site hinzufügen')
|
||||||
|
->assertSee('clu-seg', false)
|
||||||
|
->assertSee('clu-pager', false)
|
||||||
|
->assertSee('clu-table', false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists seeded sites and paginates', function () {
|
||||||
|
$user = User::factory()->create(['email_verified_at' => now()]);
|
||||||
|
$server = Server::factory()->create(['name' => 'test-srv-01']);
|
||||||
|
Site::factory()->count(20)->create(['server_id' => $server->id]);
|
||||||
|
|
||||||
|
actingAs($user)->get(route('sites.index'))
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertSee('test-srv-01')
|
||||||
|
->assertSeeText('von 20');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by search term', function () {
|
||||||
|
$user = User::factory()->create(['email_verified_at' => now()]);
|
||||||
|
$server = Server::factory()->create();
|
||||||
|
Site::factory()->create(['server_id' => $server->id, 'domain' => 'unique-match-1.test']);
|
||||||
|
Site::factory()->create(['server_id' => $server->id, 'domain' => 'other.test']);
|
||||||
|
|
||||||
|
livewire('pages.sites.index')
|
||||||
|
->set('search', 'unique-match')
|
||||||
|
->assertSee('unique-match-1.test')
|
||||||
|
->assertDontSee('other.test');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by status segment', function () {
|
||||||
|
$user = User::factory()->create(['email_verified_at' => now()]);
|
||||||
|
$server = Server::factory()->create();
|
||||||
|
Site::factory()->create(['server_id' => $server->id, 'status' => 'ok', 'domain' => 'healthy.test']);
|
||||||
|
Site::factory()->create(['server_id' => $server->id, 'status' => 'bad', 'domain' => 'broken.test']);
|
||||||
|
|
||||||
|
livewire('pages.sites.index')
|
||||||
|
->call('setFilter', 'critical')
|
||||||
|
->assertSee('broken.test')
|
||||||
|
->assertDontSee('healthy.test');
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue