]> ### 1.0.0 - Initial public release. - Adds individually configurable dropdown menus to the Unraid WebGUI main navigation for Main, Shares, Users, Settings, Plugins, Docker, VMs, and Tools while preserving normal parent navigation behavior. - Provides configurable hover-open timings and sorting options for Shares, Users, Docker containers, and VMs. - Displays native-style icons for all dropdown menus. - Displays currently mounted Unassigned Devices remote shares under Main when enabled and the Unassigned Devices plugin is installed. - Provides configurable Docker status indicators, native log buttons, WebUI buttons for running containers with valid WebUI URLs, and optional quick start/stop controls with configurable stop confirmations. - Provides configurable VM status indicators, native log buttons, VNC Console buttons for supported running VMs, and optional quick start/stop controls with configurable stop confirmations. - Provides configurable Docker WebUI new-tab behavior. - Follows the active Dynamix color theme automatically. - Does not replace or modify stock Unraid WebGUI files. 'class', 'className' => $class]; } /* * Matches PageBuilder::tab_title() for category/menu headings. * Tag="cog" -> fa fa-cog title * Tag="icon-..." -> icon-... title * Tag="something.png" -> /icons/something.png when present */ function jnd_title_icon_descriptor($page) { global $docroot; $tag = trim((string)($page['Tag'] ?? '')); $root = jnd_web_root($page['root'] ?? ''); $title = jnd_page_label($page); if ($tag === '' || str_ends_with(strtolower($tag), '.png')) { $fileName = $tag !== '' ? basename($tag) : strtolower(str_replace(' ', '', $title)) . '.png'; if ($root !== '' && $fileName !== '') { $fs = rtrim($docroot, '/') . '/' . $root . '/icons/' . $fileName; if (is_file($fs)) { return [ 'kind' => 'img', 'src' => '/' . $root . '/icons/' . rawurlencode($fileName), ]; } } return jnd_class_icon('fa-th', 'title'); } return jnd_class_icon($tag, 'title'); } /* * Matches MainContent::process_icon() for Settings/Tools panel entries. * Missing Icon -> icon-app PanelIcon. * PNG -> plugin/page-owned image. * icon-* -> native Unraid icon font. * otherwise -> Font Awesome. */ function jnd_panel_icon_descriptor($page) { global $docroot; $icon = trim((string)($page['Icon'] ?? '')); $root = jnd_web_root($page['root'] ?? ''); if ($icon === '') { return ['kind' => 'class', 'className' => 'icon-app PanelIcon']; } if (str_ends_with(strtolower(parse_url($icon, PHP_URL_PATH) ?: $icon), '.png')) { if (str_starts_with($icon, '/')) { $safe = jnd_safe_web_path($icon); if ($safe !== null && is_file(rtrim($docroot, '/') . parse_url($safe, PHP_URL_PATH))) { return ['kind' => 'img', 'src' => $safe]; } } $base = basename(parse_url($icon, PHP_URL_PATH) ?: $icon); if ($root !== '' && $base !== '') { foreach ([ [$root . '/images/' . $base, '/' . $root . '/images/' . rawurlencode($base)], [$root . '/' . $base, '/' . $root . '/' . rawurlencode($base)], ] as [$fsRel, $web]) { if (is_file(rtrim($docroot, '/') . '/' . $fsRel)) { return ['kind' => 'img', 'src' => $web]; } } } return ['kind' => 'class', 'className' => 'icon-app PanelIcon']; } if (str_starts_with($icon, '<')) { /* * Do not inject arbitrary plugin HTML into the dropdown. Support the * two safe icon forms Unraid commonly embeds, otherwise fall back. */ if (preg_match('/]*\bsrc=["\']([^"\']+)["\']/i', $icon, $m)) { $safe = jnd_safe_web_path(html_entity_decode($m[1], ENT_QUOTES)); if ($safe !== null) return ['kind' => 'img', 'src' => $safe]; } if (preg_match('/]*\bclass=["\']([^"\']+)["\']/i', $icon, $m)) { $classes = preg_split('/\s+/', trim($m[1])); $allowed = []; foreach ($classes as $class) { if ( $class === 'fa' || $class === 'title' || $class === 'PanelIcon' || preg_match('/^(?:fa|icon)-[A-Za-z0-9_-]+$/', $class) ) { $allowed[] = $class; } } if (!empty($allowed)) { return ['kind' => 'class', 'className' => implode(' ', array_unique($allowed))]; } } return ['kind' => 'class', 'className' => 'icon-app PanelIcon']; } return jnd_class_icon($icon, 'PanelIcon'); } function jnd_plugin_icon_descriptor($docroot, $pluginName, $pluginFile) { $pluginName = basename((string)$pluginName); $raw = function_exists('plugin') ? trim((string)(plugin('icon', $pluginFile) ?: '')) : ''; /* * Mirror stock ShowPlugins.php: * - explicit plugin "icon" attribute: PNG / icon-* / Font Awesome * - otherwise PluginHelpers::icon($name), which returns a path relative * to the emhttp document root (for example * plugins/composerize/images/composerize.png). */ if ($raw !== '') { if (str_ends_with(strtolower(parse_url($raw, PHP_URL_PATH) ?: $raw), '.png')) { $base = basename(parse_url($raw, PHP_URL_PATH) ?: $raw); if (str_starts_with($raw, '/')) { $safe = jnd_safe_web_path($raw); if ($safe !== null && is_file(rtrim($docroot, '/') . parse_url($safe, PHP_URL_PATH))) { return [ 'kind' => 'img', 'src' => $safe, 'fallback' => '/webGui/images/plg.png', ]; } } foreach ([ "plugins/$pluginName/images/$base", "plugins/$pluginName/$base", ] as $candidate) { if (is_file(rtrim($docroot, '/') . '/' . $candidate)) { return [ 'kind' => 'img', 'src' => '/' . $candidate, 'fallback' => '/webGui/images/plg.png', ]; } } return [ 'kind' => 'img', 'src' => '/plugins/dynamix.plugin.manager/images/dynamix.plugin.manager.png', 'fallback' => '/webGui/images/plg.png', ]; } $classIcon = jnd_class_icon($raw, 'PanelIcon'); if ($classIcon !== null) return $classIcon; } /* * Do not call PluginHelpers::icon() here. That stock helper uses * relative filesystem paths such as "plugins//...", which works * inside ShowPlugins.php because its request executes with emhttp as * the working directory. This hidden navigation page can execute with * a different CWD, causing icon() to incorrectly fall through to the * generic green plugin icon. * * Reproduce the stock lookup order with absolute $docroot filesystem * checks, while returning the equivalent browser path. */ $imageBase = @preg_split('/[\._\- ]/', $pluginName)[0] ?? $pluginName; foreach ([ "plugins/$pluginName/images/$pluginName.png", "plugins/$pluginName/$pluginName.png", "plugins/$pluginName/images/$imageBase.png", "plugins/$pluginName/$imageBase.png", ] as $candidate) { if (is_file(rtrim($docroot, '/') . '/' . $candidate)) { return [ 'kind' => 'img', 'src' => '/' . $candidate, 'fallback' => '/webGui/images/plg.png', ]; } } return [ 'kind' => 'img', 'src' => '/webGui/images/plg.png', ]; } function jnd_tree($menu, $basePath, $depth = 0, $maxDepth = 1, $parentLinks = true) { $out = []; if (!function_exists('find_pages')) return $out; try { $pages = find_pages($menu); } catch (Throwable $e) { return $out; } foreach ($pages as $page) { $name = jnd_safe_page_name($page['name'] ?? ''); if ($name === '') continue; $href = rtrim($basePath, '/') . '/' . rawurlencode($name); $type = strtolower((string)($page['Type'] ?? '')); $isMenu = ($type === 'menu' || $type === 'xmenu'); $item = [ 'type' => 'item', 'label' => jnd_page_label($page), 'href' => $href, 'plugin' => basename((string)($page['root'] ?? '')), 'icon' => ($depth === 0 && $isMenu) ? jnd_title_icon_descriptor($page) : jnd_panel_icon_descriptor($page), ]; if ($depth < $maxDepth && $isMenu) { $children = jnd_tree($name, $href, $depth + 1, $maxDepth, $parentLinks); if (!empty($children)) { $item['type'] = 'submenu'; $item['children'] = $children; if (!$parentLinks) $item['href'] = null; } } $out[] = $item; } return $out; } function jnd_plugin_key($value) { return strtolower((string)preg_replace('/[^a-z0-9]+/i', '', (string)$value)); } function jnd_collect_plugin_pages($nodes, &$index, $depth = 0) { foreach ((array)$nodes as $node) { if (!is_array($node)) continue; $plugin = basename((string)($node['plugin'] ?? '')); $href = (string)($node['href'] ?? ''); $label = jnd_clean_label($node['label'] ?? ''); if ($plugin !== '' && $href !== '' && $label !== '') { $key = jnd_plugin_key($plugin); if ($key !== '') { $index[$key][] = [ 'plugin' => $plugin, 'label' => $label, 'href' => $href, 'depth' => $depth, ]; } } if (!empty($node['children']) && is_array($node['children'])) { jnd_collect_plugin_pages($node['children'], $index, $depth + 1); } } } function jnd_find_plugin_page($identities, $index) { $keys = []; foreach ((array)$identities as $identity) { $key = jnd_plugin_key($identity); if ($key !== '') $keys[$key] = true; } $keys = array_keys($keys); $candidates = []; /* Exact normalized plugin-directory match first. */ foreach ($keys as $key) { foreach ((array)($index[$key] ?? []) as $candidate) { $candidate['matchRank'] = 0; $candidates[] = $candidate; } } /* * Some plugin package IDs and WebGUI directory IDs differ only by a * vendor prefix (for example "dynamix."). Permit a suffix match only * when an exact match did not resolve anything. */ if (empty($candidates)) { foreach ($index as $rootKey => $items) { foreach ($keys as $key) { if ($key === '' || $rootKey === '') continue; if (str_ends_with($rootKey, $key) || str_ends_with($key, $rootKey)) { foreach ((array)$items as $candidate) { $candidate['matchRank'] = 1; $candidates[] = $candidate; } } } } } if (empty($candidates)) return null; usort($candidates, static function($a, $b) { $rank = ((int)($a['matchRank'] ?? 9)) <=> ((int)($b['matchRank'] ?? 9)); if ($rank !== 0) return $rank; $depth = ((int)($a['depth'] ?? 99)) <=> ((int)($b['depth'] ?? 99)); if ($depth !== 0) return $depth; /* Prefer Settings over Tools when both are exposed. */ $aSettings = str_starts_with((string)($a['href'] ?? ''), '/Settings/') ? 0 : 1; $bSettings = str_starts_with((string)($b['href'] ?? ''), '/Settings/') ? 0 : 1; if ($aSettings !== $bSettings) return $aSettings <=> $bSettings; return strnatcasecmp((string)($a['href'] ?? ''), (string)($b['href'] ?? '')); }); return $candidates[0]; } function jnd_markdown_display_title($docroot, $pluginNames, $fallback = '') { foreach ((array)$pluginNames as $pluginName) { $pluginName = basename((string)$pluginName); if ($pluginName === '') continue; $readme = rtrim((string)$docroot, '/') . '/plugins/' . $pluginName . '/README.md'; if (!is_readable($readme)) continue; $lines = @file($readme, FILE_IGNORE_NEW_LINES); if (!is_array($lines)) continue; foreach (array_slice($lines, 0, 40) as $line) { $line = trim((string)$line); if ($line === '') continue; $title = null; if (preg_match('/^#{1,6}\s+(.+?)\s*#*$/', $line, $m)) { $title = $m[1]; } elseif (preg_match('/^\*\*(.+?)\*\*$/', $line, $m)) { $title = $m[1]; } elseif (preg_match('/^__(.+?)__$/', $line, $m)) { $title = $m[1]; } if ($title !== null) { /* Remove simple Markdown link/image markup from a title line. */ $title = preg_replace('/!\[([^\]]*)\]\([^)]+\)/', '$1', $title); $title = preg_replace('/\[([^\]]+)\]\([^)]+\)/', '$1', $title); $title = preg_replace('/[`*_~]+/', '', (string)$title); $title = jnd_clean_label($title); if ($title !== '') return $title; } } } return jnd_clean_label($fallback); } function jnd_disk_label($name) { if (function_exists('my_disk')) { try { $label = my_disk($name); if (is_string($label) && trim(strip_tags($label)) !== '') { return trim(strip_tags($label)); } } catch (Throwable $e) { // Fall back to logical name. } } return (string)$name; } function jnd_file_browser_href($section, $mountpoint) { $mountpoint = trim((string)$mountpoint); if ($mountpoint === '' || !str_starts_with($mountpoint, '/')) return null; return '/' . $section . '/Browse?dir=' . rawurlencode($mountpoint); } function jnd_folder_icon_descriptor() { return ['kind' => 'class', 'className' => 'fa fa-folder-o']; } function jnd_user_image_descriptor($name) { $name = trim((string)$name); $fallback = '/webGui/images/user.png'; if ($name === '' || str_contains($name, '/') || str_contains($name, "\0")) { return ['kind' => 'img', 'src' => $fallback, 'variant' => 'avatar']; } /* * Match Unraid's UserList/UserEdit convention: * custom user images live under /boot/config/plugins/dynamix/users, * with /webGui/images/user.png as the stock fallback. */ $file = '/boot/config/plugins/dynamix/users/' . $name . '.png'; if (!is_file($file)) { return ['kind' => 'img', 'src' => $fallback, 'variant' => 'avatar']; } $src = '/boot/config/plugins/dynamix/users/' . rawurlencode($name) . '.png'; $mtime = @filemtime($file); if ($mtime !== false) $src .= '?v=' . (string)$mtime; return [ 'kind' => 'img', 'src' => $src, 'fallback' => $fallback, 'variant' => 'avatar', ]; } function jnd_plugin_settings() { static $cfg = null; if ($cfg !== null) return $cfg; $file = '/boot/config/plugins/nav.dropdown.menus/nav.dropdown.menus.cfg'; $parsed = is_readable($file) ? @parse_ini_file($file, false, INI_SCANNER_RAW) : []; $cfg = is_array($parsed) ? $parsed : []; return $cfg; } function jnd_cfg_bool($key, $default = false) { $cfg = jnd_plugin_settings(); if (!array_key_exists($key, $cfg)) return (bool)$default; return in_array(strtolower(trim((string)$cfg[$key])), ['1', 'yes', 'true', 'on'], true); } function jnd_cfg_choice($key, $allowed, $default) { $cfg = jnd_plugin_settings(); $value = trim((string)($cfg[$key] ?? '')); return in_array($value, $allowed, true) ? $value : $default; } } $jndBootstrap = (static function () { global $var, $display, $disks, $shares, $users, $docroot; $jndDockerStatus = jnd_cfg_bool('DOCKER_STATUS_INDICATORS', true); $jndVmStatus = jnd_cfg_bool('VM_STATUS_INDICATORS', true); $jndBootstrap = [ 'schema' => 3, 'version' => '1.0.0', 'unraidVersion' => (string)($var['version'] ?? ''), 'providers' => [], 'dropdowns' => [ 'Main' => jnd_cfg_bool('DROPDOWN_MAIN', true), 'Shares' => jnd_cfg_bool('DROPDOWN_SHARES', true), 'Users' => jnd_cfg_bool('DROPDOWN_USERS', true), 'Settings' => jnd_cfg_bool('DROPDOWN_SETTINGS', true), 'Plugins' => jnd_cfg_bool('DROPDOWN_PLUGINS', true), 'Docker' => jnd_cfg_bool('DROPDOWN_DOCKER', true), 'VMs' => jnd_cfg_bool('DROPDOWN_VMS', true), 'Tools' => jnd_cfg_bool('DROPDOWN_TOOLS', true), ], 'preferences' => [ 'hoverOpenMs' => (int)jnd_cfg_choice('HOVER_OPEN_MS', ['75', '100', '150', '200'], '100'), 'sortMode' => jnd_cfg_choice('SORT_MODE', ['unraid', 'alphabetical'], 'unraid'), ], 'features' => [ 'udAvailable' => is_file('/usr/local/emhttp/plugins/unassigned.devices/include/UnassignedDevices.php'), 'ud' => is_file('/usr/local/emhttp/plugins/unassigned.devices/include/UnassignedDevices.php') && jnd_cfg_bool('SHOW_UNASSIGNED_DEVICES', true), 'statusIndicators' => [ 'docker' => $jndDockerStatus, 'vms' => $jndVmStatus, ], 'quickActions' => [ 'docker' => $jndDockerStatus && jnd_cfg_bool('DOCKER_QUICK_ACTIONS', false), 'vms' => $jndVmStatus && jnd_cfg_bool('VM_QUICK_ACTIONS', false), ], 'confirmStop' => [ 'docker' => jnd_cfg_bool('DOCKER_CONFIRM_STOP', true), 'vms' => jnd_cfg_bool('VM_CONFIRM_STOP', true), ], 'dockerLogButtons' => jnd_cfg_bool('DOCKER_LOG_BUTTONS', true), 'vmLogButtons' => jnd_cfg_bool('VM_LOG_BUTTONS', true), 'vmVncConsoleButtons' => jnd_cfg_bool('VM_VNC_CONSOLE_BUTTONS', true), 'dockerWebUIButtons' => jnd_cfg_bool('DOCKER_WEBUI_BUTTONS', true), 'webUiNewTab' => jnd_cfg_bool('WEBUI_NEW_TAB', true), ], 'udDisplay' => (isset($display) && is_array($display)) ? $display : new stdClass(), ]; /* * MAIN * Reverse the stock Main-page emphasis: * - pool/device labels navigate into File Manager * - configuration remains on /Main itself */ $jndMain = []; $jndPools = []; $jndArray = []; $jndBoot = null; if (isset($disks) && is_array($disks)) { foreach ($disks as $diskKey => $disk) { if (!is_array($disk)) continue; $name = (string)($disk['name'] ?? $diskKey); $device = trim((string)($disk['device'] ?? '')); $type = strtolower(trim((string)($disk['type'] ?? ''))); $status = (string)($disk['status'] ?? ''); $mountpoint = trim((string)($disk['fsMountpoint'] ?? '')); if ($name === '' || $device === '' || $status === 'DISK_NP') continue; if ($type === 'flash' || $name === 'flash') { if ($mountpoint === '') $mountpoint = '/boot'; $jndBoot = [ 'type' => 'item', 'label' => 'Boot (Flash)', 'href' => jnd_file_browser_href('Main', $mountpoint), 'icon' => jnd_folder_icon_descriptor(), 'indent' => 1, ]; continue; } if ($type === 'cache') { $pool = function_exists('pool_name') ? (string)pool_name($name) : preg_replace('/\d+$/', '', $name); if ($pool === '') $pool = $name; if (!isset($jndPools[$pool])) { $jndPools[$pool] = [ 'mountpoint' => $mountpoint, 'members' => [], ]; } if ($jndPools[$pool]['mountpoint'] === '' && $mountpoint !== '') { $jndPools[$pool]['mountpoint'] = $mountpoint; } $jndPools[$pool]['members'][] = $name; continue; } if (in_array($type, ['parity', 'data', 'disk'], true) && $mountpoint !== '') { $jndArray[] = [ 'type' => 'item', 'label' => jnd_disk_label($name), 'href' => jnd_file_browser_href('Main', $mountpoint), 'icon' => jnd_folder_icon_descriptor(), 'indent' => 1, ]; } } } if (!empty($jndPools)) { $jndMain[] = ['type' => 'heading', 'label' => 'Pool Devices']; uksort($jndPools, 'strnatcasecmp'); foreach ($jndPools as $pool => $poolData) { $mountpoint = trim((string)($poolData['mountpoint'] ?? '')); if ($mountpoint === '') $mountpoint = '/mnt/' . $pool; $jndMain[] = [ 'type' => 'item', 'label' => ucfirst($pool), 'href' => jnd_file_browser_href('Main', $mountpoint), 'icon' => jnd_folder_icon_descriptor(), 'indent' => 1, ]; } } if (!empty($jndArray)) { if (!empty($jndMain)) $jndMain[] = ['type' => 'separator']; $jndMain[] = ['type' => 'heading', 'label' => 'Array Devices']; foreach ($jndArray as $item) $jndMain[] = $item; } if ($jndBoot !== null) { if (!empty($jndMain)) $jndMain[] = ['type' => 'separator']; $jndMain[] = ['type' => 'heading', 'label' => 'Boot Device']; $jndMain[] = $jndBoot; } $jndBootstrap['providers']['Main'] = $jndMain; /* SHARES — navigate directly to the same File Manager destination as the stock folder icon. */ $jndShares = []; if (isset($shares) && is_array($shares)) { $shareNames = []; foreach ($shares as $shareKey => $share) { $name = is_array($share) ? (string)($share['name'] ?? $shareKey) : (string)$shareKey; $name = trim($name); if ($name !== '') $shareNames[$name] = true; } $shareNames = array_keys($shareNames); natcasesort($shareNames); foreach ($shareNames as $name) { $jndShares[] = [ 'type' => 'item', 'label' => $name, 'href' => jnd_file_browser_href('Shares', '/mnt/user/' . $name), 'icon' => jnd_folder_icon_descriptor(), ]; } } $jndBootstrap['providers']['Shares'] = $jndShares; /* USERS — navigation only; no Add User action. */ $jndUsers = []; if (isset($users) && is_array($users)) { $userNames = []; foreach ($users as $userKey => $user) { $name = is_array($user) ? (string)($user['name'] ?? $userKey) : (string)$userKey; $name = trim($name); if ($name !== '') $userNames[$name] = true; } $userNames = array_keys($userNames); natcasesort($userNames); if (in_array('root', $userNames, true)) { $jndUsers[] = [ 'type' => 'item', 'label' => 'root', 'href' => '/Users/UserEdit?name=root', 'icon' => jnd_user_image_descriptor('root'), ]; } foreach ($userNames as $name) { if ($name === 'root') continue; $jndUsers[] = [ 'type' => 'item', 'label' => $name, 'href' => '/Users/UserEdit?name=' . rawurlencode($name), 'icon' => jnd_user_image_descriptor($name), ]; } } $jndBootstrap['providers']['Users'] = $jndUsers; /* SETTINGS / TOOLS — top-level menu parents are non-linking hover headers. */ $jndSettings = jnd_tree('Settings', '/Settings', 0, 1, false); $jndTools = jnd_tree('Tools', '/Tools', 0, 1, false); $jndBootstrap['providers']['Settings'] = $jndSettings; $jndBootstrap['providers']['Tools'] = $jndTools; /* * PLUGINS * * The .plg package's optional "launch" metadata is not authoritative for * navigation on this host. Installed plugins expose their actual WebGUI * destinations through the live .page registry. Build an index of plugin-owned * Settings/Tools pages, then resolve each installed plugin to that registry. * * Display name policy: * 1. first Markdown heading/bold title from the same README rendered by the * stock /Plugins page; * 2. matched WebGUI page Title; * 3. plugin package name as a final fallback. */ $jndPlugins = []; try { require_once "$docroot/plugins/dynamix.plugin.manager/include/PluginHelpers.php"; $jndPluginPageIndex = []; jnd_collect_plugin_pages($jndSettings, $jndPluginPageIndex); jnd_collect_plugin_pages($jndTools, $jndPluginPageIndex); foreach (glob('/var/log/plugins/*.plg', GLOB_NOSORT) ?: [] as $pluginLink) { $pluginFile = @readlink($pluginLink); if ($pluginFile === false) continue; $packageBase = basename($pluginFile, '.plg'); $name = function_exists('plugin') ? (plugin('name', $pluginFile) ?: $packageBase) : $packageBase; if ($name === 'unRAIDServer') continue; $identities = array_values(array_unique(array_filter([ $name, $packageBase, basename($pluginLink, '.plg'), ]))); $page = jnd_find_plugin_page($identities, $jndPluginPageIndex); $launch = function_exists('plugin') ? (plugin('launch', $pluginFile) ?: '') : ''; $fallbackLaunch = jnd_safe_internal_launch($launch); if ($fallbackLaunch === '/null' || $fallbackLaunch === '/undefined') $fallbackLaunch = null; $href = is_array($page) ? (string)($page['href'] ?? '') : ''; if ($href === '') $href = $fallbackLaunch; $pageLabel = is_array($page) ? jnd_clean_label($page['label'] ?? '') : ''; $label = jnd_markdown_display_title($docroot, $identities, $pageLabel ?: $name); if ($label === '') $label = jnd_clean_label($name, $packageBase); $jndPlugins[] = [ 'type' => 'item', 'label' => $label, 'href' => $href ?: null, 'icon' => jnd_plugin_icon_descriptor($docroot, $name, $pluginFile), ]; } usort($jndPlugins, static function($a, $b) { return strnatcasecmp((string)$a['label'], (string)$b['label']); }); } catch (Throwable $e) { $jndPlugins = []; } $jndBootstrap['providers']['Plugins'] = $jndPlugins; /* Dynamic providers are populated lazily. */ $jndBootstrap['providers']['Docker'] = []; $jndBootstrap['providers']['VMs'] = []; return $jndBootstrap; })(); $jndJsonFlags = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT; ?> ]]> '; echo ''; echo ''; echo ''; } $dropdowns = [ ['Main', 'jnd-dropdown-main', 'dropdown_main', jnd_settings_bool($cfg, 'DROPDOWN_MAIN', true)], ['Shares', 'jnd-dropdown-shares', 'dropdown_shares', jnd_settings_bool($cfg, 'DROPDOWN_SHARES', true)], ['Users', 'jnd-dropdown-users', 'dropdown_users', jnd_settings_bool($cfg, 'DROPDOWN_USERS', true)], ['Settings', 'jnd-dropdown-settings', 'dropdown_settings', jnd_settings_bool($cfg, 'DROPDOWN_SETTINGS', true)], ['Plugins', 'jnd-dropdown-plugins', 'dropdown_plugins', jnd_settings_bool($cfg, 'DROPDOWN_PLUGINS', true)], ['Docker', 'jnd-dropdown-docker', 'dropdown_docker', jnd_settings_bool($cfg, 'DROPDOWN_DOCKER', true)], ['VMs', 'jnd-dropdown-vms', 'dropdown_vms', jnd_settings_bool($cfg, 'DROPDOWN_VMS', true)], ['Tools', 'jnd-dropdown-tools', 'dropdown_tools', jnd_settings_bool($cfg, 'DROPDOWN_TOOLS', true)], ]; $showUD = jnd_settings_bool($cfg, 'SHOW_UNASSIGNED_DEVICES', true); $dockerStatus = jnd_settings_bool($cfg, 'DOCKER_STATUS_INDICATORS', true); $dockerQuick = jnd_settings_bool($cfg, 'DOCKER_QUICK_ACTIONS', false); $dockerConfirm = jnd_settings_bool($cfg, 'DOCKER_CONFIRM_STOP', true); $dockerLogs = jnd_settings_bool($cfg, 'DOCKER_LOG_BUTTONS', true); $dockerWebUI = jnd_settings_bool($cfg, 'DOCKER_WEBUI_BUTTONS', true); $vmStatus = jnd_settings_bool($cfg, 'VM_STATUS_INDICATORS', true); $vmQuick = jnd_settings_bool($cfg, 'VM_QUICK_ACTIONS', false); $vmConfirm = jnd_settings_bool($cfg, 'VM_CONFIRM_STOP', true); $vmLogs = jnd_settings_bool($cfg, 'VM_LOG_BUTTONS', true); $vmVnc = jnd_settings_bool($cfg, 'VM_VNC_CONSOLE_BUTTONS', true); $webUiNewTab = jnd_settings_bool($cfg, 'WEBUI_NEW_TAB', true); $hoverOpenMs = jnd_settings_choice($cfg, 'HOVER_OPEN_MS', ['75', '100', '150', '200'], '100'); $sortMode = jnd_settings_choice($cfg, 'SORT_MODE', ['unraid', 'alphabetical'], 'unraid'); ?>

Navigation Dropdown Menus

Adds dynamic dropdown menus to the Unraid WebGUI main navigation for Main, Shares, Users, Settings, Plugins, Docker, VMs, and Tools, with native icons, status indicators, and optional quick start-stop controls for Docker and VMs.

Plugin is additive-only: it does not replace or modify stock Unraid WebGUI files. If a dropdown is disabled or something it needs is unavailable, Unraid's normal parent navigation link keeps working as usual.

Dropdown menus

dropdown
Disabling a dropdown leaves the normal Unraid parent navigation link unchanged.

Menu behavior

Hover-open delay
Sets how long the pointer must remain over an enabled navigation item before its dropdown opens.
Sort mode
Alphabetical sorting applies to Shares, Users, Docker containers, and VMs. Match Unraid order preserves the order supplied by Unraid.

Main

Show Unassigned Devices
When enabled and Unassigned Devices is installed, currently mounted remote shares are included in the Main dropdown.

Docker

Show status indicators
Shows the current started, stopped, or paused status icon for each Docker container.
Docker quick actions
When enabled, clicking a Docker status icon in the dropdown menu will start or gracefully stop that container.
Confirm stop actions
When enabled, a confirmation prompt is shown before a Docker quick action stops a running container.
Show log buttons
Shows a log button for each Docker container using Unraid's native container-log viewer.
Show WebUI buttons
Shows a globe button for Docker containers that have a valid WebUI URL reported by Unraid.
Open WebUI links in new tab
When enabled, Docker WebUI buttons open their destination in a new browser tab. When disabled, the current tab is used.

Virtual Machines

Show status indicators
Shows the current started, stopped, or paused status icon for each VM.
VM quick actions
When enabled, clicking a VM status icon in the dropdown menu will start or gracefully stop that VM.
Confirm stop actions
When enabled, a confirmation prompt is shown before a VM quick action requests a graceful stop.
Show VNC Console buttons
Shows a desktop button for running VMs when Unraid provides its native web VNC console.
Show log buttons
Shows a log button when Unraid reports a log for that VM.

Information

Appearance: Dropdown menus automatically follow Unraid's active Dynamix color theme configured under Settings → User Preferences → Display Settings. No separate color configuration is required.

After installing or updating: The plugin normally appears after the WebGUI reloads. If the dropdown menus do not appear in the main navigation, perform a hard refresh of the Unraid WebGUI. On Windows or Linux, press Ctrl+F5. On macOS, use Command+Shift+R where supported.

Emergency browser-session bypass: append ?navdropdowns=off to an Unraid WebGUI URL. Use ?navdropdowns=on to re-enable it. This temporary, non-persistent bypass affects only the current browser tab.

]]>
'img', 'src' => $value]; } if (str_starts_with($value, 'icon-') && preg_match('/^icon-[A-Za-z0-9_-]+$/', $value)) { return ['kind' => 'class', 'className' => $value]; } $fa = $value; if (str_starts_with($fa, 'fa-')) { if (!preg_match('/^fa-[A-Za-z0-9_-]+$/', $fa)) return null; return ['kind' => 'class', 'className' => 'fa ' . $fa]; } if (preg_match('/^[A-Za-z0-9_-]+$/', $fa)) { return ['kind' => 'class', 'className' => 'fa fa-' . $fa]; } return null; } function jnd_settings(): array { static $cfg = null; if ($cfg !== null) return $cfg; $file = '/boot/config/plugins/nav.dropdown.menus/nav.dropdown.menus.cfg'; $parsed = is_readable($file) ? @parse_ini_file($file, false, INI_SCANNER_RAW) : []; $cfg = is_array($parsed) ? $parsed : []; return $cfg; } function jnd_setting_bool(string $key, bool $default = false): bool { $cfg = jnd_settings(); if (!array_key_exists($key, $cfg)) return $default; return in_array(strtolower(trim((string)$cfg[$key])), ['1', 'yes', 'true', 'on'], true); } function jnd_decode_mount_field(string $value): string { return str_replace(['\\040', '\\011', '\\134'], [' ', "\t", '\\'], $value); } function jnd_proc_mounts(): array { $rows = []; if (!is_readable('/proc/mounts')) return $rows; foreach (@file('/proc/mounts', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) { $parts = preg_split('/\s+/', trim((string)$line)); if (count($parts) < 3) continue; $rows[] = [ 'source' => jnd_decode_mount_field((string)$parts[0]), 'mountpoint' => jnd_decode_mount_field((string)$parts[1]), 'fstype' => (string)$parts[2], ]; } return $rows; } function jnd_ud_safe_component(string $value): string { $value = html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8'); $value = preg_replace('/[\x00-\x1F\x7F]+/u', '', $value) ?? ''; $value = str_replace(["'", '"', '?', '#', '&', '!', '<', '>', '[', ']', '{', '}', '|', '+', '@', ' '], '_', $value); return trim($value, '/'); } function jnd_ud_remote_items(): array { $pluginRoot = '/usr/local/emhttp/plugins/unassigned.devices'; if (!is_dir($pluginRoot)) return []; $items = []; $seen = []; foreach (jnd_proc_mounts() as $mount) { $mountpoint = (string)($mount['mountpoint'] ?? ''); if (!str_starts_with($mountpoint, '/mnt/remotes/')) continue; $label = trim((string)basename($mountpoint)); if ($label === '' || $label === '.' || $label === '/') continue; $key = strtolower($mountpoint); if (isset($seen[$key])) continue; $seen[$key] = true; $items[] = [ 'type' => 'item', 'label' => $label, 'href' => '/Main/Browse?dir=' . rawurlencode($mountpoint), 'icon' => ['kind' => 'class', 'className' => 'fa fa-folder-o'], 'indent' => 1, ]; } usort($items, static function($a, $b) { return strnatcasecmp((string)($a['label'] ?? ''), (string)($b['label'] ?? '')); }); return $items; } if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'GET') { jnd_json(['ok' => false, 'error' => 'Method not allowed'], 405); } $provider = (string)($_GET['provider'] ?? ''); $docroot = $_SERVER['DOCUMENT_ROOT'] ?: '/usr/local/emhttp'; try { switch ($provider) { case 'docker': { require_once "$docroot/webGui/include/Helpers.php"; require_once "$docroot/plugins/dynamix.docker.manager/include/DockerClient.php"; $DockerClient = new DockerClient(); $DockerTemplates = new DockerTemplates(); $containers = $DockerClient->getDockerContainers() ?: []; $allInfo = $DockerTemplates->getAllInfo() ?: []; $showStatus = jnd_setting_bool('DOCKER_STATUS_INDICATORS', true); $quickActions = $showStatus && jnd_setting_bool('DOCKER_QUICK_ACTIONS', false); $userPrefs = $dockerManPaths['user-prefs'] ?? ''; if ($userPrefs && is_file($userPrefs)) { $prefs = (array)@parse_ini_file($userPrefs); $sort = []; foreach ($containers as $ct) $sort[] = array_search($ct['Name'], $prefs); if (!empty($containers)) array_multisort($sort, SORT_NUMERIC, $containers); } $items = []; foreach ($containers as $ct) { $name = (string)($ct['Name'] ?? ''); if ($name === '') continue; $info = $allInfo[$name] ?? []; $running = !empty($info['running']); $paused = !empty($info['paused']); $state = $running ? ($paused ? 'paused' : 'started') : 'stopped'; $template = (string)($info['template'] ?? ''); $compose = (string)($ct['ComposeProject'] ?? ''); $href = null; if ($template !== '' && $compose === '') { $href = '/Docker/UpdateContainer?xmlTemplate=' . rawurlencode('edit:' . $template); } $webui = trim(html_entity_decode( (string)($info['url'] ?? ($ct['Url'] ?? '')), ENT_QUOTES | ENT_HTML5, 'UTF-8' )); if ($webui === '#') $webui = ''; $iconValue = (string)($info['icon'] ?? ''); $icon = jnd_icon_descriptor($iconValue); if ($icon === null) { $icon = ['kind' => 'img', 'src' => '/plugins/dynamix.docker.manager/images/question.png']; } $control = null; $containerId = trim((string)($ct['Id'] ?? '')); if ($quickActions && $containerId !== '' && $state !== 'paused') { $control = [ 'kind' => 'docker', 'id' => $containerId, 'action' => $state === 'started' ? 'stop' : 'start', ]; } $items[] = [ 'type' => 'item', 'label' => $name, 'href' => $href, 'state' => $state, 'statusKind' => 'docker', 'icon' => $icon, 'control' => $control, 'log' => [ 'kind' => 'docker', 'name' => $name, 'more' => '.log', ], 'webui' => $webui !== '' ? $webui : null, ]; } jnd_json(['ok' => true, 'items' => $items]); } case 'vms': { require_once "$docroot/webGui/include/Helpers.php"; require_once "$docroot/plugins/dynamix.vm.manager/include/libvirt_helpers.php"; $domains = []; if (isset($lv)) { $domains = $lv->get_domains() ?: []; } $showStatus = jnd_setting_bool('VM_STATUS_INDICATORS', true); $quickActions = $showStatus && jnd_setting_bool('VM_QUICK_ACTIONS', false); $userPrefs = '/boot/config/plugins/dynamix.vm.manager/userprefs.cfg'; if (is_file($userPrefs)) { $prefs = (array)@parse_ini_file($userPrefs); $sort = []; foreach ($domains as $vm) $sort[] = array_search($vm, $prefs); if (!empty($domains)) array_multisort($sort, SORT_NUMERIC, $domains); } else { natcasesort($domains); } $items = []; foreach ($domains as $vm) { $res = $lv->get_domain_by_name($vm); if (!$res) continue; $uuid = (string)$lv->domain_get_uuid($res); if ($uuid === '') continue; $dom = $lv->domain_get_info($res); $translated = strtolower((string)$lv->domain_state_translate($dom['state'] ?? '')); $state = match ($translated) { 'running' => 'started', 'paused', 'pmsuspended' => 'paused', default => 'stopped', }; $iconValue = (string)$lv->domain_get_icon_url($res); $icon = jnd_icon_descriptor($iconValue); // Match Unraid's native VM log discovery. $vmLog = is_file("/var/log/libvirt/qemu/$vm.log") ? "libvirt/qemu/$vm.log" : ''; // Match Unraid's native web-console availability for a running VNC VM. $vncConsole = ''; if ($state === 'started') { $vmrcPort = (int)$lv->domain_get_vnc_port($res); $vmrcProtocol = strtolower((string)$lv->domain_get_vmrc_protocol($res)); $consoleMode = strtolower((string)($domain_cfg['CONSOLE'] ?? 'web')); if ($vmrcPort > 0 && $vmrcProtocol === 'vnc' && in_array($consoleMode, ['web', 'both'], true)) { $wsPort = (int)$lv->domain_get_ws_port($res); $httpHost = trim((string)($_SERVER['HTTP_HOST'] ?? '')); if ($wsPort > 0 && $httpHost !== '') { $vncConsole = autov('/plugins/dynamix.vm.manager/vnc.html', true) . '&resize=scale' . '&autoconnect=true' . '&host=' . rawurlencode($httpHost) . '&port=' . '&path=/wsproxy/' . $wsPort . '/'; } } } $control = null; if ($quickActions && $state !== 'paused') { $control = [ 'kind' => 'vm', 'id' => $uuid, 'action' => $state === 'started' ? 'domain-stop' : 'domain-start', ]; } $items[] = [ 'type' => 'item', 'label' => (string)$vm, 'href' => '/VMs/UpdateVM?uuid=' . rawurlencode($uuid), 'state' => $state, 'statusKind' => 'vm', 'icon' => $icon, 'control' => $control, 'log' => $vmLog !== '' ? [ 'kind' => 'vm', 'name' => (string)$vm, 'more' => $vmLog, ] : null, 'vncConsole' => $vncConsole !== '' ? $vncConsole : null, ]; } jnd_json(['ok' => true, 'items' => array_values($items)]); } case 'ud_remotes': { jnd_json(['ok' => true, 'items' => jnd_ud_remote_items()]); } default: jnd_json(['ok' => false, 'error' => 'Unknown provider'], 400); } } catch (Throwable $e) { jnd_json([ 'ok' => false, 'error' => 'Provider failed', 'provider' => $provider, ], 500); } ]]> false, 'error' => 'Method not allowed'], 405); } function jnd_post_bool(string $key): ?string { if (!array_key_exists($key, $_POST)) return null; $value = strtolower(trim((string)$_POST[$key])); if (in_array($value, ['1', 'yes', 'true', 'on'], true)) return '1'; if (in_array($value, ['0', 'no', 'false', 'off'], true)) return '0'; return null; } function jnd_post_choice(string $key, array $allowed): ?string { if (!array_key_exists($key, $_POST)) return null; $value = trim((string)$_POST[$key]); return in_array($value, $allowed, true) ? $value : null; } $boolFields = [ 'dropdown_main' => 'DROPDOWN_MAIN', 'dropdown_shares' => 'DROPDOWN_SHARES', 'dropdown_users' => 'DROPDOWN_USERS', 'dropdown_settings' => 'DROPDOWN_SETTINGS', 'dropdown_plugins' => 'DROPDOWN_PLUGINS', 'dropdown_docker' => 'DROPDOWN_DOCKER', 'dropdown_vms' => 'DROPDOWN_VMS', 'dropdown_tools' => 'DROPDOWN_TOOLS', 'show_unassigned_devices' => 'SHOW_UNASSIGNED_DEVICES', 'docker_status_indicators' => 'DOCKER_STATUS_INDICATORS', 'docker_quick_actions' => 'DOCKER_QUICK_ACTIONS', 'docker_confirm_stop' => 'DOCKER_CONFIRM_STOP', 'docker_log_buttons' => 'DOCKER_LOG_BUTTONS', 'docker_webui_buttons' => 'DOCKER_WEBUI_BUTTONS', 'vm_status_indicators' => 'VM_STATUS_INDICATORS', 'vm_quick_actions' => 'VM_QUICK_ACTIONS', 'vm_confirm_stop' => 'VM_CONFIRM_STOP', 'vm_log_buttons' => 'VM_LOG_BUTTONS', 'vm_vnc_console_buttons' => 'VM_VNC_CONSOLE_BUTTONS', 'webui_new_tab' => 'WEBUI_NEW_TAB', ]; $values = []; foreach ($boolFields as $postKey => $configKey) { $value = jnd_post_bool($postKey); if ($value === null) { jnd_settings_json(['ok' => false, 'error' => 'Invalid setting value'], 400); } $values[$configKey] = $value; } $hoverOpenMs = jnd_post_choice('hover_open_ms', ['75', '100', '150', '200']); $sortMode = jnd_post_choice('sort_mode', ['unraid', 'alphabetical']); if ($hoverOpenMs === null || $sortMode === null) { jnd_settings_json(['ok' => false, 'error' => 'Invalid setting value'], 400); } $values['HOVER_OPEN_MS'] = $hoverOpenMs; $values['SORT_MODE'] = $sortMode; /* * A hidden status indicator cannot safely expose an invisible quick-action * control. Enforce that dependency server-side as well as in the Settings UI. */ if ($values['DOCKER_STATUS_INDICATORS'] !== '1') $values['DOCKER_QUICK_ACTIONS'] = '0'; if ($values['VM_STATUS_INDICATORS'] !== '1') $values['VM_QUICK_ACTIONS'] = '0'; $order = [ 'DROPDOWN_MAIN', 'DROPDOWN_SHARES', 'DROPDOWN_USERS', 'DROPDOWN_SETTINGS', 'DROPDOWN_PLUGINS', 'DROPDOWN_DOCKER', 'DROPDOWN_VMS', 'DROPDOWN_TOOLS', 'SHOW_UNASSIGNED_DEVICES', 'HOVER_OPEN_MS', 'SORT_MODE', 'DOCKER_STATUS_INDICATORS', 'DOCKER_QUICK_ACTIONS', 'DOCKER_CONFIRM_STOP', 'DOCKER_LOG_BUTTONS', 'DOCKER_WEBUI_BUTTONS', 'WEBUI_NEW_TAB', 'VM_STATUS_INDICATORS', 'VM_QUICK_ACTIONS', 'VM_CONFIRM_STOP', 'VM_LOG_BUTTONS', 'VM_VNC_CONSOLE_BUTTONS', ]; $lines = []; foreach ($order as $key) { $lines[] = $key . '="' . $values[$key] . '"'; } $content = implode("\n", $lines) . "\n"; $dir = '/boot/config/plugins/nav.dropdown.menus'; $file = $dir . '/nav.dropdown.menus.cfg'; if (!is_dir($dir) && !@mkdir($dir, 0755, true) && !is_dir($dir)) { jnd_settings_json(['ok' => false, 'error' => 'Unable to create configuration directory'], 500); } $tmp = $file . '.tmp.' . getmypid(); if (@file_put_contents($tmp, $content, LOCK_EX) === false) { jnd_settings_json(['ok' => false, 'error' => 'Unable to write configuration'], 500); } @chmod($tmp, 0644); if (!@rename($tmp, $file)) { @unlink($tmp); jnd_settings_json(['ok' => false, 'error' => 'Unable to commit configuration'], 500); } jnd_settings_json([ 'ok' => true, 'settings' => $values, ]); ]]> button.jnd-caret, #menu .nav-item > button.jnd-caret i { display: none !important; } #jnd-overlay { position: fixed; display: none; z-index: 10050; min-width: 260px; max-width: min(440px, calc(100vw - 16px)); box-sizing: border-box; color: var(--text-color); background: var(--background-color); border: 1px solid var(--table-border-color); border-top: 2px solid var(--brand-orange); border-radius: 3px; box-shadow: var(--small-shadow); padding: 5px 0; font-size: 1.3rem; overflow: visible; } #jnd-overlay.jnd-visible { display: block; } /* No scrollbar for normal menus. Nested flyouts must be allowed to escape. */ #jnd-overlay .jnd-scroll { overflow: visible; max-height: none; } /* Plugins are the deliberate exception: a vertical scrollbar is acceptable. */ #jnd-overlay.jnd-target-plugins .jnd-scroll { max-height: min(72vh, 700px); overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; scrollbar-color: var(--scrollbar-color) transparent; } #jnd-overlay a, #jnd-overlay a:visited { color: var(--text-color); text-decoration: none; } #jnd-overlay .jnd-row, #jnd-overlay .jnd-heading, #jnd-overlay .jnd-static-row, #jnd-overlay .jnd-submenu-trigger { position: relative; box-sizing: border-box; min-height: 34px; display: flex; align-items: center; width: 100%; } #jnd-overlay .jnd-row > a, #jnd-overlay .jnd-heading > a, #jnd-overlay .jnd-heading > span, #jnd-overlay .jnd-static-row, #jnd-overlay .jnd-submenu-trigger { box-sizing: border-box; width: 100%; min-width: 0; padding: 8px 13px; } #jnd-overlay .jnd-row > a { display: flex; align-items: center; gap: 9px; } #jnd-overlay .jnd-row > a:hover, #jnd-overlay .jnd-row > a:focus-visible, #jnd-overlay .jnd-heading > a:hover, #jnd-overlay .jnd-heading > a:focus-visible, #jnd-overlay .jnd-submenu-wrap:hover > .jnd-submenu-trigger, #jnd-overlay .jnd-submenu-wrap:focus-within > .jnd-submenu-trigger { background: var(--hover-table-row-background-color); color: var(--link-text-color); outline: none; } #jnd-overlay .jnd-heading { font-weight: 700; letter-spacing: .02em; } #jnd-overlay .jnd-heading > a, #jnd-overlay .jnd-heading > span { color: var(--text-color); } #jnd-overlay .jnd-indent-1 > a, #jnd-overlay .jnd-static-row.jnd-indent-1 { padding-left: 29px; } #jnd-overlay .jnd-separator { height: 1px; margin: 5px 9px; background: var(--table-border-color); opacity: .8; } #jnd-overlay .jnd-static-row { color: var(--text-color); cursor: default; gap: 9px; } #jnd-overlay .jnd-label { min-width: 0; flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } #jnd-overlay .jnd-app-icon { width: 20px; height: 20px; flex: 0 0 20px; display: inline-flex; align-items: center; justify-content: center; object-fit: contain; font-size: 1.7rem; } #jnd-overlay .jnd-app-icon.jnd-user-avatar { object-fit: cover; border-radius: 2px; } #jnd-overlay .jnd-app-icon.PanelIcon, #jnd-overlay .jnd-app-icon.title { font-size: 1.7rem; line-height: 1; } #jnd-overlay .jnd-status-row { box-sizing: border-box; min-height: 34px; display: flex; align-items: center; width: 100%; gap: 9px; padding: 8px 13px; } #jnd-overlay .jnd-status-row:hover, #jnd-overlay .jnd-status-row:focus-within { background: var(--hover-table-row-background-color); } #jnd-overlay .jnd-status-row > a, #jnd-overlay .jnd-status-row > a:visited { min-width: 0; flex: 1 1 auto; color: var(--text-color); text-decoration: none; } #jnd-overlay .jnd-status-row > a:hover, #jnd-overlay .jnd-status-row > a:focus-visible { color: var(--link-text-color); outline: none; } /* * Preserve the compact v0.2.x icon/state/name geometry. Unraid's global * button rules can impose a substantial minimum width, so the status button * explicitly neutralizes that while remaining a separate accessible control. */ #jnd-overlay .jnd-status-control { box-sizing: border-box; appearance: none; -webkit-appearance: none; width: 14px !important; min-width: 0 !important; max-width: 14px !important; height: 20px; min-height: 0 !important; flex: 0 0 14px; display: inline-flex; align-items: center; justify-content: center; padding: 0 !important; margin: 0 !important; border: 0; border-radius: 2px; box-shadow: none; background: transparent; color: inherit; cursor: pointer; line-height: 1; font: inherit; } #jnd-overlay .jnd-status-control:hover { background: var(--mild-background-color); } #jnd-overlay .jnd-status-control:focus-visible { outline: 1px solid var(--brand-orange); outline-offset: 2px; } #jnd-overlay .jnd-status-control:disabled { cursor: wait; opacity: .8; } #jnd-overlay .jnd-status-static { width: 14px; height: 20px; flex: 0 0 14px; display: inline-flex; align-items: center; justify-content: center; } #jnd-overlay .jnd-inline-control { box-sizing: border-box; appearance: none; -webkit-appearance: none; width: 16px !important; min-width: 0 !important; max-width: 16px !important; height: 20px; min-height: 0 !important; flex: 0 0 16px; display: inline-flex; align-items: center; justify-content: center; padding: 0 !important; margin: 0 !important; border: 0; border-radius: 2px; box-shadow: none; background: transparent; color: var(--text-color); cursor: pointer; line-height: 1; font: inherit; } #jnd-overlay .jnd-inline-control:hover { background: var(--mild-background-color); color: var(--link-text-color); } #jnd-overlay .jnd-inline-control:focus-visible { outline: 1px solid var(--brand-orange); outline-offset: 2px; } #jnd-overlay .jnd-inline-control i { width: 14px; flex: 0 0 14px; text-align: center; font-size: 1.1rem; } #jnd-overlay .jnd-inline-placeholder { box-sizing: border-box; width: 16px; min-width: 16px; max-width: 16px; height: 20px; flex: 0 0 16px; display: inline-block; padding: 0; margin: 0; } #jnd-overlay img.jnd-app-icon { object-fit: contain; } #jnd-overlay .jnd-state-icon { width: 14px; flex: 0 0 14px; text-align: center; font-size: 1.1rem; } #jnd-overlay .jnd-submenu-wrap { position: relative; } #jnd-overlay .jnd-submenu-trigger { gap: 9px; cursor: default; font-weight: 700; } #jnd-overlay .jnd-submenu-arrow { flex: 0 0 auto; margin-left: auto; color: var(--alt-text-color); } #jnd-overlay .jnd-submenu { display: none; position: absolute; z-index: 10051; top: -6px; left: calc(100% - 2px); min-width: 260px; max-width: min(430px, calc(100vw - 16px)); box-sizing: border-box; padding: 5px 0; color: var(--text-color); background: var(--background-color); border: 1px solid var(--table-border-color); border-top: 2px solid var(--brand-orange); border-radius: 3px; box-shadow: var(--small-shadow); overflow: visible; } #jnd-overlay.jnd-flip-submenus .jnd-submenu { left: auto; right: calc(100% - 2px); } #jnd-overlay .jnd-submenu-wrap:hover > .jnd-submenu, #jnd-overlay .jnd-submenu-wrap:focus-within > .jnd-submenu { display: block; } /* Categories use columns instead of scrollbars. */ #jnd-overlay .jnd-submenu.jnd-grid-submenu { min-width: 480px; width: max-content; max-width: min(760px, calc(100vw - 16px)); grid-template-columns: repeat(2, minmax(210px, 1fr)); gap: 0; } #jnd-overlay .jnd-submenu-wrap:hover > .jnd-grid-submenu, #jnd-overlay .jnd-submenu-wrap:focus-within > .jnd-grid-submenu { display: grid; } #jnd-overlay .jnd-submenu.jnd-grid-submenu.jnd-grid-3 { grid-template-columns: repeat(3, minmax(180px, 1fr)); } #jnd-overlay .jnd-loading { padding: 11px 13px; color: var(--alt-text-color); white-space: nowrap; } #jnd-overlay .jnd-loading i { margin-right: 8px; } @media (max-width: 767px) { #jnd-overlay { min-width: min(320px, calc(100vw - 16px)); } #jnd-overlay .jnd-submenu, #jnd-overlay .jnd-submenu.jnd-grid-submenu, #jnd-overlay .jnd-submenu.jnd-grid-submenu.jnd-grid-3 { position: static; display: none; min-width: 0; width: auto; max-width: none; grid-template-columns: 1fr; margin: 0 7px 6px 18px; border-top-width: 1px; box-shadow: none; } #jnd-overlay .jnd-submenu-wrap.jnd-mobile-submenu-open > .jnd-submenu { display: block; } #jnd-overlay .jnd-submenu-wrap.jnd-mobile-submenu-open > .jnd-grid-submenu { display: grid; } } ]]> { 'use strict'; const BUILD = '1.0.0'; const BOOT = window.JND_BOOTSTRAP; const ALL_TARGETS = ['Main', 'Shares', 'Users', 'Settings', 'Plugins', 'Docker', 'VMs', 'Tools']; const TARGETS = ALL_TARGETS.filter(target => BOOT?.dropdowns?.[target] !== false); const SORTABLE_TARGETS = new Set(['Shares', 'Users', 'Docker', 'VMs']); const STATIC_CACHE_MS = 60000; const DYNAMIC_CACHE_MS = 12000; const REQUEST_TIMEOUT_MS = 6000; const ACTION_TIMEOUT_MS = 30000; const TRANSITION_POLL_MS = 850; const TRANSITION_TIMEOUT_MS = 60000; const ALLOWED_HOVER_OPEN_MS = new Set([75, 100, 150, 200]); const configuredHoverOpenMs = Number(BOOT?.preferences?.hoverOpenMs); const HOVER_OPEN_MS = ALLOWED_HOVER_OPEN_MS.has(configuredHoverOpenMs) ? configuredHoverOpenMs : 100; const HOVER_CLOSE_MS = 320; const SORT_MODE = BOOT?.preferences?.sortMode === 'alphabetical' ? 'alphabetical' : 'unraid'; const LABEL_COLLATOR = new Intl.Collator(undefined, {numeric: true, sensitivity: 'base'}); if (!BOOT || BOOT.schema !== 3) { console.warn('[JND] Bootstrap unavailable or incompatible; normal Unraid navigation will continue to work.'); return; } const query = new URLSearchParams(window.location.search); const DISABLE_KEY = `jnd-disabled:${BOOT.version || BUILD}`; try { if (query.get('navdropdowns') === 'off') sessionStorage.setItem(DISABLE_KEY, '1'); else if (query.get('navdropdowns') === 'on') sessionStorage.removeItem(DISABLE_KEY); if (sessionStorage.getItem(DISABLE_KEY) === '1') { console.info('[JND] Disabled for this browser session. Use ?navdropdowns=on to re-enable.'); return; } } catch (_) { if (query.get('navdropdowns') === 'off') return; } const state = { overlay: null, scroll: null, activeTarget: null, activeNavItem: null, openTimer: null, closeTimer: null, cache: new Map(), udItems: [], udLoaded: false, udCacheTime: 0, udInFlight: null, transitions: new Map(), transitionTimer: null, transitionPollInFlight: false, attached: false, }; function sameOriginPath(value) { if (value === null || value === undefined) return null; const raw = String(value).trim(); if (!raw || raw.toLowerCase() === 'null' || raw.toLowerCase() === 'undefined') return null; try { const u = new URL(raw, window.location.origin); if (u.origin !== window.location.origin) return null; return u.pathname + u.search + u.hash; } catch (_) { return null; } } function safeWebUrl(value) { if (value === null || value === undefined) return null; const raw = String(value).trim(); if (!raw || raw === '#' || raw.toLowerCase() === 'null' || raw.toLowerCase() === 'undefined') return null; try { const url = new URL(raw, window.location.href); if (!['http:', 'https:'].includes(url.protocol)) return null; if (url.username || url.password) return null; return url.href; } catch (_) { return null; } } function cleanText(value) { return String(value ?? '').replace(/\s+/g, ' ').trim(); } function cloneData(value) { if (Array.isArray(value)) return value.map(cloneData); if (value && typeof value === 'object') { const copy = {}; for (const [key, child] of Object.entries(value)) copy[key] = cloneData(child); return copy; } return value; } function compatibilityCheck() { const style = getComputedStyle(document.documentElement); if (!style.getPropertyValue('--background-color').trim() || !style.getPropertyValue('--text-color').trim() || !style.getPropertyValue('--header-background-color').trim()) { console.warn('[JND] Required Dynamix theme variables are absent; stock navigation left untouched.'); return null; } const navTile = document.querySelector('#menu .nav-tile:not(.right)'); if (!navTile) { console.warn('[JND] Compatible stock navigation container was not found; stock navigation left untouched.'); return null; } return navTile; } function ensureOverlay() { if (state.overlay) return state.overlay; const overlay = document.createElement('div'); overlay.id = 'jnd-overlay'; overlay.setAttribute('role', 'menu'); overlay.setAttribute('aria-label', 'Navigation submenu'); const scroll = document.createElement('div'); scroll.className = 'jnd-scroll'; overlay.appendChild(scroll); overlay.addEventListener('mouseenter', cancelClose); overlay.addEventListener('mouseleave', scheduleClose); overlay.addEventListener('keydown', onOverlayKeyDown); overlay.addEventListener('click', (event) => { if (!window.matchMedia('(max-width: 767px)').matches) return; const trigger = event.target.closest('.jnd-submenu-trigger'); if (!trigger) return; const wrap = trigger.closest('.jnd-submenu-wrap'); if (!wrap?.querySelector(':scope > .jnd-submenu')) return; event.preventDefault(); wrap.classList.toggle('jnd-mobile-submenu-open'); }); document.body.appendChild(overlay); state.overlay = overlay; state.scroll = scroll; return overlay; } function createLabel(item) { const span = document.createElement('span'); span.className = 'jnd-label'; span.textContent = cleanText(item.label) || 'Untitled'; return span; } function createAppIcon(icon) { if (!icon || typeof icon !== 'object') return null; if (icon.kind === 'img') { const src = sameOriginPath(icon.src); if (!src) return null; const img = document.createElement('img'); img.className = 'jnd-app-icon'; if (icon.variant === 'avatar') img.classList.add('jnd-user-avatar'); img.src = src; img.alt = ''; img.setAttribute('aria-hidden', 'true'); const fallback = sameOriginPath(icon.fallback); if (fallback && fallback !== src) { img.addEventListener('error', () => { if (img.dataset.jndFallbackApplied === '1') return; img.dataset.jndFallbackApplied = '1'; img.src = fallback; }, { once: true }); } return img; } if (icon.kind === 'class') { const classes = cleanText(icon.className) .split(' ') .filter(c => /^(?:fa|fa-[A-Za-z0-9_-]+|icon-[A-Za-z0-9_-]+|PanelIcon|PanelImg|title)$/.test(c)); if (!classes.length) return null; const i = document.createElement('i'); i.className = `${classes.join(' ')} jnd-app-icon`; i.setAttribute('aria-hidden', 'true'); return i; } return null; } function stateIconClass(value) { const stateName = cleanText(value).toLowerCase(); if (stateName === 'started' || stateName === 'running') { return 'fa fa-play started green-text jnd-state-icon'; } if (stateName === 'paused') { return 'fa fa-pause paused orange-text jnd-state-icon'; } return 'fa fa-square stopped red-text jnd-state-icon'; } function createStateIcon(value) { const stateName = cleanText(value).toLowerCase(); if (!stateName) return null; const i = document.createElement('i'); i.className = stateIconClass(stateName); i.setAttribute('aria-hidden', 'true'); return i; } function appendRowContents(container, item) { const icon = createAppIcon(item.icon); if (icon) container.appendChild(icon); const status = createStateIcon(item.state); if (status) container.appendChild(status); container.appendChild(createLabel(item)); } function actionLabel(control) { if (!control || typeof control !== 'object') return ''; if (control.kind === 'docker') { return control.action === 'stop' ? 'Stop container' : 'Start container'; } if (control.kind === 'vm') { return control.action === 'domain-stop' ? 'Stop VM' : 'Start VM'; } return ''; } function controlKey(control) { if (!control || typeof control !== 'object') return ''; const kind = cleanText(control.kind).toLowerCase(); const id = cleanText(control.id); if (!kind || !id) return ''; return `${kind}:${id}`; } function pruneExpiredTransitions() { const now = Date.now(); for (const [key, transition] of state.transitions) { if (!transition || transition.expiresAt <= now) { state.transitions.delete(key); } } } function getTransition(control) { pruneExpiredTransitions(); const key = controlKey(control); return key ? (state.transitions.get(key) || null) : null; } function beginTransition(target, control, expectedState) { const key = controlKey(control); if (!key) return null; const now = Date.now(); const transition = { key, target, kind: cleanText(control.kind).toLowerCase(), id: cleanText(control.id), expectedState: cleanText(expectedState).toLowerCase(), startedAt: now, expiresAt: now + TRANSITION_TIMEOUT_MS, }; state.transitions.set(key, transition); return transition; } function clearTransition(control) { const key = controlKey(control); if (key) state.transitions.delete(key); } function hasTransitionsFor(target) { pruneExpiredTransitions(); for (const transition of state.transitions.values()) { if (transition?.target === target) return true; } return false; } function findControlItem(items, control) { const key = controlKey(control); if (!key) return null; for (const item of items || []) { if (!item || typeof item !== 'object') continue; if (controlKey(item.control) === key) return item; if (Array.isArray(item.children)) { const child = findControlItem(item.children, control); if (child) return child; } } return null; } function reconcileTransitions(target, items) { pruneExpiredTransitions(); for (const [key, transition] of state.transitions) { if (transition?.target !== target) continue; const item = findControlItem(items, transition); if (!item) continue; const currentState = cleanText(item.state).toLowerCase(); if (currentState === transition.expectedState) { state.transitions.delete(key); } } } function createTransitionSpinner() { const spinner = document.createElement('i'); spinner.className = 'fa fa-refresh fa-spin jnd-state-icon'; spinner.setAttribute('aria-hidden', 'true'); return spinner; } function stopTransitionPolling() { if (state.transitionTimer) { clearTimeout(state.transitionTimer); state.transitionTimer = null; } } function scheduleTransitionPolling(delay = TRANSITION_POLL_MS) { stopTransitionPolling(); const target = state.activeTarget; if (!target || !['Docker', 'VMs'].includes(target) || !hasTransitionsFor(target)) return; state.transitionTimer = setTimeout(pollVisibleTransitions, delay); } async function pollVisibleTransitions() { state.transitionTimer = null; const target = state.activeTarget; if (!target || !['Docker', 'VMs'].includes(target) || !hasTransitionsFor(target)) return; if (state.transitionPollInFlight) { scheduleTransitionPolling(); return; } state.transitionPollInFlight = true; try { const latest = await getProviderItems(target, {forceFresh: true}); if (state.activeTarget === target && Array.isArray(latest)) { renderMenu(latest); requestAnimationFrame(positionOverlay); } } catch (error) { console.warn(`[JND] ${target} transition refresh failed:`, error); } finally { state.transitionPollInFlight = false; if (state.activeTarget === target && hasTransitionsFor(target)) { scheduleTransitionPolling(); } } } function statusKind(item) { const value = cleanText(item?.statusKind || item?.control?.kind).toLowerCase(); return value === 'docker' || value === 'vm' ? value : ''; } function statusIndicatorsEnabled(item) { const kind = statusKind(item); if (kind === 'docker') return BOOT.features?.statusIndicators?.docker === true; if (kind === 'vm') return BOOT.features?.statusIndicators?.vms === true; return false; } function createInlinePlaceholder(kind) { const span = document.createElement('span'); span.className = `jnd-inline-placeholder jnd-${kind}-placeholder`; span.setAttribute('aria-hidden', 'true'); return span; } function createWebUiControl(item) { if (BOOT.features?.dockerWebUIButtons !== true) return null; if (statusKind(item) !== 'docker' || cleanText(item?.state).toLowerCase() !== 'started') return null; const href = safeWebUrl(item?.webui); if (!href) return null; const button = document.createElement('button'); button.type = 'button'; button.className = 'jnd-inline-control jnd-webui-control'; button.title = `Open WebUI: ${cleanText(item?.label)}`; button.setAttribute('aria-label', button.title); const icon = document.createElement('i'); icon.className = 'fa fa-globe'; icon.setAttribute('aria-hidden', 'true'); button.appendChild(icon); button.addEventListener('click', (event) => { event.preventDefault(); event.stopPropagation(); if (BOOT.features?.webUiNewTab === true) { window.open(href, '_blank', 'noopener,noreferrer'); } else { window.location.assign(href); } }); return button; } function createLogControl(item) { const kind = statusKind(item); const enabled = (kind === 'docker' && BOOT.features?.dockerLogButtons === true) || (kind === 'vm' && BOOT.features?.vmLogButtons === true); if (!enabled || !item?.log || typeof item.log !== 'object') return null; if (typeof window.openTerminal !== 'function') return null; const tag = kind === 'docker' ? 'docker' : 'log'; const name = cleanText(item.log.name); const more = String(item.log.more ?? ''); if (!name || !more) return null; const button = document.createElement('button'); button.type = 'button'; button.className = 'jnd-inline-control jnd-log-control'; button.title = `Open log: ${cleanText(item?.label)}`; button.setAttribute('aria-label', button.title); const icon = document.createElement('i'); icon.className = 'fa fa-navicon'; icon.setAttribute('aria-hidden', 'true'); button.appendChild(icon); button.addEventListener('click', (event) => { event.preventDefault(); event.stopPropagation(); window.openTerminal(tag, name, more); }); return button; } function safeSameOriginUrl(value) { const href = safeWebUrl(value); if (!href) return null; try { const url = new URL(href); return url.origin === window.location.origin ? url.href : null; } catch (_) { return null; } } function createVncConsoleControl(item) { if (BOOT.features?.vmVncConsoleButtons !== true) return null; if (statusKind(item) !== 'vm') return null; const href = safeSameOriginUrl(item?.vncConsole); if (!href) return null; const button = document.createElement('button'); button.type = 'button'; button.className = 'jnd-inline-control jnd-vnc-control'; button.title = `Open VNC console: ${cleanText(item?.label)}`; button.setAttribute('aria-label', button.title); const icon = document.createElement('i'); icon.className = 'fa fa-desktop'; icon.setAttribute('aria-hidden', 'true'); button.appendChild(icon); button.addEventListener('click', (event) => { event.preventDefault(); event.stopPropagation(); window.open(href, '_blank', 'scrollbars=yes,resizable=yes'); }); return button; } function buildInlineLayout(items) { const list = Array.isArray(items) ? items : []; return { reserveDockerWebUi: BOOT.features?.dockerWebUIButtons === true && list.some(item => statusKind(item) === 'docker' && cleanText(item?.state).toLowerCase() === 'started' && safeWebUrl(item?.webui) ), reserveDockerLog: BOOT.features?.dockerLogButtons === true && list.some(item => statusKind(item) === 'docker' && item?.log), reserveVmLog: BOOT.features?.vmLogButtons === true && list.some(item => statusKind(item) === 'vm' && item?.log), reserveVmVnc: BOOT.features?.vmVncConsoleButtons === true && list.some(item => statusKind(item) === 'vm' && safeSameOriginUrl(item?.vncConsole)), }; } function createStatusControl(item) { const control = item?.control; if (!statusIndicatorsEnabled(item)) return null; const icon = createStateIcon(item?.state); if (!icon) return null; const kind = statusKind(item); const quickEnabled = kind === 'docker' ? BOOT.features?.quickActions?.docker === true : (kind === 'vm' ? BOOT.features?.quickActions?.vms === true : false); const transition = getTransition(control); if (transition) { const busy = document.createElement('button'); busy.type = 'button'; busy.className = 'jnd-status-control'; busy.disabled = true; busy.title = `Transitioning: ${cleanText(item.label)}`; busy.setAttribute('aria-label', busy.title); busy.setAttribute('aria-busy', 'true'); busy.appendChild(createTransitionSpinner()); return busy; } if (!quickEnabled || !control || typeof control !== 'object') { const span = document.createElement('span'); span.className = 'jnd-status-static'; span.appendChild(icon); return span; } const label = actionLabel(control); if (!label) { const span = document.createElement('span'); span.className = 'jnd-status-static'; span.appendChild(icon); return span; } const button = document.createElement('button'); button.type = 'button'; button.className = 'jnd-status-control'; button.title = `${label}: ${cleanText(item.label)}`; button.setAttribute('aria-label', button.title); button.appendChild(icon); button.addEventListener('click', async (event) => { event.preventDefault(); event.stopPropagation(); await performQuickAction(item, button); }); return button; } function createStatusRow(item, layout) { const row = document.createElement('div'); row.className = `jnd-status-row${Number(item.indent) === 1 ? ' jnd-indent-1' : ''}`; const icon = createAppIcon(item.icon); if (icon) row.appendChild(icon); const kind = statusKind(item); // For VMs, VNC is the first inline control to the right of the VM icon. if (kind === 'vm' && layout?.reserveVmVnc) { row.appendChild(createVncConsoleControl(item) || createInlinePlaceholder('vnc')); } const status = createStatusControl(item); if (status) row.appendChild(status); if (kind === 'docker' && layout?.reserveDockerLog) { row.appendChild(createLogControl(item) || createInlinePlaceholder('log')); } else if (kind === 'vm' && layout?.reserveVmLog) { row.appendChild(createLogControl(item) || createInlinePlaceholder('log')); } if (kind === 'docker' && layout?.reserveDockerWebUi) { row.appendChild(createWebUiControl(item) || createInlinePlaceholder('webui')); } const href = sameOriginPath(item.href); if (href) { const a = document.createElement('a'); a.href = href; a.setAttribute('role', 'menuitem'); a.appendChild(createLabel(item)); row.appendChild(a); } else { row.appendChild(createLabel(item)); } return row; } function createLink(item) { const href = sameOriginPath(item.href); if (!href) return null; const a = document.createElement('a'); a.href = href; a.setAttribute('role', 'menuitem'); appendRowContents(a, item); return a; } function renderItems(items, container) { const inlineLayout = buildInlineLayout(items); for (const raw of items || []) { const item = raw && typeof raw === 'object' ? raw : {}; const type = item.type || 'item'; if (type === 'separator') { const sep = document.createElement('div'); sep.className = 'jnd-separator'; sep.setAttribute('role', 'separator'); container.appendChild(sep); continue; } if (type === 'heading') { const row = document.createElement('div'); row.className = 'jnd-heading'; const link = item.href ? createLink(item) : null; if (link) { row.appendChild(link); } else { const span = document.createElement('span'); span.textContent = cleanText(item.label); row.appendChild(span); } container.appendChild(row); continue; } if (type === 'submenu' && Array.isArray(item.children) && item.children.length) { const wrap = document.createElement('div'); wrap.className = 'jnd-submenu-wrap'; const href = item.href ? sameOriginPath(item.href) : null; let trigger; if (href) { trigger = document.createElement('a'); trigger.href = href; } else { trigger = document.createElement('div'); trigger.tabIndex = 0; trigger.setAttribute('role', 'menuitem'); trigger.setAttribute('aria-haspopup', 'menu'); } trigger.className = 'jnd-submenu-trigger'; appendRowContents(trigger, item); const arrow = document.createElement('span'); arrow.className = 'jnd-submenu-arrow'; arrow.setAttribute('aria-hidden', 'true'); arrow.innerHTML = ''; trigger.appendChild(arrow); wrap.appendChild(trigger); const sub = document.createElement('div'); sub.className = 'jnd-submenu'; sub.setAttribute('role', 'menu'); if (item.layout === 'grid') { sub.classList.add('jnd-grid-submenu'); if (item.children.length > 20) sub.classList.add('jnd-grid-3'); } renderItems(item.children, sub); wrap.appendChild(sub); container.appendChild(wrap); continue; } if (item.state) { container.appendChild(createStatusRow(item, inlineLayout)); continue; } const row = document.createElement('div'); const link = createLink(item); if (link) { row.className = `jnd-row${Number(item.indent) === 1 ? ' jnd-indent-1' : ''}`; row.appendChild(link); } else { row.className = `jnd-static-row${Number(item.indent) === 1 ? ' jnd-indent-1' : ''}`; appendRowContents(row, item); } container.appendChild(row); } } function renderLoading() { ensureOverlay(); state.scroll.replaceChildren(); const loading = document.createElement('div'); loading.className = 'jnd-loading'; loading.innerHTML = 'Loading…'; state.scroll.appendChild(loading); } function renderMenu(items) { ensureOverlay(); state.scroll.replaceChildren(); const safeItems = Array.isArray(items) ? items : []; if (!safeItems.length) { renderItems([{type: 'item', label: 'No navigation items available', href: null}], state.scroll); } else { renderItems(safeItems, state.scroll); } } function positionOverlay() { if (!state.overlay || !state.activeNavItem) return; const rect = state.activeNavItem.getBoundingClientRect(); const overlay = state.overlay; const gap = 4; const margin = 8; const sidebar = document.documentElement.classList.contains('Theme--sidebar'); overlay.style.left = '0px'; overlay.style.top = '0px'; overlay.classList.remove('jnd-flip-submenus'); const width = overlay.offsetWidth || 300; const height = overlay.offsetHeight || 200; let left; let top; if (sidebar) { left = rect.right + gap; top = rect.top; if (left + width > window.innerWidth - margin) { left = Math.max(margin, rect.left - width - gap); } } else { left = rect.left; top = rect.bottom + gap; if (left + width > window.innerWidth - margin) { left = Math.max(margin, window.innerWidth - width - margin); } if (top + height > window.innerHeight - margin && rect.top - height - gap >= margin) { top = rect.top - height - gap; } } left = Math.max(margin, left); top = Math.max(margin, top); overlay.style.left = `${Math.round(left)}px`; overlay.style.top = `${Math.round(top)}px`; const estimatedSubmenuWidth = 430; if (left + width + estimatedSubmenuWidth > window.innerWidth - margin) { overlay.classList.add('jnd-flip-submenus'); } } function cancelOpen() { if (state.openTimer) { clearTimeout(state.openTimer); state.openTimer = null; } } function cancelClose() { if (state.closeTimer) { clearTimeout(state.closeTimer); state.closeTimer = null; } } function scheduleClose() { cancelClose(); state.closeTimer = setTimeout(closeMenu, HOVER_CLOSE_MS); } function closeMenu() { cancelOpen(); cancelClose(); stopTransitionPolling(); if (state.overlay) { state.overlay.classList.remove('jnd-visible', 'jnd-flip-submenus'); } if (state.activeNavItem) { state.activeNavItem.classList.remove('jnd-open'); } state.activeTarget = null; state.activeNavItem = null; } function scheduleOpen(target, navItem) { cancelOpen(); cancelClose(); if (target === 'Main') ensureMainUDFresh(); state.openTimer = setTimeout(() => openMenu(target, navItem, false), HOVER_OPEN_MS); } async function openMenu(target, navItem, focusFirst) { cancelOpen(); cancelClose(); if (!TARGETS.includes(target) || !navItem?.isConnected) return; if (state.activeNavItem && state.activeNavItem !== navItem) { state.activeNavItem.classList.remove('jnd-open'); } state.activeTarget = target; state.activeNavItem = navItem; navItem.classList.add('jnd-open'); ensureOverlay(); for (const className of Array.from(state.overlay.classList)) { if (className.startsWith('jnd-target-')) state.overlay.classList.remove(className); } state.overlay.classList.add(`jnd-target-${target.toLowerCase()}`); renderLoading(); state.overlay.classList.add('jnd-visible'); positionOverlay(); let items; try { const forceFresh = target === 'Docker' || target === 'VMs'; items = await getProviderItems(target, {forceFresh}); } catch (error) { console.warn(`[JND] ${target} provider failed:`, error); items = [{type: 'item', label: 'Dropdown data unavailable', href: null}]; } if (state.activeTarget !== target || state.activeNavItem !== navItem) return; renderMenu(items); if (hasTransitionsFor(target)) { scheduleTransitionPolling(); } requestAnimationFrame(() => { positionOverlay(); if (focusFirst) { state.overlay.querySelector('a[role="menuitem"], .jnd-submenu-trigger[tabindex="0"]')?.focus(); } }); } function onOverlayKeyDown(event) { if (event.key === 'Escape') { event.preventDefault(); const anchor = state.activeNavItem?.querySelector(':scope > a'); closeMenu(); anchor?.focus(); return; } if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return; const focusable = Array.from( state.overlay.querySelectorAll('a[role="menuitem"], .jnd-submenu-trigger[tabindex="0"]') ).filter(el => el.offsetParent !== null); if (!focusable.length) return; event.preventDefault(); const current = Math.max(0, focusable.indexOf(document.activeElement)); const delta = event.key === 'ArrowDown' ? 1 : -1; focusable[(current + delta + focusable.length) % focusable.length].focus(); } function cacheGet(key, maxAge) { const hit = state.cache.get(key); if (!hit) return null; if (Date.now() - hit.time > maxAge) { state.cache.delete(key); return null; } return cloneData(hit.items); } function cachePut(key, items) { state.cache.set(key, {time: Date.now(), items: cloneData(items)}); return items; } function jqAjax(options) { return new Promise((resolve, reject) => { if (!window.jQuery?.ajax) { reject(new Error('jQuery AJAX unavailable')); return; } const request = window.jQuery.ajax({ cache: false, timeout: REQUEST_TIMEOUT_MS, ...options, }); request.done(resolve); request.fail((_xhr, status, error) => reject(new Error(error || status || 'AJAX request failed'))); }); } function showQuickActionError(message) { const text = cleanText(message) || 'The requested action failed.'; if (typeof window.swal === 'function') { window.swal({ title: 'Execution error', text, type: 'error', html: false, confirmButtonText: 'Ok', }); } else { window.alert(`Execution error: ${text}`); } } function confirmStopAction(item, control) { const kind = cleanText(control?.kind).toLowerCase(); const action = cleanText(control?.action); const requiresConfirmation = (kind === 'docker' && action === 'stop' && BOOT.features?.confirmStop?.docker === true) || (kind === 'vm' && action === 'domain-stop' && BOOT.features?.confirmStop?.vms === true); if (!requiresConfirmation) return true; const label = cleanText(item?.label); const subject = kind === 'docker' ? 'Docker container' : 'VM'; return window.confirm(`Stop ${subject} "${label}"?`); } async function performQuickAction(item, button) { const control = item?.control; const enabled = control?.kind === 'docker' ? BOOT.features?.quickActions?.docker === true : (control?.kind === 'vm' ? BOOT.features?.quickActions?.vms === true : false); if (!control || button.disabled || !enabled) return; const kind = cleanText(control.kind).toLowerCase(); const id = cleanText(control.id); const action = cleanText(control.action); if (!id) return; let target; let expectedState; if (kind === 'docker' && (action === 'start' || action === 'stop')) { target = 'Docker'; expectedState = action === 'start' ? 'started' : 'stopped'; } else if (kind === 'vm' && (action === 'domain-start' || action === 'domain-stop')) { target = 'VMs'; expectedState = action === 'domain-start' ? 'started' : 'stopped'; } else { return; } if (!confirmStopAction(item, control)) return; beginTransition(target, control, expectedState); button.disabled = true; button.setAttribute('aria-busy', 'true'); button.replaceChildren(createTransitionSpinner()); if (state.activeTarget === target) { scheduleTransitionPolling(300); } try { if (kind === 'docker') { const data = await jqAjax({ url: '/plugins/dynamix.docker.manager/include/Events.php', type: 'POST', dataType: 'json', timeout: ACTION_TIMEOUT_MS, data: { action, container: id, }, }); if (data?.success !== true) { throw new Error(typeof data?.success === 'string' ? data.success : 'Docker action failed.'); } } else { const data = await jqAjax({ url: '/plugins/dynamix.vm.manager/include/VMajax.php', type: 'POST', dataType: 'json', timeout: ACTION_TIMEOUT_MS, data: { action, uuid: id, }, }); if (data?.error) { throw new Error(String(data.error)); } } state.cache.delete(target); if (state.activeTarget === target) { try { const latest = await getProviderItems(target, {forceFresh: true}); if (state.activeTarget === target) { renderMenu(latest); requestAnimationFrame(positionOverlay); } } catch (_) {} if (hasTransitionsFor(target)) { scheduleTransitionPolling(); } } } catch (error) { clearTransition(control); console.warn(`[JND] ${kind} quick action failed:`, error); showQuickActionError(error?.message || error); state.cache.delete(target); stopTransitionPolling(); try { const latest = await getProviderItems(target, {forceFresh: true}); if (state.activeTarget === target) { renderMenu(latest); requestAnimationFrame(positionOverlay); } } catch (_) {} } } function parseInertHtml(html) { const doc = document.implementation.createHTMLDocument(''); const template = doc.createElement('template'); template.innerHTML = String(html || ''); return template.content; } function parseUDBrowseItems(html) { const seen = new Set(); const children = []; const fragment = parseInertHtml(html); for (const a of fragment.querySelectorAll('a[href*="/Browse?dir="]')) { const href = sameOriginPath(a.getAttribute('href')); const label = cleanText(a.textContent); if (!href || !label || seen.has(href)) continue; if (!href.startsWith('/Main/Browse?dir=')) continue; seen.add(href); children.push({ type: 'item', label, href, icon: {kind: 'class', className: 'fa fa-folder-o'}, indent: 1, }); } return children; } async function loadUDItems() { if (!BOOT.features?.ud) return []; /* * The public behavior is intentionally mounted-only for Unassigned * Devices remote shares. Use the lightweight provider backed by the * current mount table instead of asking Unassigned Devices to build * its full HTML content payload. */ const remotes = await loadDynamicProvider('ud_remotes'); const items = Array.isArray(remotes) ? remotes : []; if (!items.length) return []; return [ {type: 'separator'}, {type: 'heading', label: 'Unassigned Devices'}, ...items, ]; } function composeMainItems() { const items = cloneData(BOOT.providers.Main || []); if (state.udLoaded && state.udItems.length) { items.push(...cloneData(state.udItems)); } return items; } function mainUDIsFresh() { return state.udLoaded && (Date.now() - state.udCacheTime <= DYNAMIC_CACHE_MS); } function refreshMainUD() { if (!BOOT.features?.ud) return Promise.resolve([]); if (state.udInFlight) return state.udInFlight; state.udInFlight = loadUDItems() .then(items => { state.udItems = cloneData(Array.isArray(items) ? items : []); state.udLoaded = true; state.udCacheTime = Date.now(); /* * If MAIN is still visible, supplement it in place. The user * never has to close/reopen the dropdown to see the refreshed * Unassigned Devices state. */ if (state.activeTarget === 'Main' && state.activeNavItem?.isConnected) { renderMenu(composeMainItems()); requestAnimationFrame(positionOverlay); } return cloneData(state.udItems); }) .catch(error => { console.warn('[JND] Unassigned Devices read failed:', error); return cloneData(state.udItems); }) .finally(() => { state.udInFlight = null; }); return state.udInFlight; } function ensureMainUDFresh() { if (!BOOT.features?.ud || mainUDIsFresh()) return; void refreshMainUD(); } function sortItemsForTarget(target, items) { if (SORT_MODE !== 'alphabetical' || !SORTABLE_TARGETS.has(target) || !Array.isArray(items)) { return items; } if (items.some(item => (item?.type || 'item') !== 'item')) return items; return [...items].sort((left, right) => LABEL_COLLATOR.compare(cleanText(left?.label), cleanText(right?.label)) ); } async function loadDynamicProvider(provider) { const data = await jqAjax({ url: `/plugins/nav.dropdown.menus/provider.php?provider=${encodeURIComponent(provider)}`, type: 'GET', dataType: 'json', }); if (!data?.ok || !Array.isArray(data.items)) { throw new Error(`${provider} provider returned an invalid response`); } return data.items; } async function getProviderItems(target, options = {}) { /* * MAIN's array/pool/boot inventory is already present in BOOT. Return * it immediately and refresh optional Unassigned Devices content in * the background instead of holding the entire menu behind AJAX. */ if (target === 'Main') { ensureMainUDFresh(); return composeMainItems(); } const dynamic = ['Docker', 'VMs'].includes(target); const maxAge = dynamic ? DYNAMIC_CACHE_MS : STATIC_CACHE_MS; const forceFresh = options?.forceFresh === true; if (!forceFresh) { const cached = cacheGet(target, maxAge); if (cached) { reconcileTransitions(target, cached); return cached; } } let items; if (target === 'Docker') { items = await loadDynamicProvider('docker'); } else if (target === 'VMs') { items = await loadDynamicProvider('vms'); } else { items = cloneData(BOOT.providers[target] || []); } items = sortItemsForTarget(target, items); reconcileTransitions(target, items); return cachePut(target, items); } function attachNav(anchor, target) { const navItem = anchor.parentElement; if (!navItem || navItem.dataset.jndAttached === '1') return; navItem.dataset.jndAttached = '1'; navItem.addEventListener('mouseenter', () => scheduleOpen(target, navItem)); navItem.addEventListener('mouseleave', scheduleClose); anchor.addEventListener('keydown', (event) => { if (event.key === 'ArrowDown') { event.preventDefault(); openMenu(target, navItem, true); } }); } function attach() { if (state.attached) return false; if (!TARGETS.length) { state.attached = true; console.info(`[JND] Navigation dropdowns ${BUILD}: all dropdowns are disabled; stock Unraid navigation remains unchanged.`); return true; } const navTile = compatibilityCheck(); if (!navTile) return false; let count = 0; for (const target of TARGETS) { const anchor = navTile.querySelector(`.nav-item > a[href="/${target}"]`); if (!anchor) continue; const navItem = anchor.parentElement; if (!navItem?.classList.contains('nav-item') || anchor.parentElement !== navItem) { console.warn(`[JND] ${target} stock anchor structure is incompatible; that dropdown was skipped.`); continue; } attachNav(anchor, target); count++; } if (!count) return false; ensureOverlay(); state.attached = true; /* * Prewarm MAIN's optional Unassigned Devices supplement immediately * after the stock navigation is attached. This is fire-and-forget: * page rendering and menu interaction never wait for it. */ if (TARGETS.includes('Main')) ensureMainUDFresh(); document.addEventListener('pointerdown', (event) => { if (!state.overlay?.classList.contains('jnd-visible')) return; if (state.overlay.contains(event.target) || state.activeNavItem?.contains(event.target)) return; closeMenu(); }, true); window.addEventListener('resize', positionOverlay, {passive: true}); window.addEventListener('scroll', positionOverlay, {passive: true, capture: true}); console.info(`[JND] Navigation dropdowns ${BUILD} active on Unraid ${BOOT.unraidVersion || 'unknown'}.`); return true; } function boot() { if (attach()) return; const observer = new MutationObserver(() => { if (attach()) observer.disconnect(); }); observer.observe(document.documentElement, {childList: true, subtree: true}); setTimeout(() => observer.disconnect(), 8000); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', boot, {once: true}); } else { boot(); } })(); ]]> "$RUNTIME/images/nav.dropdown.menus.png" chmod 0644 "$RUNTIME/images/nav.dropdown.menus.png" rm -f "/tmp/nav.dropdown.menus.icon.b64" # Preserve settings on reinstall/upgrade. New installations seed the documented defaults. # Existing configuration files remain untouched; missing keys use the same defaults at runtime. if [ ! -f "$CFG" ]; then cat > "$CFG" <<'JND_CFG_EOF' DROPDOWN_MAIN="1" DROPDOWN_SHARES="1" DROPDOWN_USERS="1" DROPDOWN_SETTINGS="1" DROPDOWN_PLUGINS="1" DROPDOWN_DOCKER="1" DROPDOWN_VMS="1" DROPDOWN_TOOLS="1" SHOW_UNASSIGNED_DEVICES="1" HOVER_OPEN_MS="100" SORT_MODE="unraid" DOCKER_STATUS_INDICATORS="1" DOCKER_QUICK_ACTIONS="0" DOCKER_CONFIRM_STOP="1" DOCKER_LOG_BUTTONS="1" DOCKER_WEBUI_BUTTONS="1" WEBUI_NEW_TAB="1" VM_STATUS_INDICATORS="1" VM_QUICK_ACTIONS="0" VM_CONFIRM_STOP="1" VM_LOG_BUTTONS="1" VM_VNC_CONSOLE_BUTTONS="1" JND_CFG_EOF chmod 0644 "$CFG" fi echo "" echo "-----------------------------------------------------------" echo " Navigation Dropdown Menus installed" echo " Version: 1.0.0" echo " Runtime: $RUNTIME" echo " Config: $CFG" echo " Stock Unraid files modified: NO" echo " Dropdown menus default: ON" echo " Status indicators default: ON" echo " Quick actions default: OFF" echo " Stop confirmations default: ON" echo " Docker/VM log buttons default: ON" echo " Docker WebUI buttons default: ON" echo "-----------------------------------------------------------" echo "" ]]>