Dashboard: live service health + fault surfacing; declutter sidebar

Addresses user feedback on Phase 1:
- Dashboard reoriented from static stack facts to LIVE operational status.
  New health banner surfaces faults prominently (green when all online;
  amber/red with a pluralized problem count when a service is down).
  Service tiles probe DB, Redis, Reverb (socket) and Horizon in real time
  with latency; versions moved to a small, de-emphasized "System" block.
  wire:poll.10s + a refresh button keep it live. Verified: stopping Reverb
  flips the banner to "Ein Dienst mit Störung" and the Echtzeit tile to
  Offline/nicht erreichbar (screenshotted), healthy state restores clean.
- Sidebar decluttered: removed the wrapping "In Kürze" count badges;
  coming-soon items are now dimmed (still show lock icons where gated).

R12 re-verified in headless Chromium: 15/15 assertions, 0 console errors,
0 failed requests, breakpoints 375/768/1280. 7 feature tests still green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/phase-1-bootstrap
HomeOS Bootstrap 2026-07-17 20:56:32 +02:00
parent beac04ec51
commit d4f667437c
6 changed files with 231 additions and 91 deletions

2
.gitignore vendored
View File

@ -29,6 +29,6 @@ Thumbs.db
# HomeOS # HomeOS
/docker-compose.override.yml /docker-compose.override.yml
/_bootstrap /_bootstrap
/_verify.mjs /_verify*.mjs
/sidecar/__pycache__ /sidecar/__pycache__
*.pyc *.pyc

View File

@ -2,18 +2,117 @@
namespace App\Livewire; namespace App\Livewire;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
use Livewire\Component; use Livewire\Component;
#[Layout('layouts.app')] #[Layout('layouts.app')]
class Dashboard extends Component class Dashboard extends Component
{ {
/** Interactive demo toggle — verifies Livewire reactivity in Phase 1. */ /** @var array<int, array{key:string,label:string,icon:string,state:string,detail:string}> */
public bool $demoOn = true; public array $services = [];
public function toggleDemo(): void public ?string $checkedAt = null;
public function mount(): void
{ {
$this->demoOn = ! $this->demoOn; $this->refreshHealth();
}
/** Re-run all service health probes (called on mount, wire:poll and the refresh button). */
public function refreshHealth(): void
{
$this->services = [
$this->checkDatabase(),
$this->checkRedis(),
$this->checkReverb(),
$this->checkHorizon(),
];
$this->checkedAt = now()->format('H:i:s');
}
/** Worst state across services (drives the banner): offline > warning > online. */
public function worstState(): string
{
$states = array_column($this->services, 'state');
return in_array('offline', $states, true) ? 'offline'
: (in_array('warning', $states, true) ? 'warning' : 'online');
}
public function problemCount(): int
{
return count(array_filter($this->services, fn ($s) => $s['state'] !== 'online'));
}
protected function checkDatabase(): array
{
$base = ['key' => 'database', 'label' => __('dashboard.svc_database'), 'icon' => 'network'];
try {
$start = microtime(true);
DB::connection()->select('select 1');
$ms = (int) round((microtime(true) - $start) * 1000);
return $base + ['state' => 'online', 'detail' => $ms.' ms'];
} catch (\Throwable) {
return $base + ['state' => 'offline', 'detail' => __('dashboard.svc_unreachable')];
}
}
protected function checkRedis(): array
{
$base = ['key' => 'cache', 'label' => __('dashboard.svc_cache'), 'icon' => 'devices'];
try {
$start = microtime(true);
Redis::connection()->ping();
$ms = (int) round((microtime(true) - $start) * 1000);
return $base + ['state' => 'online', 'detail' => $ms.' ms'];
} catch (\Throwable) {
return $base + ['state' => 'offline', 'detail' => __('dashboard.svc_unreachable')];
}
}
protected function checkReverb(): array
{
$base = ['key' => 'realtime', 'label' => __('dashboard.svc_realtime'), 'icon' => 'activity'];
$host = config('broadcasting.connections.reverb.options.host', 'reverb');
$port = (int) config('broadcasting.connections.reverb.options.port', 8080);
$conn = @fsockopen($host, $port, $errno, $errstr, 1.0);
if ($conn) {
fclose($conn);
return $base + ['state' => 'online', 'detail' => $host.':'.$port];
}
return $base + ['state' => 'offline', 'detail' => __('dashboard.svc_unreachable')];
}
protected function checkHorizon(): array
{
$base = ['key' => 'queue', 'label' => __('dashboard.svc_queue'), 'icon' => 'automation'];
try {
$masters = app(\Laravel\Horizon\Contracts\MasterSupervisorRepository::class)->all();
if (empty($masters)) {
return $base + ['state' => 'offline', 'detail' => __('dashboard.svc_stopped')];
}
$running = collect($masters)->contains(fn ($m) => ($m->status ?? null) === 'running');
return $base + ($running
? ['state' => 'online', 'detail' => __('dashboard.svc_running')]
: ['state' => 'warning', 'detail' => __('dashboard.svc_stopped')]);
} catch (\Throwable) {
return $base + ['state' => 'offline', 'detail' => __('dashboard.svc_unreachable')];
}
} }
public function render() public function render()

View File

@ -2,22 +2,31 @@
return [ return [
'title' => 'Dashboard', 'title' => 'Dashboard',
'subtitle' => 'Überblick über Haus, Geräte und Anwesenheit.', 'subtitle' => 'Live-Status von System und Diensten.',
'welcome_title' => 'Willkommen bei HomeOS', // health banner
'welcome_body' => 'Das Grundgerüst steht. Räume, Geräte und Live-Zustände folgen in der nächsten Phase.', 'health_ok_title' => 'Alle Dienste online',
'health_ok_body' => 'Das System läuft ohne bekannte Störungen.',
'health_problem_title' => '{1}Ein Dienst mit Störung|[2,*]:count Dienste mit Störungen',
'health_problem_body' => 'Bitte die betroffenen Dienste unten prüfen.',
'phase_label' => 'Aufbau', // services
'phase_value' => 'Phase 1', 'services_title' => 'Dienste',
'phase_meta' => 'Fundament · Auth · Design-System', 'svc_database' => 'Datenbank',
'svc_cache' => 'Cache / Redis',
'svc_realtime' => 'Echtzeit',
'svc_queue' => 'Queue-Worker',
'svc_unreachable'=> 'nicht erreichbar',
'svc_running' => 'läuft',
'svc_stopped' => 'gestoppt',
'refresh' => 'Aktualisieren',
'checked_at' => 'Geprüft um :time',
// compact system info (versions — belongs to its own area later)
'system_title' => 'System',
'stack_framework' => 'Framework', 'stack_framework' => 'Framework',
'stack_database' => 'Datenbank', 'stack_database' => 'Datenbank',
'stack_realtime' => 'Echtzeit', 'stack_realtime' => 'Echtzeit',
'stack_queue' => 'Queue', 'stack_queue' => 'Queue',
'legend_title' => 'Design-System',
'legend_status' => 'Statusfarben',
'demo_toggle' => 'Beispiel-Schalter',
'demo_chips' => 'Geräte-Chips',
]; ];

View File

@ -2,22 +2,31 @@
return [ return [
'title' => 'Dashboard', 'title' => 'Dashboard',
'subtitle' => 'Overview of home, devices and presence.', 'subtitle' => 'Live status of system and services.',
'welcome_title' => 'Welcome to HomeOS', // health banner
'welcome_body' => 'The foundation is in place. Rooms, devices and live state arrive in the next phase.', 'health_ok_title' => 'All services online',
'health_ok_body' => 'The system is running with no known issues.',
'health_problem_title' => '{1}One service has an issue|[2,*]:count services have issues',
'health_problem_body' => 'Please check the affected services below.',
'phase_label' => 'Build', // services
'phase_value' => 'Phase 1', 'services_title' => 'Services',
'phase_meta' => 'Foundation · Auth · Design system', 'svc_database' => 'Database',
'svc_cache' => 'Cache / Redis',
'svc_realtime' => 'Realtime',
'svc_queue' => 'Queue worker',
'svc_unreachable'=> 'unreachable',
'svc_running' => 'running',
'svc_stopped' => 'stopped',
'refresh' => 'Refresh',
'checked_at' => 'Checked at :time',
// compact system info (versions — belongs to its own area later)
'system_title' => 'System',
'stack_framework' => 'Framework', 'stack_framework' => 'Framework',
'stack_database' => 'Database', 'stack_database' => 'Database',
'stack_realtime' => 'Realtime', 'stack_realtime' => 'Realtime',
'stack_queue' => 'Queue', 'stack_queue' => 'Queue',
'legend_title' => 'Design system',
'legend_status' => 'Status colors',
'demo_toggle' => 'Example switch',
'demo_chips' => 'Device chips',
]; ];

View File

@ -51,16 +51,14 @@
<span>{{ __('nav.'.$item['key']) }}</span> <span>{{ __('nav.'.$item['key']) }}</span>
</a> </a>
@else @else
<button type="button" title="{{ __('nav.soon') }}" <span title="{{ __('nav.soon') }}"
class="flex items-center gap-2.5 w-full px-2.5 py-2 rounded-lg text-[13px] font-semibold text-ink-2 hover:bg-raised hover:text-ink transition-colors cursor-default"> class="flex items-center gap-2.5 w-full px-2.5 py-2 rounded-lg text-[13px] font-medium text-ink-3 cursor-default select-none">
<x-icon :name="$item['icon']" :size="16" /> <x-icon :name="$item['icon']" :size="16" class="opacity-80" />
<span>{{ __('nav.'.$item['key']) }}</span> <span class="leading-tight">{{ __('nav.'.$item['key']) }}</span>
@if ($item['lock']) @if ($item['lock'])
<span class="ml-auto text-ink-3"><x-icon name="lock" :size="13" /></span> <span class="ml-auto shrink-0"><x-icon name="lock" :size="13" /></span>
@else
<x-badge class="ml-auto">{{ __('nav.soon') }}</x-badge>
@endif @endif
</button> </span>
@endif @endif
@endforeach @endforeach
</div> </div>

View File

@ -1,68 +1,93 @@
<div> <div wire:poll.10s="refreshHealth">
<x-topbar :title="__('dashboard.title')" :subtitle="__('dashboard.subtitle')" /> <x-topbar :title="__('dashboard.title')" :subtitle="__('dashboard.subtitle')" />
<div class="px-5 lg:px-[26px] py-6 flex flex-col gap-5 max-w-[1560px] mx-auto w-full"> <div class="px-5 lg:px-[26px] py-6 flex flex-col gap-5 max-w-[1560px] mx-auto w-full">
{{-- stack facts --}} @php
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-3 reveal"> $worst = $this->worstState();
<x-kpi :label="__('dashboard.stack_framework')" value="Laravel 13" icon="bolt" /> $problems = $this->problemCount();
<x-kpi :label="__('dashboard.stack_database')" value="PG 17" :meta="'TimescaleDB'" icon="network" /> $bannerMap = [
<x-kpi :label="__('dashboard.stack_realtime')" value="Reverb" :meta="'WebSocket'" icon="activity" /> 'online' => ['bg' => 'bg-online/10', 'bar' => 'bg-online', 'icon' => 'check', 'tint' => 'text-online'],
<x-kpi :label="__('dashboard.stack_queue')" value="Redis" :meta="'Horizon'" icon="devices" /> 'warning' => ['bg' => 'bg-warning/10', 'bar' => 'bg-warning', 'icon' => 'alert', 'tint' => 'text-warning'],
</div> 'offline' => ['bg' => 'bg-offline/10', 'bar' => 'bg-offline', 'icon' => 'alert', 'tint' => 'text-offline'],
];
$b = $bannerMap[$worst];
$stateLabel = [
'online' => __('common.status_online'),
'warning' => __('common.status_warning'),
'offline' => __('common.status_offline'),
];
@endphp
<div class="grid grid-cols-1 lg:grid-cols-[1fr_340px] gap-5 items-start"> {{-- health banner surfaces faults prominently --}}
{{-- welcome --}} <section class="relative overflow-hidden rounded-card border border-line-soft {{ $b['bg'] }} reveal">
<x-panel class="reveal"> <span class="absolute inset-y-0 left-0 w-[3px] {{ $b['bar'] }}"></span>
<div class="flex flex-col gap-4"> <div class="flex items-center gap-3.5 p-4 pl-5">
<div class="flex items-start gap-3"> <span class="grid place-items-center w-10 h-10 rounded-lg bg-base/40 {{ $b['tint'] }} shrink-0">
<span class="logo-mark grid place-items-center w-11 h-11 rounded-[12px] text-accent shrink-0"> <x-icon :name="$b['icon']" :size="20" />
<x-icon name="dashboard" :size="22" />
</span> </span>
<div class="flex flex-col gap-1"> <div class="min-w-0">
<h2 class="text-base font-bold text-ink">{{ __('dashboard.welcome_title') }}</h2> <h2 class="text-[15px] font-bold text-ink">
<p class="text-[13px] text-ink-2 leading-relaxed">{{ __('dashboard.welcome_body') }}</p> {{ $worst === 'online'
? __('dashboard.health_ok_title')
: trans_choice('dashboard.health_problem_title', $problems, ['count' => $problems]) }}
</h2>
<p class="text-[12.5px] text-ink-2">
{{ $worst === 'online' ? __('dashboard.health_ok_body') : __('dashboard.health_problem_body') }}
</p>
</div>
<div class="ml-auto flex items-center gap-3 shrink-0">
<span class="hidden sm:block text-[11px] font-mono text-ink-3">{{ __('dashboard.checked_at', ['time' => $checkedAt]) }}</span>
<button type="button" wire:click="refreshHealth" wire:loading.attr="disabled" wire:target="refreshHealth"
class="grid place-items-center w-9 h-9 rounded-lg bg-base/40 text-ink-2 hover:text-ink transition-colors disabled:opacity-50"
aria-label="{{ __('dashboard.refresh') }}" title="{{ __('dashboard.refresh') }}">
<span wire:loading.remove wire:target="refreshHealth"><x-icon name="activity" :size="17" /></span>
<span wire:loading wire:target="refreshHealth" class="pulse-live"><x-icon name="activity" :size="17" /></span>
</button>
</div> </div>
</div> </div>
<div class="flex flex-wrap items-center gap-2 pt-1"> </section>
<x-status-pill state="online">
<x-status-dot state="online" class="!w-1.5 !h-1.5" /> {{ __('common.system_ok') }} {{-- live service status "was läuft gerade" --}}
<x-panel :title="__('dashboard.services_title')" class="reveal">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
@foreach ($services as $svc)
<div class="flex items-center gap-3 rounded-lg border border-line-soft bg-raised p-3">
<span class="grid place-items-center w-9 h-9 rounded-lg bg-inset text-ink-2 shrink-0">
<x-icon :name="$svc['icon']" :size="18" />
</span>
<div class="min-w-0">
<div class="text-[13px] font-semibold text-ink">{{ $svc['label'] }}</div>
<div class="text-[11.5px] text-ink-3 font-mono tabular-nums">{{ $svc['detail'] }}</div>
</div>
<x-status-pill :state="$svc['state']" class="ml-auto shrink-0">
<x-status-dot :state="$svc['state']" :pulse="$svc['state'] === 'online'" class="!w-1.5 !h-1.5" />
{{ $stateLabel[$svc['state']] }}
</x-status-pill> </x-status-pill>
<x-status-pill state="neutral">{{ __('dashboard.phase_label') }} · {{ __('dashboard.phase_value') }}</x-status-pill>
</div> </div>
@endforeach
</div> </div>
</x-panel> </x-panel>
{{-- design-system legend --}} {{-- compact system info (versions) de-emphasized, own area later --}}
<x-panel :title="__('dashboard.legend_title')" class="reveal"> <x-panel :title="__('dashboard.system_title')" class="reveal">
<div class="flex flex-col gap-4"> <dl class="grid grid-cols-2 sm:grid-cols-4 gap-x-4 gap-y-3">
{{-- status colors --}} <div class="flex flex-col gap-0.5">
<div class="flex flex-col gap-2"> <dt class="text-[11px] font-semibold uppercase tracking-[0.08em] text-ink-3">{{ __('dashboard.stack_framework') }}</dt>
<span class="text-[11px] font-semibold uppercase tracking-[0.08em] text-ink-3">{{ __('dashboard.legend_status') }}</span> <dd class="text-[13px] font-mono text-ink-2">Laravel {{ app()->version() }}</dd>
<div class="flex flex-col gap-1.5 text-[13px] text-ink-2">
<span class="flex items-center gap-2"><x-status-dot state="online" :pulse="true" /> {{ __('common.status_online') }}</span>
<span class="flex items-center gap-2"><x-status-dot state="warning" /> {{ __('common.status_warning') }}</span>
<span class="flex items-center gap-2"><x-status-dot state="offline" /> {{ __('common.status_offline') }}</span>
</div> </div>
<div class="flex flex-col gap-0.5">
<dt class="text-[11px] font-semibold uppercase tracking-[0.08em] text-ink-3">{{ __('dashboard.stack_database') }}</dt>
<dd class="text-[13px] font-mono text-ink-2">PostgreSQL 17 · TSDB</dd>
</div> </div>
<div class="flex flex-col gap-0.5">
{{-- interactive toggle (verifies Livewire) --}} <dt class="text-[11px] font-semibold uppercase tracking-[0.08em] text-ink-3">{{ __('dashboard.stack_realtime') }}</dt>
<div class="flex items-center gap-3 border-t border-line-soft pt-3"> <dd class="text-[13px] font-mono text-ink-2">Reverb</dd>
<span class="text-[13px] text-ink-2">{{ __('dashboard.demo_toggle') }}</span>
<x-toggle :on="$demoOn" wire:click="toggleDemo" :label="__('dashboard.demo_toggle')" class="ml-auto" />
<x-status-pill :state="$demoOn ? 'online' : 'neutral'">{{ $demoOn ? __('common.status_online') : __('common.status_offline') }}</x-status-pill>
</div>
{{-- device chips --}}
<div class="flex flex-col gap-2 border-t border-line-soft pt-3">
<span class="text-[11px] font-semibold uppercase tracking-[0.08em] text-ink-3">{{ __('dashboard.demo_chips') }}</span>
<div class="flex flex-wrap gap-1.5">
<x-device-chip state="online">Shelly Plus 1</x-device-chip>
<x-device-chip state="warning">BLU Door</x-device-chip>
<x-device-chip state="offline" :off="true">Plug S</x-device-chip>
</div>
</div> </div>
<div class="flex flex-col gap-0.5">
<dt class="text-[11px] font-semibold uppercase tracking-[0.08em] text-ink-3">{{ __('dashboard.stack_queue') }}</dt>
<dd class="text-[13px] font-mono text-ink-2">Redis · Horizon</dd>
</div> </div>
</dl>
</x-panel> </x-panel>
</div> </div>
</div>
</div> </div>