Warnings → modal, device detail page, stable mock online state

- Warnings no longer clutter the dashboard: a compact "Warnungen (n)" button in
  the topbar opens a themed wire-elements/modal (R5) listing all messages. Dark
  modal wrapper overridden; HomeStatus service shared by dashboard + modal.
- Device detail page (/devices/{uuid}, UUID route key): edit name, room and
  active state; view info + live capabilities; Neustart (with confirm modal) and
  "Update prüfen" as mock commands (Phase 3 routes them through the real driver).
  Devices index (/devices) added; "Geräte" nav activated; dashboard device rows
  and index link to the detail. Generic Confirm modal + x-detail component.
- Mock devices no longer rot: online is now "active and (no last_seen or seen
  <10min)", so the demo stays healthy; the one offline device keeps a stale
  timestamp. Full DE/EN i18n for devices + modal copy.

Verified: R12 30/30 in headless Chromium (0 console errors, 0 failed requests);
10 feature tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/phase-1-bootstrap
HomeOS Bootstrap 2026-07-17 21:52:57 +02:00
parent 1b69c39a93
commit f00a1ac069
24 changed files with 702 additions and 128 deletions

View File

@ -2,87 +2,21 @@
namespace App\Livewire;
use App\Models\Room;
use App\Services\SystemHealth;
use Illuminate\Support\Collection;
use App\Services\HomeStatus;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')]
class Dashboard extends Component
{
private const LOW_BATTERY = 20;
public function render()
public function render(HomeStatus $home)
{
$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),
];
$rooms = $home->load();
return view('livewire.dashboard', [
'rooms' => $rooms,
'summary' => $summary,
'warnings' => $this->warnings($devices, $entities),
'summary' => $home->summary($rooms),
'warningCount' => count($home->warnings($rooms)),
]);
}
/** @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();
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Livewire\Devices;
use App\Models\Device;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')]
class Index extends Component
{
public function render()
{
return view('livewire.devices.index', [
'devices' => Device::query()
->with(['room', 'entities.state'])
->orderBy('name')
->get(),
]);
}
}

View File

@ -0,0 +1,77 @@
<?php
namespace App\Livewire\Devices;
use App\Models\Device;
use App\Models\Room;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Validate;
use Livewire\Component;
#[Layout('layouts.app')]
class Show extends Component
{
public Device $device;
#[Validate('required|string|max:100')]
public string $name = '';
public ?int $roomId = null;
public ?string $flash = null;
public function mount(Device $device): void
{
$this->device = $device;
$this->name = $device->name;
$this->roomId = $device->room_id;
}
public function saveName(): void
{
$this->validate();
$this->device->update(['name' => $this->name]);
$this->flashMessage(__('devices.saved'));
}
public function saveRoom(): void
{
$this->device->update(['room_id' => $this->roomId ?: null]);
$this->flashMessage(__('devices.saved'));
}
public function toggleStatus(): void
{
$this->device->update([
'status' => $this->device->status === 'active' ? 'ignored' : 'active',
]);
$this->flashMessage(__('devices.saved'));
}
// Mock command paths — Phase 3 routes these through DeviceCommandService → driver (H1).
#[On('restartDevice')]
public function restart(): void
{
$this->flashMessage(__('devices.command_sent_demo'));
}
public function checkUpdate(): void
{
$this->flashMessage(__('devices.no_update_demo'));
}
protected function flashMessage(string $message): void
{
$this->flash = $message;
}
public function render()
{
$this->device->load(['entities.state', 'room']);
return view('livewire.devices.show', [
'rooms' => Room::orderBy('sort')->orderBy('name')->get(),
]);
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Livewire\Modals;
use LivewireUI\Modal\ModalComponent;
/**
* Generic confirm dialog (R5). Opens via:
* $this->dispatch('openModal', component: 'modals.confirm', arguments: [
* 'title' => ..., 'body' => ..., 'confirmLabel' => ..., 'event' => 'someEvent', 'danger' => true,
* ]);
* On confirm it dispatches `event` (caught by the opener via #[On('someEvent')]) and closes.
*/
class Confirm extends ModalComponent
{
public string $title = '';
public string $body = '';
public string $confirmLabel = '';
public string $event = '';
public bool $danger = false;
public static function modalMaxWidth(): string
{
return 'md';
}
public function mount(string $title, string $body, string $confirmLabel, string $event, bool $danger = false): void
{
$this->title = $title;
$this->body = $body;
$this->confirmLabel = $confirmLabel;
$this->event = $event;
$this->danger = $danger;
}
public function confirm(): void
{
$this->dispatch($this->event);
$this->closeModal();
}
public function render()
{
return view('livewire.modals.confirm');
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Livewire\Modals;
use App\Services\HomeStatus;
use LivewireUI\Modal\ModalComponent;
class Warnings extends ModalComponent
{
public static function modalMaxWidth(): string
{
return 'lg';
}
public function render()
{
$home = app(HomeStatus::class);
return view('livewire.modals.warnings', [
'warnings' => $home->warnings($home->load()),
]);
}
}

View File

@ -31,11 +31,17 @@ class Device extends Model
return $this->hasMany(Entity::class);
}
/** Active and seen recently. */
/**
* Reachable now. Real devices use last_seen recency (kept fresh by MQTT from Phase 3);
* a mock device with no last_seen is treated as reachable so the demo doesn't rot.
*/
public function isOnline(): bool
{
return $this->status === 'active'
&& $this->last_seen_at !== null
&& $this->last_seen_at->gt(now()->subMinutes(10));
if ($this->status !== 'active') {
return false;
}
return $this->last_seen_at === null
|| $this->last_seen_at->gt(now()->subMinutes(10));
}
}

View File

@ -0,0 +1,84 @@
<?php
namespace App\Services;
use App\Models\Room;
use Illuminate\Support\Collection;
/**
* Aggregates the current home state (rooms/devices/entities) into a summary and a
* warning list. Shared by the Dashboard (grid + warning count) and the Warnings modal.
*/
class HomeStatus
{
private const LOW_BATTERY = 20;
public function __construct(private readonly SystemHealth $health) {}
public function load(): Collection
{
return Room::query()
->with(['devices' => fn ($q) => $q->orderBy('name'), 'devices.entities.state'])
->orderBy('sort')->orderBy('name')
->get();
}
public function summary(Collection $rooms): array
{
$devices = $rooms->flatMap->devices;
$entities = $devices->flatMap->entities;
return [
'devices_online' => $devices->filter->isOnline()->count(),
'devices_total' => $devices->count(),
'lights_on' => $entities->filter(fn ($e) => in_array($e->type, ['light', 'switch']) && data_get($e->state, 'state.on') === true)->count(),
'contacts_open' => $entities->filter(fn ($e) => $e->type === 'contact' && data_get($e->state, 'state.open') === true)->count(),
'low_battery' => $entities->filter(fn ($e) => $e->type === 'battery' && (int) data_get($e->state, 'state.percent', 100) < self::LOW_BATTERY)->count(),
];
}
/** @return array<int, array{level:string,icon:string,title:string,meta:?string,link:?string}> */
public function warnings(Collection $rooms): array
{
$devices = $rooms->flatMap->devices;
$entities = $devices->flatMap->entities;
$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 (! $this->health->allOnline()) {
$warnings[] = [
'level' => 'warning', 'icon' => 'activity',
'title' => __('dashboard.warn_host'), 'meta' => __('dashboard.warn_host_meta'),
'link' => route('host'),
];
}
return $warnings;
}
}

View File

@ -20,7 +20,9 @@ class DemoHomeSeeder extends Seeder
{
public function run(): void
{
$now = now();
// Online mock devices have no last_seen (treated as reachable, so the demo never
// rots); the one offline device carries a stale timestamp.
$onlineSeen = null;
$stale = now()->subHours(2);
// room => devices. Each device: [name, model, online, entities[]]
@ -88,7 +90,7 @@ class DemoHomeSeeder extends Seeder
'model' => $model,
'protocol' => 'mqtt',
'status' => 'active',
'last_seen_at' => $online ? $now : $stale,
'last_seen_at' => $online ? $onlineSeen : $stale,
],
);

View File

@ -14,4 +14,5 @@ return [
'logout' => 'Abmelden',
'menu' => 'Menü',
'close' => 'Schließen',
'cancel' => 'Abbrechen',
];

View File

@ -1,6 +1,7 @@
<?php
return [
// states
'on' => 'An',
'off' => 'Aus',
'open' => 'Offen',
@ -10,4 +11,43 @@ return [
'no_motion' => 'Ruhe',
'online' => 'Online',
'offline' => 'Nicht erreichbar',
// index
'index_subtitle' => 'Alle Geräte im Haus.',
'empty' => 'Noch keine Geräte.',
// detail
'device' => 'Gerät',
'name' => 'Name',
'save' => 'Speichern',
'saved' => 'Gespeichert.',
'vendor' => 'Hersteller',
'model' => 'Modell',
'protocol' => 'Protokoll',
'room' => 'Raum',
'no_room' => 'Kein Raum',
'last_seen' => 'Zuletzt gesehen',
'uuid' => 'UUID',
'capabilities' => 'Fähigkeiten',
'no_capabilities' => 'Keine Fähigkeiten gemeldet.',
'status_active' => 'Aktiv',
'status_ignored' => 'Ignoriert',
'status_discovered' => 'Neu erkannt',
// settings
'settings' => 'Einstellungen',
'active' => 'Aktiv',
'active_hint' => 'Deaktivierte Geräte werden ausgeblendet.',
// actions (mock until Phase 3 / DeviceCommandService)
'actions' => 'Aktionen',
'actions_demo_hint' => 'Demo — Befehle laufen ab Phase 3 über den echten Treiber.',
'restart' => 'Neustart',
'check_update' => 'Update prüfen',
'command_sent_demo' => 'Neustart-Befehl gesendet (Demo).',
'no_update_demo' => 'Kein Update verfügbar (Demo).',
'restart_confirm_title' => 'Gerät neu starten?',
'restart_confirm_body' => ':name wird neu gestartet und ist kurz nicht erreichbar.',
];

View File

@ -14,4 +14,5 @@ return [
'logout' => 'Sign out',
'menu' => 'Menu',
'close' => 'Close',
'cancel' => 'Cancel',
];

View File

@ -1,6 +1,7 @@
<?php
return [
// states
'on' => 'On',
'off' => 'Off',
'open' => 'Open',
@ -10,4 +11,43 @@ return [
'no_motion' => 'Idle',
'online' => 'Online',
'offline' => 'Unreachable',
// index
'index_subtitle' => 'All devices in the home.',
'empty' => 'No devices yet.',
// detail
'device' => 'Device',
'name' => 'Name',
'save' => 'Save',
'saved' => 'Saved.',
'vendor' => 'Vendor',
'model' => 'Model',
'protocol' => 'Protocol',
'room' => 'Room',
'no_room' => 'No room',
'last_seen' => 'Last seen',
'uuid' => 'UUID',
'capabilities' => 'Capabilities',
'no_capabilities' => 'No capabilities reported.',
'status_active' => 'Active',
'status_ignored' => 'Ignored',
'status_discovered' => 'Discovered',
// settings
'settings' => 'Settings',
'active' => 'Active',
'active_hint' => 'Disabled devices are hidden.',
// actions (mock until Phase 3 / DeviceCommandService)
'actions' => 'Actions',
'actions_demo_hint' => 'Demo — commands route through the real driver from Phase 3.',
'restart' => 'Restart',
'check_update' => 'Check for update',
'command_sent_demo' => 'Restart command sent (demo).',
'no_update_demo' => 'No update available (demo).',
'restart_confirm_title' => 'Restart device?',
'restart_confirm_body' => ':name will restart and be briefly unavailable.',
];

View File

@ -3,6 +3,8 @@
/* Scan Blade sources for utility classes */
@source '../views';
@source '../../app/Livewire';
/* wire-elements/modal builds width classes in PHP — force-generate them */
@source inline("sm:max-w-md md:max-w-lg md:max-w-xl lg:max-w-2xl lg:max-w-3xl");
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php';

View File

@ -0,0 +1,6 @@
@props(['label', 'mono' => false])
<div class="flex flex-col gap-0.5">
<dt class="text-[11px] font-semibold uppercase tracking-[0.08em] text-ink-3">{{ $label }}</dt>
<dd {{ $attributes->merge(['class' => 'text-[13px] text-ink-2 '.($mono ? 'font-mono' : '')]) }}>{{ $slot }}</dd>
</div>

View File

@ -3,7 +3,7 @@
'nav.section_overview' => [
['key' => 'dashboard', 'icon' => 'dashboard', 'route' => 'dashboard', 'lock' => false],
['key' => 'rooms', 'icon' => 'rooms', 'route' => null, 'lock' => false],
['key' => 'devices', 'icon' => 'devices', 'route' => null, 'lock' => false],
['key' => 'devices', 'icon' => 'devices', 'route' => 'devices.index', 'active' => 'devices.*', 'lock' => false],
],
'nav.section_persons' => [
['key' => 'persons', 'icon' => 'persons', 'route' => null, 'lock' => false],
@ -39,7 +39,7 @@
<div class="flex flex-col gap-0.5" aria-label="{{ __($label) }}">
<div class="px-2.5 mb-1.5 text-[10px] font-bold uppercase tracking-[0.14em] text-ink-3">{{ __($label) }}</div>
@foreach ($items as $item)
@php $active = $item['route'] && request()->routeIs($item['route']); @endphp
@php $active = $item['route'] && request()->routeIs($item['active'] ?? $item['route']); @endphp
@if ($item['route'])
<a href="{{ route($item['route']) }}" wire:navigate
@class([

View File

@ -18,6 +18,10 @@
</div>
<div class="ml-auto flex items-center gap-3">
@isset($actions)
{{ $actions }}
@endisset
<span class="hidden sm:inline-flex items-center gap-1.5 rounded-full bg-raised border border-line-soft px-2.5 py-1 text-[11px] font-semibold text-ink-2">
<span class="w-[7px] h-[7px] rounded-full bg-accent pulse-live"></span>
{{ __('common.live') }}

View File

@ -43,6 +43,8 @@
</div>
</div>
@livewire('wire-elements-modal')
@livewireScripts
</body>
</html>

View File

@ -1,53 +1,23 @@
<div wire:poll.15s>
<x-topbar :title="__('dashboard.title')" :subtitle="__('dashboard.subtitle')" />
<x-topbar :title="__('dashboard.title')" :subtitle="__('dashboard.subtitle')">
<x-slot:actions>
<button type="button" wire:click="$dispatch('openModal', { component: 'modals.warnings' })"
@class([
'inline-flex items-center gap-2 rounded-lg border px-2.5 py-1.5 text-[12px] font-semibold transition-colors',
'border-warning/30 bg-warning/10 text-warning hover:bg-warning/20' => $warningCount > 0,
'border-line-soft bg-raised text-ink-2 hover:text-ink' => $warningCount === 0,
])
aria-label="{{ __('dashboard.warnings_title') }}">
<x-icon name="alert" :size="15" />
<span class="hidden sm:inline">{{ __('dashboard.warnings_title') }}</span>
@if ($warningCount > 0)
<span class="font-mono tabular-nums rounded-full bg-warning/20 px-1.5 py-px text-[11px] leading-none">{{ $warningCount }}</span>
@endif
</button>
</x-slot:actions>
</x-topbar>
<div class="px-5 lg:px-[26px] py-6 flex flex-col gap-5 max-w-[1560px] mx-auto w-full">
{{-- 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">
<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>
@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
{{-- 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" />
@ -64,18 +34,20 @@
<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">
<a href="{{ route('devices.show', $device) }}" wire:navigate
class="flex flex-col gap-2 py-3 first:pt-1 last:pb-1 -mx-4 px-4 hover:bg-raised/50 transition-colors">
<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>
<x-icon name="chevron" :size="14" class="text-ink-3 shrink-0" />
</div>
<div class="flex flex-wrap gap-1.5">
@foreach ($device->entities as $entity)
<x-entity-state :entity="$entity" />
@endforeach
</div>
</div>
</a>
@endforeach
</div>
</x-panel>

View File

@ -0,0 +1,36 @@
<div>
<x-topbar :title="__('nav.devices')" :subtitle="__('devices.index_subtitle')" />
<div class="px-5 lg:px-[26px] py-6 flex flex-col gap-5 max-w-[1560px] mx-auto w-full">
<x-panel :title="__('nav.devices')">
<x-slot:actions>
<x-badge>{{ $devices->count() }}</x-badge>
</x-slot:actions>
@if ($devices->isEmpty())
<p class="text-[13px] text-ink-3">{{ __('devices.empty') }}</p>
@else
<div class="flex flex-col divide-y divide-line-soft -my-1">
@foreach ($devices as $device)
<a href="{{ route('devices.show', $device) }}" wire:navigate
class="flex items-center gap-3 py-3 first:pt-1 last:pb-1 -mx-4 px-4 hover:bg-raised/50 transition-colors">
<x-status-dot :state="$device->isOnline() ? 'online' : 'offline'" :pulse="$device->isOnline()" />
<div class="min-w-0">
<div class="text-[13px] font-semibold text-ink truncate">{{ $device->name }}</div>
<div class="text-[11.5px] text-ink-3 truncate">
{{ $device->room?->name ?? __('devices.no_room') }} · <span class="font-mono">{{ $device->model }}</span>
</div>
</div>
<div class="ml-auto hidden md:flex flex-wrap justify-end gap-1.5 max-w-[45%]">
@foreach ($device->entities->take(3) as $entity)
<x-entity-state :entity="$entity" />
@endforeach
</div>
<x-icon name="chevron" :size="15" class="text-ink-3 shrink-0" />
</a>
@endforeach
</div>
@endif
</x-panel>
</div>
</div>

View File

@ -0,0 +1,129 @@
<div>
<x-topbar :title="$device->name" :subtitle="$device->model">
<x-slot:actions>
<a href="{{ route('devices.index') }}" wire:navigate
class="inline-flex items-center gap-1.5 rounded-lg border border-line-soft bg-raised px-2.5 py-1.5 text-[12px] font-semibold text-ink-2 hover:text-ink transition-colors">
<x-icon name="chevron" :size="14" class="rotate-180" />
<span class="hidden sm:inline">{{ __('nav.devices') }}</span>
</a>
</x-slot:actions>
</x-topbar>
<div class="px-5 lg:px-[26px] py-6 flex flex-col gap-5 max-w-[1560px] mx-auto w-full">
@if ($flash)
<div class="flex items-center gap-3 rounded-card border border-line-soft bg-online/10 px-4 py-3">
<x-icon name="check" :size="17" class="text-online shrink-0" />
<span class="text-[13px] text-ink">{{ $flash }}</span>
<button type="button" wire:click="$set('flash', null)" class="ml-auto text-ink-3 hover:text-ink">
<x-icon name="close" :size="16" />
</button>
</div>
@endif
<div class="grid grid-cols-1 lg:grid-cols-[1fr_340px] gap-5 items-start">
{{-- main --}}
<div class="flex flex-col gap-5">
{{-- identity + name edit --}}
<x-panel :title="__('devices.device')">
<div class="flex flex-col gap-4">
<div class="flex items-center gap-3">
<x-status-dot :state="$device->isOnline() ? 'online' : 'offline'" :pulse="$device->isOnline()" />
<x-status-pill :state="$device->isOnline() ? 'online' : 'offline'">
{{ $device->isOnline() ? __('devices.online') : __('devices.offline') }}
</x-status-pill>
@if ($device->status !== 'active')
<x-status-pill state="neutral">{{ __('devices.status_'.$device->status) }}</x-status-pill>
@endif
</div>
<div class="flex flex-col gap-1.5">
<label for="name" class="text-[12px] font-semibold text-ink-2">{{ __('devices.name') }}</label>
<div class="flex gap-2">
<input wire:model="name" id="name" type="text"
class="flex-1 rounded-lg bg-raised border border-line px-3 py-2.5 text-sm text-ink outline-none transition-colors focus:border-accent focus:ring-1 focus:ring-accent">
<button type="button" wire:click="saveName"
class="rounded-lg bg-accent px-4 py-2.5 text-sm font-bold text-base transition-[filter] hover:brightness-110">
{{ __('devices.save') }}
</button>
</div>
@error('name') <p class="text-[12px] text-offline">{{ $message }}</p> @enderror
</div>
<dl class="grid grid-cols-2 sm:grid-cols-3 gap-x-4 gap-y-3 pt-1">
<x-detail :label="__('devices.vendor')">{{ $device->vendor ?? '—' }}</x-detail>
<x-detail :label="__('devices.model')">{{ $device->model ?? '—' }}</x-detail>
<x-detail :label="__('devices.protocol')" mono>{{ $device->protocol ?? '—' }}</x-detail>
<x-detail :label="__('devices.room')">{{ $device->room?->name ?? '—' }}</x-detail>
<x-detail :label="__('devices.last_seen')" mono>{{ $device->last_seen_at?->diffForHumans() ?? '—' }}</x-detail>
<x-detail :label="__('devices.uuid')" mono>{{ \Illuminate\Support\Str::limit($device->uuid, 13, '…') }}</x-detail>
</dl>
</div>
</x-panel>
{{-- capabilities / entities --}}
<x-panel :title="__('devices.capabilities')">
@if ($device->entities->isEmpty())
<p class="text-[13px] text-ink-3">{{ __('devices.no_capabilities') }}</p>
@else
<div class="flex flex-col divide-y divide-line-soft -my-1">
@foreach ($device->entities as $entity)
<div class="flex items-center gap-3 py-3 first:pt-1 last:pb-1">
<span class="text-[13px] font-semibold text-ink">{{ $entity->name ?? $entity->key }}</span>
<span class="text-[11px] font-mono text-ink-3">{{ $entity->type }}</span>
<span class="ml-auto"><x-entity-state :entity="$entity" /></span>
</div>
@endforeach
</div>
@endif
</x-panel>
</div>
{{-- rail: actions + settings --}}
<div class="flex flex-col gap-5">
<x-panel :title="__('devices.actions')">
<div class="flex flex-col gap-2">
<button type="button"
wire:click="$dispatch('openModal', { component: 'modals.confirm', arguments: { title: @js(__('devices.restart_confirm_title')), body: @js(__('devices.restart_confirm_body', ['name' => $device->name])), confirmLabel: @js(__('devices.restart')), event: 'restartDevice', danger: true } })"
class="flex items-center gap-2.5 rounded-lg border border-line-soft bg-raised px-3 py-2.5 text-[13px] font-semibold text-ink hover:border-line transition-colors">
<x-icon name="activity" :size="16" class="text-ink-2" /> {{ __('devices.restart') }}
</button>
<button type="button" wire:click="checkUpdate"
class="flex items-center gap-2.5 rounded-lg border border-line-soft bg-raised px-3 py-2.5 text-[13px] font-semibold text-ink hover:border-line transition-colors">
<x-icon name="wifi" :size="16" class="text-ink-2" /> {{ __('devices.check_update') }}
</button>
</div>
<p class="mt-3 text-[11.5px] text-ink-3">{{ __('devices.actions_demo_hint') }}</p>
</x-panel>
<x-panel :title="__('devices.settings')">
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1.5">
<label for="room" class="text-[12px] font-semibold text-ink-2">{{ __('devices.room') }}</label>
<div class="flex gap-2">
<select wire:model="roomId" id="room"
class="flex-1 rounded-lg bg-raised border border-line px-3 py-2.5 text-sm text-ink outline-none focus:border-accent focus:ring-1 focus:ring-accent">
<option value="">{{ __('devices.no_room') }}</option>
@foreach ($rooms as $room)
<option value="{{ $room->id }}">{{ $room->name }}</option>
@endforeach
</select>
<button type="button" wire:click="saveRoom"
class="rounded-lg border border-line px-3.5 py-2.5 text-[13px] font-semibold text-ink-2 hover:text-ink hover:bg-raised transition-colors">
{{ __('devices.save') }}
</button>
</div>
</div>
<div class="flex items-center gap-3 border-t border-line-soft pt-3">
<div class="min-w-0">
<div class="text-[13px] font-semibold text-ink">{{ __('devices.active') }}</div>
<div class="text-[11.5px] text-ink-3">{{ __('devices.active_hint') }}</div>
</div>
<x-toggle :on="$device->status === 'active'" wire:click="toggleStatus" :label="__('devices.active')" class="ml-auto" />
</div>
</div>
</x-panel>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,30 @@
<div class="p-5 flex flex-col gap-4">
<div class="flex items-start gap-3">
<span @class([
'grid place-items-center w-10 h-10 rounded-lg shrink-0',
'bg-offline/10 text-offline' => $danger,
'bg-accent/10 text-accent' => ! $danger,
])>
<x-icon :name="$danger ? 'alert' : 'activity'" :size="20" />
</span>
<div class="min-w-0">
<h2 class="text-[15px] font-bold text-ink">{{ $title }}</h2>
<p class="text-[13px] text-ink-2 mt-1">{{ $body }}</p>
</div>
</div>
<div class="flex items-center justify-end gap-2 pt-1">
<button type="button" wire:click="closeModal"
class="rounded-lg border border-line px-3.5 py-2 text-[13px] font-semibold text-ink-2 hover:text-ink hover:bg-raised transition-colors">
{{ __('common.cancel') }}
</button>
<button type="button" wire:click="confirm"
@class([
'rounded-lg px-3.5 py-2 text-[13px] font-bold transition-[filter] hover:brightness-110',
'bg-offline text-base' => $danger,
'bg-accent text-base' => ! $danger,
])>
{{ $confirmLabel }}
</button>
</div>
</div>

View File

@ -0,0 +1,57 @@
<div class="flex flex-col">
<header class="flex items-center gap-3 px-5 py-4 border-b border-line-soft">
<span class="grid place-items-center w-9 h-9 rounded-lg bg-warning/10 text-warning shrink-0">
<x-icon name="alert" :size="18" />
</span>
<h2 class="text-[15px] font-bold text-ink flex items-center gap-2">
{{ __('dashboard.warnings_title') }}
@if (count($warnings) > 0)
<x-badge variant="warning">{{ count($warnings) }}</x-badge>
@endif
</h2>
<button type="button" wire:click="closeModal"
class="ml-auto grid place-items-center w-9 h-9 rounded-lg text-ink-3 hover:bg-raised hover:text-ink transition-colors"
aria-label="{{ __('common.close') }}">
<x-icon name="close" :size="18" />
</button>
</header>
<div class="p-4 max-h-[60vh] overflow-y-auto">
@if (count($warnings) > 0)
<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">
<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>
@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>
@else
<div class="flex items-center gap-3 px-1 py-3 text-ink-2">
<span class="grid place-items-center w-9 h-9 rounded-lg bg-online/10 text-online shrink-0">
<x-icon name="check" :size="18" />
</span>
<div>
<div class="text-[13px] font-semibold text-ink">{{ __('dashboard.all_clear_title') }}</div>
<div class="text-[12px] text-ink-3">{{ __('dashboard.all_clear_body') }}</div>
</div>
</div>
@endif
</div>
</div>

View File

@ -0,0 +1,57 @@
<div>
@isset($jsPath)
<script>{!! file_get_contents($jsPath) !!}</script>
@endisset
@isset($cssPath)
<style>{!! file_get_contents($cssPath) !!}</style>
@endisset
<div
x-data="LivewireUIModal()"
x-on:close.stop="setShowPropertyTo(false)"
x-on:keydown.escape.window="show && closeModalOnEscape()"
x-show="show"
class="fixed inset-0 z-50 overflow-y-auto"
style="display: none;"
>
<div class="flex items-end justify-center min-h-dvh px-4 pt-4 pb-10 text-center sm:block sm:p-0">
<div
x-show="show"
x-on:click="closeModalOnClickAway()"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 transition-all transform"
>
<div class="absolute inset-0 bg-black/70 backdrop-blur-sm"></div>
</div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<div
x-show="show && showActiveComponent"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-bind:class="modalWidth"
class="relative inline-block w-full align-bottom bg-surface border border-line rounded-card text-left overflow-hidden transform transition-all sm:my-8 sm:align-middle sm:w-full"
id="modal-container"
x-trap.noscroll.inert="show && showActiveComponent"
aria-modal="true"
>
@forelse($components as $id => $component)
<div x-show.immediate="activeComponent == '{{ $id }}'" x-ref="{{ $id }}" wire:key="{{ $id }}">
@livewire($component['name'], $component['arguments'], key($id))
</div>
@empty
@endforelse
</div>
</div>
</div>
</div>

View File

@ -2,6 +2,8 @@
use App\Livewire\Auth\Login;
use App\Livewire\Dashboard;
use App\Livewire\Devices\Index as DeviceIndex;
use App\Livewire\Devices\Show as DeviceShow;
use App\Livewire\Host;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route;
@ -14,6 +16,8 @@ Route::middleware('guest')->group(function () {
Route::middleware('auth')->group(function () {
Route::get('/dashboard', Dashboard::class)->name('dashboard');
Route::get('/devices', DeviceIndex::class)->name('devices.index');
Route::get('/devices/{device}', DeviceShow::class)->name('devices.show');
Route::get('/host', Host::class)->name('host');
Route::post('/logout', function () {