diff --git a/app/Http/Controllers/DevicePopupController.php b/app/Http/Controllers/DevicePopupController.php index f19a9af27d..6519fcecae 100644 --- a/app/Http/Controllers/DevicePopupController.php +++ b/app/Http/Controllers/DevicePopupController.php @@ -28,12 +28,14 @@ use App\Facades\LibrenmsConfig; use App\Models\Device; +use Illuminate\Http\Request; use Illuminate\Support\Facades\Gate; +use Illuminate\Support\Str; use LibreNMS\Util\Graph; class DevicePopupController { - public function __invoke(Device $device) + public function __invoke(Request $request, Device $device) { if (! LibrenmsConfig::get('web_mouseover', true)) { return response('Disabled'); @@ -42,7 +44,31 @@ public function __invoke(Device $device) // Check access permissions Gate::authorize('view', $device); - // Build graphs HTML using existing graph-row component + return view('device.popup', [ + 'device' => $device, + 'osText' => LibrenmsConfig::getOsSetting($device->os ?? '', 'text'), + 'href' => route('device', ['device' => $device->device_id]), + 'graphs' => $this->buildGraphs($request, $device), + ]); + } + + /** + * @return array[] + */ + private function buildGraphs(Request $request, Device $device): array + { + $type = $request->string('type'); + if ($type->isNotEmpty()) { + return [ + [ + 'device' => $device, + 'type' => $type, + 'title' => Str::title($type), + 'graphs' => [['from' => '-1d'], ['from' => '-7d'], ['from' => '-14d'], ['from' => '-30d']], + ], + ]; + } + $graphs = []; foreach (Graph::getOverviewGraphsForDevice($device) as $graph) { if (isset($graph['text'], $graph['graph'])) { @@ -55,11 +81,6 @@ public function __invoke(Device $device) } } - return view('device.popup', [ - 'device' => $device, - 'osText' => LibrenmsConfig::getOsSetting($device->os ?? '', 'text'), - 'href' => route('device', ['device' => $device->device_id]), - 'graphs' => $graphs, - ]); + return $graphs; } } diff --git a/app/Http/Controllers/DevicesController.php b/app/Http/Controllers/DevicesController.php new file mode 100644 index 0000000000..e6cdf77b00 --- /dev/null +++ b/app/Http/Controllers/DevicesController.php @@ -0,0 +1,229 @@ +validate([ + 'format' => 'in:list_basic,list_detail,graph_bits,graph_processor,graph_ucd_load,graph_mempool,graph_uptime,graph_storage,graph_diskio,graph_poller_perf,graph_icmp_perf,graph_temperature', // legacy + 'bare' => ['nullable', 'in:yes'], + 'searchbar' => ['nullable', 'in:hide'], + 'per_page' => ['nullable', 'integer'], + 'page' => ['nullable', 'integer'], + 'to' => ['nullable', 'date_or_relative'], + 'from' => ['nullable', 'date_or_relative'], + ...Device::filterValidationRules(), + 'sort' => Rule::in([ + 'status', + 'device_id', + 'maintenance', + 'hostname', + 'hardware', + 'os', + 'uptime', + 'location', + ]), + ]); + + $bare = $request->input('bare') === 'yes'; + $hideFilter = $request->input('searchbar') === 'hide'; + $perPage = $request->integer('per_page', 50); + + $legacyFormat = (string) $request->string('format'); + if ($legacyFormat) { + if (str_starts_with($legacyFormat, 'graph_')) { + $view ??= 'graph'; + $graph ??= substr($legacyFormat, 6); + } else { + $view ??= match ($legacyFormat) { + 'list_basic' => 'basic', + default => 'detail', + }; + $graph ??= ''; + } + } + + $graphTemplate = [ + 'height' => 110, + 'width' => session('widescreen') ? 270 : 315, + 'id' => 0, + 'type' => 'device_' . $graph, + 'from' => $request->input('from', '-1d'), + 'legend' => 'no', + 'title' => 'yes', + ]; + if ($request->input('to')) { + $graphTemplate['to'] = $request->input('to'); + } + + $devices = $this->getDevices($view, $perPage); + + return view('device.index', [ + 'view' => $view, + 'graph' => $graph, + 'detailed' => $view === 'detail', + 'devices' => $devices, + 'deviceGraphs' => $devices->map(function (Device $device) use ($graphTemplate) { + $graph = array_merge($graphTemplate, ['device' => $device->device_id]); + + return [ + 'link' => Url::graphPageUrl($graph['type'], Arr::except($graph, ['height', 'width', 'legend', 'title'])), + 'graphTag' => Url::lazyGraphTag($graph, 'tw:w-full tw:h-auto'), + 'deviceLinkOptions' => ['device_id' => $device->device_id, 'params' => ['type' => $graph['type']]], + ]; + }), + 'perPage' => $perPage, + 'paginationOptions' => [50, 100, 250, -1], + 'nav' => [ + 'detail' => ['text' => 'Detail', 'link' => route('devices', ['view' => 'detail', ...$request->query()])], + 'basic' => ['text' => 'Basic', 'link' => route('devices', ['view' => 'basic', ...$request->query()])], + ], + 'graphNav' => [ + 'bits' => ['text' => 'Bits', 'link' => route('devices', ['view' => 'graph', 'graph' => 'bits', ...$request->query()])], + 'processor' => ['text' => 'CPU', 'link' => route('devices', ['view' => 'graph', 'graph' => 'processor', ...$request->query()])], + 'ucd_load' => ['text' => 'Load', 'link' => route('devices', ['view' => 'graph', 'graph' => 'ucd_load', ...$request->query()])], + 'mempool' => ['text' => 'Memory', 'link' => route('devices', ['view' => 'graph', 'graph' => 'mempool', ...$request->query()])], + 'uptime' => ['text' => 'Uptime', 'link' => route('devices', ['view' => 'graph', 'graph' => 'uptime', ...$request->query()])], + 'storage' => ['text' => 'Storage', 'link' => route('devices', ['view' => 'graph', 'graph' => 'storage', ...$request->query()])], + 'diskio' => ['text' => 'Disk I/O', 'link' => route('devices', ['view' => 'graph', 'graph' => 'diskio', ...$request->query()])], + 'poller_perf' => ['text' => 'Poller', 'link' => route('devices', ['view' => 'graph', 'graph' => 'poller_perf', ...$request->query()])], + 'icmp_perf' => ['text' => 'Ping', 'link' => route('devices', ['view' => 'graph', 'graph' => 'icmp_perf', ...$request->query()])], + 'temperature' => ['text' => 'Temperature', 'link' => route('devices', ['view' => 'graph', 'graph' => 'temperature', ...$request->query()])], + ], + 'bare' => $bare, + 'bareLink' => $bare ? $request->fullUrlWithoutQuery('bare') : $request->fullUrlWithQuery(['bare' => 'yes']), + 'filter' => $request->array('filter'), + 'hideFilterLink' => $hideFilter ? $request->fullUrlWithoutQuery('searchbar') : $request->fullUrlWithQuery(['searchbar' => 'hide']), + 'hideFilter' => $hideFilter, + 'filterFields' => $this->filterFields(), + 'graphTemplate' => $graphTemplate, + 'group' => $request->input('filter.groups\.id.eq'), + ]); + } + + private function getDevices(?string $view, int $perPage): LengthAwarePaginator|Collection + { + if ($view !== 'graph') { + return new Collection; + } + + $devicesQuery = Device::hasAccess(request()->user()) + ->select(['devices.*']) + ->with('location') + ->when(request()->array('filter'), fn (Builder $query, $filters) => $query->applyFilters($filters)); + + $devicesQuery->orderBy('hostname'); + + return $devicesQuery->paginate($perPage); + } + + private function filterFields(): array + { + return [ + [ + 'key' => 'search', + 'label' => __('Search'), + 'type' => 'text', + ], + [ + 'key' => 'state', + 'label' => __('device.status'), + 'type' => 'select', + 'options' => [ + 'up' => __('device.status_up'), + 'down' => __('device.status_down'), + ], + ], + [ + 'key' => 'os', + 'label' => __('device.os'), + 'type' => 'select', + 'endpoint' => route('ajax.select.device-field'), + 'params' => [ + 'field' => 'os', + ], + ], + [ + 'key' => 'version', + 'label' => __('Version'), + 'type' => 'select', + 'endpoint' => route('ajax.select.device-field'), + 'params' => [ + 'field' => 'version', + ], + ], + [ + 'key' => 'hardware', + 'label' => __('Platform'), + 'type' => 'select', + 'endpoint' => route('ajax.select.device-field'), + 'params' => [ + 'field' => 'hardware', + ], + ], + [ + 'key' => 'features', + 'label' => __('Featureset'), + 'type' => 'select', + 'endpoint' => route('ajax.select.device-field'), + 'params' => [ + 'field' => 'features', + ], + ], + [ + 'key' => 'location_id', + 'label' => __('Location'), + 'type' => 'select', + 'endpoint' => route('ajax.select.location'), + ], + [ + 'key' => 'type', + 'label' => __('device.device_type'), + 'type' => 'select', + 'endpoint' => route('ajax.select.device-field'), + 'params' => [ + 'field' => 'type', + ], + ], + [ + 'key' => 'groups.id', + 'label' => __('device.device_group'), + 'type' => 'select', + 'endpoint' => route('ajax.select.device-group'), + ], + [ + 'key' => 'poller_group', + 'label' => __('device.edit.poller_group'), + 'type' => 'select', + 'endpoint' => route('ajax.select.poller-group'), + ], + [ + 'key' => 'disabled', + 'label' => __('Disabled'), + 'type' => 'boolean', + ], + [ + 'key' => 'ignore', + 'label' => __('Ignored'), + 'type' => 'boolean', + ], + [ + 'key' => 'disable_notify', + 'label' => __('device.alerts_disabled'), + 'type' => 'boolean', + ], + ]; + } +} diff --git a/app/Http/Controllers/Table/DeviceController.php b/app/Http/Controllers/Table/DeviceController.php index bd36bdb080..0e86e1aa21 100644 --- a/app/Http/Controllers/Table/DeviceController.php +++ b/app/Http/Controllers/Table/DeviceController.php @@ -53,6 +53,7 @@ protected function rules(): array { return [ 'format' => 'nullable|in:list_basic,list_detail', + ...Device::filterValidationRules(), 'os' => 'nullable|string', 'version' => 'nullable|string', 'hardware' => 'nullable|string', @@ -101,6 +102,7 @@ protected function baseQuery(Request $request): Builder /** @var Builder $query */ $query = Device::hasAccess($request->user()) ->with(['location', 'groups']) + ->applyFilters($request->array('filter')) ->withCount(['ports', 'sensors', 'wirelessSensors']); // if searching or sorting the location field, join the locations table diff --git a/app/Models/Device.php b/app/Models/Device.php index e4df3d084d..a17cbe6d53 100644 --- a/app/Models/Device.php +++ b/app/Models/Device.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Facades\LibrenmsConfig; +use App\Models\Traits\Filterable; use App\View\SimpleTemplate; use Carbon\Carbon; use Fico7489\Laravel\Pivot\Traits\PivotEventTrait; @@ -38,7 +39,7 @@ */ class Device extends BaseModel { - use PivotEventTrait, HasFactory; + use PivotEventTrait, HasFactory, Filterable; private ?MaintenanceStatus $maintenanceStatus = null; @@ -88,6 +89,27 @@ class Device extends BaseModel 'uptime', ]; + protected array $filterable = [ + 'device_id', + 'hostname', + 'sysName', + 'display', + 'hardware', + 'os', + 'location_id', + 'version', + 'features', + 'type', + 'status', + 'disabled', + 'ignore', + 'disable_notify', + 'poller_group', + 'groups.id', + 'search', + 'state', + ]; + /** * @return array{inserted: 'datetime', last_discovered: 'datetime', last_polled: 'datetime', last_ping: 'datetime', status: 'boolean'} */ @@ -540,6 +562,25 @@ public function setSysNameAttribute(?string $sysName): void // ---- Query scopes ---- + public function filterState(Builder $query, mixed $value, array $config): void + { + $this->applyMappedFilter($query, $value, $config, fn (Builder $q, $state) => match ($state) { + 'up' => $q->where('status', 1)->where('disabled', 0)->where('disable_notify', 0), + 'down' => $q->where('status', 0)->where('disabled', 0)->where('disable_notify', 0), + default => $q, + }); + } + + public function filterSearch(Builder $query, mixed $value, array $config): void + { + $this->applyFilterSearch( + ['sysName', 'hostname', 'display', 'hardware', 'os', 'location.location'], + $query, + $value, + $config, + ); + } + public function scopeIsUp($query) { return $query->where([ diff --git a/includes/html/pages/devices.inc.php b/includes/html/pages/devices.inc.php index 33d02d4b50..cfd64cde4f 100644 --- a/includes/html/pages/devices.inc.php +++ b/includes/html/pages/devices.inc.php @@ -1,433 +1,3 @@ where('id', $device_group_id)->value('name') ?? __('Group not found'); - } - ?> -
- - - - -
- ' . __('Lists') . ': '; - -$menu_options = ['basic' => __('Basic'), 'detail' => __('Detail')]; - -$sep = ''; -foreach ($menu_options as $option => $text) { - $listoptions .= $sep; - if ($vars['format'] == 'list_' . $option) { - $listoptions .= ''; - } - $listoptions .= '' . $text . ''; - if ($vars['format'] == 'list_' . $option) { - $listoptions .= ''; - } - $sep = ' | '; -} - -$listoptions .= '   ' . __('Graphs') . ': '; - -$menu_options = ['bits' => __('Bits'), - 'processor' => __('CPU'), - 'ucd_load' => __('Load'), - 'mempool' => __('Memory'), - 'uptime' => __('Uptime'), - 'storage' => __('Storage'), - 'diskio' => __('Disk I/O'), - 'poller_perf' => __('Poller'), - 'icmp_perf' => __('Ping'), - 'temperature' => __('sensors.temperature.long'), -]; -$sep = ''; -foreach ($menu_options as $option => $text) { - $listoptions .= $sep; - if ($vars['format'] == 'graph_' . $option) { - $listoptions .= ''; - } - $listoptions .= '' . $text . ''; - if ($vars['format'] == 'graph_' . $option) { - $listoptions .= ''; - } - $sep = ' | '; -} - -$headeroptions = ''; - -if (isset($vars['searchbar']) && $vars['searchbar'] == 'hide') { - $headeroptions .= '' . __('Restore Search') . ''; -} else { - $headeroptions .= '' . __('Remove Search') . ''; -} - -$headeroptions .= ' | '; - -if (isset($vars['bare']) && $vars['bare'] == 'yes') { - $headeroptions .= '' . __('Restore Header') . ''; -} else { - $headeroptions .= '' . __('Remove Header') . ''; -} - -[$format, $subformat] = explode('_', $vars['format'], 2); -$detailed = $subformat == 'detail'; -$no_refresh = $format == 'list'; - -if ($format == 'graph') { - if (empty($vars['from'])) { - $graph_array['from'] = LibrenmsConfig::get('time.day'); - } else { - $graph_array['from'] = $vars['from']; - } - if (empty($vars['to'])) { - $graph_array['to'] = LibrenmsConfig::get('time.now'); - } else { - $graph_array['to'] = $vars['to']; - } - - echo '
'; - echo '
'; - echo '
'; - echo '
' . $listoptions . '
'; - echo '
' . $headeroptions . '
'; - echo '
'; - include_once 'includes/html/print-date-selector.inc.php'; - echo '
'; - echo '
'; - echo '
'; - echo '
'; - - $where = ''; - $sql_param = []; - - if (isset($vars['state'])) { - if ($vars['state'] == 'up') { - $state = '1'; - } elseif ($vars['state'] == 'down') { - $state = '0'; - } - } - - if (! empty($vars['searchquery'])) { - $where .= ' AND (sysName LIKE ? OR hostname LIKE ? OR display LIKE ? OR hardware LIKE ? OR os LIKE ? OR location LIKE ?)'; - $sql_param += array_fill(0, 6, '%' . $vars['searchquery'] . '%'); - } - if (! empty($vars['os'])) { - $where .= ' AND os = ?'; - $sql_param[] = $vars['os']; - } - if (! empty($vars['version'])) { - $where .= ' AND version = ?'; - $sql_param[] = $vars['version']; - } - if (! empty($vars['hardware'])) { - $where .= ' AND hardware = ?'; - $sql_param[] = $vars['hardware']; - } - if (! empty($vars['features'])) { - $where .= ' AND features = ?'; - $sql_param[] = $vars['features']; - } - - if (! empty($vars['type'])) { - if ($vars['type'] == 'generic') { - $where .= " AND ( type = ? OR type = '')"; - $sql_param[] = $vars['type']; - } else { - $where .= ' AND type = ?'; - $sql_param[] = $vars['type']; - } - } - if (! empty($vars['state'])) { - $where .= ' AND status= ?'; - $sql_param[] = $state; - $where .= " AND disabled='0' AND `disable_notify`='0'"; - } - if (! empty($vars['disabled'])) { - $where .= ' AND disabled= ?'; - $sql_param[] = $vars['disabled']; - } - if (! empty($vars['ignore'])) { - $where .= ' AND `ignore`= ?'; - $sql_param[] = $vars['ignore']; - } - if (! empty($vars['disable_notify'])) { - $where .= ' AND `disable_notify`= ?'; - $sql_param[] = $vars['disable_notify']; - } - if (! empty($vars['location']) && $vars['location'] != 'Unset') { - if (is_numeric($vars['location'])) { - $where .= ' AND `locations`.`id`= ?'; - $sql_param[] = $vars['location']; - } else { - $where .= ' AND `locations`.`location`= ?'; - $sql_param[] = $vars['location']; - } - } - if (isset($vars['poller_group'])) { - $where .= ' AND `poller_group`= ?'; - $sql_param[] = $vars['poller_group']; - } - if (! empty($vars['group'])) { - $where .= ' AND ( '; - foreach (DB::table('device_group_device')->where('device_group_id', $vars['group'])->pluck('device_id') as $dev) { - $where .= 'device_id = ? OR '; - $sql_param[] = $dev; - } - $where = substr($where, 0, strlen($where) - 3); - $where .= ' )'; - - show_device_group($vars['group']); - } - - $query = 'SELECT * FROM `devices` LEFT JOIN `locations` ON `devices`.`location_id` = `locations`.`id` WHERE 1'; - - if (isset($where)) { - $query .= $where; - } - - $query .= ' ORDER BY hostname'; - - $row = 1; - foreach (dbFetchRows($query, $sql_param) as $device) { - if (is_int($row / 2)) { - $row_colour = LibrenmsConfig::get('list_colour.even'); - } else { - $row_colour = LibrenmsConfig::get('list_colour.odd'); - } - - if (device_permitted($device['device_id'])) { - $graph_type = 'device_' . $subformat; - - if (session('widescreen')) { - $width = 270; - } else { - $width = 315; - } - - $graph_array_new = []; - $graph_array_new['type'] = $graph_type; - $graph_array_new['device'] = $device['device_id']; - $graph_array_new['height'] = '110'; - $graph_array_new['width'] = $width; - $graph_array_new['legend'] = 'no'; - $graph_array_new['title'] = 'yes'; - $graph_array_new['from'] = $graph_array['from']; - $graph_array_new['to'] = $graph_array['to']; - - $graph_array_zoom = $graph_array_new; - $graph_array_zoom['height'] = '150'; - $graph_array_zoom['width'] = '400'; - $graph_array_zoom['legend'] = 'yes'; - - $link_array = $graph_array; - $link_array['page'] = 'graphs'; - $link_array['type'] = $graph_type; - $link_array['device'] = $device['device_id']; - unset($link_array['height'], $link_array['width']); - $overlib_link = Url::generate($link_array); - echo '
'; - echo '
'; - echo Url::overlibLink($overlib_link, Url::lazyGraphTag($graph_array_new), Url::graphTag($graph_array_zoom)); - echo "
\n\n"; - } - } - echo '
'; -} else { - $state = $vars['state'] ?? ''; - $state_selection = "'; - - $features_selected = isset($vars['features']) ? json_encode(['id' => $vars['features'], 'text' => $vars['features']]) : '""'; - $hardware_selected = isset($vars['hardware']) ? json_encode(['id' => $vars['hardware'], 'text' => $vars['hardware']]) : '""'; - $os_selected = isset($vars['os']) ? json_encode(['id' => $vars['os'], 'text' => $vars['os']]) : '""'; - $type_selected = isset($vars['type']) ? json_encode(['id' => $vars['type'], 'text' => ucfirst($vars['type'])]) : '""'; - $version_selected = isset($vars['version']) ? json_encode(['id' => $vars['version'], 'text' => $vars['version']]) : '""'; - - $os_selected = '""'; - if (isset($vars['os'])) { - $os_selected = json_encode(['id' => $vars['os'], 'text' => LibrenmsConfig::getOsSetting($vars['os'], 'text', $vars['os'])]); - } - - $location_selected = '""'; - if (isset($vars['location'])) { - $location_text = $vars['location']; - if (is_numeric($vars['location'])) { - $location_text = Location::where('id', $vars['location'])->value('location') ?: $vars['location']; - } - $location_selected = json_encode(['id' => $vars['location'], 'text' => $location_text]); - } ?> -
-
-
-
-
-
-
-
- - - - - - - - - - - - - - - - - -
>
-
-
- - 'Please select', 'warning_monitored' => 'Warning, this will remove the device from being monitored!', 'warning_data' => 'It will also remove historical data about this device such as:', + 'show_filter' => 'Show Filter', + 'show_header' => 'Show Header', + 'os' => 'OS', + 'status' => 'Status', + 'status_up' => 'Up', + 'status_down' => 'Down', + 'device_type' => 'Device Type', + 'device_group' => 'Device Group', + 'alerts_disabled' => 'Alerts Disabled', 'edit' => [ 'delete_device' => 'Delete Device', diff --git a/lang/en/port.php b/lang/en/port.php index 613b4a91e1..a804e8a4cd 100644 --- a/lang/en/port.php +++ b/lang/en/port.php @@ -1,7 +1,7 @@ 'Show Filter', + 'show_filter' => 'Show Filter', 'show_header' => 'Show Header', 'purge' => 'Purge all deleted', 'purged' => 'Purged', diff --git a/resources/views/device/graphs.blade.php b/resources/views/device/graphs.blade.php new file mode 100644 index 0000000000..f8a8a799c3 --- /dev/null +++ b/resources/views/device/graphs.blade.php @@ -0,0 +1,59 @@ +
+ + +
+
+ +
+

+ {{ __('Device Group') }}: + +

+ +
+ @foreach($deviceGraphs as $graph) +
+ + {!! $graph['graphTag'] !!} + +
+ @endforeach +
+
+ + +
+
+ + +
+
{{ $devices->appends(request()->all())->links() }}
+
+
+ +@push('scripts') + +@endpush diff --git a/resources/views/device/index.blade.php b/resources/views/device/index.blade.php new file mode 100644 index 0000000000..a48597005b --- /dev/null +++ b/resources/views/device/index.blade.php @@ -0,0 +1,35 @@ +@extends('layouts.librenmsv1') + +@section('title', __('Devices')) + +@section('content') +
+ + + + + @if($view === 'graph') + @include('device.graphs') + @else + @include('device.list') + @endif + + +
+@endsection diff --git a/resources/views/device/list.blade.php b/resources/views/device/list.blade.php new file mode 100644 index 0000000000..860ea951ce --- /dev/null +++ b/resources/views/device/list.blade.php @@ -0,0 +1,91 @@ + + +
+

+ {{ __('Device Group') }}: + +

+
+ + + + + + + + + + + + + + + + +
{{ $detailed ? 'S.' : __('Status') }}{{ __('Id') }}{{ $detailed ? 'M.' : __('Maintenance') }}{{ __('Vendor') }}{{ __('Device') }}{{ __('Metrics') }}{{ __('Platform') }}{{ __('device.attributes.os') }}{{ __('Up/Down Time') }}{{ __('device.attributes.location') }}{{ __('Actions') }}
+
+ + @push('scripts') + + @endpush +
diff --git a/routes/web.php b/routes/web.php index 6f522efe99..543f5e6fee 100644 --- a/routes/web.php +++ b/routes/web.php @@ -15,6 +15,7 @@ use App\Http\Controllers\Device; use App\Http\Controllers\DeviceController; use App\Http\Controllers\DeviceGroupController; +use App\Http\Controllers\DevicesController; use App\Http\Controllers\GraphController; use App\Http\Controllers\Install; use App\Http\Controllers\LegacyController; @@ -90,6 +91,7 @@ Route::middleware(['auth'])->group(function (): void { // pages Route::post('alert/{alert}/ack', [AlertController::class, 'ack'])->name('alert.ack'); + Route::get('devices/{view?}/{graph?}', [DevicesController::class, 'index'])->name('devices'); Route::resource('device-groups', DeviceGroupController::class); Route::any('inventory', App\Http\Controllers\InventoryController::class)->name('inventory'); Route::get('inventory/purge', [App\Http\Controllers\InventoryController::class, 'purge'])->name('inventory.purge');