diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1f535a78..1fb6800d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -44,6 +44,26 @@ jobs: - name: Run tests run: python -m pytest tests/ -v --tb=short --ignore=tests/e2e + test-js: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "22" + cache: npm + cache-dependency-path: package-lock.json + + - name: Install JavaScript dependencies + run: npm ci + + - name: Run JavaScript tests + run: npm test + test-windows: runs-on: windows-latest diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1193dbf5..4570d478 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -55,9 +55,11 @@ This runs on port **8767** (`http://localhost:8767`) in demo mode. Production us ```bash python -m pytest tests/ -v +npm ci +npm test ``` -The test suite covers analyzers, collectors, drivers, event detection, API endpoints, config, MQTT, i18n, and PDF generation. All tests must pass before submitting a PR. +The Python suite covers analyzers, collectors, drivers, event detection, API endpoints, config, MQTT, i18n, and PDF generation. The zero-dependency JavaScript lane uses Node 22's built-in test runner for browser bootstrap and pure frontend contracts. Run both suites before submitting a PR. ## Running Locally diff --git a/app/modules/connection_monitor/static/js/connection-monitor-settings.js b/app/modules/connection_monitor/static/js/connection-monitor-settings.js new file mode 100644 index 00000000..556f6bdd --- /dev/null +++ b/app/modules/connection_monitor/static/js/connection-monitor-settings.js @@ -0,0 +1,142 @@ +(function () { + var listEl = document.getElementById('cm-targets-list'); + var addBtn = document.getElementById('cm-add-target-btn'); + var bootstrapElement = document.getElementById('docsight-connection-monitor-settings-bootstrap'); + var i18n = DOCSightBrowserContracts.parseConnectionMonitorBootstrapText( + bootstrapElement && bootstrapElement.textContent + ); + + function makeInput(id, value, placeholder) { + var el = document.createElement('input'); + el.className = 'form-input'; + el.type = 'text'; + el.id = id; + el.value = value || ''; + el.placeholder = placeholder || ''; + return el; + } + + function makeLabel(forId, text) { + var el = document.createElement('label'); + el.className = 'form-label'; + el.htmlFor = forId; + el.textContent = text; + return el; + } + + function renderTarget(target) { + var row = document.createElement('div'); + row.className = 'form-grid cols-2'; + row.style.cssText = 'align-items: end; margin-bottom: var(--space-sm);'; + row.dataset.targetId = target.id; + + // Label field + var labelField = document.createElement('div'); + labelField.className = 'form-field'; + var labelInput = makeInput('cm-label-' + target.id, target.label, 'Gateway'); + labelInput.dataset.field = 'label'; + labelField.appendChild(makeLabel('cm-label-' + target.id, i18n.label)); + labelField.appendChild(labelInput); + + // Host field + remove button wrapper + var hostField = document.createElement('div'); + hostField.className = 'form-field'; + hostField.style.cssText = 'display: flex; gap: var(--space-sm); align-items: flex-end;'; + + var hostWrap = document.createElement('div'); + hostWrap.style.flex = '1'; + var hostInput = makeInput('cm-host-' + target.id, target.host, '8.8.8.8'); + hostInput.dataset.field = 'host'; + hostWrap.appendChild(makeLabel('cm-host-' + target.id, i18n.host)); + hostWrap.appendChild(hostInput); + + var removeBtn = document.createElement('button'); + removeBtn.type = 'button'; + removeBtn.className = 'btn btn-ghost btn-sm'; + removeBtn.style.flexShrink = '0'; + removeBtn.dataset.remove = target.id; + + var trashIcon = document.createElement('i'); + trashIcon.setAttribute('data-lucide', 'trash-2'); + trashIcon.style.cssText = 'width:14px;height:14px;'; + var removeText = document.createElement('span'); + removeText.style.marginLeft = '4px'; + removeText.textContent = i18n.remove; + removeBtn.appendChild(trashIcon); + removeBtn.appendChild(removeText); + + hostField.appendChild(hostWrap); + hostField.appendChild(removeBtn); + + row.appendChild(labelField); + row.appendChild(hostField); + + // Save on blur + [labelInput, hostInput].forEach(function (input) { + input.addEventListener('blur', function () { + var patch = {}; + patch[input.dataset.field] = input.value.trim(); + if (!patch[input.dataset.field]) return; + fetch(docsightUrl('/api/connection-monitor/targets/' + target.id), { + method: 'PUT', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(patch) + }).then(function (res) { + if (res.ok) { + input.style.borderColor = 'var(--success, #10b981)'; + setTimeout(function () { input.style.borderColor = ''; }, 1500); + } + }); + }); + }); + + // Remove target + removeBtn.addEventListener('click', function () { + var tid = removeBtn.dataset.remove; + fetch(docsightUrl('/api/connection-monitor/targets/' + tid), {method: 'DELETE'}) + .then(function (res) { + if (res.ok) row.remove(); + }); + }); + + if (window.lucide) window.lucide.createIcons({nameAttr: 'data-lucide', nodes: [row]}); + return row; + } + + function loadTargets() { + fetch(docsightUrl('/api/connection-monitor/targets')) + .then(function (res) { return res.json(); }) + .then(function (targets) { + while (listEl.firstChild) listEl.removeChild(listEl.firstChild); + targets.forEach(function (target) { + listEl.appendChild(renderTarget(target)); + }); + }) + .catch(function () {}); + } + + addBtn.addEventListener('click', function () { + fetch(docsightUrl('/api/connection-monitor/targets'), { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({label: 'New target', host: ''}) + }) + .then(function (res) { return res.json(); }) + .then(function (data) { + if (data.id) { + var newRow = renderTarget({id: data.id, label: 'New target', host: ''}); + listEl.appendChild(newRow); + if (window.lucide) window.lucide.createIcons({nameAttr: 'data-lucide'}); + var hostInput = document.getElementById('cm-host-' + data.id); + if (hostInput) hostInput.focus(); + } + }) + .catch(function () {}); + }); + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', loadTargets); + } else { + loadTargets(); + } +})(); diff --git a/app/modules/connection_monitor/templates/connection_monitor_settings.html b/app/modules/connection_monitor/templates/connection_monitor_settings.html index 43cdb248..00f17e2c 100644 --- a/app/modules/connection_monitor/templates/connection_monitor_settings.html +++ b/app/modules/connection_monitor/templates/connection_monitor_settings.html @@ -75,149 +75,9 @@ - + + diff --git a/app/static/js/browser-contracts.js b/app/static/js/browser-contracts.js new file mode 100644 index 00000000..57a869b3 --- /dev/null +++ b/app/static/js/browser-contracts.js @@ -0,0 +1,237 @@ +(function (root, factory) { + 'use strict'; + var api = factory(); + if (typeof module === 'object' && module.exports) module.exports = api; + if (root) Object.defineProperty(root, 'DOCSightBrowserContracts', { + configurable: false, + writable: false, + value: api + }); +})(typeof window !== 'undefined' ? window : null, function () { + 'use strict'; + + var BOOTSTRAP_ERROR = 'Invalid DOCSight bootstrap data'; + var UNSAFE_KEYS = {__proto__: true, constructor: true, prototype: true}; + var DRIVER_HINT_KEYS = { + needs_user: true, + needs_password: true, + default_url: true, + default_user: true, + username_required: true, + credentials_required: true, + url_hint: true, + user_hint: true, + password_hint: true + }; + + function failBootstrap() { + throw new Error(BOOTSTRAP_ERROR); + } + + function isRecord(value) { + return value !== null && Object.prototype.toString.call(value) === '[object Object]'; + } + + function hasExactKeys(value, expected) { + var actual = Object.keys(value).sort(); + var wanted = expected.slice().sort(); + if (actual.length !== wanted.length) return false; + for (var i = 0; i < actual.length; i++) { + if (actual[i] !== wanted[i]) return false; + } + return true; + } + + function isSafeJson(value, depth) { + depth = depth || 0; + if (depth > 12) return false; + if (value === null || typeof value === 'boolean') return true; + if (typeof value === 'number') return Number.isFinite(value); + if (typeof value === 'string') return value.length <= 20000; + if (Array.isArray(value)) { + if (value.length > 5000) return false; + return value.every(function (item) { return isSafeJson(item, depth + 1); }); + } + if (!isRecord(value) || Object.keys(value).length > 5000) return false; + return Object.keys(value).every(function (key) { + return key.length <= 256 && !UNSAFE_KEYS[key] && isSafeJson(value[key], depth + 1); + }); + } + + function parseRecord(text, keys) { + if (typeof text !== 'string' || text.length === 0 || text.length > 1000000) failBootstrap(); + var value; + try { + value = JSON.parse(text); + } catch (error) { + failBootstrap(); + } + if (!isRecord(value) || !hasExactKeys(value, keys) || !isSafeJson(value)) failBootstrap(); + return value; + } + + function validLanguage(value) { + return value === null || (typeof value === 'string' && /^[A-Za-z]{2}(?:-[A-Za-z0-9]{2,8})?$/.test(value)); + } + + function validTranslationRecord(value) { + return isRecord(value) && isSafeJson(value); + } + + function validateDriverHints(value) { + if (!isRecord(value)) failBootstrap(); + Object.keys(value).forEach(function (driverId) { + if (!/^[A-Za-z0-9._-]{1,128}$/.test(driverId) || !isRecord(value[driverId])) failBootstrap(); + Object.keys(value[driverId]).forEach(function (key) { + var hint = value[driverId][key]; + if (!DRIVER_HINT_KEYS[key]) failBootstrap(); + if (key === 'needs_user' || key === 'needs_password' || key === 'username_required' || key === 'credentials_required') { + if (typeof hint !== 'boolean') failBootstrap(); + } else if (hint !== null && typeof hint !== 'string') { + failBootstrap(); + } + }); + var defaultUrl = value[driverId].default_url; + if (defaultUrl) { + var parsed; + try { parsed = new URL(defaultUrl); } catch (error) { failBootstrap(); } + if ((parsed.protocol !== 'http:' && parsed.protocol !== 'https:') || parsed.username || parsed.password) failBootstrap(); + } + }); + return value; + } + + function parseDashboardBootstrapText(text) { + var value = parseRecord(text, [ + 'translations', 'language', 'temperatureUnit', 'connectionMonitorAvailable' + ]); + if (!validTranslationRecord(value.translations) || !validLanguage(value.language)) failBootstrap(); + if (value.temperatureUnit !== 'celsius' && value.temperatureUnit !== 'fahrenheit') failBootstrap(); + if (typeof value.connectionMonitorAvailable !== 'boolean') failBootstrap(); + return value; + } + + function validInternalCandidate(value) { + return typeof value === 'string' && value.charAt(0) === '/' && value.charAt(1) !== '/'; + } + + function parseSetupBootstrapText(text) { + var value = parseRecord(text, ['translations', 'driverHints', 'indexUrl', 'loginUrl']); + if (!validTranslationRecord(value.translations)) failBootstrap(); + validateDriverHints(value.driverHints); + if (!validInternalCandidate(value.indexUrl) || !validInternalCandidate(value.loginUrl)) failBootstrap(); + return value; + } + + function parseConnectionMonitorBootstrapText(text) { + var value = parseRecord(text, ['label', 'host', 'remove']); + if (typeof value.label !== 'string' || typeof value.host !== 'string' || typeof value.remove !== 'string') failBootstrap(); + return value; + } + + function validModule(value) { + return isRecord(value) && hasExactKeys(value, ['id', 'labelKey', 'name']) && + typeof value.id === 'string' && /^[a-z][a-z0-9_.]+$/.test(value.id) && + typeof value.labelKey === 'string' && + typeof value.name === 'string'; + } + + function validStringList(value) { + return Array.isArray(value) && value.every(function (item) { + return typeof item === 'string'; + }); + } + + function parseSettingsBootstrapText(text) { + var value = parseRecord(text, [ + 'translations', 'modules', 'serverOffsetMin', 'serverTimezone', 'language', + 'currentTimezone', 'notificationCooldowns', 'driverHints', + 'moduleSecretFields', 'savedModuleSecretFields' + ]); + if (!validTranslationRecord(value.translations) || !Array.isArray(value.modules) || !value.modules.every(validModule)) failBootstrap(); + if (typeof value.serverOffsetMin !== 'number' || !Number.isFinite(value.serverOffsetMin) || Math.abs(value.serverOffsetMin) > 1440) failBootstrap(); + if (typeof value.serverTimezone !== 'string' || value.serverTimezone.length > 128) failBootstrap(); + if (!validLanguage(value.language) || (value.currentTimezone !== null && typeof value.currentTimezone !== 'string')) failBootstrap(); + if (typeof value.notificationCooldowns !== 'string' || value.notificationCooldowns.length > 100000) failBootstrap(); + validateDriverHints(value.driverHints); + if (!validStringList(value.moduleSecretFields) || !validStringList(value.savedModuleSecretFields)) failBootstrap(); + if (!value.savedModuleSecretFields.every(function (key) { return value.moduleSecretFields.indexOf(key) !== -1; })) failBootstrap(); + + var cooldowns = {}; + try { + var candidate = JSON.parse(value.notificationCooldowns); + if (isRecord(candidate) && isSafeJson(candidate)) cooldowns = candidate; + } catch (error) { + cooldowns = {}; + } + return { + translations: value.translations, + modules: value.modules, + serverOffsetMin: value.serverOffsetMin, + serverTimezone: value.serverTimezone, + language: value.language, + currentTimezone: value.currentTimezone || '', + notificationCooldowns: cooldowns, + driverHints: value.driverHints, + moduleSecretFields: value.moduleSecretFields, + savedModuleSecretFields: value.savedModuleSecretFields + }; + } + + function selectSetupDriverState(driverHints, modemType, currentUrl, currentUsername, notRequiredText) { + var hints = isRecord(driverHints) && isRecord(driverHints[modemType]) ? driverHints[modemType] : {}; + var knownDefaults = {}; + Object.keys(driverHints || {}).forEach(function (key) { + var hint = driverHints[key]; + if (isRecord(hint) && hint.default_url) knownDefaults[hint.default_url] = true; + }); + var url = currentUrl || ''; + if (hints.default_url && (!url || knownDefaults[url])) url = hints.default_url; + var credentialsVisible = hints.credentials_required !== false; + var usernameEnabled = credentialsVisible && hints.username_required !== false; + var username = usernameEnabled ? (currentUsername || '') : ''; + var placeholder = usernameEnabled ? (hints.default_user || 'admin') : (notRequiredText || 'Not required'); + if (usernameEnabled && !username && hints.default_user) username = hints.default_user; + return { + url: url, + credentialsVisible: credentialsVisible, + usernameEnabled: usernameEnabled, + username: username, + usernamePlaceholder: placeholder + }; + } + + function formatLastKnownTimestamp(value, formatter) { + if (!value) return ''; + try { + return formatter ? formatter(value) : new Date(value).toLocaleString(); + } catch (error) { + return value; + } + } + + function computeServiceWorkerPolicy(hostname, search, scopeHref) { + var scope; + try { scope = new URL(scopeHref); } catch (error) { throw new Error('Invalid service-worker scope'); } + if ((scope.protocol !== 'http:' && scope.protocol !== 'https:') || scope.search || scope.hash || !scope.pathname.endsWith('/')) { + throw new Error('Invalid service-worker scope'); + } + var params = new URLSearchParams(typeof search === 'string' ? search : ''); + var local = hostname === 'localhost' || hostname === '127.0.0.1'; + return { + action: local && !params.has('enable-sw-test') ? 'cleanup' : 'register', + scopeHref: scope.href, + cacheNamespace: 'docsight-' + encodeURIComponent(scope.pathname) + '-' + }; + } + + return { + parseDashboardBootstrapText: parseDashboardBootstrapText, + parseSetupBootstrapText: parseSetupBootstrapText, + parseSettingsBootstrapText: parseSettingsBootstrapText, + parseConnectionMonitorBootstrapText: parseConnectionMonitorBootstrapText, + selectSetupDriverState: selectSetupDriverState, + formatLastKnownTimestamp: formatLastKnownTimestamp, + computeServiceWorkerPolicy: computeServiceWorkerPolicy + }; +}); diff --git a/app/static/js/dashboard-donuts.js b/app/static/js/dashboard-donuts.js new file mode 100644 index 00000000..e92c01de --- /dev/null +++ b/app/static/js/dashboard-donuts.js @@ -0,0 +1,76 @@ +(function() { + 'use strict'; + + // Store chart instances globally to prevent memory leaks on refresh + window.donutCharts = window.donutCharts || {}; + + function renderDonut(canvasId) { + var c = document.getElementById(canvasId); + if (!c) return; + var container = c.parentElement; + if (!container) return; + + var good = parseInt(container.getAttribute('data-good') || '0', 10); + var tolerated = parseInt(container.getAttribute('data-tolerated') || '0', 10); + var warn = parseInt(container.getAttribute('data-warn') || '0', 10); + var crit = parseInt(container.getAttribute('data-crit') || '0', 10); + var total = good + tolerated + warn + crit; + if (total === 0) return; + + // Match mockup: raw Canvas arcs with thin stroke + var dpr = window.devicePixelRatio || 1; + var size = c.parentElement.offsetWidth || 80; + c.width = size * dpr; + c.height = size * dpr; + c.style.width = size + 'px'; + c.style.height = size + 'px'; + var x = c.getContext('2d'); + x.scale(dpr, dpr); + + var cx = size / 2, cy = size / 2, r = size * 0.38, lw = size * 0.08; + var styles = getComputedStyle(document.documentElement); + var segments = [ + { val: good, color: styles.getPropertyValue('--good').trim() }, + { val: tolerated, color: styles.getPropertyValue('--tolerated').trim() }, + { val: warn, color: styles.getPropertyValue('--warn').trim() }, + { val: crit, color: styles.getPropertyValue('--crit').trim() } + ]; + + // Background ring + x.beginPath(); + x.arc(cx, cy, r, 0, Math.PI * 2); + x.strokeStyle = 'rgba(255,255,255,0.06)'; + x.lineWidth = lw; + x.stroke(); + + // Colored segments + var angle = -Math.PI / 2; + segments.forEach(function(s) { + if (s.val === 0) return; + var sweep = (s.val / total) * Math.PI * 2; + x.beginPath(); + x.arc(cx, cy, r, angle, angle + sweep); + x.strokeStyle = s.color; + x.lineWidth = lw; + x.lineCap = 'round'; + x.stroke(); + angle += sweep; + }); + } + + function initDonuts() { + renderDonut('ds-health-donut'); + renderDonut('us-health-donut'); + } + + // Expose refresh function globally for manual updates (refreshData after innerHTML replace) + window.refreshDonuts = initDonuts; + + // Wait for DOM to be fully loaded + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initDonuts); + } else { + // DOM already loaded (script deferred or loaded late) + initDonuts(); + } +})(); diff --git a/app/static/js/dashboard-routing.js b/app/static/js/dashboard-routing.js new file mode 100644 index 00000000..fd212f92 --- /dev/null +++ b/app/static/js/dashboard-routing.js @@ -0,0 +1,10 @@ + // Apply deferred hash-based view routing (all scripts including modules are now loaded) + if (window._pendingView) { + window.switchView(window._pendingView, true); + delete window._pendingView; + } + // Initialize Lucide icons after DOM is ready + document.addEventListener('DOMContentLoaded', function() { + lucide.createIcons(); + }); + diff --git a/app/static/js/dashboard.js b/app/static/js/dashboard.js new file mode 100644 index 00000000..25feaac1 --- /dev/null +++ b/app/static/js/dashboard.js @@ -0,0 +1,573 @@ +var dashboardBootstrapElement = document.getElementById('docsight-dashboard-bootstrap'); +var dashboardBootstrap = DOCSightBrowserContracts.parseDashboardBootstrapText( + dashboardBootstrapElement && dashboardBootstrapElement.textContent +); +var T = dashboardBootstrap.translations; +var currentLang = dashboardBootstrap.language; +var TEMPERATURE_UNIT = dashboardBootstrap.temperatureUnit; +var CORRELATION_CM_AVAILABLE = dashboardBootstrap.connectionMonitorAvailable; + +(function() { + var LAST_KNOWN_STORAGE_KEY = 'docsight:last-known-dashboard-shell'; + + function rememberOnlineShell() { + if (!navigator.onLine) return; + try { + localStorage.setItem(LAST_KNOWN_STORAGE_KEY, new Date().toISOString()); + } catch (err) { + /* Storage can be unavailable in hardened/private browser contexts. */ + } + } + + function updateOfflineStatus() { + var offlineMarker = document.querySelector('meta[name="docsight-offline-shell"][content="true"]'); + var offline = !navigator.onLine || !!offlineMarker || window.__DOCSIGHT_OFFLINE_SHELL__ === true; + var banner = document.getElementById('offline-status-banner'); + var lastKnown = document.getElementById('offline-last-known'); + var refreshButton = document.getElementById('refresh-btn'); + document.documentElement.classList.toggle('is-offline', offline); + document.body.classList.toggle('is-offline', offline); + if (banner) banner.hidden = !offline; + if (refreshButton) refreshButton.disabled = offline; + if (lastKnown) { + var stored = ''; + try { stored = localStorage.getItem(LAST_KNOWN_STORAGE_KEY) || ''; } catch (err) {} + lastKnown.textContent = stored ? 'Last-known shell: ' + DOCSightBrowserContracts.formatLastKnownTimestamp(stored) : 'Last-known shell timestamp unavailable'; + } + if (!offline) rememberOnlineShell(); + } + + window.updateOfflineStatus = updateOfflineStatus; + window.addEventListener('online', updateOfflineStatus); + window.addEventListener('offline', updateOfflineStatus); + document.addEventListener('DOMContentLoaded', updateOfflineStatus); + updateOfflineStatus(); +})(); + +(function() { + 'use strict'; + + /* ── Theme ── */ + var saved = localStorage.getItem('docsis-theme'); + if (saved) document.documentElement.setAttribute('data-theme', saved); + + var themeToggle = document.getElementById('theme-toggle-sidebar'); + function updateThemeState() { + var isDark = document.documentElement.getAttribute('data-theme') !== 'light'; + themeToggle.checked = isDark; + var mc = document.querySelector('meta[name="theme-color"]'); + if (mc) mc.setAttribute('content', isDark ? '#06080f' : '#f8f6f3'); + } + updateThemeState(); + themeToggle.addEventListener('change', function() { + var next = this.checked ? 'dark' : 'light'; + document.documentElement.setAttribute('data-theme', next); + localStorage.setItem('docsis-theme', next); + var mc = document.querySelector('meta[name="theme-color"]'); + if (mc) mc.setAttribute('content', next === 'dark' ? '#06080f' : '#f8f6f3'); + // Re-render charts with updated theme colors + if (typeof window.refreshDonuts === 'function') { + setTimeout(window.refreshDonuts, 50); + } + }); + + /* ── State ── */ + /* currentView is global — defined in chart-engine.js */ + /* charts registry is global — defined in chart-engine.js */ + /* _trendRange → trends.js */ + /* BQM state variables → bqm.js */ + /* todayStr, pad, formatDateDE → chart-engine.js */ + + /* ── Sortable Tables ── */ + function initSortableTables() { + document.querySelectorAll('table.sortable').forEach(function(table) { + var headers = table.querySelectorAll('th'); + headers.forEach(function(th, colIdx) { + th.addEventListener('click', function() { + sortTable(table, colIdx, th); + }); + }); + }); + } + initSortableTables(); + + function sortTable(table, colIdx, th) { + var tbody = table.querySelector('tbody'); + var rows = Array.from(tbody.querySelectorAll('tr')); + var asc = th.getAttribute('data-sort-dir') !== 'asc'; + + table.querySelectorAll('th').forEach(function(h) { + h.removeAttribute('data-sort-dir'); + h.removeAttribute('aria-sort'); + h.classList.remove('sort-asc', 'sort-desc'); + }); + th.setAttribute('data-sort-dir', asc ? 'asc' : 'desc'); + th.setAttribute('aria-sort', asc ? 'ascending' : 'descending'); + th.classList.add(asc ? 'sort-asc' : 'sort-desc'); + + rows.sort(function(a, b) { + var cellA = a.cells[colIdx]; + var cellB = b.cells[colIdx]; + var valA = cellA.getAttribute('data-sort') || cellA.textContent.trim(); + var valB = cellB.getAttribute('data-sort') || cellB.textContent.trim(); + var numA = parseFloat(valA); + var numB = parseFloat(valB); + if (!isNaN(numA) && !isNaN(numB)) { + return asc ? numA - numB : numB - numA; + } + return asc ? valA.localeCompare(valB) : valB.localeCompare(valA); + }); + rows.forEach(function(row) { tbody.appendChild(row); }); + } + + /* ── Sidebar ── */ + var sidebar = document.getElementById('sidebar'); + var sidebarBackdrop = document.getElementById('sidebar-backdrop'); + + function isMobile() { return window.matchMedia('(max-width: 1023px)').matches; } + + var sidebarLastOpener = null; + var sidebarFocusableSelector = 'a[href], button, input, select, textarea, [role="button"], [tabindex]'; + + function getSidebarFocusables() { + return Array.from(sidebar.querySelectorAll(sidebarFocusableSelector)); + } + + function setSidebarFocusSuppressed(suppressed) { + getSidebarFocusables().forEach(function(el) { + if (suppressed) { + if (!el.hasAttribute('data-sidebar-prev-tabindex')) { + el.setAttribute('data-sidebar-prev-tabindex', el.getAttribute('tabindex') || ''); + } + el.setAttribute('tabindex', '-1'); + } else if (el.hasAttribute('data-sidebar-prev-tabindex')) { + var previous = el.getAttribute('data-sidebar-prev-tabindex'); + if (previous) { + el.setAttribute('tabindex', previous); + } else { + el.removeAttribute('tabindex'); + } + el.removeAttribute('data-sidebar-prev-tabindex'); + } + }); + if ('inert' in sidebar) { + sidebar.inert = suppressed; + } + } + + function syncSidebarAccessibility() { + var closedOnMobile = isMobile() && !sidebar.classList.contains('open'); + sidebar.setAttribute('aria-hidden', closedOnMobile ? 'true' : 'false'); + setSidebarFocusSuppressed(closedOnMobile); + var hamburger = document.getElementById('hamburger'); + if (hamburger) { + hamburger.setAttribute('aria-expanded', sidebar.classList.contains('open') ? 'true' : 'false'); + } + } + + function focusFirstSidebarItem() { + var first = getSidebarFocusables().find(function(el) { + return el.classList && el.classList.contains('nav-item') && !el.disabled && el.offsetParent !== null; + }) || getSidebarFocusables().find(function(el) { + return !el.disabled && el.offsetParent !== null; + }); + if (first) first.focus({ preventScroll: true }); + } + + function getSidebarLinks() { + return Array.from(sidebar.querySelectorAll('.nav-section .nav-item[data-view]')); + } + + function syncNavActiveState(view) { + getSidebarLinks().forEach(function(link) { + link.classList.toggle('active', link.getAttribute('data-view') === view); + }); + } + + window.toggleNavSection = function(labelEl) { + var section = labelEl.closest('.nav-section-collapsible'); + if (section) { + section.classList.toggle('collapsed'); + labelEl.setAttribute('aria-expanded', labelEl.getAttribute('aria-expanded') === 'true' ? 'false' : 'true'); + if (typeof lucide !== 'undefined') lucide.createIcons(); + } + }; + + window.openSidebar = function() { + sidebarLastOpener = document.activeElement; + sidebar.classList.add('open'); + sidebarBackdrop.style.display = 'block'; + document.body.classList.add('sidebar-open'); + syncSidebarAccessibility(); + window.requestAnimationFrame(focusFirstSidebarItem); + }; + window.closeSidebar = function(options) { + options = options || {}; + sidebar.classList.remove('open'); + sidebarBackdrop.style.display = 'none'; + document.body.classList.remove('sidebar-open'); + syncSidebarAccessibility(); + if (options.restoreFocus !== false && sidebarLastOpener && typeof sidebarLastOpener.focus === 'function') { + window.requestAnimationFrame(function() { sidebarLastOpener.focus({ preventScroll: true }); }); + } + }; + + syncSidebarAccessibility(); + window.addEventListener('resize', syncSidebarAccessibility); + document.addEventListener('keydown', function(event) { + if (event.key === 'Escape' && sidebar.classList.contains('open') && isMobile()) { + event.preventDefault(); + closeSidebar(); + } + }); + + /* Swipe-to-close sidebar (mobile) */ + (function() { + var startX = 0, startY = 0, currentX = 0, swiping = false, locked = false; + sidebar.addEventListener('touchstart', function(e) { + if (!isMobile()) return; + startX = e.touches[0].clientX; + startY = e.touches[0].clientY; + currentX = startX; + swiping = true; + locked = false; + }, { passive: true }); + sidebar.addEventListener('touchmove', function(e) { + if (!swiping) return; + currentX = e.touches[0].clientX; + var dx = currentX - startX; + var dy = e.touches[0].clientY - startY; + /* Lock direction on first significant movement */ + if (!locked && (Math.abs(dx) > 10 || Math.abs(dy) > 10)) { + locked = true; + /* If vertical movement dominates, cancel swipe */ + if (Math.abs(dy) > Math.abs(dx)) { swiping = false; return; } + } + if (locked && dx < -10) { + sidebar.style.transform = 'translateX(' + Math.max(dx, -280) + 'px)'; + } + }, { passive: true }); + sidebar.addEventListener('touchend', function() { + if (!swiping) { sidebar.style.transform = ''; return; } + swiping = false; + var dx = currentX - startX; + if (dx < -80) { closeSidebar(); } + sidebar.style.transform = ''; + }, { passive: true }); + })(); + + sidebar.addEventListener('click', function(event) { + var link = event.target.closest('.nav-item[data-view]'); + if (!link || !sidebar.contains(link)) return; + if (isMobile()) { closeSidebar({ restoreFocus: false }); } + switchView(link.getAttribute('data-view')); + }); + /* Nav items are now