Dynamic Select setting (#13179)

* Dynamic Select setting
embeds select2 and uses ajax to call to backend for options
poller-group included

* fix validation a bit

* fix typehint

* move minProperties into the select schema

* Change dashboard-select to select-dynamic
Love deleting code

* Change dashboard-select to select-dynamic
Love deleting code
wire up a few select2 options

* fix whitespace

* Not a model, just an object

* Suggestion from @SourceDoctor autocomplete values of select and select-dynamic

Got a little creative with InternalHttpRequest...
This commit is contained in:
Tony Murray
2021-08-28 06:46:53 -05:00
committed by GitHub
parent 94ee737f3d
commit d646562e4e
13 changed files with 346 additions and 109 deletions

View File

@@ -60,7 +60,7 @@ class BashCompletionCommand extends Command
if (method_exists($command, 'completeArgument')) { if (method_exists($command, 'completeArgument')) {
foreach ($input->getArguments() as $name => $value) { foreach ($input->getArguments() as $name => $value) {
if ($current == $value) { if ($current == $value) {
$values = $command->completeArgument($name, $value); $values = $command->completeArgument($name, $value, $previous);
if (! empty($values)) { if (! empty($values)) {
echo implode(PHP_EOL, $values); echo implode(PHP_EOL, $values);

View File

@@ -0,0 +1,45 @@
<?php
/*
* InternalHttpRequest.php
*
* Access to the internal http request code used in tests.
*
* 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 <http://www.gnu.org/licenses/>.
*
* @package LibreNMS
* @link http://librenms.org
* @copyright 2021 Tony Murray
* @author Tony Murray <murraytony@gmail.com>
*/
namespace App\Console\Commands;
use Illuminate\Foundation\Testing\Concerns\InteractsWithAuthentication;
use Illuminate\Foundation\Testing\Concerns\MakesHttpRequests;
class InternalHttpRequest
{
use MakesHttpRequests;
use InteractsWithAuthentication;
/**
* @var \Illuminate\Contracts\Foundation\Application|mixed
*/
private $app;
public function __construct()
{
$this->app = app();
}
}

View File

@@ -24,21 +24,70 @@
namespace App\Console\Commands\Traits; namespace App\Console\Commands\Traits;
use App\Console\Commands\InternalHttpRequest;
use App\Models\User;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use LibreNMS\Util\DynamicConfig; use LibreNMS\Util\DynamicConfig;
use LibreNMS\Util\DynamicConfigItem;
trait CompletesConfigArgument trait CompletesConfigArgument
{ {
public function completeArgument($name, $value) public function completeArgument($name, $value, $previous)
{ {
if ($name == 'setting') { if ($name == 'setting') {
$config = new DynamicConfig(); return (new DynamicConfig())->all()->keys()->filter(function ($setting) use ($value) {
return $config->all()->keys()->filter(function ($setting) use ($value) {
return Str::startsWith($setting, $value); return Str::startsWith($setting, $value);
})->toArray(); })->toArray();
} elseif ($name == 'value') {
$config = (new DynamicConfig())->get($previous);
switch ($config->getType()) {
case 'select-dynamic':
return $this->suggestionsForSelectDynamic($config, $value);
case 'select':
return $this->suggestionsForSelect($config, $value);
}
} }
return false; return false;
} }
protected function suggestionsForSelect(DynamicConfigItem $config, ?string $value): array
{
$options = collect($config['options']);
$keyStartsWith = $options->filter(function ($description, $key) use ($value) {
return Str::startsWith($key, $value);
});
// try to see if it matches a value (aka key)
if ($keyStartsWith->isNotEmpty()) {
return $keyStartsWith->keys()->all();
}
// last chance to try to find by the description
return $options->filter(function ($description, $key) use ($value) {
return Str::contains($description, $value);
})->keys()->all();
}
protected function suggestionsForSelectDynamic(DynamicConfigItem $config, ?string $value): array
{
// need auth to make http request
if ($admin = User::adminOnly()->first()) {
$target = $config['options']['target'];
$data = ['limit' => 10];
if ($value) {
$data['term'] = $value; // filter in sql
}
// make "http" request
$results = (new InternalHttpRequest())
->actingAs($admin)
->json('GET', route("ajax.select.$target"), $data)->json('results');
return array_column($results, 'id');
}
return [];
}
} }

View File

@@ -30,36 +30,53 @@ class DashboardController extends SelectController
{ {
protected function searchFields($request) protected function searchFields($request)
{ {
return ['dashboard_name']; return ['dashboard_name', 'username'];
} }
/** /**
* Defines the base query for this resource * Defines the base query for this resource
* *
* @param \Illuminate\Http\Request $request * @param \Illuminate\Http\Request $request
* @return \Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Query\Builder * @return \Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Query\Builder
*/ */
protected function baseQuery($request) protected function baseQuery($request)
{ {
return Dashboard::query() return Dashboard::query()
->where('access', '>', 0) ->where('access', '>', 0)
->with('user') ->leftJoin('users', 'dashboards.user_id', 'users.user_id') // left join so we can search username
->orderBy('user_id') ->orderBy('dashboards.user_id')
->orderBy('dashboard_name'); ->orderBy('dashboard_name')
->select(['dashboard_id', 'username', 'dashboard_name']);
} }
public function formatItem($dashboard) /**
* @param object $dashboard
* @return array
*/
public function formatItem($dashboard): array
{ {
/** @var Dashboard $dashboard */
return [ return [
'id' => $dashboard->dashboard_id, 'id' => $dashboard->dashboard_id,
'text' => $this->describe($dashboard), 'text' => $this->describe($dashboard),
]; ];
} }
private function describe($dashboard) public function formatResponse($paginator)
{ {
return "{$dashboard->user->username}: {$dashboard->dashboard_name} (" if (! request()->has('term')) {
$paginator->prepend((object) ['dashboard_id' => 0]);
}
return parent::formatResponse($paginator);
}
private function describe($dashboard): string
{
if ($dashboard->dashboard_id == 0) {
return 'No Default Dashboard';
}
return "{$dashboard->username}: {$dashboard->dashboard_name} ("
. ($dashboard->access == 1 ? __('read-only') : __('read-write')) . ')'; . ($dashboard->access == 1 ? __('read-only') : __('read-write')) . ')';
} }
} }

View File

@@ -0,0 +1,66 @@
<?php
/*
* PollerGroupController.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 <http://www.gnu.org/licenses/>.
*
* @package LibreNMS
* @link http://librenms.org
* @copyright 2021 Tony Murray
* @author Tony Murray <murraytony@gmail.com>
*/
namespace App\Http\Controllers\Select;
use App\Models\PollerGroup;
use Illuminate\Support\Str;
class PollerGroupController extends SelectController
{
protected function searchFields($request)
{
return ['group_name', 'descr'];
}
protected function baseQuery($request)
{
return PollerGroup::query()->select(['id', 'group_name']);
}
protected function formatResponse($paginator)
{
// prepend the default group, unless filtered out
if ($this->includeGeneral()) {
$general = new PollerGroup;
$general->id = 0;
$general->group_name = 'General';
$paginator->prepend($general);
}
return parent::formatResponse($paginator);
}
private function includeGeneral(): bool
{
if (request()->has('id') && request('id') !== 0) {
return false;
} elseif (request()->has('term') && ! Str::contains('general', strtolower(request('term')))) {
return false;
}
return true;
}
}

View File

@@ -51,7 +51,10 @@ abstract class SelectController extends PaginatedAjaxController
$this->validate($request, $this->rules()); $this->validate($request, $this->rules());
$limit = $request->get('limit', 50); $limit = $request->get('limit', 50);
$query = $this->search($request->get('term'), $this->baseQuery($request), $this->searchFields($request)); $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->sort($request, $query); $this->sort($request, $query);
$paginator = $query->simplePaginate($limit); $paginator = $query->simplePaginate($limit);

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,5 @@
{ {
"/js/app.js": "/js/app.js?id=a6bdef835406a9a4e138", "/js/app.js": "/js/app.js?id=a3b972f53d547c6c17ba",
"/js/manifest.js": "/js/manifest.js?id=1514baaea419f38abb7d", "/js/manifest.js": "/js/manifest.js?id=1514baaea419f38abb7d",
"/css/app.css": "/css/app.css?id=996b9e3da0c3ab98067e", "/css/app.css": "/css/app.css?id=996b9e3da0c3ab98067e",
"/js/vendor.js": "/js/vendor.js?id=ccca9695062f1f68aaa8", "/js/vendor.js": "/js/vendor.js?id=ccca9695062f1f68aaa8",

View File

@@ -1309,10 +1309,16 @@
}, },
"default_poller_group": { "default_poller_group": {
"default": 0, "default": 0,
"type": "integer", "type": "select-dynamic",
"group": "poller", "group": "poller",
"section": "distributed", "section": "distributed",
"order": 3 "order": 3,
"options": {
"target": "poller-group"
},
"validate": {
"value": "integer|zero_or_exists:poller_groups,id"
}
}, },
"distributed_poller_memcached_host": { "distributed_poller_memcached_host": {
"default": "example.net", "default": "example.net",
@@ -5439,9 +5445,12 @@
"group": "webui", "group": "webui",
"section": "dashboard", "section": "dashboard",
"order": 0, "order": 0,
"type": "dashboard-select", "type": "select-dynamic",
"options": {
"target": "dashboard"
},
"validate": { "validate": {
"value": "zero_or_exists:dashboards,dashboard_id" "value": "integer|zero_or_exists:dashboards,dashboard_id"
} }
}, },
"webui.dynamic_graphs": { "webui.dynamic_graphs": {

View File

@@ -27,8 +27,7 @@
"type": "integer" "type": "integer"
}, },
"options": { "options": {
"type": "object", "type": "object"
"minProperties": 2
}, },
"units": { "units": {
"type": "string" "type": "string"
@@ -67,7 +66,31 @@
"additionalProperties": false, "additionalProperties": false,
"anyOf": [ "anyOf": [
{ {
"properties": { "type": {"const": "select"} }, "properties": {
"type": {"const": "select"},
"options": {
"type": "object",
"minProperties": 2
}
},
"required": ["options"]
},
{
"properties": {
"type": {"const": "select-dynamic"},
"options": {
"type": "object",
"properties": {
"allowClear": {"type": "boolean"},
"callback": {"type": "string"},
"placeholder": {"type": "string"},
"target": {"type": "string"}
},
"minProperties": 1,
"required": ["target"],
"additionalProperties": false
}
},
"required": ["options"] "required": ["options"]
}, },
{ {
@@ -84,7 +107,6 @@
"color", "color",
"float", "float",
"graph", "graph",
"dashboard-select",
"snmp3auth", "snmp3auth",
"ldap-groups", "ldap-groups",
"ad-groups", "ad-groups",

View File

@@ -1,67 +0,0 @@
<!--
- SettingDashboardSelect.vue
-
- 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/>.
-
- @package LibreNMS
- @link https://www.librenms.org
- @copyright 2019 Tony Murray
- @author Tony Murray <murraytony@gmail.com>
-->
<template>
<v-select
:options="localOptions"
label="text"
:clearable="false"
:value="selected"
@input="$emit('input', $event.id)"
:required="required"
:disabled="disabled"
>
</v-select>
</template>
<script>
import BaseSetting from "./BaseSetting";
export default {
name: "SettingDashboardSelect",
mixins: [BaseSetting],
data() {
return {
ajaxData: {results: []},
default: {id: 0, text: this.$t('No Default Dashboard')}
}
},
mounted() {
axios.get(route('ajax.select.dashboard')).then((response) => this.ajaxData = response.data);
},
computed: {
localOptions() {
return [this.default].concat(this.ajaxData.results)
},
selected() {
return this.value === 0 ? this.default : this.ajaxData.results.find(dash => dash.id === this.value);
}
}
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,92 @@
<!--
- SettingSelect2.vue
-
- 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 <http://www.gnu.org/licenses/>.
-
- @package LibreNMS
- @link http://librenms.org
- @copyright 2021 Tony Murray
- @author Tony Murray <murraytony@gmail.com>
-->
<template>
<div>
<select class="form-control"
:name="name"
:value="value"
:required="required"
:disabled="disabled"
>
</select>
</div>
</template>
<script>
import BaseSetting from "./BaseSetting";
export default {
name: "SettingSelectDynamic",
mixins: [BaseSetting],
data() {
return {
select2: null
};
},
watch: {
value(value) {
this.select2.val(value).trigger('change');
}
},
computed: {
settings() {
return {
theme: "bootstrap",
dropdownAutoWidth : true,
width: "auto",
allowClear: Boolean(this.options.allowClear),
placeholder: this.options.placeholder,
ajax: {
url: route('ajax.select.' + this.options.target).toString(),
delay: 250,
data: this.options.callback
}
}
}
},
mounted() {
// load initial data
axios.get(route('ajax.select.' + this.options.target), {params: {id: this.value}}).then((response) => {
response.data.results.forEach((item) => {
if (item.id == this.value) {
this.select2.append(new Option(item.text, item.id, true, true))
.trigger('change');
}
})
});
this.select2 = $(this.$el)
.find('select')
.select2(this.settings);
},
beforeDestroy() {
this.select2.select2('destroy');
}
}
</script>
<style scoped>
</style>

View File

@@ -103,24 +103,25 @@ Route::group(['middleware' => ['auth'], 'guard' => 'auth'], function () {
// js select2 data controllers // js select2 data controllers
Route::group(['prefix' => 'select', 'namespace' => 'Select'], function () { Route::group(['prefix' => 'select', 'namespace' => 'Select'], function () {
Route::get('application', 'ApplicationController'); Route::get('application', 'ApplicationController')->name('ajax.select.application');
Route::get('bill', 'BillController'); Route::get('bill', 'BillController')->name('ajax.select.bill');
Route::get('dashboard', 'DashboardController')->name('ajax.select.dashboard'); Route::get('dashboard', 'DashboardController')->name('ajax.select.dashboard');
Route::get('device', 'DeviceController'); Route::get('device', 'DeviceController')->name('ajax.select.device');
Route::get('device-field', 'DeviceFieldController'); Route::get('device-field', 'DeviceFieldController')->name('ajax.select.device-field');
Route::get('device-group', 'DeviceGroupController'); Route::get('device-group', 'DeviceGroupController')->name('ajax.select.device-group');
Route::get('port-group', 'PortGroupController'); Route::get('port-group', 'PortGroupController')->name('ajax.select.port-group');
Route::get('eventlog', 'EventlogController'); Route::get('eventlog', 'EventlogController')->name('ajax.select.eventlog');
Route::get('graph', 'GraphController'); Route::get('graph', 'GraphController')->name('ajax.select.graph');
Route::get('graph-aggregate', 'GraphAggregateController'); Route::get('graph-aggregate', 'GraphAggregateController')->name('ajax.select.graph-aggregate');
Route::get('graylog-streams', 'GraylogStreamsController'); Route::get('graylog-streams', 'GraylogStreamsController')->name('ajax.select.graylog-streams');
Route::get('syslog', 'SyslogController'); Route::get('syslog', 'SyslogController')->name('ajax.select.syslog');
Route::get('location', 'LocationController'); Route::get('location', 'LocationController')->name('ajax.select.location');
Route::get('munin', 'MuninPluginController'); Route::get('munin', 'MuninPluginController')->name('ajax.select.munin');
Route::get('service', 'ServiceController'); Route::get('service', 'ServiceController')->name('ajax.select.service');
Route::get('template', 'ServiceTemplateController'); Route::get('template', 'ServiceTemplateController')->name('ajax.select.template');
Route::get('port', 'PortController'); Route::get('poller-group', 'PollerGroupController')->name('ajax.select.poller-group');
Route::get('port-field', 'PortFieldController'); Route::get('port', 'PortController')->name('ajax.select.port');
Route::get('port-field', 'PortFieldController')->name('ajax.select.port-field');
}); });
// jquery bootgrid data controllers // jquery bootgrid data controllers