Page: Servers — Volt cards grid with gauges + 3 Pest tests

- pages/servers/index Volt: 4 metrics (active nodes, avg CPU, avg RAM,
  maintenance), env segmented filter (all/prod/edge/staging), region select.
- Server card grid with country flag, name, env, spec line (vCPU/RAM/disk),
  3 horizontal gauges (CPU/RAM/disk) color-coded warn>70 / bad>90,
  sites_count footer, SSH + more row actions.
- CSS additions: clu-server-card/head/flag/name/meta/spec/foot, clu-gauge*.
- Routes: Volt::route servers.index.
- 3 Pest tests green (redirect, chrome+grid, env filter).
master
Boban Blaskovic 2026-05-31 22:52:01 +02:00
parent 1622ac3a18
commit b15e6f2146
4 changed files with 243 additions and 2 deletions

View File

@ -1001,4 +1001,32 @@
transition: background .15s, color .15s; transition: background .15s, color .15s;
} }
.clu-row-actions button:hover { background: rgba(255,255,255,0.7); color: var(--color-fg); } .clu-row-actions button:hover { background: rgba(255,255,255,0.7); color: var(--color-fg); }
/* Server card (Server.html) */
.clu-server-card {
background: var(--color-glass-strong);
border: 1px solid var(--color-glass-border);
border-radius: var(--radius);
padding: 18px;
box-shadow: var(--shadow-glass), var(--shadow-glass-inset);
backdrop-filter: blur(20px) saturate(170%);
-webkit-backdrop-filter: blur(20px) saturate(170%);
display: flex; flex-direction: column; gap: 14px;
}
.clu-server-head { display: flex; align-items: center; gap: 12px; }
.clu-server-flag { font-size: 22px; line-height: 1; }
.clu-server-name { font-weight: 600; font-size: 14px; }
.clu-server-meta { font-size: 11.5px; color: var(--color-muted); font-family: var(--font-mono); }
.clu-gauge-row { display: flex; flex-direction: column; gap: 9px; }
.clu-gauge {
display: flex; align-items: center; gap: 10px;
}
.clu-gauge-label { font-size: 11px; color: var(--color-muted); width: 38px; flex: none; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.05em; }
.clu-gauge-bar { flex: 1; height: 6px; border-radius: 4px; background: rgba(30,35,60,0.10); overflow: hidden; }
.clu-gauge-bar > span { display: block; height: 100%; border-radius: 4px; background: var(--color-accent); }
.clu-gauge-bar > span.warn { background: var(--color-warning); }
.clu-gauge-bar > span.bad { background: var(--color-danger); }
.clu-gauge-val { font-family: var(--font-mono); font-size: 11px; color: var(--color-fg); width: 36px; text-align: right; }
.clu-server-spec { display: flex; gap: 8px; font-family: var(--font-mono); font-size: 11px; color: var(--color-muted); }
.clu-server-foot { display: flex; align-items: center; justify-content: space-between; padding-top: 10px; border-top: 1px solid var(--color-hairline); }
} }

View File

@ -0,0 +1,174 @@
<?php
use App\Models\Server;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Url;
use Livewire\Volt\Component;
new #[Layout('layouts.app')] class extends Component {
#[Url(as: 'env')]
public string $envFilter = 'all'; // all | prod | edge | staging
#[Url(as: 'region')]
public string $regionFilter = '';
public function setEnv(string $env): void
{
$this->envFilter = $env;
}
public function with(): array
{
$query = Server::query()->withCount('sites');
if ($this->envFilter !== 'all') {
$query->where('env', $this->envFilter);
}
if ($this->regionFilter !== '') {
$query->where('region', $this->regionFilter);
}
return [
'servers' => $query->orderBy('name')->get(),
'totalServers' => Server::count(),
'okCount' => Server::where('status', 'ok')->count(),
'avgCpu' => (int) Server::avg('cpu_load_pct'),
'avgRam' => (int) Server::avg('ram_used_pct'),
'maintenance' => Server::where('status', 'maintenance')->count(),
'regions' => Server::query()->select('region')->distinct()->pluck('region'),
'prodCount' => Server::where('env', 'prod')->count(),
'edgeCount' => Server::where('env', 'edge')->count(),
'stagingCount' => Server::where('env', 'staging')->count(),
];
}
}; ?>
<div>
{{-- ───── Topbar ───── --}}
<div class="clu-topbar">
<div>
<h1 class="clu-page-title">Server</h1>
<div class="clu-page-sub">{{ $totalServers }} nodes · cluster acme</div>
</div>
<span class="clu-status-chip"><span class="clu-pulse-dot"></span> {{ $okCount }}/{{ $totalServers }} aktiv</span>
<div class="spacer"></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>
Cluster anlegen
</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">Aktive Nodes</span>
<span class="clu-metric-ico"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="7" rx="1.5"/><rect x="3" y="13" width="18" height="7" rx="1.5"/></svg></span>
</div>
<div class="clu-metric-value">{{ $okCount }}<span class="unit">/{{ $totalServers }}</span></div>
<div class="clu-metric-delta">{{ $maintenance }} im Maintenance</div>
</div>
<div class="clu-metric @if($avgCpu > 70) warning @endif">
<div class="clu-metric-row">
<span class="clu-metric-label">Ø CPU Load</span>
<span class="clu-metric-ico"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12h4l3-9 4 18 3-9h4"/></svg></span>
</div>
<div class="clu-metric-value">{{ $avgCpu }}<span class="unit">%</span></div>
<div class="clu-metric-delta @if($avgCpu > 70) warn @endif">across {{ $totalServers }} nodes</div>
</div>
<div class="clu-metric @if($avgRam > 70) warning @endif">
<div class="clu-metric-row">
<span class="clu-metric-label">Ø RAM</span>
<span class="clu-metric-ico"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="7" width="18" height="10" rx="2"/><path d="M7 7v10M11 7v10M15 7v10M19 7v10"/></svg></span>
</div>
<div class="clu-metric-value">{{ $avgRam }}<span class="unit">%</span></div>
<div class="clu-metric-delta">used</div>
</div>
<div class="clu-metric">
<div class="clu-metric-row">
<span class="clu-metric-label">Maintenance</span>
<span class="clu-metric-ico"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 1v6M12 17v6M4.2 4.2l4.3 4.3M15.5 15.5l4.3 4.3M1 12h6M17 12h6M4.2 19.8l4.3-4.3M15.5 8.5l4.3-4.3"/></svg></span>
</div>
<div class="clu-metric-value">{{ $maintenance }}</div>
<div class="clu-metric-delta">geplant</div>
</div>
</div>
{{-- ───── Toolbar ───── --}}
<div class="clu-card mb-[18px]">
<div class="clu-toolbar">
<div class="clu-seg">
<button wire:click="setEnv('all')" class="@if($envFilter==='all') active @endif">Alle <span class="count">{{ $totalServers }}</span></button>
<button wire:click="setEnv('prod')" class="@if($envFilter==='prod') active @endif">Prod <span class="count">{{ $prodCount }}</span></button>
<button wire:click="setEnv('edge')" class="@if($envFilter==='edge') active @endif">Edge <span class="count">{{ $edgeCount }}</span></button>
<button wire:click="setEnv('staging')" class="@if($envFilter==='staging') active @endif">Staging <span class="count">{{ $stagingCount }}</span></button>
</div>
<div>
<select wire:model.live="regionFilter" class="clu-ghost-btn" style="appearance:auto;">
<option value="">Region: Alle</option>
@foreach ($regions as $r)
<option value="{{ $r }}">{{ $r }}</option>
@endforeach
</select>
</div>
</div>
</div>
{{-- ───── Server cards grid ───── --}}
<div class="grid gap-[14px] mb-[24px]" style="grid-template-columns:repeat(auto-fit,minmax(320px,1fr));">
@forelse ($servers as $server)
<div class="clu-server-card">
<div class="clu-server-head">
<div class="clu-server-flag">{{ $server->country_flag }}</div>
<div class="flex-1 min-w-0">
<div class="clu-server-name">{{ $server->name }}</div>
<div class="clu-server-meta">{{ $server->region }} · {{ $server->env }}</div>
</div>
<span class="clu-pill @if($server->status === 'ok') success @elseif($server->status === 'warn') warning @else danger @endif">{{ strtoupper($server->status) }}</span>
</div>
<div class="clu-server-spec">
<span>{{ $server->cpu_cores }} vCPU</span>
<span>·</span>
<span>{{ $server->ram_gb }} GB RAM</span>
<span>·</span>
<span>{{ $server->disk_gb }} GB SSD</span>
</div>
<div class="clu-gauge-row">
<div class="clu-gauge">
<span class="clu-gauge-label">CPU</span>
<div class="clu-gauge-bar"><span style="width:{{ $server->cpu_load_pct }}%;" class="@if($server->cpu_load_pct > 70) warn @endif @if($server->cpu_load_pct > 90) bad @endif"></span></div>
<span class="clu-gauge-val">{{ $server->cpu_load_pct }}%</span>
</div>
<div class="clu-gauge">
<span class="clu-gauge-label">RAM</span>
<div class="clu-gauge-bar"><span style="width:{{ $server->ram_used_pct }}%;" class="@if($server->ram_used_pct > 70) warn @endif @if($server->ram_used_pct > 90) bad @endif"></span></div>
<span class="clu-gauge-val">{{ $server->ram_used_pct }}%</span>
</div>
<div class="clu-gauge">
<span class="clu-gauge-label">DISK</span>
<div class="clu-gauge-bar"><span style="width:{{ $server->disk_used_pct }}%;" class="@if($server->disk_used_pct > 70) warn @endif @if($server->disk_used_pct > 90) bad @endif"></span></div>
<span class="clu-gauge-val">{{ $server->disk_used_pct }}%</span>
</div>
</div>
<div class="clu-server-foot">
<span class="clu-server-meta">{{ $server->sites_count }} sites</span>
<div class="clu-row-actions">
<button title="SSH" type="button">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 17l5-5-5-5M13 19h7"/></svg>
</button>
<button title="Mehr" type="button">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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>
</div>
</div>
@empty
<div class="clu-card"><div class="clu-card-body" style="text-align:center;padding:40px;color:var(--color-muted);">Keine Server gefunden.</div></div>
@endforelse
</div>
</div>

View File

@ -16,7 +16,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 () {
Volt::route('sites', 'pages.sites.index')->name('sites.index'); Volt::route('sites', 'pages.sites.index')->name('sites.index');
Route::view('servers', 'placeholder', ['title' => 'Server'])->name('servers.index'); Volt::route('servers', 'pages.servers.index')->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');
Route::view('team', 'placeholder', ['title' => 'Team'])->name('team.index'); Route::view('team', 'placeholder', ['title' => 'Team'])->name('team.index');

View File

@ -0,0 +1,39 @@
<?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('servers.index'))->assertRedirect(route('login'));
});
it('renders servers chrome + grid', function () {
$user = User::factory()->create(['email_verified_at' => now()]);
$s = Server::factory()->create(['name' => 'eu-prod-77', 'env' => 'prod']);
Site::factory()->count(4)->create(['server_id' => $s->id]);
actingAs($user)->get(route('servers.index'))
->assertStatus(200)
->assertSeeText('Server')
->assertSeeText('Aktive Nodes')
->assertSeeText('Ø CPU Load')
->assertSeeText('Ø RAM')
->assertSeeText('eu-prod-77')
->assertSeeText('4 sites')
->assertSee('clu-server-card', false)
->assertSee('clu-gauge-bar', false);
});
it('filters by env segment', function () {
Server::factory()->create(['env' => 'prod', 'name' => 'p-1']);
Server::factory()->create(['env' => 'edge', 'name' => 'e-1']);
livewire('pages.servers.index')
->call('setEnv', 'edge')
->assertSee('e-1')
->assertDontSee('p-1');
});