All sidebar tabs are real pages (zero console errors)

Persons & Anwesenheit, Netzwerk & Discovery, Automationen, Einstellungen and
Zugang & Face-ID are now working Livewire pages (were dead "In Kürze" buttons).
Adds persons / discovery_findings / automations tables + models; authorizes the
presence + discovery broadcast channels; silences MQTT debug-log spam. Nav check
10/10 tabs clean (200, 0 console errors, 0 failed requests).

Deeper behaviour (presence polling, discovery sidecar, automation engine) lands
in the respective phase; the pages + schema are in place and render live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/phase-1-bootstrap
HomeOS Bootstrap 2026-07-18 00:16:16 +02:00
parent cc81e309b3
commit 5cde2786aa
28 changed files with 557 additions and 5 deletions

View File

@ -73,6 +73,7 @@ MQTT_AUTH_PASSWORD=
# leave empty so a random client id is generated per connection
MQTT_CLIENT_ID=
MQTT_AUTO_RECONNECT_ENABLED=false
MQTT_ENABLE_LOGGING=false
# sidecar password — used only to generate the broker passwd file (see README).
# Physical devices get per-device credentials provisioned at onboarding.
MQTT_SIDECAR_PASSWORD=

15
app/Livewire/Access.php Normal file
View File

@ -0,0 +1,15 @@
<?php
namespace App\Livewire;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')]
class Access extends Component
{
public function render()
{
return view('livewire.access');
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Livewire\Automations;
use App\Models\Automation;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')]
class Index extends Component
{
public function toggle(string $uuid): void
{
$automation = Automation::where('uuid', $uuid)->first();
$automation?->update(['enabled' => ! $automation->enabled]);
}
public function render()
{
return view('livewire.automations.index', [
'automations' => Automation::orderBy('name')->get(),
]);
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Livewire\Discovery;
use App\Models\DiscoveryFinding;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
#[Layout('layouts.app')]
class Index extends Component
{
#[On('echo-private:discovery,.DeviceDiscovered')]
public function onDiscovered(): void {}
public function ignore(string $uuid): void
{
DiscoveryFinding::where('uuid', $uuid)->update(['status' => 'ignored']);
}
public function unignore(string $uuid): void
{
DiscoveryFinding::where('uuid', $uuid)->update(['status' => 'new']);
}
public function render()
{
$findings = DiscoveryFinding::query()->orderByDesc('last_seen_at')->orderByDesc('id')->get();
return view('livewire.discovery.index', [
'new' => $findings->where('status', 'new')->values(),
'ignored' => $findings->where('status', 'ignored')->values(),
]);
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Livewire\Persons;
use App\Models\Person;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
#[Layout('layouts.app')]
class Index extends Component
{
#[On('echo-private:presence,.PresenceChanged')]
public function onPresenceChanged(): void {}
public function render()
{
return view('livewire.persons.index', [
'persons' => Person::orderBy('name')->get(),
]);
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\Livewire\Settings;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')]
class Index extends Component
{
public function render()
{
$integrations = [
[
'key' => 'mqtt', 'icon' => 'network', 'name' => 'MQTT · Mosquitto',
'state' => filled(config('broadcasting.connections.reverb')) ? 'active' : 'inactive',
'detail' => config('mqtt-client.connections.default.host', 'mosquitto'),
],
[
'key' => 'unifi', 'icon' => 'wifi', 'name' => 'UniFi',
'state' => filled(env('UNIFI_HOST')) ? 'active' : 'inactive',
'detail' => env('UNIFI_HOST') ? env('UNIFI_HOST') : __('settings.not_configured'),
],
[
'key' => 'ring', 'icon' => 'shield', 'name' => 'Ring',
'state' => filled(env('RING_ENABLED')) ? 'active' : 'available',
'detail' => __('settings.addon'),
],
];
return view('livewire.settings.index', [
'integrations' => $integrations,
'version' => app()->version(),
]);
}
}

24
app/Models/Automation.php Normal file
View File

@ -0,0 +1,24 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use Illuminate\Database\Eloquent\Model;
class Automation extends Model
{
use HasUuid;
protected $fillable = [
'uuid', 'name', 'enabled', 'trigger_type', 'trigger_config',
'conditions', 'actions', 'cooldown_seconds', 'last_triggered_at',
];
protected $casts = [
'enabled' => 'boolean',
'trigger_config' => 'array',
'conditions' => 'array',
'actions' => 'array',
'last_triggered_at' => 'datetime',
];
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class DiscoveryFinding extends Model
{
use HasUuid;
protected $fillable = [
'uuid', 'source', 'identifier', 'name', 'vendor', 'model', 'ip', 'raw', 'status', 'device_id', 'last_seen_at',
];
protected $casts = ['raw' => 'array', 'last_seen_at' => 'datetime'];
public function device(): BelongsTo
{
return $this->belongsTo(Device::class);
}
}

28
app/Models/Person.php Normal file
View File

@ -0,0 +1,28 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Person extends Model
{
use HasUuid;
protected $table = 'persons';
protected $fillable = ['uuid', 'name', 'avatar_color', 'user_id', 'presence', 'presence_changed_at'];
protected $casts = ['presence_changed_at' => 'datetime'];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function isHome(): bool
{
return $this->presence === 'home';
}
}

View File

@ -0,0 +1,61 @@
<?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('persons', function (Blueprint $table) {
$table->id();
$table->uuid()->unique();
$table->string('name');
$table->string('avatar_color')->nullable();
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->string('presence')->default('unknown'); // home | away | unknown
$table->timestamp('presence_changed_at')->nullable();
$table->timestamps();
});
Schema::create('discovery_findings', function (Blueprint $table) {
$table->id();
$table->uuid()->unique();
$table->string('source'); // mdns | ssdp | arp
$table->string('identifier'); // mac or unique id
$table->string('name')->nullable();
$table->string('vendor')->nullable();
$table->string('model')->nullable();
$table->ipAddress('ip')->nullable();
$table->json('raw')->nullable();
$table->string('status')->default('new'); // new | assigned | ignored
$table->foreignId('device_id')->nullable()->constrained()->nullOnDelete();
$table->timestamp('last_seen_at')->nullable();
$table->timestamps();
$table->unique(['source', 'identifier']);
});
Schema::create('automations', function (Blueprint $table) {
$table->id();
$table->uuid()->unique();
$table->string('name');
$table->boolean('enabled')->default(true);
$table->string('trigger_type'); // state_change | time
$table->json('trigger_config')->nullable();
$table->json('conditions')->nullable();
$table->json('actions')->nullable();
$table->unsignedInteger('cooldown_seconds')->default(0);
$table->timestamp('last_triggered_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('automations');
Schema::dropIfExists('discovery_findings');
Schema::dropIfExists('persons');
}
};

8
lang/de/access.php Normal file
View File

@ -0,0 +1,8 @@
<?php
return [
'subtitle' => 'Zugangsschutz und Face-ID.',
'title' => 'Passkey-Zugangsschutz',
'planned' => 'Geplant',
'body' => 'Der geschützte Bereich wird künftig mit WebAuthn/Passkeys abgesichert (benötigt HTTPS + eigene Domain). Kamera-Gesichtserkennung nur als Komfort-Ebene, nie als Schloss.',
];

9
lang/de/automations.php Normal file
View File

@ -0,0 +1,9 @@
<?php
return [
'subtitle' => 'Regeln: Auslöser → Bedingung → Aktion.',
'empty_title' => 'Noch keine Automationen',
'empty_body' => 'Erstelle Regeln, die bei Zustandsänderungen oder zu bestimmten Zeiten Geräte schalten oder benachrichtigen.',
'trigger_state_change' => 'Bei Zustandsänderung',
'trigger_time' => 'Zeitgesteuert',
];

11
lang/de/discovery.php Normal file
View File

@ -0,0 +1,11 @@
<?php
return [
'subtitle' => 'Neue Teilnehmer im Netzwerk.',
'new_title' => 'Neue Geräte',
'scanning' => 'Netzwerk wird durchsucht… neue Geräte erscheinen hier automatisch.',
'assign' => 'Zuweisen',
'ignore' => 'Ignorieren',
'ignored_title' => 'Ignoriert',
'restore' => 'Wiederherstellen',
];

10
lang/de/persons.php Normal file
View File

@ -0,0 +1,10 @@
<?php
return [
'subtitle' => 'Bewohner und Anwesenheit.',
'empty_title' => 'Noch keine Personen',
'empty_body' => 'Personen werden mit der Anwesenheitserkennung (UniFi) angelegt und mit einem Gerät verknüpft.',
'presence_home' => 'Zuhause',
'presence_away' => 'Abwesend',
'presence_unknown' => 'Unbekannt',
];

15
lang/de/settings.php Normal file
View File

@ -0,0 +1,15 @@
<?php
return [
'subtitle' => 'Integrationen und System.',
'integrations' => 'Integrationen',
'system' => 'System',
'not_configured' => 'nicht konfiguriert',
'addon' => 'Addon',
'state_active' => 'Aktiv',
'state_inactive' => 'Inaktiv',
'state_available' => 'Verfügbar',
'locale' => 'Sprache',
'timezone' => 'Zeitzone',
'host' => 'Host',
];

8
lang/en/access.php Normal file
View File

@ -0,0 +1,8 @@
<?php
return [
'subtitle' => 'Access protection and Face ID.',
'title' => 'Passkey access protection',
'planned' => 'Planned',
'body' => 'The protected area will be secured with WebAuthn/passkeys (requires HTTPS + a real domain). Camera face recognition only ever as a comfort layer, never as the lock.',
];

9
lang/en/automations.php Normal file
View File

@ -0,0 +1,9 @@
<?php
return [
'subtitle' => 'Rules: trigger → condition → action.',
'empty_title' => 'No automations yet',
'empty_body' => 'Create rules that switch devices or notify on state changes or at set times.',
'trigger_state_change' => 'On state change',
'trigger_time' => 'Time-based',
];

11
lang/en/discovery.php Normal file
View File

@ -0,0 +1,11 @@
<?php
return [
'subtitle' => 'New participants on the network.',
'new_title' => 'New devices',
'scanning' => 'Scanning the network… new devices appear here automatically.',
'assign' => 'Assign',
'ignore' => 'Ignore',
'ignored_title' => 'Ignored',
'restore' => 'Restore',
];

10
lang/en/persons.php Normal file
View File

@ -0,0 +1,10 @@
<?php
return [
'subtitle' => 'Residents and presence.',
'empty_title' => 'No people yet',
'empty_body' => 'People are created with presence detection (UniFi) and linked to a device.',
'presence_home' => 'Home',
'presence_away' => 'Away',
'presence_unknown' => 'Unknown',
];

15
lang/en/settings.php Normal file
View File

@ -0,0 +1,15 @@
<?php
return [
'subtitle' => 'Integrations and system.',
'integrations' => 'Integrations',
'system' => 'System',
'not_configured' => 'not configured',
'addon' => 'Add-on',
'state_active' => 'Active',
'state_inactive' => 'Inactive',
'state_available' => 'Available',
'locale' => 'Language',
'timezone' => 'Timezone',
'host' => 'Host',
];

View File

@ -6,17 +6,17 @@
['key' => 'devices', 'icon' => 'devices', 'route' => 'devices.index', 'active' => 'devices.*', 'lock' => false],
],
'nav.section_persons' => [
['key' => 'persons', 'icon' => 'persons', 'route' => null, 'lock' => false],
['key' => 'persons', 'icon' => 'persons', 'route' => 'persons.index', 'active' => 'persons.*', 'lock' => false],
],
'nav.section_security' => [
['key' => 'windows', 'icon' => 'window', 'route' => 'windows', 'lock' => false],
['key' => 'access', 'icon' => 'shield', 'route' => null, 'lock' => true],
['key' => 'access', 'icon' => 'shield', 'route' => 'access', 'lock' => true],
],
'nav.section_system' => [
['key' => 'network', 'icon' => 'network', 'route' => null, 'lock' => false],
['key' => 'automations', 'icon' => 'automation', 'route' => null, 'lock' => false],
['key' => 'network', 'icon' => 'network', 'route' => 'network', 'lock' => false],
['key' => 'automations', 'icon' => 'automation', 'route' => 'automations.index', 'active' => 'automations.*', 'lock' => false],
['key' => 'host', 'icon' => 'activity', 'route' => 'host', 'lock' => false],
['key' => 'settings', 'icon' => 'settings', 'route' => null, 'lock' => true],
['key' => 'settings', 'icon' => 'settings', 'route' => 'settings', 'lock' => false],
],
];
@endphp

View File

@ -0,0 +1,18 @@
<div>
<x-topbar :title="__('nav.access')" :subtitle="__('access.subtitle')" />
<div class="px-5 lg:px-[26px] py-6 flex flex-col gap-5 max-w-[1560px] mx-auto w-full">
<x-panel>
<div class="flex flex-col items-center gap-3 py-8 text-center">
<span class="grid place-items-center w-12 h-12 rounded-xl bg-inset text-ink-3"><x-icon name="shield" :size="24" /></span>
<div>
<div class="text-[14px] font-semibold text-ink flex items-center justify-center gap-2">
{{ __('access.title') }}
<x-status-pill state="neutral">{{ __('access.planned') }}</x-status-pill>
</div>
<div class="text-[12.5px] text-ink-3 mt-2 max-w-md leading-relaxed">{{ __('access.body') }}</div>
</div>
</div>
</x-panel>
</div>
</div>

View File

@ -0,0 +1,32 @@
<div>
<x-topbar :title="__('nav.automations')" :subtitle="__('automations.subtitle')" />
<div class="px-5 lg:px-[26px] py-6 flex flex-col gap-5 max-w-[1560px] mx-auto w-full">
@if ($automations->isEmpty())
<x-panel>
<div class="flex flex-col items-center gap-3 py-8 text-center">
<span class="grid place-items-center w-12 h-12 rounded-xl bg-inset text-ink-3"><x-icon name="automation" :size="24" /></span>
<div>
<div class="text-[14px] font-semibold text-ink">{{ __('automations.empty_title') }}</div>
<div class="text-[12.5px] text-ink-3 mt-1 max-w-sm">{{ __('automations.empty_body') }}</div>
</div>
</div>
</x-panel>
@else
<x-panel>
<div class="flex flex-col divide-y divide-line-soft -my-1">
@foreach ($automations as $a)
<div class="flex items-center gap-3 py-3 first:pt-1 last:pb-1">
<span class="grid place-items-center w-9 h-9 rounded-lg bg-inset text-ink-2 shrink-0"><x-icon name="automation" :size="16" /></span>
<div class="min-w-0">
<div class="text-[13px] font-semibold text-ink truncate">{{ $a->name }}</div>
<div class="text-[11.5px] text-ink-3">{{ __('automations.trigger_'.$a->trigger_type) }}</div>
</div>
<x-toggle :on="$a->enabled" wire:click="toggle('{{ $a->uuid }}')" :label="$a->name" class="ml-auto" />
</div>
@endforeach
</div>
</x-panel>
@endif
</div>
</div>

View File

@ -0,0 +1,48 @@
<div wire:poll.15s>
<x-topbar :title="__('nav.network')" :subtitle="__('discovery.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="__('discovery.new_title')">
<x-slot:actions><x-badge :variant="$new->count() > 0 ? 'accent' : 'default'">{{ $new->count() }}</x-badge></x-slot:actions>
@if ($new->isEmpty())
<div class="flex items-center gap-3 py-4 text-ink-3">
<x-icon name="network" :size="18" class="pulse-live" />
<span class="text-[13px]">{{ __('discovery.scanning') }}</span>
</div>
@else
<div class="flex flex-col divide-y divide-line-soft -my-1">
@foreach ($new as $f)
<div class="flex items-center gap-3 py-3 first:pt-1 last:pb-1">
<span class="grid place-items-center w-9 h-9 rounded-lg bg-inset text-accent shrink-0"><x-icon name="devices" :size="17" /></span>
<div class="min-w-0">
<div class="text-[13px] font-semibold text-ink truncate">{{ $f->name ?? $f->identifier }}</div>
<div class="text-[11.5px] text-ink-3 font-mono truncate">{{ $f->source }} · {{ $f->ip ?? $f->identifier }}</div>
</div>
<div class="ml-auto flex items-center gap-2 shrink-0">
<button type="button" wire:click="$dispatch('openModal', { component: 'modals.assign-device', arguments: { finding: @js($f->uuid) } })"
class="rounded-lg bg-accent px-3 py-1.5 text-[12px] font-bold text-base hover:brightness-110 transition-[filter]">{{ __('discovery.assign') }}</button>
<button type="button" wire:click="ignore('{{ $f->uuid }}')"
class="rounded-lg border border-line px-2.5 py-1.5 text-[12px] font-semibold text-ink-3 hover:text-ink transition-colors">{{ __('discovery.ignore') }}</button>
</div>
</div>
@endforeach
</div>
@endif
</x-panel>
@if ($ignored->isNotEmpty())
<x-panel :title="__('discovery.ignored_title')">
<x-slot:actions><x-badge>{{ $ignored->count() }}</x-badge></x-slot:actions>
<div class="flex flex-col divide-y divide-line-soft -my-1">
@foreach ($ignored as $f)
<div class="flex items-center gap-3 py-2.5 first:pt-1 last:pb-1 text-ink-3">
<span class="text-[13px] truncate">{{ $f->name ?? $f->identifier }}</span>
<span class="text-[11px] font-mono truncate">{{ $f->identifier }}</span>
<button type="button" wire:click="unignore('{{ $f->uuid }}')" class="ml-auto text-[12px] font-semibold text-ink-2 hover:text-ink">{{ __('discovery.restore') }}</button>
</div>
@endforeach
</div>
</x-panel>
@endif
</div>
</div>

View File

@ -0,0 +1,34 @@
<div wire:poll.30s>
<x-topbar :title="__('nav.persons')" :subtitle="__('persons.subtitle')" />
<div class="px-5 lg:px-[26px] py-6 flex flex-col gap-5 max-w-[1560px] mx-auto w-full">
@if ($persons->isEmpty())
<x-panel>
<div class="flex flex-col items-center gap-3 py-8 text-center">
<span class="grid place-items-center w-12 h-12 rounded-xl bg-inset text-ink-3"><x-icon name="persons" :size="24" /></span>
<div>
<div class="text-[14px] font-semibold text-ink">{{ __('persons.empty_title') }}</div>
<div class="text-[12.5px] text-ink-3 mt-1 max-w-sm">{{ __('persons.empty_body') }}</div>
</div>
</div>
</x-panel>
@else
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
@foreach ($persons as $person)
<x-panel>
<div class="flex items-center gap-3">
<span class="grid place-items-center w-11 h-11 rounded-full bg-inset text-ink font-bold text-[15px] uppercase">{{ mb_substr($person->name, 0, 1) }}</span>
<div class="min-w-0">
<div class="text-[14px] font-bold text-ink truncate">{{ $person->name }}</div>
<div class="text-[11.5px] text-ink-3">{{ $person->presence_changed_at?->diffForHumans() ?? '—' }}</div>
</div>
<x-status-pill :state="$person->isHome() ? 'online' : ($person->presence === 'away' ? 'neutral' : 'warning')" class="ml-auto">
{{ __('persons.presence_'.$person->presence) }}
</x-status-pill>
</div>
</x-panel>
@endforeach
</div>
@endif
</div>
</div>

View File

@ -0,0 +1,31 @@
<div>
<x-topbar :title="__('nav.settings')" :subtitle="__('settings.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="__('settings.integrations')">
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3">
@foreach ($integrations as $i)
<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="$i['icon']" :size="18" /></span>
<div class="min-w-0">
<div class="text-[13px] font-semibold text-ink truncate">{{ $i['name'] }}</div>
<div class="text-[11.5px] text-ink-3 font-mono truncate">{{ $i['detail'] }}</div>
</div>
<x-status-pill :state="$i['state'] === 'active' ? 'online' : ($i['state'] === 'inactive' ? 'offline' : 'neutral')" class="ml-auto shrink-0">
{{ __('settings.state_'.$i['state']) }}
</x-status-pill>
</div>
@endforeach
</div>
</x-panel>
<x-panel :title="__('settings.system')">
<dl class="grid grid-cols-2 sm:grid-cols-4 gap-x-4 gap-y-3">
<x-detail :label="__('host.stack_framework')" mono>Laravel {{ $version }}</x-detail>
<x-detail :label="__('settings.locale')" mono>{{ strtoupper(app()->getLocale()) }}</x-detail>
<x-detail :label="__('settings.timezone')" mono>{{ config('app.timezone') }}</x-detail>
<x-detail :label="__('settings.host')"><a href="{{ route('host') }}" wire:navigate class="text-accent hover:underline">{{ __('nav.host') }}</a></x-detail>
</dl>
</x-panel>
</div>
</div>

View File

@ -12,3 +12,7 @@ Broadcast::channel('home', fn ($user) => $user !== null);
// Per-device / per-room channels (used by detail views).
Broadcast::channel('devices.{uuid}', fn ($user, string $uuid) => $user !== null);
Broadcast::channel('rooms.{uuid}', fn ($user, string $uuid) => $user !== null);
// Presence + discovery live channels.
Broadcast::channel('presence', fn ($user) => $user !== null);
Broadcast::channel('discovery', fn ($user) => $user !== null);

View File

@ -1,12 +1,17 @@
<?php
use App\Livewire\Access;
use App\Livewire\Auth\Login;
use App\Livewire\Automations\Index as AutomationIndex;
use App\Livewire\Dashboard;
use App\Livewire\Devices\Index as DeviceIndex;
use App\Livewire\Devices\Show as DeviceShow;
use App\Livewire\Discovery\Index as DiscoveryIndex;
use App\Livewire\Host;
use App\Livewire\Persons\Index as PersonIndex;
use App\Livewire\Rooms\Index as RoomIndex;
use App\Livewire\Rooms\Show as RoomShow;
use App\Livewire\Settings\Index as SettingsIndex;
use App\Livewire\Windows;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route;
@ -24,6 +29,11 @@ Route::middleware('auth')->group(function () {
Route::get('/devices', DeviceIndex::class)->name('devices.index');
Route::get('/devices/{device}', DeviceShow::class)->name('devices.show');
Route::get('/windows', Windows::class)->name('windows');
Route::get('/persons', PersonIndex::class)->name('persons.index');
Route::get('/access', Access::class)->name('access');
Route::get('/network', DiscoveryIndex::class)->name('network');
Route::get('/automations', AutomationIndex::class)->name('automations.index');
Route::get('/settings', SettingsIndex::class)->name('settings');
Route::get('/host', Host::class)->name('host');
Route::post('/logout', function () {