diff --git a/..env.swp b/..env.swp new file mode 100644 index 0000000..1fc8bae Binary files /dev/null and b/..env.swp differ diff --git a/.env.example b/.env.example index 5f60eda..96a83c1 100644 --- a/.env.example +++ b/.env.example @@ -23,7 +23,9 @@ DB_HOST=db DB_PORT=5432 DB_DATABASE=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_LIFETIME=120 @@ -47,9 +49,10 @@ MAIL_FROM_ADDRESS="homeos@localhost" MAIL_FROM_NAME="${APP_NAME}" # --- Reverb (private channels), proxied same-origin via nginx (/app, /apps) --- -REVERB_APP_ID= -REVERB_APP_KEY= -REVERB_APP_SECRET= +# dev defaults so realtime works out of the box; regenerate (e.g. `openssl rand -hex`) for production. +REVERB_APP_ID=homeos-app +REVERB_APP_KEY=homeos-dev-key +REVERB_APP_SECRET=homeos-dev-secret REVERB_HOST=reverb REVERB_PORT=8080 REVERB_SCHEME=http diff --git a/app/Livewire/Dashboard.php b/app/Livewire/Dashboard.php index f67305d..2c8d7b4 100644 --- a/app/Livewire/Dashboard.php +++ b/app/Livewire/Dashboard.php @@ -2,121 +2,87 @@ namespace App\Livewire; -use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Redis; +use App\Models\Room; +use App\Services\SystemHealth; +use Illuminate\Support\Collection; use Livewire\Attributes\Layout; use Livewire\Component; #[Layout('layouts.app')] class Dashboard extends Component { - /** @var array */ - 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')]; - } - } + private const LOW_BATTERY = 20; 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 */ + 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(); } } diff --git a/app/Livewire/Host.php b/app/Livewire/Host.php new file mode 100644 index 0000000..26234f7 --- /dev/null +++ b/app/Livewire/Host.php @@ -0,0 +1,45 @@ + */ + 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'); + } +} diff --git a/app/Models/Concerns/HasUuid.php b/app/Models/Concerns/HasUuid.php new file mode 100644 index 0000000..0ba8d45 --- /dev/null +++ b/app/Models/Concerns/HasUuid.php @@ -0,0 +1,25 @@ +uuid)) { + $model->uuid = (string) Str::uuid(); + } + }); + } + + public function getRouteKeyName(): string + { + return 'uuid'; + } +} diff --git a/app/Models/Device.php b/app/Models/Device.php new file mode 100644 index 0000000..aebd28e --- /dev/null +++ b/app/Models/Device.php @@ -0,0 +1,41 @@ + '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)); + } +} diff --git a/app/Models/DeviceState.php b/app/Models/DeviceState.php new file mode 100644 index 0000000..b80ad7f --- /dev/null +++ b/app/Models/DeviceState.php @@ -0,0 +1,21 @@ + 'array']; + + public function entity(): BelongsTo + { + return $this->belongsTo(Entity::class); + } +} diff --git a/app/Models/Entity.php b/app/Models/Entity.php new file mode 100644 index 0000000..985fdbe --- /dev/null +++ b/app/Models/Entity.php @@ -0,0 +1,27 @@ + 'array']; + + public function device(): BelongsTo + { + return $this->belongsTo(Device::class); + } + + public function state(): HasOne + { + return $this->hasOne(DeviceState::class); + } +} diff --git a/app/Models/Room.php b/app/Models/Room.php new file mode 100644 index 0000000..9553e64 --- /dev/null +++ b/app/Models/Room.php @@ -0,0 +1,19 @@ +hasMany(Device::class); + } +} diff --git a/app/Services/SystemHealth.php b/app/Services/SystemHealth.php new file mode 100644 index 0000000..0dcd090 --- /dev/null +++ b/app/Services/SystemHealth.php @@ -0,0 +1,97 @@ + */ + 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')]; + } + } +} diff --git a/database/migrations/2026_07_17_201001_create_rooms_table.php b/database/migrations/2026_07_17_201001_create_rooms_table.php new file mode 100644 index 0000000..3eb368b --- /dev/null +++ b/database/migrations/2026_07_17_201001_create_rooms_table.php @@ -0,0 +1,25 @@ +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'); + } +}; diff --git a/database/migrations/2026_07_17_201002_create_devices_table.php b/database/migrations/2026_07_17_201002_create_devices_table.php new file mode 100644 index 0000000..804bc8e --- /dev/null +++ b/database/migrations/2026_07_17_201002_create_devices_table.php @@ -0,0 +1,32 @@ +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'); + } +}; diff --git a/database/migrations/2026_07_17_201003_create_entities_table.php b/database/migrations/2026_07_17_201003_create_entities_table.php new file mode 100644 index 0000000..777d238 --- /dev/null +++ b/database/migrations/2026_07_17_201003_create_entities_table.php @@ -0,0 +1,29 @@ +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'); + } +}; diff --git a/database/migrations/2026_07_17_201004_create_device_states_table.php b/database/migrations/2026_07_17_201004_create_device_states_table.php new file mode 100644 index 0000000..c1fe755 --- /dev/null +++ b/database/migrations/2026_07_17_201004_create_device_states_table.php @@ -0,0 +1,23 @@ +id(); + $table->foreignId('entity_id')->unique()->constrained()->cascadeOnDelete(); + $table->json('state')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('device_states'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index e06e719..5cc47b7 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -23,5 +23,7 @@ class DatabaseSeeder extends Seeder 'email_verified_at' => now(), ], ); + + $this->call(DemoHomeSeeder::class); } } diff --git a/database/seeders/DemoHomeSeeder.php b/database/seeders/DemoHomeSeeder.php new file mode 100644 index 0000000..de70e61 --- /dev/null +++ b/database/seeders/DemoHomeSeeder.php @@ -0,0 +1,109 @@ +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], + ); + } + } + } + } +} diff --git a/lang/de/dashboard.php b/lang/de/dashboard.php index 4d8b535..7b6f68f 100644 --- a/lang/de/dashboard.php +++ b/lang/de/dashboard.php @@ -2,31 +2,19 @@ return [ 'title' => 'Dashboard', - 'subtitle' => 'Live-Status von System und Diensten.', + 'subtitle' => 'Überblick über Haus und Geräte.', - // health banner - '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.', + 'warnings_title' => 'Warnungen', + 'all_clear_title' => 'Alles in Ordnung', + 'all_clear_body' => 'Keine offenen Warnungen im Haus.', - // services - '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', + 'kpi_devices_online' => 'Geräte online', + 'kpi_lights_on' => 'Lichter an', + 'kpi_contacts_open' => 'Offen', + 'kpi_low_battery' => 'Niedriger Akku', - 'refresh' => 'Aktualisieren', - 'checked_at' => 'Geprüft um :time', + 'rooms_title' => 'Räume', - // compact system info (versions — belongs to its own area later) - 'system_title' => 'System', - 'stack_framework' => 'Framework', - 'stack_database' => 'Datenbank', - 'stack_realtime' => 'Echtzeit', - 'stack_queue' => 'Queue', + 'warn_host' => 'Systemdienst gestört', + 'warn_host_meta' => 'Details im Host-Bereich', ]; diff --git a/lang/de/devices.php b/lang/de/devices.php new file mode 100644 index 0000000..e9cdcd5 --- /dev/null +++ b/lang/de/devices.php @@ -0,0 +1,13 @@ + 'An', + 'off' => 'Aus', + 'open' => 'Offen', + 'closed' => 'Geschlossen', + 'battery' => 'Batterie', + 'motion' => 'Bewegung', + 'no_motion' => 'Ruhe', + 'online' => 'Online', + 'offline' => 'Nicht erreichbar', +]; diff --git a/lang/de/host.php b/lang/de/host.php new file mode 100644 index 0000000..0bf0f72 --- /dev/null +++ b/lang/de/host.php @@ -0,0 +1,29 @@ + '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', +]; diff --git a/lang/de/nav.php b/lang/de/nav.php index 6a68eda..a7f4472 100644 --- a/lang/de/nav.php +++ b/lang/de/nav.php @@ -16,6 +16,7 @@ return [ 'access' => 'Zugang & Face-ID', 'network' => 'Netzwerk & Discovery', 'automations' => 'Automationen', + 'host' => 'Host & Dienste', 'settings' => 'Einstellungen', 'soon' => 'In Kürze', diff --git a/lang/en/dashboard.php b/lang/en/dashboard.php index 21b29bf..0ccdc1d 100644 --- a/lang/en/dashboard.php +++ b/lang/en/dashboard.php @@ -2,31 +2,19 @@ return [ 'title' => 'Dashboard', - 'subtitle' => 'Live status of system and services.', + 'subtitle' => 'Overview of home and devices.', - // health banner - '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.', + 'warnings_title' => 'Warnings', + 'all_clear_title' => 'All clear', + 'all_clear_body' => 'No open warnings in the home.', - // services - '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', + 'kpi_devices_online' => 'Devices online', + 'kpi_lights_on' => 'Lights on', + 'kpi_contacts_open' => 'Open', + 'kpi_low_battery' => 'Low battery', - 'refresh' => 'Refresh', - 'checked_at' => 'Checked at :time', + 'rooms_title' => 'Rooms', - // compact system info (versions — belongs to its own area later) - 'system_title' => 'System', - 'stack_framework' => 'Framework', - 'stack_database' => 'Database', - 'stack_realtime' => 'Realtime', - 'stack_queue' => 'Queue', + 'warn_host' => 'System service degraded', + 'warn_host_meta' => 'Details in the Host area', ]; diff --git a/lang/en/devices.php b/lang/en/devices.php new file mode 100644 index 0000000..0e1b562 --- /dev/null +++ b/lang/en/devices.php @@ -0,0 +1,13 @@ + 'On', + 'off' => 'Off', + 'open' => 'Open', + 'closed' => 'Closed', + 'battery' => 'Battery', + 'motion' => 'Motion', + 'no_motion' => 'Idle', + 'online' => 'Online', + 'offline' => 'Unreachable', +]; diff --git a/lang/en/host.php b/lang/en/host.php new file mode 100644 index 0000000..67d508c --- /dev/null +++ b/lang/en/host.php @@ -0,0 +1,29 @@ + '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', +]; diff --git a/lang/en/nav.php b/lang/en/nav.php index 76aee7b..f902e8f 100644 --- a/lang/en/nav.php +++ b/lang/en/nav.php @@ -16,6 +16,7 @@ return [ 'access' => 'Access & Face ID', 'network' => 'Network & Discovery', 'automations' => 'Automations', + 'host' => 'Host & Services', 'settings' => 'Settings', 'soon' => 'Coming soon', diff --git a/resources/views/components/entity-state.blade.php b/resources/views/components/entity-state.blade.php new file mode 100644 index 0000000..91fce80 --- /dev/null +++ b/resources/views/components/entity-state.blade.php @@ -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 + + {{ $entity->name }} · {{ $on ? __('devices.on') : __('devices.off') }} + + @break + + @case('contact') + @php $open = ($s['open'] ?? false) === true; @endphp + + {{ $entity->name }} · {{ $open ? __('devices.open') : __('devices.closed') }} + + @break + + @case('battery') + @php $p = (int) ($s['percent'] ?? 0); $low = $p < 20; @endphp + + {{ __('devices.battery') }} · {{ $p }}% + + @break + + @case('power') + + {{ (int) ($s['watts'] ?? 0) }} W + + @break + + @case('motion') + @php $active = ($s['active'] ?? false) === true; @endphp + + {{ $active ? __('devices.motion') : __('devices.no_motion') }} + + @break +@endswitch diff --git a/resources/views/components/sidebar.blade.php b/resources/views/components/sidebar.blade.php index a2bec7f..9395795 100644 --- a/resources/views/components/sidebar.blade.php +++ b/resources/views/components/sidebar.blade.php @@ -15,6 +15,7 @@ 'nav.section_system' => [ ['key' => 'network', 'icon' => 'network', '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], ], ]; diff --git a/resources/views/livewire/dashboard.blade.php b/resources/views/livewire/dashboard.blade.php index 55fde93..516f1a5 100644 --- a/resources/views/livewire/dashboard.blade.php +++ b/resources/views/livewire/dashboard.blade.php @@ -1,93 +1,86 @@ -
+
- @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 - - {{-- health banner — surfaces faults prominently --}} -
- -
- - - -
-

- {{ $worst === 'online' - ? __('dashboard.health_ok_title') - : trans_choice('dashboard.health_problem_title', $problems, ['count' => $problems]) }} -

-

- {{ $worst === 'online' ? __('dashboard.health_ok_body') : __('dashboard.health_problem_body') }} -

-
-
- - -
-
-
- - {{-- live service status — "was läuft gerade" --}} - -
- @foreach ($services as $svc) -
- - - -
-
{{ $svc['label'] }}
-
{{ $svc['detail'] }}
+ {{-- warnings — only what needs attention --}} + @if (count($warnings) > 0) + + + {{ count($warnings) }} + +
+ @foreach ($warnings as $w) +
+ $w['level'] === 'offline', + 'text-warning bg-warning/10' => $w['level'] !== 'offline', + ])> + + +
+
{{ $w['title'] }}
+ @if ($w['meta']) +
{{ $w['meta'] }}
+ @endif +
+ @if ($w['link']) + + + + @endif
- - - {{ $stateLabel[$svc['state']] }} - + @endforeach +
+
+ @else +
+ +
+ + + +
+

{{ __('dashboard.all_clear_title') }}

+

{{ __('dashboard.all_clear_body') }}

+
+
+ @endif + + {{-- home summary --}} +
+ + + + +
+ + {{-- rooms + devices --}} +
+

{{ __('dashboard.rooms_title') }}

+
+ @foreach ($rooms as $room) + +
+ @foreach ($room->devices as $device) +
+
+ + {{ $device->name }} + {{ $device->model }} +
+
+ @foreach ($device->entities as $entity) + + @endforeach +
+
+ @endforeach +
+
@endforeach
- - - {{-- compact system info (versions) — de-emphasized, own area later --}} - -
-
-
{{ __('dashboard.stack_framework') }}
-
Laravel {{ app()->version() }}
-
-
-
{{ __('dashboard.stack_database') }}
-
PostgreSQL 17 · TSDB
-
-
-
{{ __('dashboard.stack_realtime') }}
-
Reverb
-
-
-
{{ __('dashboard.stack_queue') }}
-
Redis · Horizon
-
-
-
+
diff --git a/resources/views/livewire/host.blade.php b/resources/views/livewire/host.blade.php new file mode 100644 index 0000000..d539249 --- /dev/null +++ b/resources/views/livewire/host.blade.php @@ -0,0 +1,90 @@ +
+ + +
+ @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 + +
+ +
+ + + +
+

+ {{ $worst === 'online' + ? __('host.health_ok_title') + : trans_choice('host.health_problem_title', $problems, ['count' => $problems]) }} +

+

+ {{ $worst === 'online' ? __('host.health_ok_body') : __('host.health_problem_body') }} +

+
+
+ + +
+
+
+ + +
+ @foreach ($services as $svc) +
+ + + +
+
{{ $svc['label'] }}
+
{{ $svc['detail'] }}
+
+ + + {{ $stateLabel[$svc['state']] }} + +
+ @endforeach +
+
+ + +
+
+
{{ __('host.stack_framework') }}
+
Laravel {{ app()->version() }}
+
+
+
{{ __('host.stack_database') }}
+
PostgreSQL 17 · TSDB
+
+
+
{{ __('host.stack_realtime') }}
+
Reverb
+
+
+
{{ __('host.stack_queue') }}
+
Redis · Horizon
+
+
+
+
+
diff --git a/routes/web.php b/routes/web.php index 70a7f67..8c15887 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,6 +2,7 @@ use App\Livewire\Auth\Login; use App\Livewire\Dashboard; +use App\Livewire\Host; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Route; @@ -13,6 +14,7 @@ Route::middleware('guest')->group(function () { Route::middleware('auth')->group(function () { Route::get('/dashboard', Dashboard::class)->name('dashboard'); + Route::get('/host', Host::class)->name('host'); Route::post('/logout', function () { Auth::guard('web')->logout(); diff --git a/tests/Feature/HomeTest.php b/tests/Feature/HomeTest.php new file mode 100644 index 0000000..22c92ea --- /dev/null +++ b/tests/Feature/HomeTest.php @@ -0,0 +1,58 @@ +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'); + } +}