Home dashboard + Host tab: device state & warnings, server status split out

Reworks the dashboard into a home-control view per user feedback (it is not a
server dashboard):
- Dashboard now shows home data — rooms with live device state (lights on/off,
  power draw, window/door contacts, battery %), a home summary (devices online,
  lights on, open contacts, low batteries), and a Warnings panel that only lists
  what needs attention (offline device, open window, low battery, degraded host
  service). No server internals on the dashboard.
- Server/service health (DB, Redis, Reverb, Horizon) + version info moved to a
  dedicated "Host & Dienste" page (new nav item under System); dashboard surfaces
  a host problem only as a warning that links there.
- New domain slice (handoff §3, mock-first §13.2): rooms/devices/entities/
  device_states migrations + models (UUID route keys, R11) + DemoHomeSeeder with
  Shelly-like devices incl. deliberate faults. Extracted SystemHealth service.
- Sidebar decluttered further; new x-entity-state component; full DE/EN i18n.
- Fixed R15 findings: .env.example now ships matching non-empty dev defaults for
  DB_PASSWORD and Reverb keys so a fresh `cp .env.example .env` boots cleanly.

Verified: R12 21/21 in headless Chromium (0 console errors, 0 failed requests,
breakpoints 375/768/1280); 10 feature tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/phase-1-bootstrap
HomeOS Bootstrap 2026-07-17 21:20:38 +02:00
parent d4f667437c
commit fcb6daa6ab
30 changed files with 953 additions and 241 deletions

BIN
..env.swp Normal file

Binary file not shown.

View File

@ -23,7 +23,9 @@ DB_HOST=db
DB_PORT=5432 DB_PORT=5432
DB_DATABASE=homeos DB_DATABASE=homeos
DB_USERNAME=homeos DB_USERNAME=homeos
DB_PASSWORD= # dev default — matches the compose fallback so `cp .env.example .env && docker compose up` works.
# Change to a strong value for production.
DB_PASSWORD=homeos
SESSION_DRIVER=database SESSION_DRIVER=database
SESSION_LIFETIME=120 SESSION_LIFETIME=120
@ -47,9 +49,10 @@ MAIL_FROM_ADDRESS="homeos@localhost"
MAIL_FROM_NAME="${APP_NAME}" MAIL_FROM_NAME="${APP_NAME}"
# --- Reverb (private channels), proxied same-origin via nginx (/app, /apps) --- # --- Reverb (private channels), proxied same-origin via nginx (/app, /apps) ---
REVERB_APP_ID= # dev defaults so realtime works out of the box; regenerate (e.g. `openssl rand -hex`) for production.
REVERB_APP_KEY= REVERB_APP_ID=homeos-app
REVERB_APP_SECRET= REVERB_APP_KEY=homeos-dev-key
REVERB_APP_SECRET=homeos-dev-secret
REVERB_HOST=reverb REVERB_HOST=reverb
REVERB_PORT=8080 REVERB_PORT=8080
REVERB_SCHEME=http REVERB_SCHEME=http

View File

@ -2,121 +2,87 @@
namespace App\Livewire; namespace App\Livewire;
use Illuminate\Support\Facades\DB; use App\Models\Room;
use Illuminate\Support\Facades\Redis; use App\Services\SystemHealth;
use Illuminate\Support\Collection;
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
{ {
/** @var array<int, array{key:string,label:string,icon:string,state:string,detail:string}> */ private const LOW_BATTERY = 20;
public array $services = [];
public ?string $checkedAt = null;
public function mount(): void
{
$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()
{ {
return view('livewire.dashboard'); $rooms = Room::query()
->with(['devices' => fn ($q) => $q->orderBy('name'), 'devices.entities.state'])
->orderBy('sort')->orderBy('name')
->get();
$devices = $rooms->flatMap->devices;
$entities = $devices->flatMap->entities;
$summary = [
'devices_online' => $devices->filter->isOnline()->count(),
'devices_total' => $devices->count(),
'lights_on' => $this->countWhere($entities, fn ($e) => in_array($e->type, ['light', 'switch']) && data_get($e->state, 'state.on') === true),
'contacts_open' => $this->countWhere($entities, fn ($e) => $e->type === 'contact' && data_get($e->state, 'state.open') === true),
'low_battery' => $this->countWhere($entities, fn ($e) => $e->type === 'battery' && (int) data_get($e->state, 'state.percent', 100) < self::LOW_BATTERY),
];
return view('livewire.dashboard', [
'rooms' => $rooms,
'summary' => $summary,
'warnings' => $this->warnings($devices, $entities),
]);
}
/** @return array<int, array{level:string,icon:string,title:string,meta:?string,link:?string}> */
protected function warnings(Collection $devices, Collection $entities): array
{
$warnings = [];
foreach ($devices->reject->isOnline() as $device) {
$warnings[] = [
'level' => 'offline', 'icon' => 'devices',
'title' => $device->name, 'meta' => __('devices.offline'), 'link' => null,
];
}
foreach ($entities as $entity) {
if ($entity->type === 'contact' && data_get($entity->state, 'state.open') === true) {
$warnings[] = [
'level' => 'warning', 'icon' => 'window',
'title' => $entity->device->name, 'meta' => __('devices.open'), 'link' => null,
];
}
if ($entity->type === 'battery') {
$percent = (int) data_get($entity->state, 'state.percent', 100);
if ($percent < self::LOW_BATTERY) {
$warnings[] = [
'level' => 'warning', 'icon' => 'alert',
'title' => $entity->device->name,
'meta' => __('devices.battery').': '.$percent.'%', 'link' => null,
];
}
}
}
if (! app(SystemHealth::class)->allOnline()) {
$warnings[] = [
'level' => 'warning', 'icon' => 'activity',
'title' => __('dashboard.warn_host'), 'meta' => __('dashboard.warn_host_meta'),
'link' => route('host'),
];
}
return $warnings;
}
protected function countWhere(Collection $entities, callable $fn): int
{
return $entities->filter($fn)->count();
} }
} }

45
app/Livewire/Host.php Normal file
View File

@ -0,0 +1,45 @@
<?php
namespace App\Livewire;
use App\Services\SystemHealth;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')]
class Host extends Component
{
/** @var array<int, array{key:string,label:string,icon:string,state:string,detail:string}> */
public array $services = [];
public ?string $checkedAt = null;
public function mount(): void
{
$this->refreshHealth();
}
public function refreshHealth(): void
{
$this->services = app(SystemHealth::class)->services();
$this->checkedAt = now()->format('H:i:s');
}
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'));
}
public function render()
{
return view('livewire.host');
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Models\Concerns;
use Illuminate\Support\Str;
/**
* Integer PK + a stable `uuid` used as the public route key (R11).
*/
trait HasUuid
{
protected static function bootHasUuid(): void
{
static::creating(function ($model) {
if (empty($model->uuid)) {
$model->uuid = (string) Str::uuid();
}
});
}
public function getRouteKeyName(): string
{
return 'uuid';
}
}

41
app/Models/Device.php Normal file
View File

@ -0,0 +1,41 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Device extends Model
{
use HasUuid;
protected $fillable = [
'uuid', 'name', 'vendor', 'model', 'protocol', 'config',
'room_id', 'status', 'last_seen_at',
];
protected $casts = [
'config' => 'array',
'last_seen_at' => 'datetime',
];
public function room(): BelongsTo
{
return $this->belongsTo(Room::class);
}
public function entities(): HasMany
{
return $this->hasMany(Entity::class);
}
/** Active and seen recently. */
public function isOnline(): bool
{
return $this->status === 'active'
&& $this->last_seen_at !== null
&& $this->last_seen_at->gt(now()->subMinutes(10));
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Current state only, one row per entity (pure upserts stays small and fast).
*/
class DeviceState extends Model
{
protected $fillable = ['entity_id', 'state'];
protected $casts = ['state' => 'array'];
public function entity(): BelongsTo
{
return $this->belongsTo(Entity::class);
}
}

27
app/Models/Entity.php Normal file
View File

@ -0,0 +1,27 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
class Entity extends Model
{
use HasUuid;
protected $fillable = ['uuid', 'device_id', 'type', 'key', 'name', 'capabilities'];
protected $casts = ['capabilities' => 'array'];
public function device(): BelongsTo
{
return $this->belongsTo(Device::class);
}
public function state(): HasOne
{
return $this->hasOne(DeviceState::class);
}
}

19
app/Models/Room.php Normal file
View File

@ -0,0 +1,19 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Room extends Model
{
use HasUuid;
protected $fillable = ['uuid', 'name', 'icon', 'sort'];
public function devices(): HasMany
{
return $this->hasMany(Device::class);
}
}

View File

@ -0,0 +1,97 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
/**
* Host/infrastructure health probes. Used by the Host page (full detail) and by the
* Dashboard (only to raise a warning when a service is down the home view itself
* never shows server internals).
*/
class SystemHealth
{
/** @return array<int, array{key:string,label:string,icon:string,state:string,detail:string}> */
public function services(): array
{
return [
$this->database(),
$this->redis(),
$this->reverb(),
$this->horizon(),
];
}
/** True when every probed service is online. */
public function allOnline(): bool
{
return ! collect($this->services())->contains(fn ($s) => $s['state'] !== 'online');
}
protected function database(): array
{
$base = ['key' => 'database', 'label' => __('host.svc_database'), 'icon' => 'network'];
try {
$start = microtime(true);
DB::connection()->select('select 1');
return $base + ['state' => 'online', 'detail' => (int) round((microtime(true) - $start) * 1000).' ms'];
} catch (\Throwable) {
return $base + ['state' => 'offline', 'detail' => __('host.svc_unreachable')];
}
}
protected function redis(): array
{
$base = ['key' => 'cache', 'label' => __('host.svc_cache'), 'icon' => 'devices'];
try {
$start = microtime(true);
Redis::connection()->ping();
return $base + ['state' => 'online', 'detail' => (int) round((microtime(true) - $start) * 1000).' ms'];
} catch (\Throwable) {
return $base + ['state' => 'offline', 'detail' => __('host.svc_unreachable')];
}
}
protected function reverb(): array
{
$base = ['key' => 'realtime', 'label' => __('host.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' => __('host.svc_unreachable')];
}
protected function horizon(): array
{
$base = ['key' => 'queue', 'label' => __('host.svc_queue'), 'icon' => 'automation'];
try {
$masters = app(\Laravel\Horizon\Contracts\MasterSupervisorRepository::class)->all();
if (empty($masters)) {
return $base + ['state' => 'offline', 'detail' => __('host.svc_stopped')];
}
$running = collect($masters)->contains(fn ($m) => ($m->status ?? null) === 'running');
return $base + ($running
? ['state' => 'online', 'detail' => __('host.svc_running')]
: ['state' => 'warning', 'detail' => __('host.svc_stopped')]);
} catch (\Throwable) {
return $base + ['state' => 'offline', 'detail' => __('host.svc_unreachable')];
}
}
}

View File

@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('rooms', function (Blueprint $table) {
$table->id();
$table->uuid()->unique();
$table->string('name');
$table->string('icon')->nullable();
$table->unsignedSmallInteger('sort')->default(0);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('rooms');
}
};

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('devices', function (Blueprint $table) {
$table->id();
$table->uuid()->unique();
$table->string('name');
$table->string('vendor')->nullable();
$table->string('model')->nullable();
$table->string('protocol')->nullable(); // mqtt | http | ble | …
$table->json('config')->nullable(); // topic prefix / ip / auth ref
$table->foreignId('room_id')->nullable()->constrained()->nullOnDelete();
$table->string('status')->default('active'); // discovered | active | ignored
$table->timestamp('last_seen_at')->nullable();
$table->timestamps();
$table->index('status');
});
}
public function down(): void
{
Schema::dropIfExists('devices');
}
};

View File

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('entities', function (Blueprint $table) {
$table->id();
$table->uuid()->unique();
$table->foreignId('device_id')->constrained()->cascadeOnDelete();
$table->string('type'); // switch | light | power | contact | temperature | humidity | motion | battery
$table->string('key'); // e.g. switch:0, cover:0
$table->string('name')->nullable();
$table->json('capabilities')->nullable();
$table->timestamps();
$table->unique(['device_id', 'key']);
});
}
public function down(): void
{
Schema::dropIfExists('entities');
}
};

View File

@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('device_states', function (Blueprint $table) {
$table->id();
$table->foreignId('entity_id')->unique()->constrained()->cascadeOnDelete();
$table->json('state')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('device_states');
}
};

View File

@ -23,5 +23,7 @@ class DatabaseSeeder extends Seeder
'email_verified_at' => now(), 'email_verified_at' => now(),
], ],
); );
$this->call(DemoHomeSeeder::class);
} }
} }

View File

@ -0,0 +1,109 @@
<?php
namespace Database\Seeders;
use App\Models\Device;
use App\Models\DeviceState;
use App\Models\Entity;
use App\Models\Room;
use Illuminate\Database\Seeder;
/**
* Mock smart-home so the dashboard has real (fake) state to render before hardware exists
* (handoff §13.2 "mock first, real hardware after"). Deliberately includes fault
* conditions (an offline device, low batteries, an open window) to exercise the warnings.
*
* Re-run any time: `php artisan db:seed --class=Database\\Seeders\\DemoHomeSeeder`.
* Online state is a snapshot (last_seen_at = now); Phase 3 (MQTT) keeps it live.
*/
class DemoHomeSeeder extends Seeder
{
public function run(): void
{
$now = now();
$stale = now()->subHours(2);
// room => devices. Each device: [name, model, online, entities[]]
// entity: [type, key, name, state]
$home = [
'Wohnzimmer' => [
['Deckenlicht', 'Shelly Plus 1PM', true, [
['light', 'light:0', 'Deckenlicht', ['on' => true]],
['power', 'power:0', 'Verbrauch', ['watts' => 42]],
]],
['Stehlampe', 'Shelly Plug S', true, [
['switch', 'switch:0', 'Stehlampe', ['on' => false]],
]],
],
'Küche' => [
['Arbeitslicht', 'Shelly Plus 1', true, [
['light', 'light:0', 'Arbeitslicht', ['on' => true]],
]],
['Fenstersensor Küche', 'Shelly BLU Window', true, [
['contact', 'contact:0', 'Fenster', ['open' => false]],
['battery', 'battery:0', 'Batterie', ['percent' => 88]],
]],
],
'Schlafzimmer' => [
['Nachttischlampe', 'Shelly Plug S', true, [
['switch', 'switch:0', 'Nachttischlampe', ['on' => false]],
]],
['Fenstersensor Schlafzimmer', 'Shelly BLU Window', true, [
['contact', 'contact:0', 'Fenster', ['open' => true]], // warning: open
['battery', 'battery:0', 'Batterie', ['percent' => 14]], // warning: low
]],
],
'Flur' => [
['Flurlicht', 'Shelly Plus 1', true, [
['light', 'light:0', 'Flurlicht', ['on' => false]],
]],
['Türsensor Haustür', 'Shelly BLU Door', true, [
['contact', 'contact:0', 'Haustür', ['open' => false]],
['battery', 'battery:0', 'Batterie', ['percent' => 9]], // warning: low
]],
],
'Büro' => [
['Schreibtischlampe', 'Shelly Plug S', true, [
['switch', 'switch:0', 'Schreibtischlampe', ['on' => true]],
['power', 'power:0', 'Verbrauch', ['watts' => 12]],
]],
['Drucker-Steckdose', 'Shelly Plug S', false, [ // warning: offline
['switch', 'switch:0', 'Drucker', ['on' => false]],
]],
],
];
$sort = 0;
foreach ($home as $roomName => $devices) {
$room = Room::updateOrCreate(
['name' => $roomName],
['icon' => 'rooms', 'sort' => $sort++],
);
foreach ($devices as [$deviceName, $model, $online, $entities]) {
$device = Device::updateOrCreate(
['room_id' => $room->id, 'name' => $deviceName],
[
'vendor' => 'Shelly',
'model' => $model,
'protocol' => 'mqtt',
'status' => 'active',
'last_seen_at' => $online ? $now : $stale,
],
);
foreach ($entities as [$type, $key, $entityName, $state]) {
$entity = Entity::updateOrCreate(
['device_id' => $device->id, 'key' => $key],
['type' => $type, 'name' => $entityName],
);
DeviceState::updateOrCreate(
['entity_id' => $entity->id],
['state' => $state],
);
}
}
}
}
}

View File

@ -2,31 +2,19 @@
return [ return [
'title' => 'Dashboard', 'title' => 'Dashboard',
'subtitle' => 'Live-Status von System und Diensten.', 'subtitle' => 'Überblick über Haus und Geräte.',
// health banner 'warnings_title' => 'Warnungen',
'health_ok_title' => 'Alle Dienste online', 'all_clear_title' => 'Alles in Ordnung',
'health_ok_body' => 'Das System läuft ohne bekannte Störungen.', 'all_clear_body' => 'Keine offenen Warnungen im Haus.',
'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.',
// services 'kpi_devices_online' => 'Geräte online',
'services_title' => 'Dienste', 'kpi_lights_on' => 'Lichter an',
'svc_database' => 'Datenbank', 'kpi_contacts_open' => 'Offen',
'svc_cache' => 'Cache / Redis', 'kpi_low_battery' => 'Niedriger Akku',
'svc_realtime' => 'Echtzeit',
'svc_queue' => 'Queue-Worker',
'svc_unreachable'=> 'nicht erreichbar',
'svc_running' => 'läuft',
'svc_stopped' => 'gestoppt',
'refresh' => 'Aktualisieren', 'rooms_title' => 'Räume',
'checked_at' => 'Geprüft um :time',
// compact system info (versions — belongs to its own area later) 'warn_host' => 'Systemdienst gestört',
'system_title' => 'System', 'warn_host_meta' => 'Details im Host-Bereich',
'stack_framework' => 'Framework',
'stack_database' => 'Datenbank',
'stack_realtime' => 'Echtzeit',
'stack_queue' => 'Queue',
]; ];

13
lang/de/devices.php Normal file
View File

@ -0,0 +1,13 @@
<?php
return [
'on' => 'An',
'off' => 'Aus',
'open' => 'Offen',
'closed' => 'Geschlossen',
'battery' => 'Batterie',
'motion' => 'Bewegung',
'no_motion' => 'Ruhe',
'online' => 'Online',
'offline' => 'Nicht erreichbar',
];

29
lang/de/host.php Normal file
View File

@ -0,0 +1,29 @@
<?php
return [
'title' => 'Host & Dienste',
'subtitle' => 'Status der Server-Dienste.',
'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.',
'services_title' => 'Dienste',
'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',
'system_title' => 'System',
'stack_framework' => 'Framework',
'stack_database' => 'Datenbank',
'stack_realtime' => 'Echtzeit',
'stack_queue' => 'Queue',
];

View File

@ -16,6 +16,7 @@ return [
'access' => 'Zugang & Face-ID', 'access' => 'Zugang & Face-ID',
'network' => 'Netzwerk & Discovery', 'network' => 'Netzwerk & Discovery',
'automations' => 'Automationen', 'automations' => 'Automationen',
'host' => 'Host & Dienste',
'settings' => 'Einstellungen', 'settings' => 'Einstellungen',
'soon' => 'In Kürze', 'soon' => 'In Kürze',

View File

@ -2,31 +2,19 @@
return [ return [
'title' => 'Dashboard', 'title' => 'Dashboard',
'subtitle' => 'Live status of system and services.', 'subtitle' => 'Overview of home and devices.',
// health banner 'warnings_title' => 'Warnings',
'health_ok_title' => 'All services online', 'all_clear_title' => 'All clear',
'health_ok_body' => 'The system is running with no known issues.', 'all_clear_body' => 'No open warnings in the home.',
'health_problem_title' => '{1}One service has an issue|[2,*]:count services have issues',
'health_problem_body' => 'Please check the affected services below.',
// services 'kpi_devices_online' => 'Devices online',
'services_title' => 'Services', 'kpi_lights_on' => 'Lights on',
'svc_database' => 'Database', 'kpi_contacts_open' => 'Open',
'svc_cache' => 'Cache / Redis', 'kpi_low_battery' => 'Low battery',
'svc_realtime' => 'Realtime',
'svc_queue' => 'Queue worker',
'svc_unreachable'=> 'unreachable',
'svc_running' => 'running',
'svc_stopped' => 'stopped',
'refresh' => 'Refresh', 'rooms_title' => 'Rooms',
'checked_at' => 'Checked at :time',
// compact system info (versions — belongs to its own area later) 'warn_host' => 'System service degraded',
'system_title' => 'System', 'warn_host_meta' => 'Details in the Host area',
'stack_framework' => 'Framework',
'stack_database' => 'Database',
'stack_realtime' => 'Realtime',
'stack_queue' => 'Queue',
]; ];

13
lang/en/devices.php Normal file
View File

@ -0,0 +1,13 @@
<?php
return [
'on' => 'On',
'off' => 'Off',
'open' => 'Open',
'closed' => 'Closed',
'battery' => 'Battery',
'motion' => 'Motion',
'no_motion' => 'Idle',
'online' => 'Online',
'offline' => 'Unreachable',
];

29
lang/en/host.php Normal file
View File

@ -0,0 +1,29 @@
<?php
return [
'title' => 'Host & Services',
'subtitle' => 'Status of the server services.',
'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.',
'services_title' => 'Services',
'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',
'system_title' => 'System',
'stack_framework' => 'Framework',
'stack_database' => 'Database',
'stack_realtime' => 'Realtime',
'stack_queue' => 'Queue',
];

View File

@ -16,6 +16,7 @@ return [
'access' => 'Access & Face ID', 'access' => 'Access & Face ID',
'network' => 'Network & Discovery', 'network' => 'Network & Discovery',
'automations' => 'Automations', 'automations' => 'Automations',
'host' => 'Host & Services',
'settings' => 'Settings', 'settings' => 'Settings',
'soon' => 'Coming soon', 'soon' => 'Coming soon',

View File

@ -0,0 +1,42 @@
@props(['entity'])
@php
$s = $entity->state?->state ?? [];
@endphp
@switch($entity->type)
@case('light')
@case('switch')
@php $on = ($s['on'] ?? false) === true; @endphp
<x-status-pill :state="$on ? 'online' : 'neutral'">
{{ $entity->name }} · {{ $on ? __('devices.on') : __('devices.off') }}
</x-status-pill>
@break
@case('contact')
@php $open = ($s['open'] ?? false) === true; @endphp
<x-status-pill :state="$open ? 'warning' : 'neutral'">
{{ $entity->name }} · {{ $open ? __('devices.open') : __('devices.closed') }}
</x-status-pill>
@break
@case('battery')
@php $p = (int) ($s['percent'] ?? 0); $low = $p < 20; @endphp
<x-status-pill :state="$low ? 'offline' : 'neutral'">
{{ __('devices.battery') }} · <span class="font-mono tabular-nums">{{ $p }}%</span>
</x-status-pill>
@break
@case('power')
<span class="inline-flex items-center gap-1.5 rounded-full bg-inset px-2.5 py-[3px] text-[10.5px] font-bold text-ink-2">
<span class="font-mono tabular-nums">{{ (int) ($s['watts'] ?? 0) }} W</span>
</span>
@break
@case('motion')
@php $active = ($s['active'] ?? false) === true; @endphp
<x-status-pill :state="$active ? 'warning' : 'neutral'">
{{ $active ? __('devices.motion') : __('devices.no_motion') }}
</x-status-pill>
@break
@endswitch

View File

@ -15,6 +15,7 @@
'nav.section_system' => [ 'nav.section_system' => [
['key' => 'network', 'icon' => 'network', 'route' => null, 'lock' => false], ['key' => 'network', 'icon' => 'network', 'route' => null, 'lock' => false],
['key' => 'automations', 'icon' => 'automation', 'route' => null, 'lock' => false], ['key' => 'automations', 'icon' => 'automation', 'route' => null, 'lock' => false],
['key' => 'host', 'icon' => 'activity', 'route' => 'host', 'lock' => false],
['key' => 'settings', 'icon' => 'settings', 'route' => null, 'lock' => true], ['key' => 'settings', 'icon' => 'settings', 'route' => null, 'lock' => true],
], ],
]; ];

View File

@ -1,93 +1,86 @@
<div wire:poll.10s="refreshHealth"> <div wire:poll.15s>
<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">
@php {{-- warnings only what needs attention --}}
$worst = $this->worstState(); @if (count($warnings) > 0)
$problems = $this->problemCount(); <x-panel :title="__('dashboard.warnings_title')" class="reveal">
$bannerMap = [ <x-slot:actions>
'online' => ['bg' => 'bg-online/10', 'bar' => 'bg-online', 'icon' => 'check', 'tint' => 'text-online'], <x-badge variant="warning">{{ count($warnings) }}</x-badge>
'warning' => ['bg' => 'bg-warning/10', 'bar' => 'bg-warning', 'icon' => 'alert', 'tint' => 'text-warning'], </x-slot:actions>
'offline' => ['bg' => 'bg-offline/10', 'bar' => 'bg-offline', 'icon' => 'alert', 'tint' => 'text-offline'], <div class="flex flex-col gap-2">
]; @foreach ($warnings as $w)
$b = $bannerMap[$worst]; <div class="flex items-center gap-3 rounded-lg border border-line-soft bg-raised px-3 py-2.5">
$stateLabel = [ <span @class([
'online' => __('common.status_online'), 'grid place-items-center w-8 h-8 rounded-lg shrink-0',
'warning' => __('common.status_warning'), 'text-offline bg-offline/10' => $w['level'] === 'offline',
'offline' => __('common.status_offline'), 'text-warning bg-warning/10' => $w['level'] !== 'offline',
]; ])>
@endphp <x-icon :name="$w['icon']" :size="16" />
</span>
{{-- health banner surfaces faults prominently --}} <div class="min-w-0">
<section class="relative overflow-hidden rounded-card border border-line-soft {{ $b['bg'] }} reveal"> <div class="text-[13px] font-semibold text-ink truncate">{{ $w['title'] }}</div>
<span class="absolute inset-y-0 left-0 w-[3px] {{ $b['bar'] }}"></span> @if ($w['meta'])
<div class="flex items-center gap-3.5 p-4 pl-5"> <div class="text-[11.5px] text-ink-3">{{ $w['meta'] }}</div>
<span class="grid place-items-center w-10 h-10 rounded-lg bg-base/40 {{ $b['tint'] }} shrink-0"> @endif
<x-icon :name="$b['icon']" :size="20" /> </div>
</span> @if ($w['link'])
<div class="min-w-0"> <a href="{{ $w['link'] }}" wire:navigate class="ml-auto shrink-0 text-ink-3 hover:text-ink transition-colors">
<h2 class="text-[15px] font-bold text-ink"> <x-icon name="chevron" :size="16" />
{{ $worst === 'online' </a>
? __('dashboard.health_ok_title') @endif
: 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>
</section>
{{-- 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> </div>
<x-status-pill :state="$svc['state']" class="ml-auto shrink-0"> @endforeach
<x-status-dot :state="$svc['state']" :pulse="$svc['state'] === 'online'" class="!w-1.5 !h-1.5" /> </div>
{{ $stateLabel[$svc['state']] }} </x-panel>
</x-status-pill> @else
<section class="relative overflow-hidden rounded-card border border-line-soft bg-online/10 reveal">
<span class="absolute inset-y-0 left-0 w-[3px] bg-online"></span>
<div class="flex items-center gap-3.5 p-4 pl-5">
<span class="grid place-items-center w-10 h-10 rounded-lg bg-base/40 text-online shrink-0">
<x-icon name="check" :size="20" />
</span>
<div>
<h2 class="text-[15px] font-bold text-ink">{{ __('dashboard.all_clear_title') }}</h2>
<p class="text-[12.5px] text-ink-2">{{ __('dashboard.all_clear_body') }}</p>
</div> </div>
</div>
</section>
@endif
{{-- home summary --}}
<div class="grid grid-cols-2 xl:grid-cols-4 gap-3 reveal">
<x-kpi :label="__('dashboard.kpi_devices_online')" :value="$summary['devices_online'].'/'.$summary['devices_total']" icon="devices" />
<x-kpi :label="__('dashboard.kpi_lights_on')" :value="$summary['lights_on']" icon="bolt" />
<x-kpi :label="__('dashboard.kpi_contacts_open')" :value="$summary['contacts_open']" icon="window" :warnEdge="$summary['contacts_open'] > 0" />
<x-kpi :label="__('dashboard.kpi_low_battery')" :value="$summary['low_battery']" icon="alert" :warnEdge="$summary['low_battery'] > 0" />
</div>
{{-- rooms + devices --}}
<div>
<h2 class="text-[13px] font-bold text-ink-2 uppercase tracking-[0.08em] mb-3">{{ __('dashboard.rooms_title') }}</h2>
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
@foreach ($rooms as $room)
<x-panel :title="$room->name" class="reveal">
<div class="flex flex-col divide-y divide-line-soft -my-1">
@foreach ($room->devices as $device)
<div class="flex flex-col gap-2 py-3 first:pt-1 last:pb-1">
<div class="flex items-center gap-2.5">
<x-status-dot :state="$device->isOnline() ? 'online' : 'offline'" :pulse="$device->isOnline()" />
<span class="text-[13px] font-semibold text-ink truncate">{{ $device->name }}</span>
<span class="ml-auto text-[11px] font-mono text-ink-3 truncate">{{ $device->model }}</span>
</div>
<div class="flex flex-wrap gap-1.5">
@foreach ($device->entities as $entity)
<x-entity-state :entity="$entity" />
@endforeach
</div>
</div>
@endforeach
</div>
</x-panel>
@endforeach @endforeach
</div> </div>
</x-panel> </div>
{{-- compact system info (versions) de-emphasized, own area later --}}
<x-panel :title="__('dashboard.system_title')" class="reveal">
<dl class="grid grid-cols-2 sm:grid-cols-4 gap-x-4 gap-y-3">
<div class="flex flex-col gap-0.5">
<dt class="text-[11px] font-semibold uppercase tracking-[0.08em] text-ink-3">{{ __('dashboard.stack_framework') }}</dt>
<dd class="text-[13px] font-mono text-ink-2">Laravel {{ app()->version() }}</dd>
</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 class="flex flex-col gap-0.5">
<dt class="text-[11px] font-semibold uppercase tracking-[0.08em] text-ink-3">{{ __('dashboard.stack_realtime') }}</dt>
<dd class="text-[13px] font-mono text-ink-2">Reverb</dd>
</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>
</dl>
</x-panel>
</div> </div>
</div> </div>

View File

@ -0,0 +1,90 @@
<div wire:poll.10s="refreshHealth">
<x-topbar :title="__('host.title')" :subtitle="__('host.subtitle')" />
<div class="px-5 lg:px-[26px] py-6 flex flex-col gap-5 max-w-[1560px] mx-auto w-full">
@php
$worst = $this->worstState();
$problems = $this->problemCount();
$bannerMap = [
'online' => ['bg' => 'bg-online/10', 'bar' => 'bg-online', 'icon' => 'check', 'tint' => 'text-online'],
'warning' => ['bg' => 'bg-warning/10', 'bar' => 'bg-warning', 'icon' => 'alert', 'tint' => 'text-warning'],
'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
<section class="relative overflow-hidden rounded-card border border-line-soft {{ $b['bg'] }} reveal">
<span class="absolute inset-y-0 left-0 w-[3px] {{ $b['bar'] }}"></span>
<div class="flex items-center gap-3.5 p-4 pl-5">
<span class="grid place-items-center w-10 h-10 rounded-lg bg-base/40 {{ $b['tint'] }} shrink-0">
<x-icon :name="$b['icon']" :size="20" />
</span>
<div class="min-w-0">
<h2 class="text-[15px] font-bold text-ink">
{{ $worst === 'online'
? __('host.health_ok_title')
: trans_choice('host.health_problem_title', $problems, ['count' => $problems]) }}
</h2>
<p class="text-[12.5px] text-ink-2">
{{ $worst === 'online' ? __('host.health_ok_body') : __('host.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">{{ __('host.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="{{ __('host.refresh') }}" title="{{ __('host.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>
</section>
<x-panel :title="__('host.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>
</div>
@endforeach
</div>
</x-panel>
<x-panel :title="__('host.system_title')" class="reveal">
<dl class="grid grid-cols-2 sm:grid-cols-4 gap-x-4 gap-y-3">
<div class="flex flex-col gap-0.5">
<dt class="text-[11px] font-semibold uppercase tracking-[0.08em] text-ink-3">{{ __('host.stack_framework') }}</dt>
<dd class="text-[13px] font-mono text-ink-2">Laravel {{ app()->version() }}</dd>
</div>
<div class="flex flex-col gap-0.5">
<dt class="text-[11px] font-semibold uppercase tracking-[0.08em] text-ink-3">{{ __('host.stack_database') }}</dt>
<dd class="text-[13px] font-mono text-ink-2">PostgreSQL 17 · TSDB</dd>
</div>
<div class="flex flex-col gap-0.5">
<dt class="text-[11px] font-semibold uppercase tracking-[0.08em] text-ink-3">{{ __('host.stack_realtime') }}</dt>
<dd class="text-[13px] font-mono text-ink-2">Reverb</dd>
</div>
<div class="flex flex-col gap-0.5">
<dt class="text-[11px] font-semibold uppercase tracking-[0.08em] text-ink-3">{{ __('host.stack_queue') }}</dt>
<dd class="text-[13px] font-mono text-ink-2">Redis · Horizon</dd>
</div>
</dl>
</x-panel>
</div>
</div>

View File

@ -2,6 +2,7 @@
use App\Livewire\Auth\Login; use App\Livewire\Auth\Login;
use App\Livewire\Dashboard; use App\Livewire\Dashboard;
use App\Livewire\Host;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
@ -13,6 +14,7 @@ Route::middleware('guest')->group(function () {
Route::middleware('auth')->group(function () { Route::middleware('auth')->group(function () {
Route::get('/dashboard', Dashboard::class)->name('dashboard'); Route::get('/dashboard', Dashboard::class)->name('dashboard');
Route::get('/host', Host::class)->name('host');
Route::post('/logout', function () { Route::post('/logout', function () {
Auth::guard('web')->logout(); Auth::guard('web')->logout();

View File

@ -0,0 +1,58 @@
<?php
namespace Tests\Feature;
use App\Models\Device;
use App\Models\DeviceState;
use App\Models\Entity;
use App\Models\Room;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class HomeTest extends TestCase
{
use RefreshDatabase;
public function test_host_page_renders_for_authenticated_user(): void
{
$this->actingAs(User::factory()->create())
->get('/host')
->assertOk()
->assertSee(__('host.title'))
->assertSee(__('host.services_title'));
}
public function test_dashboard_shows_rooms_and_device_state(): void
{
$room = Room::create(['name' => 'Testraum']);
$device = Device::create([
'name' => 'Testlampe', 'vendor' => 'Shelly', 'model' => 'Plug S',
'protocol' => 'mqtt', 'room_id' => $room->id, 'status' => 'active', 'last_seen_at' => now(),
]);
$entity = Entity::create(['device_id' => $device->id, 'type' => 'switch', 'key' => 'switch:0', 'name' => 'Testlampe']);
DeviceState::create(['entity_id' => $entity->id, 'state' => ['on' => true]]);
$this->actingAs(User::factory()->create())
->get('/dashboard')
->assertOk()
->assertSee('Testraum')
->assertSee('Testlampe');
}
public function test_dashboard_surfaces_a_low_battery_warning(): void
{
$room = Room::create(['name' => 'Testraum']);
$device = Device::create([
'name' => 'Türsensor', 'room_id' => $room->id, 'status' => 'active', 'last_seen_at' => now(),
]);
$battery = Entity::create(['device_id' => $device->id, 'type' => 'battery', 'key' => 'battery:0', 'name' => 'Batterie']);
DeviceState::create(['entity_id' => $battery->id, 'state' => ['percent' => 5]]);
$this->actingAs(User::factory()->create())
->get('/dashboard')
->assertOk()
->assertSee(__('dashboard.warnings_title'))
->assertSee('Türsensor');
}
}