Convert the inventory page to Laravel (#15004)

* Convert the inventory page to Laravel
Fix several XSS issues (hopefully no new ones snuck in)
Small improvement to the SelectController to allow filtering by filterFields()

* style fixes

* Fix lint issues

* Fix part device filter
This commit is contained in:
Tony Murray
2023-04-28 07:51:41 -05:00
committed by GitHub
parent 75f8fe214f
commit 5c25cece48
14 changed files with 345 additions and 208 deletions

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Http\Controllers;
use App\Models\EntPhysical;
use Illuminate\Contracts\View\View;
use Illuminate\Http\Request;
class InventoryController extends Controller
{
public function __invoke(Request $request): View
{
$this->validate($request, [
'device' => 'nullable|int',
'descr' => 'nullable|string',
'model' => 'nullable|string',
'serial' => 'nullable|string',
]);
$device = \App\Models\Device::hasAccess($request->user())
->select(['device_id', 'hostname', 'ip', 'sysName', 'display'])
->firstWhere('device_id', $request->get('device'));
$model_filter = ['field' => 'model'];
$device_selected = '';
if ($device) {
$device_selected = ['id' => $device->device_id, 'text' => $device->displayName()];
$model_filter['device_id'] = $device->device_id;
}
return view('inventory', [
'device_selected' => $device_selected,
'filter' => [
'device' => $device?->device_id,
'descr' => $request->get('descr'),
'model' => $request->get('model'),
'serial' => $request->get('serial'),
],
'model_filter' => $model_filter,
'show_purge' => EntPhysical::whereDoesntHave('device')->exists(),
]);
}
public function purge()
{
EntPhysical::whereDoesntHave('device')->delete();
return redirect()->back();
}
}

View File

@@ -0,0 +1,70 @@
<?php
/**
* EntPhysicalController.php
*
* -Description-
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* @link https://www.librenms.org
*
* @copyright 2023 Tony Murray
* @author Tony Murray <murraytony@gmail.com>
*/
namespace App\Http\Controllers\Select;
use App\Models\EntPhysical;
class InventoryController extends SelectController
{
protected function rules()
{
return [
'field' => 'required|in:name,model,descr,class',
'device' => 'nullable|int',
];
}
protected function filterFields($request)
{
return ['device_id'];
}
protected function searchFields($request)
{
return [$this->fieldToColumn($request->get('field'))];
}
protected function baseQuery($request)
{
$column = $this->fieldToColumn($request->get('field'));
return EntPhysical::hasAccess($request->user())
->select($column)
->orderBy($column)
->distinct();
}
private function fieldToColumn(string $field): string
{
return match ($field) {
'name' => 'entPhysicalName',
'model' => 'entPhysicalModelName',
'descr' => 'entPhysicalDescr',
'class' => 'entPhysicalClass',
default => 'entPhysicalName',
};
}
}

View File

@@ -42,6 +42,19 @@ class PortFieldController extends SelectController
];
}
/**
* Defines fields that can be used as filters
*
* @param $request
* @return string[]
*/
protected function filterFields($request)
{
return [
'device_id' => 'device',
];
}
/**
* Defines search fields will be searched in order
*
@@ -61,14 +74,7 @@ class PortFieldController extends SelectController
*/
protected function baseQuery($request)
{
/** @var \Illuminate\Database\Eloquent\Builder $query */
$query = Port::hasAccess($request->user())
return Port::hasAccess($request->user())
->select($request->get('field'))->distinct();
if ($device_id = $request->get('device')) {
$query->where('ports.device_id', $device_id);
}
return $query;
}
}

View File

@@ -57,7 +57,8 @@ abstract class SelectController extends PaginatedAjaxController
$query = $this->baseQuery($request)->when($request->has('id'), function ($query) {
return $query->whereKey(request('id'));
});
$query = $this->search($request->get('term'), $query, $this->searchFields($request));
$this->filter($request, $query, $this->filterFields($request));
$this->search($request->get('term'), $query, $this->searchFields($request));
$this->sort($request, $query);
$paginator = $query->simplePaginate($limit);

View File

@@ -0,0 +1,96 @@
<?php
/**
* InventoryController.php
*
* -Description-
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* @link https://www.librenms.org
*
* @copyright 2023 Tony Murray
* @author Tony Murray <murraytony@gmail.com>
*/
namespace App\Http\Controllers\Table;
use App\Models\EntPhysical;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use LibreNMS\Util\Url;
class InventoryController extends TableController
{
public function rules()
{
return [
'device' => 'nullable|int',
'descr' => 'nullable|string',
'model'=> 'nullable|string',
'serial' => 'nullable|string',
];
}
protected function filterFields($request)
{
return [
'device_id' => 'device',
];
}
protected function searchFields($request)
{
return ['entPhysicalDescr', 'entPhysicalModelName', 'entPhysicalSerialNum'];
}
protected function sortFields($request)
{
return [
'device' => 'device_id',
'name' => 'entPhysicalName',
'descr' => 'entPhysicalDescr',
'model' => 'entPhysicalModelName',
'serial' => 'entPhysicalSerialNum',
];
}
protected function baseQuery($request)
{
$query = EntPhysical::hasAccess($request->user())
->with('device')
->select(['entPhysical_id', 'device_id', 'entPhysicalDescr', 'entPhysicalName', 'entPhysicalModelName', 'entPhysicalSerialNum']);
// apply specific field filters
$this->search($request->get('descr'), $query, ['entPhysicalDescr']);
$this->search($request->get('model'), $query, ['entPhysicalModelName']);
$this->search($request->get('serial'), $query, ['entPhysicalSerialNum']);
return $query;
}
/**
* @param EntPhysical $entPhysical
* @return array|Model|Collection
*/
public function formatItem($entPhysical)
{
return [
'device' => Url::deviceLink($entPhysical->device),
'descr' => htmlspecialchars($entPhysical->entPhysicalDescr),
'name' => htmlspecialchars($entPhysical->entPhysicalName),
'model' => htmlspecialchars($entPhysical->entPhysicalModelName),
'serial' => htmlspecialchars($entPhysical->entPhysicalSerialNum),
];
}
}

View File

@@ -61,8 +61,8 @@ abstract class TableController extends PaginatedAjaxController
/** @var Builder $query */
$query = $this->baseQuery($request);
$this->search($request->get('searchPhrase'), $query, $this->searchFields($request));
$this->filter($request, $query, $this->filterFields($request));
$this->search($request->get('searchPhrase'), $query, $this->searchFields($request));
$this->sort($request, $query);
$limit = $request->get('rowCount', 25);

File diff suppressed because one or more lines are too long

View File

@@ -2,15 +2,15 @@
"/js/app.js": "/js/app.js?id=5ddec7f7302f146a8dcc",
"/js/manifest.js": "/js/manifest.js?id=2951ae529be231f05a93",
"/css/vendor.css": "/css/vendor.css?id=2568831af31dbfc3128a",
"/css/app.css": "/css/app.css?id=bd093a6a2e2682bb59ef",
"/css/app.css": "/css/app.css?id=1cd88608bf4eaee000d8",
"/js/vendor.js": "/js/vendor.js?id=c5fd3d75a63757080dbb",
"/js/lang/de.js": "/js/lang/de.js?id=613b5ca9cd06ca15e384",
"/js/lang/en.js": "/js/lang/en.js?id=efa23897934359283288",
"/js/lang/fr.js": "/js/lang/fr.js?id=4540d71a19d8ca7c824b",
"/js/lang/it.js": "/js/lang/it.js?id=71c68fae57a4a3647e43",
"/js/lang/de.js": "/js/lang/de.js?id=d74df23e729c5dabfee8",
"/js/lang/en.js": "/js/lang/en.js?id=20e52084af3a0a8f4724",
"/js/lang/fr.js": "/js/lang/fr.js?id=22902d30358443ef2877",
"/js/lang/it.js": "/js/lang/it.js?id=6220e138068a7e58387f",
"/js/lang/ru.js": "/js/lang/ru.js?id=f6b7c078755312a0907c",
"/js/lang/sr.js": "/js/lang/sr.js?id=388e38b41f63e3517506",
"/js/lang/uk.js": "/js/lang/uk.js?id=72f81fcbf77df09d0c82",
"/js/lang/zh-CN.js": "/js/lang/zh-CN.js?id=4e081fbac70d969894bf",
"/js/lang/zh-TW.js": "/js/lang/zh-TW.js?id=ed26425647721a42ee9d"
"/js/lang/uk.js": "/js/lang/uk.js?id=fdfb4cfa77a3340e50f8",
"/js/lang/zh-CN.js": "/js/lang/zh-CN.js?id=cc4309e63a32a671f107",
"/js/lang/zh-TW.js": "/js/lang/zh-TW.js?id=2248687ad44f27299377"
}

View File

@@ -1,100 +0,0 @@
<?php
$pagetitle[] = 'Inventory';
?>
<div class="panel panel-default panel-condensed">
<div class="panel-heading">
<strong>Inventory</strong>
</div>
<table id="inventory" class="table table-hover table-condensed table-striped">
<thead>
<tr>
<th data-column-id="hostname" data-order="asc">Hostname</th>
<th data-column-id="description">Description</th>
<th data-column-id="name">Name</th>
<th data-column-id="model">Part No</th>
<th data-column-id="serial">Serial No</th>
</tr>
</thead>
</table>
</div>
<script>
var grid = $("#inventory").bootgrid({
ajax: true,
rowCount: [50, 100, 250, -1],
templates: {
header: "<div id=\"{{ctx.id}}\" class=\"{{css.header}}\"><div class=\"row\">"+
"<div class=\"col-sm-9 actionBar\"><span class=\"pull-left\"><form method=\"post\" action=\"\" class=\"form-inline\" role=\"form\">"+
"<?php echo addslashes(csrf_field()) ?>"+
"<div class=\"form-group\">"+
"<input type=\"text\" name=\"string\" id=\"string\" value=\"<?php echo htmlspecialchars($_POST['string']); ?>\" placeholder=\"Description\" class=\"form-control input-sm\" />"+
"</div>"+
"<div class=\"form-group\">"+
"<strong>&nbsp;Part No&nbsp;</strong>"+
"<select name=\"part\" id=\"part\" class=\"form-control input-sm\">"+
"<option value=\"\">All Parts</option>"+
<?php
foreach (dbFetchRows('SELECT `entPhysicalModelName` FROM `entPhysical` GROUP BY `entPhysicalModelName` ORDER BY `entPhysicalModelName`') as $data) {
echo '"<option value=\"' . $data['entPhysicalModelName'] . '\""+';
if ($data['entPhysicalModelName'] == $_POST['part']) {
echo '" selected"+';
}
echo '">' . $data['entPhysicalModelName'] . '</option>"+';
}
?>
"</select>"+
"</div>"+
"<div class=\"form-group\">"+
"<input type=\"text\" name=\"serial\" id=\"serial\" value=\"<?php echo htmlspecialchars($_POST['serial']); ?>\" placeholder=\"Serial\" class=\"form-control input-sm\"/>"+
"</div>"+
"<div class=\"form-group\">"+
"<strong>&nbsp;Device&nbsp;</strong>"+
"<select name=\"device\" id=\"device\" class=\"form-control input-sm\">"+
"<option value=\"\">All Devices</option>"+
<?php
foreach (dbFetchRows('SELECT * FROM `devices` ORDER BY `hostname`') as $data) {
if (device_permitted($data['device_id'])) {
echo '"<option value=\"' . $data['device_id'] . '\""+';
if ($data['device_id'] == $_POST['device']) {
echo '" selected"+';
}
echo '">' . format_hostname($data) . '</option>"+';
}
}
?>
"</select>"+
"</div>"+
"<div class=\"form-group\">"+
"<input type=\"text\" size=24 name=\"device_string\" id=\"device_string\" value=\""+
<?php
if ($_POST['device_string']) {
echo htmlspecialchars($_POST['device_string']);
}
?>
"\" placeholder=\"Description\" class=\"form-control input-sm\"/>"+
"</div>"+
"<button type=\"submit\" class=\"btn btn-default input-sm\">Search</button>"+
"</form></span></div>"+
"<div class=\"col-sm-3 actionBar\"><p class=\"{{css.actions}}\"></p></div></div></div>"
},
post: function ()
{
return {
id: "inventory",
device: '<?php echo htmlspecialchars($_POST['device']); ?>',
string: '<?php echo htmlspecialchars($_POST['string']); ?>',
device_string: '<?php echo htmlspecialchars($_POST['device_string']); ?>',
part: '<?php echo htmlspecialchars($_POST['part']); ?>',
serial: '<?php echo htmlspecialchars($_POST['serial']); ?>'
};
},
url: "ajax_table.php"
});
</script>

View File

@@ -1,86 +0,0 @@
<?php
$where = '1';
$param = [];
if (! Auth::user()->hasGlobalRead()) {
$device_ids = Permissions::devicesForUser()->toArray() ?: [0];
$where .= ' AND `D`.`device_id` IN ' . dbGenPlaceholders(count($device_ids));
$param = array_merge($param, $device_ids);
}
$sql = " FROM entPhysical AS E, devices AS D WHERE $where AND D.device_id = E.device_id";
if (isset($searchPhrase) && ! empty($searchPhrase)) {
$sql .= ' AND (`D`.`hostname` LIKE ? OR `E`.`entPhysicalDescr` LIKE ? OR `E`.`entPhysicalModelName` LIKE ? OR `E`.`entPhysicalSerialNum` LIKE ?)';
$param[] = "%$searchPhrase%";
$param[] = "%$searchPhrase%";
$param[] = "%$searchPhrase%";
$param[] = "%$searchPhrase%";
}
if (isset($vars['string']) && strlen($vars['string'])) {
$sql .= ' AND E.entPhysicalDescr LIKE ?';
$param[] = '%' . $vars['string'] . '%';
}
if (isset($vars['device_string']) && strlen($vars['device_string'])) {
$sql .= ' AND D.hostname LIKE ?';
$param[] = '%' . $vars['device_string'] . '%';
}
if (isset($vars['part']) && strlen($vars['part'])) {
$sql .= ' AND E.entPhysicalModelName = ?';
$param[] = $vars['part'];
}
if (isset($vars['serial']) && strlen($vars['serial'])) {
$sql .= ' AND E.entPhysicalSerialNum LIKE ?';
$param[] = '%' . $vars['serial'] . '%';
}
if (isset($vars['device']) && is_numeric($vars['device'])) {
$sql .= ' AND D.device_id = ?';
$param[] = $vars['device'];
}
$count_sql = "SELECT COUNT(`entPhysical_id`) $sql";
$total = dbFetchCell($count_sql, $param);
if (empty($total)) {
$total = 0;
}
if (! isset($sort) || empty($sort)) {
$sort = '`hostname` DESC';
}
$sql .= " ORDER BY $sort";
if (isset($current)) {
$limit_low = (($current * $rowCount) - $rowCount);
$limit_high = $rowCount;
}
if ($rowCount != -1) {
$sql .= " LIMIT $limit_low,$limit_high";
}
$sql = "SELECT `D`.`device_id` AS `device_id`, `D`.`os` AS `os`, `D`.`hostname` AS `hostname`, `D`.`sysName` AS `sysName`,`entPhysicalDescr` AS `description`, `entPhysicalName` AS `name`, `entPhysicalModelName` AS `model`, `entPhysicalSerialNum` AS `serial` $sql";
foreach (dbFetchRows($sql, $param) as $invent) {
$response[] = [
'hostname' => generate_device_link($invent),
'description' => $invent['description'],
'name' => $invent['name'],
'model' => $invent['model'],
'serial' => $invent['serial'],
];
}
$output = [
'current' => $current,
'rowCount' => $rowCount,
'rows' => $response,
'total' => $total,
];
echo json_encode($output, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);

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

@@ -0,0 +1,10 @@
<?php
return [
'purge' => 'Purge Non-Existent',
'name' => 'Part Name',
'model' => 'Part No',
'serial' => 'Serial No',
'part' => 'Part',
'all_parts' => 'All Parts',
];

View File

@@ -0,0 +1,86 @@
@extends('layouts.librenmsv1')
@section('title', __('Inventory'))
@section('content')
<div class="container-fluid">
<x-panel body-class="!tw-p-0">
<x-slot name="heading">
<h3 class="panel-title">@lang('Inventory')</h3>
@if($show_purge)
<div class="tw-float-right">
<a href="{{ route('inventory.purge') }}"><i class="fa fa-trash"></i> @lang('inventory.purge')</a>
</div>
@endif
</x-slot>
<table id="inventory" class="table table-hover table-condensed table-striped">
<thead>
<tr>
<th data-column-id="device" data-order="asc">@lang('Device')</th>
<th data-column-id="descr">@lang('Description')</th>
<th data-column-id="name">@lang('inventory.name')</th>
<th data-column-id="model">@lang('inventory.model')</th>
<th data-column-id="serial">@lang('inventory.serial')</th>
</tr>
</thead>
</table>
</x-panel>
</div>
@endsection
@push('scripts')
<script>
var grid = $("#inventory").bootgrid({
ajax: true,
rowCount: [50, 100, 250, -1],
templates: {
header: "<div id=\"@{{ctx.id}}\" class=\"@{{css.header}} tw-flex tw-flex-wrap\">" +
"<form method=\"post\" action=\"\" class=\"tw-flex tw-flex-wrap tw-items-center\" role=\"form\" id=\"inventory_filter\">" +
"{!! addslashes(csrf_field()) !!}" +
"<div class=\"tw-flex tw-items-baseline tw-mr-3 tw-mt-2\">" +
"<span class=\"tw-mr-1\">@lang('inventory.part')</span>" +
"<input type=\"text\" name=\"descr\" id=\"descr\" value=\"{{ $filter['descr'] }}\" placeholder=\"@lang('Description')\" class=\"form-control\" />" +
"</div>" +
"<div class=\"tw-flex tw-items-baseline tw-mr-3 tw-mt-2\">" +
"<span class=\"tw-mr-1\">@lang('inventory.model')</span>" +
"<select name=\"model\" id=\"model\" class=\"form-control\"></select>" +
"</div>" +
"<div class=\"tw-flex tw-items-baseline tw-mr-3 tw-mt-2\">" +
"<input type=\"text\" name=\"serial\" id=\"serial\" value=\"{{ $filter['serial'] }}\" placeholder=\"@lang('inventory.serial')\" class=\"form-control\"/>" +
"</div>" +
"<div class=\"tw-flex tw-items-baseline tw-mr-3 tw-mt-2\">" +
"<span class=\"tw-mr-1\">@lang('Device')</span>" +
"<select name=\"device\" id=\"device\" class=\"form-control tw-ml-2\"></select>" +
"</div>" +
"<button type=\"submit\" class=\"btn btn-default tw-mr-2 tw-mt-2\">@lang('Search')</button>" +
"</form>" +
"<div class=\"actionBar tw-ml-auto tw-relative tw-mt-2\"><div class=\"@{{css.actions}}\"></div></div>" +
"</div>"
},
post: function () {
return @json($filter)
},
url: "{{ route('table.inventory') }}"
});
<?php
?>
init_select2("#model", "inventory", @json($model_filter), @json($filter['model']), "@lang('inventory.all_parts')");
init_select2("#device", "device", {}, @json($device_selected) , "@lang('All Devices')");
</script>
@endpush
@push('styles')
<style>
.actionBar > .actions {
display: flex;
}
.actionBar > .actions > * {
float: none;
}
</style>
@endpush

View File

@@ -133,7 +133,7 @@
aria-hidden="true"></i> {{ __('Graylog') }}</a></li>
@endconfig
<li><a href="{{ url('inventory') }}"><i class="fa fa-cube fa-fw fa-lg"
<li><a href="{{ route('inventory') }}"><i class="fa fa-cube fa-fw fa-lg"
aria-hidden="true"></i> {{ __('Inventory') }}</a></li>
<li><a href="{{ url('outages') }}"><i class="fa fa-bar-chart fa-fw fa-lg"
aria-hidden="true"></i> {{ __('Outages') }}</a></li>
@@ -762,7 +762,7 @@
@endif
$(document).ready(function(){
// Function to focus Global Search on Ctrl-F
// Function to focus Global Search on Ctrl-F
window.addEventListener("keydown",function (e) {
if (e.keyCode === 114 || (e.ctrlKey && e.keyCode === 70)){
if($('#gsearch').is(":focus")) {
@@ -775,7 +775,7 @@
}
}
})
})
</script>

View File

@@ -39,6 +39,8 @@ Route::middleware(['auth'])->group(function () {
// pages
Route::post('alert/{alert}/ack', [AlertController::class, 'ack'])->name('alert.ack');
Route::resource('device-groups', 'DeviceGroupController');
Route::any('inventory', \App\Http\Controllers\InventoryController::class)->name('inventory');
Route::get('inventory/purge', [\App\Http\Controllers\InventoryController::class, 'purge'])->name('inventory.purge');
Route::resource('port', 'PortController')->only('update');
Route::prefix('poller')->group(function () {
Route::get('', 'PollerController@pollerTab')->name('poller.index');
@@ -166,6 +168,7 @@ Route::middleware(['auth'])->group(function () {
Route::get('graph', 'GraphController')->name('ajax.select.graph');
Route::get('graph-aggregate', 'GraphAggregateController')->name('ajax.select.graph-aggregate');
Route::get('graylog-streams', 'GraylogStreamsController')->name('ajax.select.graylog-streams');
Route::get('inventory', 'InventoryController')->name('ajax.select.inventory');
Route::get('syslog', 'SyslogController')->name('ajax.select.syslog');
Route::get('location', 'LocationController')->name('ajax.select.location');
Route::get('munin', 'MuninPluginController')->name('ajax.select.munin');
@@ -185,6 +188,7 @@ Route::middleware(['auth'])->group(function () {
Route::post('eventlog', 'EventlogController');
Route::post('fdb-tables', 'FdbTablesController');
Route::post('graylog', 'GraylogController');
Route::post('inventory', 'InventoryController')->name('table.inventory');
Route::post('location', 'LocationController');
Route::post('mempools', 'MempoolsController');
Route::post('outages', 'OutagesController');