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
parent
1622ac3a18
commit
b15e6f2146
|
|
@ -1001,4 +1001,32 @@
|
|||
transition: background .15s, color .15s;
|
||||
}
|
||||
.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); }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -15,8 +15,8 @@ Route::view('profile', 'profile')
|
|||
|
||||
/* ───── Placeholder named routes for sidebar links (real Volt pages land later). ───── */
|
||||
Route::middleware(['auth'])->group(function () {
|
||||
Volt::route('sites', 'pages.sites.index')->name('sites.index');
|
||||
Route::view('servers', 'placeholder', ['title' => 'Server'])->name('servers.index');
|
||||
Volt::route('sites', 'pages.sites.index')->name('sites.index');
|
||||
Volt::route('servers', 'pages.servers.index')->name('servers.index');
|
||||
Route::view('backups', 'placeholder', ['title' => 'Backups'])->name('backups.index');
|
||||
Route::view('security', 'placeholder', ['title' => 'Sicherheit'])->name('security.index');
|
||||
Route::view('team', 'placeholder', ['title' => 'Team'])->name('team.index');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
Loading…
Reference in New Issue