feat: multi-server switcher + fleet context

- WithFleetContext concern: resolves the active server (session, default first
  online) + the fleet.
- ServerSwitcher Livewire component in the persistent sidebar: dropdown of the
  fleet; selecting persists active_server_id in the session and re-navigates so
  panels reflect the choice.
- Dashboard reacts: resource rings + panel subtitles follow the active server.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-12 13:45:40 +02:00
parent cd5f7c107e
commit db0a1de92f
6 changed files with 115 additions and 17 deletions

View File

@ -0,0 +1,27 @@
<?php
namespace App\Livewire\Concerns;
use App\Models\Server;
use Illuminate\Support\Collection;
/**
* Shared fleet context for pages: the active server (from session, default
* first) plus the whole fleet. Real metrics come from the SSH layer later.
*/
trait WithFleetContext
{
public function fleet(): Collection
{
return Server::orderBy('name')->get();
}
public function activeServer(): ?Server
{
$fleet = $this->fleet();
return $fleet->firstWhere('id', session('active_server_id'))
?? $fleet->firstWhere('status', 'online')
?? $fleet->first();
}
}

View File

@ -2,6 +2,7 @@
namespace App\Livewire;
use App\Livewire\Concerns\WithFleetContext;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
@ -10,6 +11,8 @@ use Livewire\Component;
#[Title('Clusev — Dashboard')]
class Dashboard extends Component
{
use WithFleetContext;
/**
* Mock fleet data. Replaced by the SSH layer (phpseclib) + Reverb later.
* Shapes mirror what MetricsPoller/FleetService will return.
@ -52,6 +55,6 @@ class Dashboard extends Component
public function render()
{
return view('livewire.dashboard');
return view('livewire.dashboard', ['active' => $this->activeServer()]);
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\Livewire;
use App\Models\Server;
use Livewire\Component;
/**
* Sidebar fleet switcher. Lives in the persistent layout, so it survives
* wire:navigate. Selecting a server stores it in the session and reloads the
* current page so every panel reflects the new active server.
*/
class ServerSwitcher extends Component
{
public function select(string $uuid): void
{
$server = Server::where('uuid', $uuid)->firstOrFail();
session(['active_server_id' => $server->id]);
$this->redirect(request()->header('Referer') ?: route('dashboard'), navigate: true);
}
public function render()
{
$servers = Server::orderBy('name')->get();
$active = $servers->firstWhere('id', session('active_server_id'))
?? $servers->firstWhere('status', 'online')
?? $servers->first();
return view('livewire.server-switcher', [
'servers' => $servers,
'active' => $active,
]);
}
}

View File

@ -16,17 +16,9 @@
</button>
</div>
{{-- Server switcher (mock) --}}
{{-- Server switcher --}}
<div class="shrink-0 border-b border-line p-3">
<button type="button"
class="flex min-h-11 w-full items-center gap-2.5 rounded-md border border-line bg-inset px-3 py-2 text-left transition-colors hover:border-line-strong">
<x-status-dot status="online" :ping="true" />
<span class="min-w-0 flex-1">
<span class="block truncate text-sm text-ink">web-01</span>
<span class="block truncate font-mono text-[11px] text-ink-3">10.10.90.136</span>
</span>
<x-icon name="switcher" class="h-4 w-4 shrink-0 text-ink-3" />
</button>
<livewire:server-switcher />
</div>
{{-- Nav --}}

View File

@ -31,7 +31,7 @@
{{-- Live chart (2/3) + resource rings (1/3) --}}
<div class="grid grid-cols-1 gap-3 lg:grid-cols-3">
<x-panel title="Live-Auslastung" subtitle="web-01 · CPU %" class="lg:col-span-2">
<x-panel title="Live-Auslastung" :subtitle="($active?->name ?? '—') . ' · CPU %'" class="lg:col-span-2">
<x-slot:actions>
<x-badge tone="cyan">Reverb</x-badge>
</x-slot:actions>
@ -57,11 +57,11 @@
</div>
</x-panel>
<x-panel title="Ressourcen" subtitle="web-01">
<x-panel title="Ressourcen" :subtitle="$active?->name ?? '—'">
<div class="grid grid-cols-3 gap-2">
<x-ring :value="$web['cpu']" label="CPU" :tone="$ringTone($web['cpu'])" />
<x-ring :value="$web['mem']" label="RAM" :tone="$ringTone($web['mem'])" />
<x-ring :value="$disk" label="Disk" :tone="$ringTone($disk)" />
<x-ring :value="$active?->cpu ?? 0" label="CPU" :tone="$ringTone($active?->cpu ?? 0)" />
<x-ring :value="$active?->mem ?? 0" label="RAM" :tone="$ringTone($active?->mem ?? 0)" />
<x-ring :value="$active?->disk ?? 0" label="Disk" :tone="$ringTone($active?->disk ?? 0)" />
</div>
</x-panel>
</div>
@ -82,7 +82,7 @@
</div>
</x-panel>
<x-panel title="systemd-Dienste" subtitle="web-01" :padded="false">
<x-panel title="systemd-Dienste" :subtitle="$active?->name" :padded="false">
<div class="divide-y divide-line">
@foreach ($services as $svc)
<div class="flex items-center gap-3 px-4 py-2.5 sm:px-5">

View File

@ -0,0 +1,40 @@
<div x-data="{ open: false }" @click.outside="open = false" @keydown.escape="open = false" class="relative">
<button type="button" @click="open = ! open"
class="flex min-h-11 w-full items-center gap-2.5 rounded-md border border-line bg-inset px-3 py-2 text-left transition-colors hover:border-line-strong"
:aria-expanded="open" aria-haspopup="listbox">
@if ($active)
<x-status-dot :status="$active->status" :ping="$active->status === 'online'" />
<span class="min-w-0 flex-1">
<span class="block truncate font-mono text-sm text-ink">{{ $active->name }}</span>
<span class="block truncate font-mono text-[11px] text-ink-3">{{ $active->ip }}</span>
</span>
@else
<span class="min-w-0 flex-1 text-sm text-ink-3">Kein Server</span>
@endif
<x-icon name="switcher" class="h-4 w-4 shrink-0 text-ink-3" />
</button>
<div x-show="open" x-cloak x-transition.origin.top
class="absolute inset-x-0 top-full z-50 mt-1 overflow-hidden rounded-md border border-line bg-raised shadow-pop"
role="listbox">
<div class="max-h-72 overflow-y-auto p-1">
@foreach ($servers as $server)
<button type="button" wire:click="select('{{ $server->uuid }}')" @click="open = false"
@class([
'flex min-h-11 w-full items-center gap-2.5 rounded-sm px-2.5 py-1.5 text-left transition-colors hover:bg-surface',
'bg-accent/10' => $active && $server->id === $active->id,
])
role="option" :aria-selected="{{ $active && $server->id === $active->id ? 'true' : 'false' }}">
<x-status-dot :status="$server->status" :ping="$server->status === 'online'" />
<span class="min-w-0 flex-1">
<span class="block truncate font-mono text-sm text-ink">{{ $server->name }}</span>
<span class="block truncate font-mono text-[11px] text-ink-3">{{ $server->ip }} · {{ $server->os }}</span>
</span>
@if ($active && $server->id === $active->id)
<x-icon name="activity" class="h-3.5 w-3.5 shrink-0 text-accent" />
@endif
</button>
@endforeach
</div>
</div>
</div>