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_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
|
||||
|
|
|
|||
|
|
@ -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<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();
|
||||
}
|
||||
|
||||
/** 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<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(),
|
||||
],
|
||||
);
|
||||
|
||||
$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 [
|
||||
'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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
'network' => 'Netzwerk & Discovery',
|
||||
'automations' => 'Automationen',
|
||||
'host' => 'Host & Dienste',
|
||||
'settings' => 'Einstellungen',
|
||||
|
||||
'soon' => 'In Kürze',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
'network' => 'Network & Discovery',
|
||||
'automations' => 'Automations',
|
||||
'host' => 'Host & Services',
|
||||
'settings' => 'Settings',
|
||||
|
||||
'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' => [
|
||||
['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],
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,93 +1,86 @@
|
|||
<div wire:poll.10s="refreshHealth">
|
||||
<div wire:poll.15s>
|
||||
<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">
|
||||
@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 --}}
|
||||
<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" />
|
||||
{{-- warnings — only what needs attention --}}
|
||||
@if (count($warnings) > 0)
|
||||
<x-panel :title="__('dashboard.warnings_title')" class="reveal">
|
||||
<x-slot:actions>
|
||||
<x-badge variant="warning">{{ count($warnings) }}</x-badge>
|
||||
</x-slot:actions>
|
||||
<div class="flex flex-col gap-2">
|
||||
@foreach ($warnings as $w)
|
||||
<div class="flex items-center gap-3 rounded-lg border border-line-soft bg-raised px-3 py-2.5">
|
||||
<span @class([
|
||||
'grid place-items-center w-8 h-8 rounded-lg shrink-0',
|
||||
'text-offline bg-offline/10' => $w['level'] === 'offline',
|
||||
'text-warning bg-warning/10' => $w['level'] !== 'offline',
|
||||
])>
|
||||
<x-icon :name="$w['icon']" :size="16" />
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-[15px] font-bold text-ink">
|
||||
{{ $worst === 'online'
|
||||
? __('dashboard.health_ok_title')
|
||||
: trans_choice('dashboard.health_problem_title', $problems, ['count' => $problems]) }}
|
||||
</h2>
|
||||
<p class="text-[12.5px] text-ink-2">
|
||||
{{ $worst === 'online' ? __('dashboard.health_ok_body') : __('dashboard.health_problem_body') }}
|
||||
</p>
|
||||
<div class="text-[13px] font-semibold text-ink truncate">{{ $w['title'] }}</div>
|
||||
@if ($w['meta'])
|
||||
<div class="text-[11.5px] text-ink-3">{{ $w['meta'] }}</div>
|
||||
@endif
|
||||
</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>
|
||||
<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>
|
||||
@if ($w['link'])
|
||||
<a href="{{ $w['link'] }}" wire:navigate class="ml-auto shrink-0 text-ink-3 hover:text-ink transition-colors">
|
||||
<x-icon name="chevron" :size="16" />
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-panel>
|
||||
@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>
|
||||
</section>
|
||||
@endif
|
||||
|
||||
{{-- 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>
|
||||
{{-- 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>
|
||||
<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>
|
||||
|
||||
{{-- 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-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 class="flex flex-wrap gap-1.5">
|
||||
@foreach ($device->entities as $entity)
|
||||
<x-entity-state :entity="$entity" />
|
||||
@endforeach
|
||||
</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>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-panel>
|
||||
@endforeach
|
||||
</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\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();
|
||||
|
|
|
|||
|
|
@ -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