MVC: add support for pluggable dynamic menu items and move some existing parts out of the MenuSystem class (#10113)

(cherry picked from commit 8b13deac0e)
(cherry picked from commit dfdadb6274)
(cherry picked from commit 4a94e60a42)
(cherry picked from commit 5452c5bc90)
(cherry picked from commit 1216d603f9)
(cherry picked from commit 7cfb03be5d)
This commit is contained in:
Ad Schellevis 2026-04-06 12:37:22 +02:00 committed by Franco Fichtner
parent 141f5a63fc
commit 2185cc02a2
10 changed files with 455 additions and 223 deletions

4
plist
View file

@ -655,6 +655,7 @@
/usr/local/opnsense/mvc/app/models/OPNsense/Base/FieldTypes/UpdateOnlyTextField.php
/usr/local/opnsense/mvc/app/models/OPNsense/Base/FieldTypes/UrlField.php
/usr/local/opnsense/mvc/app/models/OPNsense/Base/FieldTypes/VirtualIPField.php
/usr/local/opnsense/mvc/app/models/OPNsense/Base/Menu/MenuContainer.php
/usr/local/opnsense/mvc/app/models/OPNsense/Base/Menu/MenuInitException.php
/usr/local/opnsense/mvc/app/models/OPNsense/Base/Menu/MenuItem.php
/usr/local/opnsense/mvc/app/models/OPNsense/Base/Menu/MenuSystem.php
@ -766,6 +767,7 @@
/usr/local/opnsense/mvc/app/models/OPNsense/Firewall/Filter.xml
/usr/local/opnsense/mvc/app/models/OPNsense/Firewall/Group.php
/usr/local/opnsense/mvc/app/models/OPNsense/Firewall/Group.xml
/usr/local/opnsense/mvc/app/models/OPNsense/Firewall/Menu/Menu.php
/usr/local/opnsense/mvc/app/models/OPNsense/Firewall/Menu/Menu.xml
/usr/local/opnsense/mvc/app/models/OPNsense/Firewall/Migrations/M1_0_0.php
/usr/local/opnsense/mvc/app/models/OPNsense/Firewall/Migrations/MFP1_0_0.php
@ -826,6 +828,7 @@
/usr/local/opnsense/mvc/app/models/OPNsense/Interfaces/Lagg.xml
/usr/local/opnsense/mvc/app/models/OPNsense/Interfaces/Loopback.php
/usr/local/opnsense/mvc/app/models/OPNsense/Interfaces/Loopback.xml
/usr/local/opnsense/mvc/app/models/OPNsense/Interfaces/Menu/Menu.php
/usr/local/opnsense/mvc/app/models/OPNsense/Interfaces/Menu/Menu.xml
/usr/local/opnsense/mvc/app/models/OPNsense/Interfaces/Migrations/SET1_0_0.php
/usr/local/opnsense/mvc/app/models/OPNsense/Interfaces/Neighbor.php
@ -2209,6 +2212,7 @@
/usr/local/opnsense/www/js/opnsense.js
/usr/local/opnsense/www/js/opnsense_bootgrid.js
/usr/local/opnsense/www/js/opnsense_health.js
/usr/local/opnsense/www/js/opnsense_menusystem.js
/usr/local/opnsense/www/js/opnsense_status.js
/usr/local/opnsense/www/js/opnsense_theme.js
/usr/local/opnsense/www/js/opnsense_ui.js

View file

@ -76,6 +76,8 @@ class ControllerBase extends ControllerRoot
'/ui/js/opnsense_theme.js',
'/ui/js/opnsense_ui.js',
'/ui/js/opnsense_status.js',
// OPNsense Menusystem access
'/ui/js/opnsense_menusystem.js',
// bootstrap script
'/ui/js/bootstrap.min.js',
'/ui/js/bootstrap-select.min.js',

View file

@ -0,0 +1,49 @@
<?php
/*
* Copyright (C) 2026 Deciso B.V.
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
* AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
* AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
* OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
*/
namespace OPNsense\Base\Menu;
abstract class MenuContainer
{
private ?MenuSystem $menusystem = null;
public function __construct(MenuSystem $menusystem)
{
$this->menusystem = $menusystem;
}
public function appendItem($root, $id, $properties)
{
return $this->menusystem->appendItem($root, $id, $properties);
}
public function collect()
{
return;
}
}

View file

@ -44,7 +44,7 @@ class MenuItem
* this items id (xml tag name)
* @var item|string
*/
private $id = "";
private $id = '';
/**
* visible name, default same as id
@ -62,13 +62,19 @@ class MenuItem
* layout information, icon
* @var string
*/
private $CssClass = "";
private $CssClass = '';
/**
* Classes to add to the link
* @var string
*/
private $LinkClass = '';
/**
* link to url location
* @var string
*/
private $Url = "";
private $Url = '';
/**
* link to external page
@ -94,6 +100,12 @@ class MenuItem
*/
private $selected = false;
/**
* Tree depth
* @var int
*/
private $depth = 0;
/**
* class method getters
* @var array
@ -141,6 +153,9 @@ class MenuItem
$this->id = $id;
$this->visibleName = gettext($id);
$this->parent = $parent;
if ($parent !== null) {
$this->depth = $parent->getDepth() + 1;
}
$prop_exclude_list = ['getXmlPropertySetterName' => true];
if (self::$internalClassMethodAliases === null) {
self::$internalClassMethodAliases = [];
@ -169,6 +184,14 @@ class MenuItem
return $this->id;
}
/**
* return this nodes depth in the menu
*/
public function getDepth()
{
return $this->depth;
}
/**
* set sort order
@ -242,6 +265,40 @@ class MenuItem
return $this->CssClass;
}
/**
* setter for default link class
* @param $value
*/
public function setLinkClass($value)
{
$this->LinkClass = $value;
}
/**
* getter for css class set on the actual link
*/
public function getLinkClass()
{
$css = ['list-group-item'];
if (count($this->children) >= 1 && $this->depth < 3) {
if ($this->selected) {
$css[] = 'active-menu-title';
}
} else {
if ($this->depth == 3) {
$css[] = 'menu-level-3-item';
}
if ($this->selected) {
$css[] = 'active';
}
}
if ($this->Url != '') {
$css[] = 'menu_ref_' . md5($this->Url);
}
return implode(' ', $css) . " " . $this->LinkClass;
}
/**
* setter for url field
* @param $value
@ -398,7 +455,7 @@ class MenuItem
foreach ($this->children as $nodeId => &$node) {
if ($node->isVisible()) {
$node->toggleSelected($url);
if ($node->getUrl() != "") {
if ($node->getUrl() != '') {
// hash part isn't available on server end
$menuItemUrl = explode("#", $node->getUrl())[0];
$match = str_replace([".", "*","?", "@"], ["\.", ".*","\?", "\@"], $menuItemUrl);

View file

@ -29,6 +29,7 @@
namespace OPNsense\Base\Menu;
use ReflectionClass;
use OPNsense\Core\AppConfig;
use OPNsense\Core\Config;
@ -48,6 +49,11 @@ class MenuSystem
*/
private $menuCacheFilename = null;
/**
* @var array model directories
*/
private $modelDirs = [];
/**
* @var int time to live for merged menu xml
*/
@ -106,6 +112,20 @@ class MenuSystem
@unlink($this->menuCacheFilename);
}
private function iterateMenuPaths()
{
foreach ($this->modelDirs as $modelDir) {
foreach (glob(preg_replace('#/+#', '/', "{$modelDir}/*")) as $vendor) {
foreach (glob($vendor . '/*') as $module) {
if (is_dir($module . '/Menu')) {
$path = $module . '/Menu/';
yield ['path' => $path, 'base' => substr($path, strlen($modelDir))];
}
}
}
}
}
/**
* Load and persist Menu configuration to disk.
* @param bool $nowait when the cache is locked, skip waiting for it to become available.
@ -113,33 +133,19 @@ class MenuSystem
*/
public function persist($nowait = true)
{
// fetch our model locations
$appconfig = new AppConfig();
if (!empty($appconfig->application->modelsDir)) {
$modelDirs = $appconfig->application->modelsDir;
if (!is_array($modelDirs) && !is_object($modelDirs)) {
$modelDirs = array($modelDirs);
}
}
// collect all XML menu definitions into a single file
$menuXml = new \DOMDocument('1.0');
$root = $menuXml->createElement('menu');
$menuXml->appendChild($root);
// crawl all vendors and modules and add menu definitions
foreach ($modelDirs as $modelDir) {
foreach (glob(preg_replace('#/+#', '/', "{$modelDir}/*")) as $vendor) {
foreach (glob($vendor . '/*') as $module) {
$menu_cfg_xml = $module . '/Menu/Menu.xml';
if (file_exists($menu_cfg_xml)) {
try {
$domNode = dom_import_simplexml($this->addXML($menu_cfg_xml));
$domNode = $root->ownerDocument->importNode($domNode, true);
$root->appendChild($domNode);
} catch (MenuInitException $e) {
error_log($e);
}
}
foreach ($this->iterateMenuPaths() as $menu_dir) {
if (file_exists($menu_dir['path'] . 'Menu.xml')) {
try {
$domNode = dom_import_simplexml($this->addXML($menu_dir['path'] . 'Menu.xml'));
$domNode = $root->ownerDocument->importNode($domNode, true);
$root->appendChild($domNode);
} catch (MenuInitException $e) {
error_log($e);
}
}
}
@ -179,8 +185,16 @@ class MenuSystem
*/
public function __construct()
{
$appconfig = new AppConfig();
if (!empty($appconfig->application->modelsDir)) {
$this->modelDirs = $appconfig->application->modelsDir;
if (!is_array($this->modelDirs) && !is_object($this->modelDirs)) {
$this->modelDirs = [$this->modelDirs];
}
}
// set cache location
$this->menuCacheFilename = (new AppConfig())->application->tempDir . '/opnsense_menu_cache.xml';
$this->menuCacheFilename = $appconfig->application->tempDir . '/opnsense_menu_cache.xml';
// load menu xml's
$menuxml = null;
@ -199,189 +213,20 @@ class MenuSystem
}
}
$config = Config::getInstance()->object();
// collect interfaces for dynamic (interface) menu tabs...
$iftargets = ['if' => [], 'gr' => [], 'wl' => [], 'fw' => [], 'dhcp4' => [], 'dhcp6' => []];
$ifgroups = [];
$ifgroups_seq = [];
if ($config->interfaces->count() > 0) {
if ($config->ifgroups->count() > 0) {
foreach ($config->ifgroups->children() as $key => $node) {
if (empty($node->members) || !empty($node->nogroup)) {
continue;
}
if (!empty((string)$node->sequence)) {
$ifgroups_seq[(string)$node->ifname] = (int)((string)$node->sequence);
}
/* we need both if and gr reference */
$iftargets['if'][(string)$node->ifname] = (string)$node->ifname;
$iftargets['gr'][(string)$node->ifname] = (string)$node->ifname;
foreach (preg_split('/[ |,]+/', (string)$node->members) as $member) {
if (!array_key_exists($member, $ifgroups)) {
$ifgroups[$member] = [];
}
array_push($ifgroups[$member], (string)$node->ifname);
// collect and insert dynamic entries
foreach ($this->iterateMenuPaths() as $menu_dir) {
if (file_exists($menu_dir['path'] . 'Menu.php')) {
$classname = str_replace('/', '\\', $menu_dir['base']) . 'Menu';
try {
$cls = new ReflectionClass($classname);
if (!$cls->isInstantiable() || !$cls->isSubclassOf('OPNsense\\Base\\Menu\\MenuContainer')) {
continue; /* ignore, not ours */
}
} catch (\ReflectionException) {
continue; /* ignore, can't construct */
}
$cls->newInstance($this)->collect();
}
foreach ($config->interfaces->children() as $key => $node) {
// Interfaces tab
if (empty($node->virtual)) {
$iftargets['if'][$key] = !empty($node->descr) ? (string)$node->descr : strtoupper($key);
}
// Wireless status tab
if (isset($node->wireless)) {
$iftargets['wl'][$key] = !empty($node->descr) ? (string)$node->descr : strtoupper($key);
}
// "Firewall: Rules" menu tab...
if (isset($node->enable) && $node->if != 'lo0') {
$iftargets['fw'][$key] = !empty($node->descr) ? (string)$node->descr : strtoupper($key);
}
// "Services: DHCPv[46]" menu tab:
if (empty($node->virtual) && isset($node->enable)) {
if (!empty(filter_var($node->ipaddr, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4))) {
$iftargets['dhcp4'][$key] = !empty($node->descr) ? (string)$node->descr : strtoupper($key);
}
if (!empty(filter_var($node->ipaddrv6, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) || (string)$node->ipaddrv6 == 'track6' || (string)$node->ipaddrv6 == 'idassoc6') {
$iftargets['dhcp6'][$key] = !empty($node->descr) ? (string)$node->descr : strtoupper($key);
}
}
}
}
foreach (array_keys($iftargets) as $tab) {
natcasesort($iftargets[$tab]);
}
// add groups and interfaces to "Interfaces" menu tab...
$ordid = count($ifgroups_seq) > 0 ? max($ifgroups_seq) : 0;
foreach ($iftargets['if'] as $key => $descr) {
if (array_key_exists($key, $iftargets['gr'])) {
$this->appendItem('Interfaces', $key, [
'fixedname' => '[' . $descr . ']',
'cssclass' => 'fa fa-sitemap',
'order' => isset($ifgroups_seq[$key]) ? $ifgroups_seq[$key] : $ordid++,
]);
} elseif (!array_key_exists($key, $ifgroups)) {
$this->appendItem('Interfaces', $key, [
'url' => '/interfaces.php?if=' . $key,
'fixedname' => '[' . $descr . ']',
'cssclass' => 'fa fa-sitemap',
'order' => $ordid++,
]);
}
}
foreach ($ifgroups as $key => $groupings) {
$first = true;
foreach ($groupings as $grouping) {
if (empty($iftargets['if'][$key])) {
// referential integrity between ifgroups and interfaces isn't assured, skip when interface doesn't exist
continue;
}
$this->appendItem('Interfaces.' . $grouping, $key, [
'url' => '/interfaces.php?if=' . $key . '&group=' . $grouping,
'fixedname' => '[' . $iftargets['if'][$key] . ']',
'order' => array_search($key, array_keys($iftargets['if']))
]);
if ($first) {
$this->appendItem('Interfaces.' . $grouping . '.' . $key, 'Origin', [
'url' => '/interfaces.php?if=' . $key,
'visibility' => 'hidden',
]);
$first = false;
}
}
}
$ordid = 100;
foreach ($iftargets['wl'] as $key => $descr) {
$this->appendItem('Interfaces.Wireless', $key, [
'fixedname' => sprintf(gettext('%s Status'), $descr),
'url' => '/status_wireless.php?if=' . $key,
'order' => $ordid++,
]);
}
// add interfaces to "Firewall: Rules" menu tab...
$this->appendItem('Firewall.Rules', 'Migration', [
'url' => '/ui/firewall/migration',
'fixedname' => sprintf("<i class='fa fa-fw fa-gears'> </i> %s", gettext('Migration assistant')),
'order' => 0,
]);
$iftargets['fw'] = array_merge(['FloatingRules' => gettext('Floating')], $iftargets['fw']);
$ordid = 1;
foreach ($iftargets['fw'] as $key => $descr) {
$this->appendItem('Firewall.Rules', $key, [
'url' => '/firewall_rules.php?if=' . $key,
'fixedname' => $descr,
'order' => $ordid++,
]);
$this->appendItem('Firewall.Rules.' . $key, 'Select' . $key, [
'url' => '/firewall_rules.php?if=' . $key . '&*',
'visibility' => 'hidden',
]);
if ($key == 'FloatingRules') {
$this->appendItem('Firewall.Rules.' . $key, 'Top' . $key, [
'url' => '/firewall_rules.php',
'visibility' => 'hidden',
]);
}
$this->appendItem('Firewall.Rules.' . $key, 'Add' . $key, [
'url' => '/firewall_rules_edit.php?if=' . $key,
'visibility' => 'hidden',
]);
$this->appendItem('Firewall.Rules.' . $key, 'Edit' . $key, [
'url' => '/firewall_rules_edit.php?if=' . $key . '&*',
'visibility' => 'hidden',
]);
}
// add interfaces to "Services: DHCPv[46]" menu tab:
$ordid = 0;
foreach ($iftargets['dhcp4'] as $key => $descr) {
if (!file_exists('/usr/local/www/services_dhcp.php')) {
break;
}
$this->appendItem('Services.ISC_DHCPv4', $key, [
'url' => '/services_dhcp.php?if=' . $key,
'fixedname' => "[$descr]",
'order' => $ordid++,
]);
$this->appendItem('Services.ISC_DHCPv4.' . $key, 'Edit' . $key, [
'url' => '/services_dhcp.php?if=' . $key . '&*',
'visibility' => 'hidden',
]);
$this->appendItem('Services.ISC_DHCPv4.' . $key, 'AddStatic' . $key, [
'url' => '/services_dhcp_edit.php?if=' . $key,
'visibility' => 'hidden',
]);
$this->appendItem('Services.ISC_DHCPv4.' . $key, 'EditStatic' . $key, [
'url' => '/services_dhcp_edit.php?if=' . $key . '&*',
'visibility' => 'hidden',
]);
}
$ordid = 0;
foreach ($iftargets['dhcp6'] as $key => $descr) {
if (!file_exists('/usr/local/www/services_dhcpv6.php')) {
break;
}
$this->appendItem('Services.ISC_DHCPv6', $key, [
'url' => '/services_dhcpv6.php?if=' . $key,
'fixedname' => "[$descr]",
'order' => $ordid++,
]);
$this->appendItem('Services.ISC_DHCPv6.' . $key, 'Add' . $key, [
'url' => '/services_dhcpv6_edit.php?if=' . $key,
'visibility' => 'hidden',
]);
$this->appendItem('Services.ISC_DHCPv6.' . $key, 'Edit' . $key, [
'url' => '/services_dhcpv6_edit.php?if=' . $key . '&*',
'visibility' => 'hidden',
]);
}
}

View file

@ -0,0 +1,84 @@
<?php
/*
* Copyright (C) 2026 Deciso B.V.
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
* AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
* AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
* OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
*/
namespace OPNsense\Firewall\Menu;
use OPNsense\Base\Menu\MenuContainer;
use OPNsense\Core\Config;
class Menu extends MenuContainer
{
public function collect()
{
$config = Config::getInstance()->object();
$iftargets = [];
if ($config->interfaces->count() > 0) {
foreach ($config->interfaces->children() as $key => $node) {
// "Firewall: Rules" menu tab...
if (isset($node->enable) && $node->if != 'lo0') {
$iftargets[$key] = !empty($node->descr) ? (string)$node->descr : strtoupper($key);
}
}
}
natcasesort($iftargets);
// add interfaces to "Firewall: Rules" menu tab...
$this->appendItem('Firewall.Rules', 'Migration', [
'url' => '/ui/firewall/migration',
'fixedname' => sprintf("<i class='fa fa-fw fa-gears'> </i> %s", gettext('Migration assistant')),
'order' => 0,
]);
$iftargets = array_merge(['FloatingRules' => gettext('Floating')], $iftargets);
$ordid = 1;
foreach ($iftargets as $key => $descr) {
$this->appendItem('Firewall.Rules', $key, [
'url' => '/firewall_rules.php?if=' . $key,
'fixedname' => $descr,
'order' => $ordid++,
]);
$this->appendItem('Firewall.Rules.' . $key, 'Select' . $key, [
'url' => '/firewall_rules.php?if=' . $key . '&*',
'visibility' => 'hidden',
]);
if ($key == 'FloatingRules') {
$this->appendItem('Firewall.Rules.' . $key, 'Top' . $key, [
'url' => '/firewall_rules.php',
'visibility' => 'hidden',
]);
}
$this->appendItem('Firewall.Rules.' . $key, 'Add' . $key, [
'url' => '/firewall_rules_edit.php?if=' . $key,
'visibility' => 'hidden',
]);
$this->appendItem('Firewall.Rules.' . $key, 'Edit' . $key, [
'url' => '/firewall_rules_edit.php?if=' . $key . '&*',
'visibility' => 'hidden',
]);
}
}
}

View file

@ -0,0 +1,128 @@
<?php
/*
* Copyright (C) 2026 Deciso B.V.
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
* AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
* AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
* OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
*/
namespace OPNsense\Interfaces\Menu;
use OPNsense\Base\Menu\MenuContainer;
use OPNsense\Core\Config;
class Menu extends MenuContainer
{
public function collect()
{
$config = Config::getInstance()->object();
$iftargets = ['if' => [], 'gr' => [], 'wl' => []];
$ifgroups = [];
$ifgroups_seq = [];
if ($config->interfaces->count() > 0) {
if ($config->ifgroups->count() > 0) {
foreach ($config->ifgroups->children() as $key => $node) {
if (empty($node->members) || !empty($node->nogroup)) {
continue;
}
if (!empty((string)$node->sequence)) {
$ifgroups_seq[(string)$node->ifname] = (int)((string)$node->sequence);
}
/* we need both if and gr reference */
$iftargets['if'][(string)$node->ifname] = (string)$node->ifname;
$iftargets['gr'][(string)$node->ifname] = (string)$node->ifname;
foreach (preg_split('/[ |,]+/', (string)$node->members) as $member) {
if (!array_key_exists($member, $ifgroups)) {
$ifgroups[$member] = [];
}
array_push($ifgroups[$member], (string)$node->ifname);
}
}
}
foreach ($config->interfaces->children() as $key => $node) {
// Interfaces tab
if (empty($node->virtual)) {
$iftargets['if'][$key] = !empty($node->descr) ? (string)$node->descr : strtoupper($key);
}
// Wireless status tab
if (isset($node->wireless)) {
$iftargets['wl'][$key] = !empty($node->descr) ? (string)$node->descr : strtoupper($key);
}
}
}
foreach (array_keys($iftargets) as $tab) {
natcasesort($iftargets[$tab]);
}
// add groups and interfaces to "Interfaces" menu tab...
$ordid = count($ifgroups_seq) > 0 ? max($ifgroups_seq) : 0;
foreach ($iftargets['if'] as $key => $descr) {
if (array_key_exists($key, $iftargets['gr'])) {
$this->appendItem('Interfaces', $key, [
'fixedname' => '[' . $descr . ']',
'cssclass' => 'fa fa-sitemap',
'order' => isset($ifgroups_seq[$key]) ? $ifgroups_seq[$key] : $ordid++,
]);
} elseif (!array_key_exists($key, $ifgroups)) {
$this->appendItem('Interfaces', $key, [
'url' => '/interfaces.php?if=' . $key,
'fixedname' => '[' . $descr . ']',
'cssclass' => 'fa fa-sitemap',
'order' => $ordid++,
]);
}
}
foreach ($ifgroups as $key => $groupings) {
$first = true;
foreach ($groupings as $grouping) {
if (empty($iftargets['if'][$key])) {
// referential integrity between ifgroups and interfaces isn't assured, skip when interface doesn't exist
continue;
}
$this->appendItem('Interfaces.' . $grouping, $key, [
'url' => '/interfaces.php?if=' . $key . '&group=' . $grouping,
'fixedname' => '[' . $iftargets['if'][$key] . ']',
'order' => array_search($key, array_keys($iftargets['if']))
]);
if ($first) {
$this->appendItem('Interfaces.' . $grouping . '.' . $key, 'Origin', [
'url' => '/interfaces.php?if=' . $key,
'visibility' => 'hidden',
]);
$first = false;
}
}
}
$ordid = 100;
foreach ($iftargets['wl'] as $key => $descr) {
$this->appendItem('Interfaces.Wireless', $key, [
'fixedname' => sprintf(gettext('%s Status'), $descr),
'url' => '/status_wireless.php?if=' . $key,
'order' => $ordid++,
]);
}
}
}

View file

@ -5,14 +5,14 @@
<div class="panel list-group" style="border:0px">
{% for topMenuItem in menuSystem %}
{% if topMenuItem.Children|length >= 1 %}
<a href="#{{ topMenuItem.Id }}" class="list-group-item {% if topMenuItem.Selected %} active-menu-title {% endif %}" data-toggle="collapse" data-parent="#mainmenu">
<a href="#{{ topMenuItem.Id }}" class="{{ topMenuItem.LinkClass }}" data-toggle="collapse" data-parent="#mainmenu">
<span class="{{ topMenuItem.CssClass }} __iconspacer"></span><span style="word-break: keep-all">{{ topMenuItem.VisibleName }}</span>
</a>
<div class="collapse {% if topMenuItem.Selected %} active-menu in {% endif %}" id="{{ topMenuItem.Id }}">
{% for subMenuItem in topMenuItem.Children %}
{% if subMenuItem.Url == '' %}
{# next level items, submenu is a container #}
<a href="#{{ topMenuItem.Id }}_{{ subMenuItem.Id }}" class="list-group-item {% if subMenuItem.Selected %} active-menu-title {% endif %}"
<a href="#{{ topMenuItem.Id }}_{{ subMenuItem.Id }}" class="{{ subMenuItem.LinkClass }}"
data-toggle="collapse" data-parent="#{{ topMenuItem.Id }}">
<div style="display: table;width: 100%;">
<div style="display: table-row">
@ -25,13 +25,13 @@
</a>
<div class="collapse {% if subMenuItem.Selected %} active-menu in {% endif %}" id="{{ topMenuItem.Id }}_{{ subMenuItem.Id }}">
{% for subsubMenuItem in subMenuItem.Children %} {% if subsubMenuItem.IsExternal == "Y" %}
<a href="{{ subsubMenuItem.Url }}" target="_blank" rel="noopener noreferrer" class="list-group-item menu-level-3-item {% if subsubMenuItem.Selected %} active {% endif %}">{{ subsubMenuItem.VisibleName }}</a>
<a href="{{ subsubMenuItem.Url }}" target="_blank" rel="noopener noreferrer" class="{{ subsubMenuItem.LinkClass }}">{{ subsubMenuItem.VisibleName }}</a>
{% elseif acl.isPageAccessible(session.get('Username'),subsubMenuItem.Url) %}
<a href="{{ subsubMenuItem.Url }}" class="list-group-item menu-level-3-item {% if subsubMenuItem.Selected %} active {% endif %}">{{ subsubMenuItem.VisibleName }}</a>
<a href="{{ subsubMenuItem.Url }}" class="{{ subsubMenuItem.LinkClass }}">{{ subsubMenuItem.VisibleName }}</a>
{% endif %} {% endfor %}
</div>
{% elseif subMenuItem.IsExternal == "Y" %}
<a href="{{ subMenuItem.Url }}" target="_blank" rel="noopener noreferrer" class="list-group-item {% if subMenuItem.Selected %} active {% endif %}"
<a href="{{ subMenuItem.Url }}" target="_blank" rel="noopener noreferrer" class="{{ subMenuItem.LinkClass }}"
aria-expanded="{% if subMenuItem.Selected %}true{%else%}false{% endif %}">
<div style="display: table;width: 100%;">
<div style="display: table-row">
@ -43,7 +43,7 @@
</div>
</a>
{% elseif acl.isPageAccessible(session.get('Username'),subMenuItem.Url) %}
<a href="{{ subMenuItem.Url }}" class="list-group-item {% if subMenuItem.Selected %} active {% endif %}">
<a href="{{ subMenuItem.Url }}" class="{{ subMenuItem.LinkClass }}">
<div style="display: table;width: 100%;">
<div style="display: table-row">
<div style="display: table-cell">{{ subMenuItem.VisibleName }}</div>
@ -59,11 +59,11 @@
{% else %}
{# parent level link menu items that pivot #}
{% if topMenuItem.IsExternal == "Y" %}
<a href="{{ topMenuItem.Url }}" target="_blank" rel="noopener noreferrer" class="list-group-item {% if topMenuItem.Selected %} active-menu-title {% endif %}" data-parent="#mainmenu">
<a href="{{ topMenuItem.Url }}" target="_blank" rel="noopener noreferrer" class="{{ topMenuItem.LinkClass }}" data-parent="#mainmenu">
<span class="{{ topMenuItem.CssClass }} __iconspacer"></span>{{ topMenuItem.VisibleName }}
</a>
{% elseif acl.isPageAccessible(session.get('Username'),topMenuItem.Url) %}
<a href="{{ topMenuItem.Url }}" class="list-group-item {% if topMenuItem.Selected %} active-menu-title {% endif %}" data-parent="#mainmenu">
<a href="{{ topMenuItem.Url }}" class="{{ topMenuItem.LinkClass }}" data-parent="#mainmenu">
<span class="{{ topMenuItem.CssClass }} __iconspacer"></span>{{ topMenuItem.VisibleName }}
</a>
{% endif %}

View file

@ -0,0 +1,63 @@
/*
* Copyright (C) 2026 Deciso B.V.
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
* AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
* AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
* OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
*/
class MenuItem {
constructor(node) {
this.href = node.attr("href");
this._obj = node;
this.breadcrumb();
}
breadcrumb() {
let result = [this._obj.text().trim()];
let this_ref = this._obj;
let parent_div = this_ref.closest('div');
while (parent_div && parent_div.attr('id')) {
let container = $("a[href='#" + parent_div.attr('id')+"']");
result.push(container.text().trim());
parent_div = parent_div.parent().closest('div');
}
result.reverse()
return result.join(': ');
}
}
class MenuSystem {
constructor() {
this._menusystem = $("#mainmenu");
};
* walk (){
for (const node of this._menusystem.find("a.list-group-item").toArray()) {
const element = $(node);
const href = element.attr("href");
if (href && !href.startsWith("#")) {
/* only yield leaves */
yield new MenuItem(element);
}
}
};
}

View file

@ -89,13 +89,13 @@ $aclObj = new \OPNsense\Core\ACL();
foreach($menuSystem as $topMenuItem): ?>
<?php
if (count($topMenuItem->Children) >= 1): ?>
<a href="#<?=$topMenuItem->Id;?>" class="list-group-item <?= $topMenuItem->Selected ? 'active-menu-title' : ''; ?>" data-toggle="collapse" data-parent="#mainmenu"><span class="<?=$topMenuItem->CssClass;?> __iconspacer"></span><span style="word-break: keep-all"><?= html_safe($topMenuItem->VisibleName) ?></span></a>
<a href="#<?=$topMenuItem->Id;?>" class="<?= $topMenuItem->LinkClass; ?>" data-toggle="collapse" data-parent="#mainmenu"><span class="<?=$topMenuItem->CssClass;?> __iconspacer"></span><span style="word-break: keep-all"><?= html_safe($topMenuItem->VisibleName) ?></span></a>
<div class="collapse <?=$topMenuItem->Selected ? 'active-menu in' :'';?>" id="<?=$topMenuItem->Id;?>">
<?php
foreach($topMenuItem->Children as $subMenuItem): ?>
<?php
if ($subMenuItem->Url == '' ):?>
<a href="#<?=$topMenuItem->Id;?>_<?=$subMenuItem->Id;?>" class="list-group-item <?=$subMenuItem->Selected ? "active-menu-title" : '';?>" data-toggle="collapse" data-parent="#<?=$topMenuItem->Id;?>" aria-expanded="<?=$subMenuItem->Selected ? "true" : "false";?>">
<a href="#<?=$topMenuItem->Id;?>_<?=$subMenuItem->Id;?>" class="<?= $subMenuItem->LinkClass; ?>" data-toggle="collapse" data-parent="#<?=$topMenuItem->Id;?>" aria-expanded="<?=$subMenuItem->Selected ? "true" : "false";?>">
<div style="display: table;width: 100%;">
<div style="display: table-row">
<div style="display: table-cell"><?= html_safe($subMenuItem->VisibleName) ?></div>
@ -108,17 +108,17 @@ $aclObj = new \OPNsense\Core\ACL();
foreach ($subMenuItem->Children as $subsubMenuItem):?>
<?php
if ($subsubMenuItem->IsExternal == "Y"):?>
<a href="<?=$subsubMenuItem->Url;?>" target="_blank" rel="noopener noreferrer" class="list-group-item menu-level-3-item <?=$subsubMenuItem->Selected ? 'active' :'';?>"><?= $subsubMenuItem->VisibleName ?></a>
<a href="<?=$subsubMenuItem->Url;?>" target="_blank" rel="noopener noreferrer" class="<?= $subsubMenuItem->LinkClass; ?>"><?= $subsubMenuItem->VisibleName ?></a>
<?php
elseif ($aclObj->isPageAccessible($_SESSION['Username'],$subsubMenuItem->Url)):?>
<a href="<?=$subsubMenuItem->Url;?>" class="list-group-item menu-level-3-item <?=$subsubMenuItem->Selected ? 'active' :'';?>"><?= $subsubMenuItem->VisibleName ?></a>
<a href="<?=$subsubMenuItem->Url;?>" class="<?= $subsubMenuItem->LinkClass; ?>"><?= $subsubMenuItem->VisibleName ?></a>
<?php
endif;
endforeach;?>
</div>
<?php
elseif ($subMenuItem->IsExternal == "Y" ):?>
<a href="<?=$subMenuItem->Url;?>" target="_blank" rel="noopener noreferrer" class="list-group-item <?=$subMenuItem->Selected ? 'active' : '';?>" aria-expanded="<?=$subMenuItem->Selected ? 'true' : 'false';?>">
<a href="<?=$subMenuItem->Url;?>" target="_blank" rel="noopener noreferrer" class="<?= $subMenuItem->LinkClass; ?>" aria-expanded="<?=$subMenuItem->Selected ? 'true' : 'false';?>">
<div style="display: table;width: 100%;">
<div style="display: table-row">
<div style="display: table-cell"><?= html_safe($subMenuItem->VisibleName) ?></div>
@ -128,7 +128,7 @@ $aclObj = new \OPNsense\Core\ACL();
</a>
<?php
elseif ($aclObj->isPageAccessible($_SESSION['Username'],$subMenuItem->Url)):?>
<a href="<?=$subMenuItem->Url;?>" class="list-group-item <?=$subMenuItem->Selected ? 'active' :'';?>">
<a href="<?=$subMenuItem->Url;?>" class="<?= $subMenuItem->LinkClass; ?>">
<div style="display: table;width: 100%;">
<div style="display: table-row">
<div style="display: table-cell"><?= html_safe($subMenuItem->VisibleName) ?></div>
@ -146,12 +146,12 @@ $aclObj = new \OPNsense\Core\ACL();
else: ?>
<?php
if ($topMenuItem->IsExternal == "Y" ):?>
<a href="<?=$topMenuItem->Url;?>" target="_blank" rel="noopener noreferrer" class="list-group-item <?=$topMenuItem->Selected ? 'active-menu-title' : '';?>" data-parent="#mainmenu">
<a href="<?=$topMenuItem->Url;?>" target="_blank" rel="noopener noreferrer" class="<?= $topMenuItem->LinkClass; ?>" data-parent="#mainmenu">
<span class="<?=$topMenuItem->CssClass;?> __iconspacer"></span><?= html_safe($topMenuItem->VisibleName) ?>
</a>
<?php
elseif ($aclObj->isPageAccessible($_SESSION['Username'],$topMenuItem->Url)):?>
<a href="<?=$topMenuItem->Url;?>" class="list-group-item <?=$topMenuItem->Selected ? 'active-menu-title' : '';?>" data-parent="#mainmenu">
<a href="<?=$topMenuItem->Url;?>" class="<?= $topMenuItem->LinkClass; ?>" data-parent="#mainmenu">
<span class="<?=$topMenuItem->CssClass;?> __iconspacer"></span><?= html_safe($topMenuItem->VisibleName) ?>
</a>