Browse Source

feat(tables): universal column ordering and sorting across all application tables

Proxmox Router 1 week ago
parent
commit
672bb2f065
3 changed files with 304 additions and 2 deletions
  1. 274 0
      internal/web/static/app.js
  2. 2 2
      internal/web/static/index.html
  3. 28 0
      internal/web/static/style.css

+ 274 - 0
internal/web/static/app.js

@@ -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 });
+}
+
 
 

+ 2 - 2
internal/web/static/index.html

@@ -27,7 +27,7 @@
       }
       }
     }
     }
   </script>
   </script>
-  <link rel="stylesheet" href="style.css">
+  <link rel="stylesheet" href="style.css?v=2.1">
 </head>
 </head>
 <body class="bg-[#0b101e] text-slate-100 min-h-screen flex flex-col font-sans antialiased selection:bg-brand-500 selection:text-white">
 <body class="bg-[#0b101e] text-slate-100 min-h-screen flex flex-col font-sans antialiased selection:bg-brand-500 selection:text-white">
 
 
@@ -7727,6 +7727,6 @@
   </div>
   </div>
 
 
   <datalist id="fw-addrlist-datalist"></datalist>
   <datalist id="fw-addrlist-datalist"></datalist>
-  <script src="app.js?v=5.4"></script>
+  <script src="app.js?v=5.5"></script>
 </body>
 </body>
 </html>
 </html>

+ 28 - 0
internal/web/static/style.css

@@ -73,3 +73,31 @@
 .glow-cyan {
 .glow-cyan {
   box-shadow: 0 0 25px -5px rgba(6, 182, 212, 0.2);
   box-shadow: 0 0 25px -5px rgba(6, 182, 212, 0.2);
 }
 }
+
+/* Sortable Table Headers (WinBox / WebFig Style) */
+th.table-sortable {
+  cursor: pointer !important;
+  user-select: none !important;
+  transition: background-color 0.15s ease, color 0.15s ease;
+  position: relative;
+}
+
+th.table-sortable:hover {
+  background-color: rgba(30, 41, 59, 0.6) !important;
+  color: #ffffff !important;
+}
+
+th.table-sorted-active {
+  color: #38bdf8 !important;
+  background-color: rgba(14, 165, 233, 0.08) !important;
+}
+
+.table-sort-icon {
+  display: inline-block;
+  margin-left: 5px;
+  font-size: 10px;
+  line-height: 1;
+  vertical-align: middle;
+  font-family: monospace;
+  transition: opacity 0.15s ease, color 0.15s ease;
+}