From 2185cc02a24d9999c2ff59cac5d615195c433c26 Mon Sep 17 00:00:00 2001 From: Ad Schellevis Date: Mon, 6 Apr 2026 12:37:22 +0200 Subject: [PATCH] MVC: add support for pluggable dynamic menu items and move some existing parts out of the MenuSystem class (#10113) (cherry picked from commit 8b13deac0e98733b850420e036e7d2eed9ac8184) (cherry picked from commit dfdadb627430f4377b62ac14befa3208fdba2c7f) (cherry picked from commit 4a94e60a42ff99c60dacb57b88ffbe298fa4fa4e) (cherry picked from commit 5452c5bc90e09bce03cad692e89dd2983bdcfe2a) (cherry picked from commit 1216d603f9ce823d6a19088397a5e866744adfef) (cherry picked from commit 7cfb03be5d7ff77b3692a3ba4c7d532828b7f414) --- plist | 4 + .../OPNsense/Base/ControllerBase.php | 2 + .../OPNsense/Base/Menu/MenuContainer.php | 49 ++++ .../models/OPNsense/Base/Menu/MenuItem.php | 65 ++++- .../models/OPNsense/Base/Menu/MenuSystem.php | 251 ++++-------------- .../models/OPNsense/Firewall/Menu/Menu.php | 84 ++++++ .../models/OPNsense/Interfaces/Menu/Menu.php | 128 +++++++++ .../layout_partials/base_menu_system.volt | 16 +- src/opnsense/www/js/opnsense_menusystem.js | 63 +++++ src/www/fbegin.inc | 16 +- 10 files changed, 455 insertions(+), 223 deletions(-) create mode 100644 src/opnsense/mvc/app/models/OPNsense/Base/Menu/MenuContainer.php create mode 100644 src/opnsense/mvc/app/models/OPNsense/Firewall/Menu/Menu.php create mode 100644 src/opnsense/mvc/app/models/OPNsense/Interfaces/Menu/Menu.php create mode 100644 src/opnsense/www/js/opnsense_menusystem.js diff --git a/plist b/plist index 1cb566d8ef..a7775f149e 100644 --- a/plist +++ b/plist @@ -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 diff --git a/src/opnsense/mvc/app/controllers/OPNsense/Base/ControllerBase.php b/src/opnsense/mvc/app/controllers/OPNsense/Base/ControllerBase.php index 89afe9c367..99fc698d4f 100644 --- a/src/opnsense/mvc/app/controllers/OPNsense/Base/ControllerBase.php +++ b/src/opnsense/mvc/app/controllers/OPNsense/Base/ControllerBase.php @@ -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', diff --git a/src/opnsense/mvc/app/models/OPNsense/Base/Menu/MenuContainer.php b/src/opnsense/mvc/app/models/OPNsense/Base/Menu/MenuContainer.php new file mode 100644 index 0000000000..16cfc6517c --- /dev/null +++ b/src/opnsense/mvc/app/models/OPNsense/Base/Menu/MenuContainer.php @@ -0,0 +1,49 @@ +menusystem = $menusystem; + } + + public function appendItem($root, $id, $properties) + { + return $this->menusystem->appendItem($root, $id, $properties); + } + + public function collect() + { + return; + } +} diff --git a/src/opnsense/mvc/app/models/OPNsense/Base/Menu/MenuItem.php b/src/opnsense/mvc/app/models/OPNsense/Base/Menu/MenuItem.php index 6ffe702b8d..f65a56b90a 100644 --- a/src/opnsense/mvc/app/models/OPNsense/Base/Menu/MenuItem.php +++ b/src/opnsense/mvc/app/models/OPNsense/Base/Menu/MenuItem.php @@ -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); diff --git a/src/opnsense/mvc/app/models/OPNsense/Base/Menu/MenuSystem.php b/src/opnsense/mvc/app/models/OPNsense/Base/Menu/MenuSystem.php index be5dcfc158..d9b3a3c93d 100644 --- a/src/opnsense/mvc/app/models/OPNsense/Base/Menu/MenuSystem.php +++ b/src/opnsense/mvc/app/models/OPNsense/Base/Menu/MenuSystem.php @@ -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(" %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', - ]); } } diff --git a/src/opnsense/mvc/app/models/OPNsense/Firewall/Menu/Menu.php b/src/opnsense/mvc/app/models/OPNsense/Firewall/Menu/Menu.php new file mode 100644 index 0000000000..18e0ab8f4f --- /dev/null +++ b/src/opnsense/mvc/app/models/OPNsense/Firewall/Menu/Menu.php @@ -0,0 +1,84 @@ +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(" %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', + ]); + } + } +} diff --git a/src/opnsense/mvc/app/models/OPNsense/Interfaces/Menu/Menu.php b/src/opnsense/mvc/app/models/OPNsense/Interfaces/Menu/Menu.php new file mode 100644 index 0000000000..7d70d3cb4e --- /dev/null +++ b/src/opnsense/mvc/app/models/OPNsense/Interfaces/Menu/Menu.php @@ -0,0 +1,128 @@ +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++, + ]); + } + } +} diff --git a/src/opnsense/mvc/app/views/layout_partials/base_menu_system.volt b/src/opnsense/mvc/app/views/layout_partials/base_menu_system.volt index 67cde41f07..b29fd18518 100644 --- a/src/opnsense/mvc/app/views/layout_partials/base_menu_system.volt +++ b/src/opnsense/mvc/app/views/layout_partials/base_menu_system.volt @@ -5,14 +5,14 @@
{% for topMenuItem in menuSystem %} {% if topMenuItem.Children|length >= 1 %} - + {{ topMenuItem.VisibleName }}
{% for subMenuItem in topMenuItem.Children %} {% if subMenuItem.Url == '' %} {# next level items, submenu is a container #} -
@@ -25,13 +25,13 @@ {% elseif subMenuItem.IsExternal == "Y" %} -
@@ -43,7 +43,7 @@
{% elseif acl.isPageAccessible(session.get('Username'),subMenuItem.Url) %} - +
{{ subMenuItem.VisibleName }}
@@ -59,11 +59,11 @@ {% else %} {# parent level link menu items that pivot #} {% if topMenuItem.IsExternal == "Y" %} -
+ {{ topMenuItem.VisibleName }} {% elseif acl.isPageAccessible(session.get('Username'),topMenuItem.Url) %} - + {{ topMenuItem.VisibleName }} {% endif %} diff --git a/src/opnsense/www/js/opnsense_menusystem.js b/src/opnsense/www/js/opnsense_menusystem.js new file mode 100644 index 0000000000..d53ed04367 --- /dev/null +++ b/src/opnsense/www/js/opnsense_menusystem.js @@ -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); + } + } + }; +} diff --git a/src/www/fbegin.inc b/src/www/fbegin.inc index e83e1a899b..8e4f17849b 100644 --- a/src/www/fbegin.inc +++ b/src/www/fbegin.inc @@ -89,13 +89,13 @@ $aclObj = new \OPNsense\Core\ACL(); foreach($menuSystem as $topMenuItem): ?> Children) >= 1): ?> - VisibleName) ?> + VisibleName) ?>