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
parent
d4f667437c
commit
fcb6daa6ab
11
.env.example
11
.env.example
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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')];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -23,5 +23,7 @@ class DatabaseSeeder extends Seeder
|
||||||
'email_verified_at' => now(),
|
'email_verified_at' => now(),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$this->call(DemoHomeSeeder::class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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',
|
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
];
|
||||||
|
|
@ -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',
|
||||||
|
];
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
];
|
||||||
|
|
@ -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',
|
||||||
|
];
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue