|
@@ -63,6 +63,7 @@ function escapeHtml(str) {
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
|
const hash = window.location.hash.replace('#', '') || 'dashboard';
|
|
const hash = window.location.hash.replace('#', '') || 'dashboard';
|
|
|
navigate(hash);
|
|
navigate(hash);
|
|
|
|
|
+ initTableSorting();
|
|
|
|
|
|
|
|
fetchData();
|
|
fetchData();
|
|
|
// Live polling every 2.5 seconds
|
|
// Live polling every 2.5 seconds
|
|
@@ -159,6 +160,7 @@ function navigate(tabName) {
|
|
|
if (tabName === 'pptp') loadPPTP();
|
|
if (tabName === 'pptp') loadPPTP();
|
|
|
if (tabName === 'tools') loadToolsView();
|
|
if (tabName === 'tools') loadToolsView();
|
|
|
if (tabName === 'dhcp-client') loadDHCPClients();
|
|
if (tabName === 'dhcp-client') loadDHCPClients();
|
|
|
|
|
+ setTimeout(initTableSorting, 50);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// Fetch all core system, network and VPN telemetry
|
|
// Fetch all core system, network and VPN telemetry
|
|
@@ -2607,6 +2609,7 @@ function switchFWSubTab(tab) {
|
|
|
|
|
|
|
|
if (tab === 'geo') loadGeoIP();
|
|
if (tab === 'geo') loadGeoIP();
|
|
|
if (tab === 'connections') loadConnections();
|
|
if (tab === 'connections') loadConnections();
|
|
|
|
|
+ setTimeout(initTableSorting, 50);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function openCurrentFWModal() {
|
|
function openCurrentFWModal() {
|
|
@@ -5479,6 +5482,7 @@ function switchRoutesSubTab(tab) {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
});
|
|
});
|
|
|
|
|
+ setTimeout(initTableSorting, 50);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
async function loadRoutes() {
|
|
async function loadRoutes() {
|
|
@@ -7714,6 +7718,7 @@ function switchBridgeSubTab(tab) {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
if (tab === 'hosts') loadBridgeHosts();
|
|
if (tab === 'hosts') loadBridgeHosts();
|
|
|
|
|
+ setTimeout(initTableSorting, 50);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function openCurrentBridgeModal() {
|
|
function openCurrentBridgeModal() {
|
|
@@ -9869,4 +9874,273 @@ async function deleteDHCPClient(id, ifaceName) {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// ==================== UNIVERSAL TABLE ORDERING / SORTING (WINBOX / WEBFIG STYLE) ====================
|
|
|
|
|
+
|
|
|
|
|
+function initTableSorting() {
|
|
|
|
|
+ document.querySelectorAll('table').forEach(setupSortableTable);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function setupSortableTable(table) {
|
|
|
|
|
+ const thead = table.querySelector('thead');
|
|
|
|
|
+ const tbody = table.querySelector('tbody');
|
|
|
|
|
+ if (!thead || !tbody) return;
|
|
|
|
|
+
|
|
|
|
|
+ const headerRows = thead.querySelectorAll('tr');
|
|
|
|
|
+ if (headerRows.length === 0) return;
|
|
|
|
|
+ const ths = headerRows[headerRows.length - 1].querySelectorAll('th');
|
|
|
|
|
+
|
|
|
|
|
+ ths.forEach((th, colIdx) => {
|
|
|
|
|
+ if (th._sortBound) return;
|
|
|
|
|
+ th._sortBound = true;
|
|
|
|
|
+
|
|
|
|
|
+ const rawText = th.innerText.trim().toLowerCase();
|
|
|
|
|
+ const isActionsCol = rawText === 'actions' || (rawText === 'action' && th.classList.contains('text-right'));
|
|
|
|
|
+ const isCheckboxCol = !!th.querySelector('input[type="checkbox"]');
|
|
|
|
|
+ const isExpandCol = (th.classList.contains('w-8') || th.classList.contains('w-6')) && !rawText;
|
|
|
|
|
+ const isExplicitNoSort = th.classList.contains('no-sort') || th.getAttribute('data-sortable') === 'false';
|
|
|
|
|
+
|
|
|
|
|
+ if (isActionsCol || isCheckboxCol || isExpandCol || isExplicitNoSort) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ th.classList.add('table-sortable', 'group');
|
|
|
|
|
+ if (!th.title) th.title = 'Click to sort (Ascending / Descending / Default)';
|
|
|
|
|
+
|
|
|
|
|
+ let icon = th.querySelector('.table-sort-icon');
|
|
|
|
|
+ if (!icon) {
|
|
|
|
|
+ icon = document.createElement('span');
|
|
|
|
|
+ icon.className = 'table-sort-icon opacity-0 group-hover:opacity-50 text-slate-400';
|
|
|
|
|
+ icon.innerHTML = '⇅';
|
|
|
|
|
+ th.appendChild(icon);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ th.addEventListener('click', (e) => {
|
|
|
|
|
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'BUTTON' || e.target.closest('button, input')) return;
|
|
|
|
|
+ handleHeaderClick(table, th, colIdx);
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ if (!tbody._sortObserved) {
|
|
|
|
|
+ tbody._sortObserved = true;
|
|
|
|
|
+ let sortDebounceTimer = null;
|
|
|
|
|
+ const observer = new MutationObserver((mutations) => {
|
|
|
|
|
+ if (tbody._isSorting) return;
|
|
|
|
|
+
|
|
|
|
|
+ const hasChildChanges = mutations.some(m => m.type === 'childList' && m.addedNodes.length > 0);
|
|
|
|
|
+ if (!hasChildChanges) return;
|
|
|
|
|
+
|
|
|
|
|
+ clearTimeout(sortDebounceTimer);
|
|
|
|
|
+ sortDebounceTimer = setTimeout(() => {
|
|
|
|
|
+ if (tbody._isSorting) return;
|
|
|
|
|
+ if (table._activeSort && table._activeSort.dir !== 'none') {
|
|
|
|
|
+ applyTableSort(table, table._activeSort.colIdx, table._activeSort.dir, false);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ tagOriginalRowOrder(tbody);
|
|
|
|
|
+ }
|
|
|
|
|
+ }, 10);
|
|
|
|
|
+ });
|
|
|
|
|
+ observer.observe(tbody, { childList: true });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ tagOriginalRowOrder(tbody);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function tagOriginalRowOrder(tbody) {
|
|
|
|
|
+ let idx = 0;
|
|
|
|
|
+ Array.from(tbody.children).forEach(tr => {
|
|
|
|
|
+ if (!tr.querySelector('td[colspan]')) {
|
|
|
|
|
+ if (!tr.hasAttribute('data-orig-idx')) {
|
|
|
|
|
+ tr.setAttribute('data-orig-idx', idx++);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function handleHeaderClick(table, th, colIdx) {
|
|
|
|
|
+ const currentSort = table._activeSort || { colIdx: -1, dir: 'none' };
|
|
|
|
|
+ let newDir = 'asc';
|
|
|
|
|
+
|
|
|
|
|
+ if (currentSort.colIdx === colIdx) {
|
|
|
|
|
+ if (currentSort.dir === 'asc') newDir = 'desc';
|
|
|
|
|
+ else if (currentSort.dir === 'desc') newDir = 'none';
|
|
|
|
|
+ else newDir = 'asc';
|
|
|
|
|
+ } else {
|
|
|
|
|
+ newDir = 'asc';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ table._activeSort = { colIdx, dir: newDir };
|
|
|
|
|
+ updateHeaderIcons(table, colIdx, newDir);
|
|
|
|
|
+ applyTableSort(table, colIdx, newDir, true);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function updateHeaderIcons(table, activeColIdx, dir) {
|
|
|
|
|
+ const thead = table.querySelector('thead');
|
|
|
|
|
+ if (!thead) return;
|
|
|
|
|
+ const ths = thead.querySelectorAll('th');
|
|
|
|
|
+
|
|
|
|
|
+ ths.forEach((th, idx) => {
|
|
|
|
|
+ const icon = th.querySelector('.table-sort-icon');
|
|
|
|
|
+ if (!icon) return;
|
|
|
|
|
+
|
|
|
|
|
+ if (idx === activeColIdx && dir !== 'none') {
|
|
|
|
|
+ icon.innerHTML = dir === 'asc' ? '▲' : '▼';
|
|
|
|
|
+ icon.className = 'table-sort-icon text-cyan-400 font-bold opacity-100';
|
|
|
|
|
+ th.classList.add('table-sorted-active');
|
|
|
|
|
+ } else {
|
|
|
|
|
+ icon.innerHTML = '⇅';
|
|
|
|
|
+ icon.className = 'table-sort-icon opacity-0 group-hover:opacity-50 text-slate-400';
|
|
|
|
|
+ th.classList.remove('table-sorted-active');
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function applyTableSort(table, colIdx, dir, userInitiated) {
|
|
|
|
|
+ const tbody = table.querySelector('tbody');
|
|
|
|
|
+ if (!tbody || tbody.children.length <= 1) return;
|
|
|
|
|
+
|
|
|
|
|
+ tbody._isSorting = true;
|
|
|
|
|
+ tagOriginalRowOrder(tbody);
|
|
|
|
|
+
|
|
|
|
|
+ // Group parent rows and any attached drawer/sub-rows
|
|
|
|
|
+ const groups = [];
|
|
|
|
|
+ let currentGroup = null;
|
|
|
|
|
+
|
|
|
|
|
+ Array.from(tbody.children).forEach(tr => {
|
|
|
|
|
+ if (tr.querySelector('td[colspan]')) {
|
|
|
|
|
+ if (currentGroup) {
|
|
|
|
|
+ currentGroup.subRows.push(tr);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ groups.push({ mainRow: tr, subRows: [] });
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ currentGroup = { mainRow: tr, subRows: [] };
|
|
|
|
|
+ groups.push(currentGroup);
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ if (dir === 'none') {
|
|
|
|
|
+ groups.sort((a, b) => {
|
|
|
|
|
+ const idxA = parseInt(a.mainRow.getAttribute('data-orig-idx') || '0', 10);
|
|
|
|
|
+ const idxB = parseInt(b.mainRow.getAttribute('data-orig-idx') || '0', 10);
|
|
|
|
|
+ return idxA - idxB;
|
|
|
|
|
+ });
|
|
|
|
|
+ } else {
|
|
|
|
|
+ groups.sort((a, b) => {
|
|
|
|
|
+ const cellA = a.mainRow.children[colIdx];
|
|
|
|
|
+ const cellB = b.mainRow.children[colIdx];
|
|
|
|
|
+ const res = compareTableCells(cellA, cellB);
|
|
|
|
|
+ return dir === 'asc' ? res : -res;
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Re-append rows in sorted order
|
|
|
|
|
+ groups.forEach(g => {
|
|
|
|
|
+ tbody.appendChild(g.mainRow);
|
|
|
|
|
+ g.subRows.forEach(sub => tbody.appendChild(sub));
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ tbody._isSorting = false;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function getCellRawText(cell) {
|
|
|
|
|
+ if (!cell) return '';
|
|
|
|
|
+ if (cell.hasAttribute('data-sort-value')) return cell.getAttribute('data-sort-value');
|
|
|
|
|
+ const input = cell.querySelector('input, select');
|
|
|
|
|
+ if (input) {
|
|
|
|
|
+ if (input.type === 'checkbox') return input.checked ? '1' : '0';
|
|
|
|
|
+ return input.value;
|
|
|
|
|
+ }
|
|
|
|
|
+ return cell.innerText.trim();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function parseTrafficToNumber(str) {
|
|
|
|
|
+ const m = str.match(/^([\d.,]+)\s*([KMGTkmgt]?[iI]?[Bb](?:ps|\/s)?)/);
|
|
|
|
|
+ if (!m) return null;
|
|
|
|
|
+ const num = parseFloat(m[1].replace(/,/g, ''));
|
|
|
|
|
+ if (isNaN(num)) return null;
|
|
|
|
|
+ const unit = m[2].toUpperCase();
|
|
|
|
|
+ let mult = 1;
|
|
|
|
|
+ if (unit.startsWith('K')) mult = 1024;
|
|
|
|
|
+ else if (unit.startsWith('M')) mult = 1024 * 1024;
|
|
|
|
|
+ else if (unit.startsWith('G')) mult = 1024 * 1024 * 1024;
|
|
|
|
|
+ else if (unit.startsWith('T')) mult = 1024 * 1024 * 1024 * 1024;
|
|
|
|
|
+ return num * mult;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function parseIpToNumber(str) {
|
|
|
|
|
+ const clean = str.replace(/^[!~]/, '').trim();
|
|
|
|
|
+ const m = clean.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(?:\/(\d{1,2}))?/);
|
|
|
|
|
+ if (!m) return null;
|
|
|
|
|
+ const o1 = parseInt(m[1], 10);
|
|
|
|
|
+ const o2 = parseInt(m[2], 10);
|
|
|
|
|
+ const o3 = parseInt(m[3], 10);
|
|
|
|
|
+ const o4 = parseInt(m[4], 10);
|
|
|
|
|
+ if (o1 > 255 || o2 > 255 || o3 > 255 || o4 > 255) return null;
|
|
|
|
|
+ return ((o1 * 256 + o2) * 256 + o3) * 256 + o4;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function parseCompoundPackets(str) {
|
|
|
|
|
+ const m = str.match(/^([\d,]+)p/);
|
|
|
|
|
+ if (!m) return null;
|
|
|
|
|
+ return parseInt(m[1].replace(/,/g, ''), 10);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function compareTableCells(cellA, cellB) {
|
|
|
|
|
+ const a = getCellRawText(cellA);
|
|
|
|
|
+ const b = getCellRawText(cellB);
|
|
|
|
|
+
|
|
|
|
|
+ if (a === '' && b !== '') return 1;
|
|
|
|
|
+ if (b === '' && a !== '') return -1;
|
|
|
|
|
+ if (a === b) return 0;
|
|
|
|
|
+
|
|
|
|
|
+ // 1. IP Addresses / CIDR
|
|
|
|
|
+ const ipA = parseIpToNumber(a);
|
|
|
|
|
+ const ipB = parseIpToNumber(b);
|
|
|
|
|
+ if (ipA !== null && ipB !== null) return ipA - ipB;
|
|
|
|
|
+
|
|
|
|
|
+ // 2. Packet counts (e.g. "1,250p / 45 KB")
|
|
|
|
|
+ const pktA = parseCompoundPackets(a);
|
|
|
|
|
+ const pktB = parseCompoundPackets(b);
|
|
|
|
|
+ if (pktA !== null && pktB !== null) return pktA - pktB;
|
|
|
|
|
+
|
|
|
|
|
+ // 3. Traffic / Throughput / Bytes (e.g. "1.2 MB", "500 KiB", "10 Gbps")
|
|
|
|
|
+ const trafA = parseTrafficToNumber(a);
|
|
|
|
|
+ const trafB = parseTrafficToNumber(b);
|
|
|
|
|
+ if (trafA !== null && trafB !== null) return trafA - trafB;
|
|
|
|
|
+
|
|
|
|
|
+ // 4. Numbers with '#' or formatted with commas (e.g. "#1", "24", "1,200")
|
|
|
|
|
+ const cleanNumA = a.replace(/^[#\s]+/, '').replace(/,/g, '').trim();
|
|
|
|
|
+ const cleanNumB = b.replace(/^[#\s]+/, '').replace(/,/g, '').trim();
|
|
|
|
|
+ const numA = Number(cleanNumA);
|
|
|
|
|
+ const numB = Number(cleanNumB);
|
|
|
|
|
+ if (!isNaN(numA) && !isNaN(numB) && cleanNumA !== '' && cleanNumB !== '') {
|
|
|
|
|
+ return numA - numB;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 5. Natural case-insensitive string compare
|
|
|
|
|
+ return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Global observer for dynamically injected tables (e.g. inside modals or drawers)
|
|
|
|
|
+if (typeof MutationObserver !== 'undefined' && typeof document !== 'undefined') {
|
|
|
|
|
+ const docTableObserver = new MutationObserver((mutations) => {
|
|
|
|
|
+ let needsInit = false;
|
|
|
|
|
+ for (const m of mutations) {
|
|
|
|
|
+ for (const node of m.addedNodes) {
|
|
|
|
|
+ if (node.nodeType === 1) {
|
|
|
|
|
+ if (node.tagName === 'TABLE' || (node.querySelector && node.querySelector('table'))) {
|
|
|
|
|
+ needsInit = true;
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (needsInit) break;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (needsInit) {
|
|
|
|
|
+ initTableSorting();
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ docTableObserver.observe(document.body || document.documentElement, { childList: true, subtree: true });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
|