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
Boban Blaskovic 2026-05-31 22:50:03 +02:00
parent bb0e07ca47
commit 1622ac3a18
4 changed files with 354 additions and 1 deletions

View File

@ -942,4 +942,63 @@
.clu-feed-body { flex: 1; min-width: 0; }
.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; }
/* 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); }
}

View File

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

View File

@ -15,7 +15,7 @@ Route::view('profile', 'profile')
/* ───── Placeholder named routes for sidebar links (real Volt pages land later). ───── */
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('backups', 'placeholder', ['title' => 'Backups'])->name('backups.index');
Route::view('security', 'placeholder', ['title' => 'Sicherheit'])->name('security.index');

View File

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