- Fix CPU bars: specificity override (#displaybox table.dashboard) for thin 10px color-coded bars (green/orange/red) filling full card width - Fix CPU chart: force full-width display on #cpu_chart td and canvas - Fix Docker text: force white color on all #docker_view spans and links - Fix System donut: remove saturate/brightness filter, thinner ring (78%), fully opaque center background - Add vertical pill bars for Array/Cache/Unassigned disk utilization with JS module to convert horizontal bars to 28x50px vertical pills with % text inside, MutationObserver for dynamic updates - Add legend color JS module to fix invisible System card legend circles with inline !important colors matching recolored pie segments Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
585 lines
18 KiB
JavaScript
585 lines
18 KiB
JavaScript
(function() {
|
|
'use strict';
|
|
var KEY = 'unraid-sidebar-expanded';
|
|
|
|
// Restore state immediately (before DOM ready to avoid flash)
|
|
if (localStorage.getItem(KEY) === '1') {
|
|
document.documentElement.classList.add('sidebar-expanded');
|
|
}
|
|
|
|
function init() {
|
|
var menu = document.getElementById('menu');
|
|
if (!menu) return;
|
|
|
|
// --- Unraid logo (absolute positioned at very top of sidebar) ---
|
|
if (!document.getElementById('sidebar-logo')) {
|
|
var logo = document.createElement('img');
|
|
logo.id = 'sidebar-logo';
|
|
logo.src = 'https://cdn.jsdelivr.net/gh/selfhst/icons@main/svg/unraid.svg';
|
|
logo.alt = '';
|
|
menu.appendChild(logo);
|
|
}
|
|
|
|
// --- Toggle button with chevron (below logo) ---
|
|
if (!document.getElementById('sidebar-toggle-btn')) {
|
|
var btn = document.createElement('button');
|
|
btn.id = 'sidebar-toggle-btn';
|
|
btn.type = 'button';
|
|
btn.title = 'Toggle Sidebar';
|
|
btn.innerHTML = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg>';
|
|
|
|
btn.addEventListener('click', function(e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
var expanded = document.documentElement.classList.toggle('sidebar-expanded');
|
|
localStorage.setItem(KEY, expanded ? '1' : '0');
|
|
});
|
|
|
|
menu.appendChild(btn);
|
|
}
|
|
|
|
// --- Lock button icon toggle (locked vs unlocked) ---
|
|
initLockButton();
|
|
|
|
// --- Search overlay (delayed to wait for search plugin init) ---
|
|
setTimeout(initSearchOverlay, 800);
|
|
}
|
|
|
|
function initLockButton() {
|
|
var lockItem = document.querySelector('.nav-item.LockButton');
|
|
if (!lockItem) return;
|
|
var lockLink = lockItem.querySelector('a');
|
|
if (!lockLink) return;
|
|
|
|
// Set initial state based on title
|
|
function updateLockState() {
|
|
var title = lockLink.getAttribute('title') || '';
|
|
if (title.indexOf('Unlock') === 0) {
|
|
lockItem.classList.add('is-locked');
|
|
} else {
|
|
lockItem.classList.remove('is-locked');
|
|
}
|
|
}
|
|
updateLockState();
|
|
|
|
// Watch for title attribute changes (Unraid toggles this on click)
|
|
var observer = new MutationObserver(function() { updateLockState(); });
|
|
observer.observe(lockLink, { attributes: true, attributeFilter: ['title'] });
|
|
}
|
|
|
|
function initSearchOverlay() {
|
|
if (!window.jQuery) return;
|
|
|
|
var searchItem = document.querySelector('.nav-item.gui_search');
|
|
if (!searchItem) return;
|
|
|
|
// Remove default hover-based open/close handlers
|
|
$(searchItem).off('mouseenter mouseleave');
|
|
|
|
var link = searchItem.querySelector('a');
|
|
if (!link) return;
|
|
|
|
// Remove any inline onclick handler (Unraid sets onclick="gui_search()")
|
|
link.removeAttribute('onclick');
|
|
link.onclick = null;
|
|
|
|
// Replace hover with click
|
|
link.addEventListener('click', function(e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
toggleSearch();
|
|
});
|
|
|
|
// Add Cmd+K / Ctrl+K keyboard shortcut (sidebar theme doesn't have one by default)
|
|
document.addEventListener('keydown', function(e) {
|
|
var isMac = navigator.platform.indexOf('Mac') > -1;
|
|
if ((isMac ? e.metaKey : e.ctrlKey) && (e.key === 'k' || e.keyCode === 75)) {
|
|
if ($('[role="modal"]:visible').length) return;
|
|
e.preventDefault();
|
|
toggleSearch();
|
|
}
|
|
});
|
|
}
|
|
|
|
function toggleSearch() {
|
|
var existing = document.getElementById('guiSearchBoxSpan');
|
|
if (existing) {
|
|
closeSearch();
|
|
return;
|
|
}
|
|
|
|
// Call original gui_search to create the input
|
|
if (typeof gui_search === 'function') {
|
|
gui_search();
|
|
}
|
|
|
|
// Move search box to <body> — backdrop-filter on #menu creates a new
|
|
// containing block, so position:fixed inside it is relative to #menu
|
|
// and gets clipped by overflow:hidden. Moving to body fixes this.
|
|
var searchSpan = document.getElementById('guiSearchBoxSpan');
|
|
if (searchSpan) {
|
|
document.body.appendChild(searchSpan);
|
|
}
|
|
|
|
// Create backdrop overlay
|
|
if (!document.getElementById('search-backdrop')) {
|
|
var backdrop = document.createElement('div');
|
|
backdrop.id = 'search-backdrop';
|
|
backdrop.addEventListener('click', function() {
|
|
closeSearch();
|
|
});
|
|
document.body.appendChild(backdrop);
|
|
}
|
|
|
|
// Override blur/keydown behavior on the input
|
|
var input = document.getElementById('guiSearchBox');
|
|
if (input && window.jQuery) {
|
|
$(input).off('blur keydown');
|
|
|
|
input.addEventListener('blur', function() {
|
|
setTimeout(function() {
|
|
var span = document.getElementById('guiSearchBoxSpan');
|
|
if (span && !span.contains(document.activeElement)) {
|
|
closeSearch();
|
|
}
|
|
}, 300);
|
|
});
|
|
|
|
input.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Escape' || e.keyCode === 27) {
|
|
e.preventDefault();
|
|
closeSearch();
|
|
}
|
|
});
|
|
|
|
input.focus();
|
|
}
|
|
}
|
|
|
|
function closeSearch() {
|
|
// Remove the search span directly (works whether moved to body or not)
|
|
var span = document.getElementById('guiSearchBoxSpan');
|
|
if (span) span.remove();
|
|
var backdrop = document.getElementById('search-backdrop');
|
|
if (backdrop) backdrop.remove();
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
} else {
|
|
init();
|
|
}
|
|
})();
|
|
|
|
// =============================================================
|
|
// Icon Replacement Module — selfh.st CDN icons for Docker/VMs
|
|
// =============================================================
|
|
(function() {
|
|
'use strict';
|
|
|
|
var CDN_PNG = 'https://cdn.jsdelivr.net/gh/selfhst/icons@main/png';
|
|
var CDN_SVG = 'https://cdn.jsdelivr.net/gh/selfhst/icons@main/svg';
|
|
|
|
// Container name → selfh.st icon name (lowercase)
|
|
// Only needed for names that don't auto-match.
|
|
// Add entries here or use the glass.icon Docker label.
|
|
var ICON_MAP = {
|
|
'autokuma': 'uptime-kuma',
|
|
'uptimekuma': 'uptime-kuma',
|
|
'uptime-kuma-api': 'uptime-kuma',
|
|
'homeassistant_inabox': 'home-assistant',
|
|
'dockersocket': 'docker',
|
|
'pgadmin4': 'pgadmin',
|
|
'postgresql17': 'postgresql',
|
|
'woodpecker-agent': 'woodpecker-ci',
|
|
'woodpecker-server': 'woodpecker-ci',
|
|
'rustdesk-hbbr': 'rustdesk',
|
|
'rustdesk-hbbs': 'rustdesk',
|
|
'rustfs': 'rustdesk',
|
|
'netbox-redis-cache': 'redis',
|
|
'netbox-worker': 'netbox',
|
|
'netdisco-backend': 'networking-toolbox',
|
|
'netdisco-web': 'networking-toolbox',
|
|
'diode-agent': 'netbox',
|
|
'diode-auth': 'netbox',
|
|
'diode-auth-bootstrap': 'netbox',
|
|
'diode-hydra': 'netbox',
|
|
'diode-hydra-migrate': 'netbox',
|
|
'diode-ingester': 'netbox',
|
|
'diode-ingress': 'netbox',
|
|
'diode-reconciler': 'netbox',
|
|
'authentik-worker': 'authentik',
|
|
'adguardhome': 'adguard-home',
|
|
'adguardhome-sync': 'adguard-home',
|
|
'timemachine': 'apple',
|
|
'libation': 'audible',
|
|
'netalertx': 'netalertx',
|
|
'actual-budget': 'actual-budget',
|
|
'speedtest-tracker': 'speedtest-tracker',
|
|
'open-webui': 'open-webui',
|
|
'urbackup': 'networking-toolbox',
|
|
'seekandwatch': 'databasement',
|
|
'unmarr': 'homarr',
|
|
'dockhand': 'docker'
|
|
};
|
|
|
|
// Resolve container name to selfh.st CDN URL
|
|
// mode: 'color' for expanded containers, 'light' for dashboard/folder-preview
|
|
function resolveIconUrl(name, mode) {
|
|
var key = name.toLowerCase();
|
|
var iconName = ICON_MAP[key] || key;
|
|
if (mode === 'light') {
|
|
return CDN_SVG + '/' + iconName + '-light.svg';
|
|
}
|
|
return CDN_PNG + '/' + iconName + '.png';
|
|
}
|
|
|
|
// Extract container name from an <img> element by walking up the DOM
|
|
function getContainerName(img) {
|
|
// Strategy 1: sibling .inner > .appname text
|
|
var outer = img.closest('span.outer');
|
|
if (outer) {
|
|
var appname = outer.querySelector('span.appname');
|
|
if (appname) {
|
|
var a = appname.querySelector('a');
|
|
return (a ? a.textContent : appname.textContent).trim();
|
|
}
|
|
}
|
|
|
|
// Strategy 2: .inner > first non-state span (Dashboard view)
|
|
if (outer) {
|
|
var inner = outer.querySelector('span.inner');
|
|
if (inner) {
|
|
var nameSpan = inner.querySelector('span:not(.state)');
|
|
if (nameSpan && nameSpan.textContent.trim()) {
|
|
return nameSpan.textContent.trim();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Strategy 3: .inner > a.exec text (FolderView uses this)
|
|
if (outer) {
|
|
var exec = outer.querySelector('a.exec');
|
|
if (exec) return exec.textContent.trim();
|
|
}
|
|
|
|
// Strategy 4: parent row ct-name
|
|
var row = img.closest('tr');
|
|
if (row) {
|
|
var ctName = row.querySelector('td.ct-name span.appname');
|
|
if (ctName) {
|
|
var a2 = ctName.querySelector('a');
|
|
return (a2 ? a2.textContent : ctName.textContent).trim();
|
|
}
|
|
}
|
|
|
|
// Strategy 4: extract from icon src path (fallback)
|
|
var src = img.getAttribute('src') || '';
|
|
var match = src.match(/images\/(.+?)-icon\.png/);
|
|
if (match) return match[1];
|
|
|
|
return null;
|
|
}
|
|
|
|
// Determine icon mode based on DOM context
|
|
function getIconMode(img) {
|
|
// Dashboard → light
|
|
if (img.closest('table.dashboard')) return 'light';
|
|
// Folder preview (collapsed row) → light
|
|
if (img.closest('div.folder-preview')) return 'light';
|
|
// Expanded container inside folder → color
|
|
if (img.closest('tr.folder-element')) return 'color';
|
|
// Regular container row → color
|
|
return 'color';
|
|
}
|
|
|
|
// Replace all container icons on the page
|
|
function replaceIcons() {
|
|
var imgs = document.querySelectorAll('img.img');
|
|
imgs.forEach(function(img) {
|
|
// Skip sidebar logo and non-container icons
|
|
if (img.id === 'sidebar-logo') return;
|
|
// Skip folder category icons (SVGs managed by FolderView)
|
|
if (img.classList.contains('folder-img') && img.closest('td.folder-name')) return;
|
|
if (img.classList.contains('folder-img-docker')) return;
|
|
|
|
var name = getContainerName(img);
|
|
if (!name) return;
|
|
|
|
// Skip folder names (they start with "folder-")
|
|
if (name.indexOf('folder-') === 0) return;
|
|
|
|
var mode = getIconMode(img);
|
|
var url = resolveIconUrl(name, mode);
|
|
|
|
// Only replace if different (avoid infinite MutationObserver loop)
|
|
if (img.src !== url && img.getAttribute('src') !== url) {
|
|
img.src = url;
|
|
// Remove cache-busting onerror that resets to question mark
|
|
img.removeAttribute('onerror');
|
|
}
|
|
});
|
|
}
|
|
|
|
// Debounce to avoid excessive calls during DOM manipulation
|
|
var replaceTimer = null;
|
|
function debouncedReplace() {
|
|
if (replaceTimer) clearTimeout(replaceTimer);
|
|
replaceTimer = setTimeout(replaceIcons, 200);
|
|
}
|
|
|
|
// Watch for DOM changes (AJAX loads, FolderView manipulation)
|
|
function startObserver() {
|
|
var target = document.getElementById('displaybox') || document.body;
|
|
var observer = new MutationObserver(function(mutations) {
|
|
var dominated = false;
|
|
for (var i = 0; i < mutations.length; i++) {
|
|
if (mutations[i].addedNodes.length > 0) {
|
|
dominated = true;
|
|
break;
|
|
}
|
|
}
|
|
if (dominated) debouncedReplace();
|
|
});
|
|
observer.observe(target, { childList: true, subtree: true });
|
|
}
|
|
|
|
// Replace Tailscale icon on Dashboard and Settings pages
|
|
function replaceTailscaleIcon() {
|
|
var TAILSCALE_SVG = CDN_SVG + '/tailscale-light.svg';
|
|
// Dashboard: img with alt="Tailscale" inside a card header
|
|
var imgs = document.querySelectorAll('img[alt="Tailscale"], img[src*="tailscale" i]');
|
|
imgs.forEach(function(img) {
|
|
if (img.src !== TAILSCALE_SVG && img.getAttribute('src') !== TAILSCALE_SVG) {
|
|
img.src = TAILSCALE_SVG;
|
|
img.removeAttribute('onerror');
|
|
}
|
|
});
|
|
}
|
|
|
|
// Initialize when DOM is ready
|
|
function initIcons() {
|
|
// Initial replacement
|
|
setTimeout(replaceIcons, 500);
|
|
setTimeout(replaceTailscaleIcon, 500);
|
|
// Second pass after FolderView finishes its DOM work
|
|
setTimeout(replaceIcons, 2000);
|
|
setTimeout(replaceTailscaleIcon, 2000);
|
|
// Start watching for future changes
|
|
startObserver();
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', initIcons);
|
|
} else {
|
|
initIcons();
|
|
}
|
|
})();
|
|
|
|
// =============================================================
|
|
// Pie Chart Recoloring — vibrant theme colors for System card
|
|
// =============================================================
|
|
(function() {
|
|
'use strict';
|
|
|
|
// Map Unraid's muted conic-gradient colors → vibrant theme colors
|
|
var COLOR_MAP = [
|
|
// gray-blue used portions → theme accent
|
|
[/rgb\(96,\s*110,\s*127\)/g, 'rgb(126, 184, 218)'],
|
|
// near-black unused → subtle light
|
|
[/rgb\(35,\s*37,\s*35\)/g, 'rgba(255, 255, 255, 0.08)'],
|
|
// muted orange → vibrant orange
|
|
[/rgb\(215,\s*126,\s*13\)/g, 'rgb(245, 166, 35)'],
|
|
// muted yellow → vibrant yellow
|
|
[/rgb\(212,\s*172,\s*13\)/g, 'rgb(241, 196, 15)'],
|
|
// muted red → vibrant red-coral
|
|
[/rgb\(205,\s*92,\s*92\)/g, 'rgb(231, 76, 60)'],
|
|
];
|
|
|
|
function recolorPie(pie) {
|
|
var style = pie.getAttribute('style');
|
|
if (!style || style.indexOf('conic-gradient') === -1) return;
|
|
|
|
var newStyle = style;
|
|
for (var i = 0; i < COLOR_MAP.length; i++) {
|
|
newStyle = newStyle.replace(COLOR_MAP[i][0], COLOR_MAP[i][1]);
|
|
}
|
|
|
|
if (newStyle !== style) {
|
|
pie.setAttribute('style', newStyle);
|
|
}
|
|
}
|
|
|
|
function recolorAll() {
|
|
var pies = document.querySelectorAll('div.pie');
|
|
pies.forEach(recolorPie);
|
|
}
|
|
|
|
function startPieObserver() {
|
|
var pies = document.querySelectorAll('div.pie');
|
|
if (pies.length === 0) return;
|
|
|
|
var observer = new MutationObserver(function(mutations) {
|
|
for (var i = 0; i < mutations.length; i++) {
|
|
if (mutations[i].attributeName === 'style') {
|
|
recolorPie(mutations[i].target);
|
|
}
|
|
}
|
|
});
|
|
|
|
pies.forEach(function(pie) {
|
|
recolorPie(pie);
|
|
observer.observe(pie, { attributes: true, attributeFilter: ['style'] });
|
|
});
|
|
}
|
|
|
|
function initPieCharts() {
|
|
setTimeout(recolorAll, 1000);
|
|
setTimeout(startPieObserver, 2000);
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', initPieCharts);
|
|
} else {
|
|
initPieCharts();
|
|
}
|
|
})();
|
|
|
|
// =============================================================
|
|
// Vertical Pill Bars — Array/Cache/Unassigned disk utilization
|
|
// =============================================================
|
|
(function() {
|
|
'use strict';
|
|
|
|
function convertBar(bar) {
|
|
// Skip bars with no data or already converted
|
|
if (bar.classList.contains('none') || bar.classList.contains('vertical-bar')) return;
|
|
|
|
var fill = bar.querySelector('span:first-child');
|
|
if (!fill) return;
|
|
|
|
// Read width (e.g. "83%") and convert to height
|
|
var w = fill.style.width;
|
|
if (w) {
|
|
fill.style.height = w;
|
|
fill.style.width = '100%';
|
|
}
|
|
|
|
// Copy % text from sibling .load span into bar's last span
|
|
var parent = bar.parentElement;
|
|
if (parent) {
|
|
var loadSpan = parent.querySelector('.load');
|
|
var textSpan = bar.querySelector('span:last-child');
|
|
if (loadSpan && textSpan && textSpan !== fill) {
|
|
textSpan.textContent = loadSpan.textContent.trim();
|
|
}
|
|
}
|
|
|
|
bar.classList.add('vertical-bar');
|
|
}
|
|
|
|
function convertAllBars(container) {
|
|
var bars = container.querySelectorAll('.usage-disk');
|
|
bars.forEach(convertBar);
|
|
}
|
|
|
|
function initVerticalBars() {
|
|
var tbodyIds = ['array_list', 'pool_list0', 'devs_list'];
|
|
var targets = [];
|
|
|
|
tbodyIds.forEach(function(id) {
|
|
var el = document.getElementById(id);
|
|
if (el) {
|
|
targets.push(el);
|
|
convertAllBars(el);
|
|
}
|
|
});
|
|
|
|
if (targets.length === 0) return;
|
|
|
|
// Watch for style changes (Unraid updates bar widths) and childList (dynamic adds)
|
|
var observer = new MutationObserver(function(mutations) {
|
|
mutations.forEach(function(m) {
|
|
var tbody = m.target.closest ? m.target.closest('tbody') : null;
|
|
if (!tbody) tbody = m.target;
|
|
// Reconvert: remove vertical-bar class from affected bars, then reconvert
|
|
var bars = tbody.querySelectorAll('.usage-disk');
|
|
bars.forEach(function(bar) {
|
|
if (bar.classList.contains('vertical-bar')) {
|
|
bar.classList.remove('vertical-bar');
|
|
}
|
|
convertBar(bar);
|
|
});
|
|
});
|
|
});
|
|
|
|
targets.forEach(function(el) {
|
|
observer.observe(el, { attributes: true, attributeFilter: ['style'], subtree: true, childList: true });
|
|
});
|
|
}
|
|
|
|
function init() {
|
|
setTimeout(initVerticalBars, 1500);
|
|
// Second pass for late-loading content
|
|
setTimeout(initVerticalBars, 3000);
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
} else {
|
|
init();
|
|
}
|
|
})();
|
|
|
|
// =============================================================
|
|
// System Card Legend Colors — fix invisible legend circles
|
|
// =============================================================
|
|
(function() {
|
|
'use strict';
|
|
|
|
// Legend circle colors matching recolored pie segments (in DOM order)
|
|
var LEGEND_COLORS = [
|
|
'rgb(126, 184, 218)', // System — accent blue
|
|
'rgb(245, 166, 35)', // VM — orange
|
|
'rgb(241, 196, 15)', // ZFS cache — yellow
|
|
'rgb(231, 76, 60)', // Docker — red
|
|
'rgba(255, 255, 255, 0.3)' // Free — subtle white
|
|
];
|
|
|
|
function colorLegend() {
|
|
var dynamic = document.getElementById('dynamic');
|
|
if (!dynamic) return;
|
|
|
|
var circles = dynamic.querySelectorAll('i.fa-circle');
|
|
circles.forEach(function(circle, i) {
|
|
if (i < LEGEND_COLORS.length) {
|
|
circle.style.setProperty('color', LEGEND_COLORS[i], 'important');
|
|
}
|
|
});
|
|
}
|
|
|
|
function initLegendColors() {
|
|
var dynamic = document.getElementById('dynamic');
|
|
if (!dynamic) return;
|
|
|
|
colorLegend();
|
|
|
|
// Re-apply when Unraid re-renders the system card
|
|
var observer = new MutationObserver(function() {
|
|
colorLegend();
|
|
});
|
|
observer.observe(dynamic, { childList: true, subtree: true });
|
|
}
|
|
|
|
function init() {
|
|
setTimeout(initLegendColors, 2000);
|
|
setTimeout(colorLegend, 4000);
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
} else {
|
|
init();
|
|
}
|
|
})();
|