dnsmasq: Add filter function for interfaces and tags with multiselect (#8465)

* dnsmasq: Add filter function for interfaces and tags with multiselect

* dnsmasq: Small cleanup in filter selectpicker previous

* Refactor search actions and tag filtering

- Use single helper function for building filter
- Use tag UUIDs instead of names for filtering
- Avoid building filter functions when filters are empty
- Pass null to searchBase() when no filtering is required
- Use UUID-based filtering for dhcp_tags via record attributes

* dnsmasq: Make tags and interfaces dropdown just a tad nicer

* Services: Dnsmasq DNS & DHCP - cleanups for https://github.com/opnsense/core/pull/8465

simplify recurring pattern for tag search and move select options generation into common jquery function.

---------

Co-authored-by: Ad Schellevis <ad@opnsense.org>
This commit is contained in:
Monviech 2025-03-26 18:05:00 +01:00 committed by GitHub
parent b163c68bf9
commit 8db4e28614
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 207 additions and 7 deletions

View File

@ -37,6 +37,39 @@ class SettingsController extends ApiMutableModelControllerBase
protected static $internalModelClass = '\OPNsense\Dnsmasq\Dnsmasq';
protected static $internalModelUseSafeDelete = true;
/**
* Tags and interface filter function.
* Interfaces are tags too, in the sense of dnsmasq.
*
* @return callable|null
*/
private function buildFilterFunction(): ?callable
{
$filterValues = $this->request->get('tags') ?? [];
$fieldNames = ['interface', 'set_tag', 'tag'];
if (empty($filterValues)) {
return null;
}
return function ($record) use ($filterValues, $fieldNames) {
foreach ($fieldNames as $fieldName) {
// Skip this field if not present in current record
if (!isset($record->{$fieldName})) {
continue;
}
// Match field values against filter list
foreach (array_map('trim', explode(',', (string)$record->{$fieldName})) as $value) {
if (in_array($value, $filterValues, true)) {
return true;
}
}
}
return false;
};
}
/**
* @inheritdoc
*/
@ -51,7 +84,7 @@ class SettingsController extends ApiMutableModelControllerBase
/* hosts */
public function searchHostAction()
{
return $this->searchBase('hosts');
return $this->searchBase('hosts', null, null, $this->buildFilterFunction());
}
public function getHostAction($uuid = null)
@ -137,7 +170,18 @@ class SettingsController extends ApiMutableModelControllerBase
/* dhcp tags */
public function searchTagAction()
{
return $this->searchBase('dhcp_tags');
$filters = $this->request->get('tags') ?? [];
$filter_funct = null;
if (!empty($filters)) {
$filter_funct = function ($record) use ($filters) {
$attributes = $record->getAttributes();
$uuid = $attributes['uuid'] ?? null;
return in_array($uuid, $filters, true);
};
}
return $this->searchBase('dhcp_tags', null, null, $filter_funct);
}
public function getTagAction($uuid = null)
@ -163,7 +207,7 @@ class SettingsController extends ApiMutableModelControllerBase
/* dhcp ranges */
public function searchRangeAction()
{
return $this->searchBase('dhcp_ranges');
return $this->searchBase('dhcp_ranges', null, null, $this->buildFilterFunction());
}
public function getRangeAction($uuid = null)
@ -189,7 +233,7 @@ class SettingsController extends ApiMutableModelControllerBase
/* dhcp options */
public function searchOptionAction()
{
return $this->searchBase('dhcp_options');
return $this->searchBase('dhcp_options', null, null, $this->buildFilterFunction());
}
public function getOptionAction($uuid = null)
@ -215,7 +259,7 @@ class SettingsController extends ApiMutableModelControllerBase
/* dhcp match options */
public function searchMatchAction()
{
return $this->searchBase('dhcp_options_match');
return $this->searchBase('dhcp_options_match', null, null, $this->buildFilterFunction());
}
public function getMatchAction($uuid = null)
@ -241,7 +285,7 @@ class SettingsController extends ApiMutableModelControllerBase
/* dhcp boot options */
public function searchBootAction()
{
return $this->searchBase('dhcp_boot');
return $this->searchBase('dhcp_boot', null, null, $this->buildFilterFunction());
}
public function getBootAction($uuid = null)
@ -263,4 +307,51 @@ class SettingsController extends ApiMutableModelControllerBase
{
return $this->delBase('dhcp_boot', $uuid);
}
/**
* Return selectpicker options for interfaces and tags
*/
public function getTagListAction()
{
$result = [
'tags' => [
'label' => gettext('Tags'),
'icon' => 'fa fa-tag text-primary',
'items' => []
],
'interfaces' => [
'label' => gettext('Interfaces'),
'icon' => 'fa fa-ethernet text-info',
'items' => []
]
];
// Interfaces
foreach (Config::getInstance()->object()->interfaces->children() as $key => $intf) {
if ((string)$intf->type === 'group') {
continue;
}
$result['interfaces']['items'][] = [
'value' => $key,
'label' => empty($intf->descr) ? strtoupper($key) : (string)$intf->descr
];
}
// Tags
foreach ($this->getModel()->dhcp_tags->iterateItems() as $uuid => $tag) {
$result['tags']['items'][] = [
'value' => $uuid,
'label' => (string)$tag->tag
];
}
foreach (array_keys($result) as $key) {
usort($result[$key]['items'], fn($a, $b) => strcasecmp($a['label'], $b['label']));
}
// Assemble result
return $result;
}
}

View File

@ -73,7 +73,18 @@
'get':'/api/dnsmasq/settings/get_' + grid_id + '/',
'set':'/api/dnsmasq/settings/set_' + grid_id + '/',
'add':'/api/dnsmasq/settings/add_' + grid_id + '/',
'del':'/api/dnsmasq/settings/del_' + grid_id + '/'
'del':'/api/dnsmasq/settings/del_' + grid_id + '/',
options: {
triggerEditFor: getUrlHash('edit'),
initialSearchPhrase: getUrlHash('search'),
requestHandler: function(request) {
const selectedTags = $('#tag_select').val();
if (selectedTags && selectedTags.length > 0) {
request['tags'] = selectedTags;
}
return request;
}
}
});
/* insert headers when multiple grids exist on a single tab */
let header = $("#" + grid_id + "-header");
@ -90,6 +101,16 @@
}
} else {
all_grids[grid_id].bootgrid('reload');
}
// insert tag selectpicker in all grids that use tags or interfaces, boot excluded cause two grids in same tab
if (!['domain', 'boot'].includes(grid_id)) {
let header = $("#" + grid_id + "-header");
let $actionBar = header.find('.actionBar');
if ($actionBar.length) {
$('#tag_select_container').detach().insertBefore($actionBar.find('.search'));
$('#tag_select_container').show();
}
}
});
}
@ -150,6 +171,18 @@
});
});
// Populate tag selectpicker
$('#tag_select').fetch_options('/api/dnsmasq/settings/get_tag_list');
$('#tag_select').change(function () {
Object.keys(all_grids).forEach(function (grid_id) {
// boot is not excluded here, as it reloads in same tab as options
if (!['domain'].includes(grid_id)) {
all_grids[grid_id].bootgrid('reload');
}
});
});
});
</script>
@ -157,6 +190,9 @@
tbody.collapsible > tr > td:first-child {
padding-left: 30px;
}
#tag_select_container {
margin-right: 20px;
}
</style>
<div style="display: none;" id="hosts_tfoot_append">
@ -172,6 +208,11 @@
<button id="download_hosts" type="button" title="{{ lang._('Export as csv') }}" data-toggle="tooltip" class="btn btn-xs"><span class="fa fa-fw fa-table"></span></button>
</div>
<div id="tag_select_container" class="btn-group" style="display: none;">
<select id="tag_select" class="selectpicker" multiple data-title="{{ lang._('Tags & Interfaces') }}" data-show-subtext="true" data-live-search="true" data-size="15" data-width="200px" data-container="body">
</select>
</div>
<!-- Navigation bar -->
<ul class="nav nav-tabs" data-tabs="tabs" id="maintabs">
<li><a data-toggle="tab" href="#general">{{ lang._('General') }}</a></li>

View File

@ -622,6 +622,74 @@ $.fn.SimpleActionButton = function (params) {
});
}
/**
* fetch option list for remote url, when a list is returned, expects either a list of items formatted like
* [
* {'label': 'my_label', value: 'my_value'}
* ]
* or an option group like:
* {
* group_value: {
* label: 'my_group_label',
* icon: 'fa fa-tag text-primary',
* items: [{'label': 'my_label', value: 'my_value'}]
* }
* }
*
* When data is formatted differently, the data_callback can be used to re-format.
*
* @param url
* @param params
* @param data_callback callout to cleanse data before usage
* @param store_data store data in data attribute (in its original form)
*/
$.fn.fetch_options = function(url, params, data_callback, store_data) {
var deferred = $.Deferred();
var $obj = $(this);
$obj.empty();
ajaxGet(url, params ?? {}, function(data){
if (store_data === true) {
$obj.data("store", data);
}
if (typeof data_callback === "function") {
data = data_callback(data);
}
if (Array.isArray(data)) {
data.map(function (item) {
$obj.append($("<option/>").attr({"value": item.value}).text(item.label));
});
} else {
for (const groupKey in data) {
const group = data[groupKey];
if (group.items.length > 0) {
const $optgroup = $('<optgroup>', {
label: group.label,
'data-icon': group.icon
});
for (const item of group.items) {
$optgroup.append(
$('<option>', {
value: item.value,
text: item.label,
'data-subtext': group.label
})
);
}
$obj.append($optgroup);
}
}
}
if ($obj.hasClass('selectpicker')) {
$obj.selectpicker('refresh');
}
$obj.change();
deferred.resolve();
});
return deferred.promise();
};
/**
* File upload dialog, constructs a modal, asks for a file to upload and sets {'payload': ..,, 'filename': ...}