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
parent
1b69c39a93
commit
f00a1ac069
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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()),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -14,4 +14,5 @@ return [
|
|||
'logout' => 'Abmelden',
|
||||
'menu' => 'Menü',
|
||||
'close' => 'Schließen',
|
||||
'cancel' => 'Abbrechen',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -14,4 +14,5 @@ return [
|
|||
'logout' => 'Sign out',
|
||||
'menu' => 'Menu',
|
||||
'close' => 'Close',
|
||||
'cancel' => 'Cancel',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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') }}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
@livewire('wire-elements-modal')
|
||||
|
||||
@livewireScripts
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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">​</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>
|
||||
|
|
@ -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 () {
|
||||
|
|
|
|||
Loading…
Reference in New Issue