diff --git a/cmd/server/api.go b/cmd/server/api.go index ebeb7e4..f9d7dc4 100644 --- a/cmd/server/api.go +++ b/cmd/server/api.go @@ -4247,13 +4247,13 @@ func (a *API) BatchConfig(w http.ResponseWriter, r *http.Request) { switch key { case "ssid": v, ok := value.(string) - if !ok || strings.TrimSpace(v) == "" || len([]byte(v)) > 64 { + if !ok || strings.TrimSpace(v) == "" || len([]byte(v)) > 32 { http.Error(w, "invalid ssid", http.StatusBadRequest) return } case "channel": v, ok := value.(float64) - if !ok || math.IsNaN(v) || math.IsInf(v, 0) || v <= 0 { + if !ok || math.IsNaN(v) || math.IsInf(v, 0) || v <= 0 || math.Trunc(v) != v { http.Error(w, "invalid channel", http.StatusBadRequest) return } @@ -4293,7 +4293,7 @@ func (a *API) BatchConfig(w http.ResponseWriter, r *http.Request) { results = append(results, map[string]any{"device_id": deviceID, "status": "failed", "error": "not found"}) continue } - if err := a.Firmware.ApplyConfig(deviceID, ip, username, password, req.Changes); err != nil { + if err := a.Firmware.ApplyConfigContext(r.Context(), deviceID, ip, username, password, req.Changes); err != nil { results = append(results, map[string]any{"device_id": deviceID, "status": "failed", "error": err.Error()}) continue } diff --git a/internal/firmware/service.go b/internal/firmware/service.go index 0bd383f..f22c294 100644 --- a/internal/firmware/service.go +++ b/internal/firmware/service.go @@ -1255,9 +1255,13 @@ func (s *Service) doUpgradeAirMAX(ctx context.Context, deviceID int64, ip, usern return nil } -// login authenticates to a Wave device and returns the auth token -// host should be just the IP/hostname, not a full URL +// login authenticates to a Wave device and returns the auth token. func (s *Service) login(host, username, password string) (string, error) { + return s.loginContext(context.Background(), host, username, password) +} + +// loginContext is the cancellation-aware form used by long-running callers. +func (s *Service) loginContext(ctx context.Context, host, username, password string) (string, error) { baseURL := fmt.Sprintf("https://%s", host) loginURL := baseURL + "/api/v1.0/user/login" @@ -1266,7 +1270,7 @@ func (s *Service) login(host, username, password string) (string, error) { "password": password, }) - req, err := http.NewRequest("POST", loginURL, bytes.NewReader(body)) + req, err := http.NewRequestWithContext(ctx, "POST", loginURL, bytes.NewReader(body)) if err != nil { return "", fmt.Errorf("create login request: %w", err) } @@ -2282,8 +2286,13 @@ func (s *Service) PushConfig(ip, username, password string, config []byte) error return nil } -// ApplyConfig applies specific configuration changes to a device +// ApplyConfig applies specific configuration changes to a device. func (s *Service) ApplyConfig(deviceID int, ip, username, password string, changes map[string]any) error { + return s.ApplyConfigContext(context.Background(), deviceID, ip, username, password, changes) +} + +// ApplyConfigContext is the cancellation-aware form used by request/job callers. +func (s *Service) ApplyConfigContext(ctx context.Context, deviceID int, ip, username, password string, changes map[string]any) error { credential, err := s.resolveCredential(username, password, true) if err != nil { return err @@ -2291,7 +2300,7 @@ func (s *Service) ApplyConfig(deviceID int, ip, username, password string, chang username, password = credential.Username, credential.Password // Login to Wave device - token, err := s.login(ip, username, password) + token, err := s.loginContext(ctx, ip, username, password) if err != nil { return fmt.Errorf("login failed: %w", err) } @@ -2338,7 +2347,7 @@ func (s *Service) ApplyConfig(deviceID int, ip, username, password string, chang } url := fmt.Sprintf("https://%s/api/v1.0/system/config", ip) - req, err := http.NewRequest("PATCH", url, bytes.NewReader(body)) + req, err := http.NewRequestWithContext(ctx, "PATCH", url, bytes.NewReader(body)) if err != nil { return fmt.Errorf("create request: %w", err) } @@ -2361,7 +2370,13 @@ func (s *Service) ApplyConfig(deviceID int, ip, username, password string, chang } if storedNewPassword != "" { - if _, err := s.db.Exec(`UPDATE devices SET username = $2, password = $3 WHERE id = $1`, deviceID, username, storedNewPassword); err != nil { + // The device has already accepted the new password at this point. Do not + // let a client disconnect cancel persistence of the credential we now + // need for future management access. Preserve context values, but bound + // this post-accept commit independently. + persistCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) + defer cancel() + if _, err := s.db.ExecContext(persistCtx, `UPDATE devices SET username = $2, password = $3 WHERE id = $1`, deviceID, username, storedNewPassword); err != nil { return fmt.Errorf("configuration applied but new device credential could not be persisted: %w", err) } } diff --git a/web/css/styles.css b/web/css/styles.css index f13580c..d90ed6d 100644 --- a/web/css/styles.css +++ b/web/css/styles.css @@ -847,6 +847,7 @@ body.page-devices #app.main-content { .device-table .cell-site { width: 60px; min-width: 50px; } .device-table .cell-signal { width: 65px; min-width: 55px; text-align: center; } .device-table .cell-health { width: 60px; min-width: 50px; } +.device-table .cell-dir { width: 60px; min-width: 50px; } .device-table .cell-distance { width: 60px; min-width: 50px; } .device-table .cell-capacity { width: 75px; min-width: 65px; } .device-table .cell-firmware { width: 100px; min-width: 80px; font-size: 0.8rem; } @@ -7687,9 +7688,14 @@ html[data-theme="dark"] #mapContainer .leaflet-control-attribution a { overflow: hidden; } -/* Header table - fixed at top, not scrolled */ -.virtual-header-table { +/* Header stays fixed vertically while its viewport mirrors body scrollLeft. */ +.virtual-header-viewport { flex-shrink: 0; + width: 100%; + overflow: hidden; +} + +.virtual-header-table { width: 100%; table-layout: fixed; border-collapse: collapse; @@ -7705,8 +7711,7 @@ html[data-theme="dark"] #mapContainer .leaflet-control-attribution a { .virtual-scroll-container { flex: 1; min-height: 0; - overflow-y: auto; - overflow-x: hidden; + overflow: auto; position: relative; -webkit-overflow-scrolling: touch; } @@ -7723,7 +7728,6 @@ html[data-theme="dark"] #mapContainer .leaflet-control-attribution a { position: absolute; top: 0; left: 0; - right: 0; width: 100%; table-layout: fixed; border-collapse: collapse; @@ -7781,6 +7785,9 @@ html[data-theme="dark"] #mapContainer .leaflet-control-attribution a { .virtual-header-table .cell-health, .virtual-body-table .cell-health { width: 60px; min-width: 50px; max-width: 70px; } +.virtual-header-table .cell-dir, +.virtual-body-table .cell-dir { width: 60px; min-width: 50px; max-width: 70px; } + .virtual-header-table .cell-distance, .virtual-body-table .cell-distance { width: 60px; min-width: 50px; max-width: 70px; } @@ -9565,3 +9572,17 @@ body.drilldown-active .main-content { min-width: 760px; } } + + +.virtual-empty-state { + position: absolute; + inset: 0; + z-index: 2; + display: flex; + align-items: flex-start; + justify-content: center; + pointer-events: none; +} +.virtual-empty-state.hidden { + display: none; +} diff --git a/web/index.html b/web/index.html index 419a22b..009a7a8 100644 --- a/web/index.html +++ b/web/index.html @@ -467,6 +467,6 @@

Batch Configuration

- + diff --git a/web/js/app.js b/web/js/app.js index c45855b..b5c11bf 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -2,13 +2,16 @@ import { api, auth, ws, sync } from './api.js?v=28' import { store } from './store.js?v=17' import { renderDevices, renderTree, renderLogs, renderDeviceDetail, renderDirectionalCell, showToast, updateWarningsPanel, showJobPanel, hideJobPanel, toggleJobPanel, updateJobProgress, updateJobStatus, - addJobEvent, startTrackedJob, trackJob, getActiveJobCount, cleanupVirtualTable } from './components.js?v=68' + addJobEvent, startTrackedJob, trackJob, getActiveJobCount, cleanupVirtualTable, refreshDeviceTableRow } from './components.js?v=70' import { - wsBatcher, shouldUseVirtualTable, setUpdateCountsCallback, scrollToDeviceById -} from './virtual-integration.js?v=14' + wsBatcher, shouldUseVirtualTable, setUpdateCountsCallback, setVirtualBatchFlushCallback, scrollToDeviceById, refreshVirtualRowClasses +} from './virtual-integration.js?v=16' // Debounced renderTree - prevents excessive re-renders with many devices let renderTreeTimeout = null +let deviceMembershipRenderTimeout = null +let antennaLiveUpdateCallback = null + function debouncedRenderTree(filter = '') { clearTimeout(renderTreeTimeout) renderTreeTimeout = setTimeout(() => { @@ -16,6 +19,31 @@ function debouncedRenderTree(filter = '') { }, 100) } +function scheduleDeviceMembershipRender() { + clearTimeout(deviceMembershipRenderTimeout) + deviceMembershipRenderTimeout = setTimeout(() => { + deviceMembershipRenderTimeout = null + if (!['dashboard', 'devices'].includes(store.currentPage)) return + + const currentScroller = + document.querySelector('.virtual-scroll-container') || + document.querySelector('.device-table-wrapper') + const scrollTop = currentScroller?.scrollTop || 0 + const scrollLeft = currentScroller?.scrollLeft || 0 + + renderCurrentPage() + + requestAnimationFrame(() => { + const nextScroller = + document.querySelector('.virtual-scroll-container') || + document.querySelector('.device-table-wrapper') + if (!nextScroller) return + nextScroller.scrollTop = scrollTop + nextScroller.scrollLeft = scrollLeft + }) + }, 250) +} + // HTML escaping to prevent XSS from device-controlled fields function escapeHTML(str) { if (str === null || str === undefined) return '' @@ -623,6 +651,18 @@ async function init() { ws.startPing() lastFullReconcileAt = Date.now() setUpdateCountsCallback(updateCounts) + setVirtualBatchFlushCallback(() => { + try { + updateWarningsPanel(computeActiveWarnings()) + } catch (e) { + console.warn('warning panel update failed', e) + } + try { + antennaLiveUpdateCallback?.() + } catch (e) { + console.warn('antenna live update failed', e) + } + }) } catch (e) { auth.clear() store.set({ user: null }) @@ -1234,7 +1274,8 @@ ws.on(msg => { // Check if already exists (by MAC) if (!currentDevices.some(d => d.mac === newDevice.mac)) { store.set({ devices: [...currentDevices, newDevice] }) - renderTree() + debouncedRenderTree() + scheduleDeviceMembershipRender() // Show toast notification const name = newDevice.hostname || newDevice.ip_address || newDevice.mac @@ -1285,163 +1326,38 @@ ws.on(msg => { } }) -// Incremental update of a single device row - surgically updates DOM without re-render -// Prefer updating by unique device id; fall back to ip only when no id exists. +// Incremental update of one device using the canonical dashboard row renderer. +// The tree still gets a tiny targeted patch, but table cells/classes are rebuilt +// from the authoritative in-memory device object in both table modes. function updateDeviceRow(id, ip, data) { const statusVal = data.status || (data.online === true ? 'online' : (data.online === false ? 'offline' : 'unknown')) + const fullDevice = (id !== undefined && id !== null) + ? store.getDeviceById(id) + : (ip ? store.getDeviceByIp(ip) : null) - // Prefer the canonical device object from the store for derived UI (e.g. directional diagnosis) - const fullDevice = (id !== undefined && id !== null) ? store.getDeviceById(id) : (ip ? store.getDeviceByIp(ip) : null) + const treeSelector = (id !== undefined && id !== null) + ? `.tree-node[data-id="${id}"]` + : (ip ? `.tree-node[data-ip="${ip}"]` : null) - // Update tree nodes - const treeSelector = (id !== undefined && id !== null) ? `.tree-node[data-id="${id}"]` : (ip ? `.tree-node[data-ip="${ip}"]` : null) if (treeSelector) { document.querySelectorAll(treeSelector).forEach(node => { - const statusDot = node.querySelector('.tree-status') - if (statusDot) { - const newStatus = statusVal - if (!statusDot.classList.contains(newStatus)) { - statusDot.className = `tree-status ${newStatus}` + const statusDot = node.querySelector('.tree-status') + if (statusDot && !statusDot.classList.contains(statusVal)) { + statusDot.className = `tree-status ${statusVal}` } - } - // Update tree label if hostname provided - if (data.hostname) { - const label = node.querySelector('.tree-label') - if (label && label.textContent !== data.hostname) { - label.textContent = data.hostname + if (data.hostname) { + const label = node.querySelector('.tree-label') + if (label && label.textContent !== data.hostname) label.textContent = data.hostname } - } - }) + }) } - - // Update table rows - const rowSelector = (id !== undefined && id !== null) ? `tr[data-id="${id}"]` : (ip ? `tr[data-ip="${ip}"]` : null) - if (!rowSelector) return - document.querySelectorAll(rowSelector).forEach(row => { - // Update status dot - const statusDot = row.querySelector('.status-dot') - if (statusDot) { - const newStatus = statusVal - if (!statusDot.classList.contains(newStatus)) { - statusDot.className = `status-dot ${newStatus}` - } - } - - // Update device name if hostname provided - if (data.hostname) { - const nameEl = row.querySelector('.device-name') - if (nameEl && nameEl.textContent !== data.hostname) { - nameEl.textContent = data.hostname - } - } - - // Update 60GHz signal cell - prefer server-computed quality - const signal60Cell = row.querySelector('.cell-signal-60') - if (signal60Cell) { - const sig60 = data.signal_60ghz - if (typeof sig60 === 'number' && sig60 !== 0) { - const newText = `${sig60} dBm` - const quality = data.radio_60ghz?.signal_quality - const cls = quality ? `signal-${quality}` : getSignalClass60(sig60) - const newClass = `cell-signal cell-signal-60 ${cls}` - if (signal60Cell.textContent !== newText) signal60Cell.textContent = newText - if (signal60Cell.className !== newClass) signal60Cell.className = newClass - } - } - - // Update 5GHz combined signal cell - prefer server-computed quality - const signal5Cell = row.querySelector('.cell-signal-5ghz') - if (signal5Cell) { - const sig5 = get5GHzCombined(data) - if (sig5 && sig5 !== 0) { - const newText = `${sig5} dBm` - const quality = data.radio_5ghz?.signal_quality || data.radio_ltu?.signal_quality - const cls = quality ? `signal-${quality}` : getSignalClass5(sig5) - const newClass = `cell-signal cell-signal-5ghz ${cls}` - if (signal5Cell.textContent !== newText) signal5Cell.textContent = newText - if (signal5Cell.className !== newClass) signal5Cell.className = newClass - } - } - - // Update 5GHz chain cells (no server quality for per-chain) - const c0Cell = row.querySelector('.cell-signal-c0') - const c1Cell = row.querySelector('.cell-signal-c1') - const chains = get5GHzChains(data) - if (c0Cell && typeof chains[0] === 'number' && chains[0] !== 0) { - const newText = `${chains[0]}` - const newClass = `cell-signal cell-signal-c0 ${getSignalClass5(chains[0])}` - if (c0Cell.textContent !== newText) c0Cell.textContent = newText - if (c0Cell.className !== newClass) c0Cell.className = newClass - } - if (c1Cell && typeof chains[1] === 'number' && chains[1] !== 0) { - const newText = `${chains[1]}` - const newClass = `cell-signal cell-signal-c1 ${getSignalClass5(chains[1])}` - if (c1Cell.textContent !== newText) c1Cell.textContent = newText - if (c1Cell.className !== newClass) c1Cell.className = newClass - } - - // Update health bars (based on primary signal) - const healthCell = row.querySelector('.cell-health') - if (healthCell) { - const primarySignal = data.signal_60ghz || get5GHzCombined(data) || 0 - const band = data.signal_60ghz ? '60ghz' : '5ghz' - if (primarySignal) { - healthCell.innerHTML = getSignalBarsHTML(primarySignal, band) - } - } - - // Update distance - const distCell = row.querySelector('.cell-distance') - if (distCell && data.distance) { - const newText = `${(data.distance / 1000).toFixed(2)} km` - if (distCell.textContent !== newText) distCell.textContent = newText - } - - // Update capacity - const capCell = row.querySelector('.cell-capacity') - const cap = data.capacity_60ghz || data.capacity_ltu || data.capacity_5ghz - if (capCell && cap) { - const newText = `${(cap / 1e6).toFixed(0)} Mbps` - if (capCell.textContent !== newText) capCell.textContent = newText - } - - // Update directional diagnosis (derived) - if (store.columns.dir && fullDevice) { - const dirCell = row.querySelector('.cell-dir') - if (dirCell) { - dirCell.innerHTML = renderDirectionalCell(fullDevice) - } - } - }) - // If a STA changed, refresh the parent's directional summary (if visible) - if (store.columns.dir && fullDevice && fullDevice.parent_id) { - const parent = store.getDeviceById(fullDevice.parent_id) - if (parent) { - document.querySelectorAll(`tr[data-id="${parent.id}"]`).forEach(pRow => { - const dirCell = pRow.querySelector('.cell-dir') - if (dirCell) { - dirCell.innerHTML = renderDirectionalCell(parent) - } - }) - } - } -} + if (!fullDevice) return + refreshDeviceTableRow(fullDevice.id) -// Generate signal bars HTML for health column -function getSignalBarsHTML(level, band = '5ghz') { - if (!level) return '
' - const t = SIGNAL_THRESHOLDS[band] || SIGNAL_THRESHOLDS['5ghz'] - // 5 bars: excellent (>good+5), very good (>good), good (>good-5), fair (>fair), poor - let bars = 0 - if (level >= t.good + 5) bars = 5 - else if (level >= t.good) bars = 4 - else if (level >= t.good - 5) bars = 3 - else if (level >= t.fair) bars = 2 - else bars = 1 - const cls = bars >= 4 ? 'excellent' : (bars >= 3 ? 'good' : (bars >= 2 ? 'fair' : 'poor')) - return `
${[1,2,3,4,5].map(i => - `
`).join('')}
` + // Directional diagnosis on an AP depends on its STA state, so refresh the + // parent row as well when a child changes. + if (fullDevice.parent_id) refreshDeviceTableRow(fullDevice.parent_id) } // Incremental update of detail panel - updates values without full re-render @@ -8175,6 +8091,9 @@ function showAntennaConfigModal() { clearTimeout(filterTimer) filterTimer = null } + if (antennaLiveUpdateCallback === scheduleUpdate) { + antennaLiveUpdateCallback = null + } } const closeBtn = document.getElementById('closeAntennaConfig') const closeFooter = document.getElementById('closeAntennaConfigFooter') @@ -8827,6 +8746,7 @@ function showAntennaConfigModal() { } applyFilter('') + antennaLiveUpdateCallback = scheduleUpdate unsubscribe = store.subscribe((st, oldSt) => { if (modal.classList.contains('hidden')) return @@ -9174,66 +9094,9 @@ window.restoreConfig = async function(deviceId, path) { } function showBatchConfigModal() { - const modal = document.getElementById('batchConfigModal') - if (!modal) return - - // Populate device list - const select = modal.querySelector('#batchDevices') - if (select) { - select.innerHTML = '' - store.devices.forEach(d => { - const opt = document.createElement('option') - opt.value = d.id - opt.textContent = `${d.hostname || d.ip_address} (${d.product || 'Unknown'})` - select.appendChild(opt) - }) - } - - modal.classList.remove('hidden') + openBatchConfigForDevices(store.devices.map(device => device.id), false) } -// Batch config submit -document.getElementById('confirmBatchConfig')?.addEventListener('click', async () => { - const modal = document.getElementById('batchConfigModal') - const select = modal?.querySelector('#batchDevices') - const deviceIds = Array.from(select?.selectedOptions || []).map(o => parseInt(o.value)) - - if (deviceIds.length === 0) { - showToast('Select at least one device', 'error') - return - } - - const changes = {} - if (document.getElementById('cfgSSID')?.checked) { - changes.ssid = document.getElementById('cfgSSIDValue')?.value - } - if (document.getElementById('cfgChannel')?.checked) { - changes.channel = parseInt(document.getElementById('cfgChannelValue')?.value) - } - if (document.getElementById('cfgPower')?.checked) { - changes.tx_power = parseInt(document.getElementById('cfgPowerValue')?.value) - } - if (document.getElementById('cfgPassword')?.checked) { - changes.password = document.getElementById('cfgPasswordValue')?.value - } - - if (Object.keys(changes).length === 0) { - showToast('Select at least one configuration option', 'error') - return - } - - showToast(`Applying config to ${deviceIds.length} devices...`, 'info') - - try { - const result = await api.batchConfig(deviceIds, changes) - const success = result.results?.filter(r => r.status === 'success').length || 0 - showToast(`Config applied: ${success}/${deviceIds.length} success`, success > 0 ? 'success' : 'error') - modal?.classList.add('hidden') - } catch (e) { - showToast('Config failed: ' + e.message, 'error') - } -}) - // ===== REPORTS PAGE ===== // Drilldown page state let drilldownSort = { col: null, dir: null } @@ -11385,6 +11248,7 @@ store.on(() => { setTimeout(() => window.dispatchEvent(new Event('resize')), 50) } } + refreshVirtualRowClasses() updateBulkToolbar() }) @@ -12875,30 +12739,38 @@ function resetBatchConfigForm() { document.getElementById('batchConfigResults')?.replaceChildren() } -function openBatchConfigForSelection() { - const ids = getSelectedDeviceIds() - if (ids.length === 0) return +function openBatchConfigForDevices(deviceIds, preselect = true) { const roles = store.user?.roles || [] if (!roles.includes('editor') && !roles.includes('administrator')) { showToast('Batch configuration requires editor access', 'error') return } + const select = document.getElementById('batchDevices') if (!select) return + + const ids = Array.from(new Set((deviceIds || []).map(Number).filter(Number.isFinite))) select.replaceChildren() ids.forEach(id => { const device = store.getDeviceById(id) if (!device) return const option = document.createElement('option') option.value = String(id) - option.selected = true + option.selected = preselect option.textContent = device.hostname || device.ip_address || device.mac || `Device ${id}` select.appendChild(option) }) + resetBatchConfigForm() openModalElement('batchConfigModal') } +function openBatchConfigForSelection() { + const ids = getSelectedDeviceIds() + if (ids.length === 0) return + openBatchConfigForDevices(ids, true) +} + document.getElementById('bulkConfig')?.addEventListener('click', openBatchConfigForSelection) document.getElementById('confirmBatchConfig')?.addEventListener('click', async () => { @@ -12913,12 +12785,14 @@ document.getElementById('confirmBatchConfig')?.addEventListener('click', async ( const changes = {} if (document.getElementById('cfgSSID')?.checked) { const value = document.getElementById('cfgSSIDValue')?.value || '' + const byteLength = new TextEncoder().encode(value).length if (!value.trim()) { showToast('SSID cannot be empty', 'error'); return } + if (byteLength > 32) { showToast('SSID must be at most 32 bytes', 'error'); return } changes.ssid = value } if (document.getElementById('cfgChannel')?.checked) { const value = Number(document.getElementById('cfgChannelValue')?.value) - if (!Number.isFinite(value) || value <= 0) { showToast('Enter a valid channel', 'error'); return } + if (!Number.isInteger(value) || value <= 0) { showToast('Enter a positive integer channel', 'error'); return } changes.channel = value } if (document.getElementById('cfgPower')?.checked) { diff --git a/web/js/components.js b/web/js/components.js index c35d636..32ecd02 100644 --- a/web/js/components.js +++ b/web/js/components.js @@ -8,7 +8,7 @@ import { VIRTUAL_THRESHOLD, triggerUpdateCounts, scrollToDeviceById -} from './virtual-integration.js?v=14' +} from './virtual-integration.js?v=16' async function requestConfirmation(message, options = {}) { @@ -266,9 +266,10 @@ function setupDashboardScopeHandlers(container) { }) } - // Close menus when the dashboard/table scrolls or viewport changes - const wrapper = container.querySelector('.device-table-wrapper') - wrapper?.addEventListener('scroll', closeAllMenus, { passive: true }) + // Scroll does not bubble, so listen in capture phase on the stable page + // container. This covers both the regular wrapper and virtual scroller even + // after their DOM is rebuilt. + container.addEventListener('scroll', closeAllMenus, { passive: true, capture: true }) window.addEventListener('resize', closeAllMenus) container.addEventListener('click', (e) => { @@ -517,84 +518,17 @@ export function renderDevices(container) { renderDevicesVirtual(container) return } - + + cleanupVirtualTableInstance() renderDevicesRegular(container) } // Regular (non-virtual) device rendering function renderDevicesRegular(container) { - const devices = store.filteredDevices - const query = store.searchQuery.toLowerCase() - - // Get selected device - const selectedDevice = store.selectedDevice - ? devices.find(d => d.id === store.selectedDevice) - : null - - // Filter by search - const filtered = query ? devices.filter(d => - (d.hostname || '').toLowerCase().includes(query) || - (d.ip_address || '').toLowerCase().includes(query) || - (d.product || '').toLowerCase().includes(query) || - (d.mac || '').toLowerCase().includes(query) || - (d.flavor || '').toLowerCase().includes(query) - ) : devices - - // Current sort state - null means hierarchical (tree) view + const sorted = getSortedFilteredDevices() const sortCol = store.sortColumn const sortDir = store.sortDirection || 'asc' - let sorted - - // If sorting by column, do flat sort (skip hierarchical grouping) - if (sortCol) { - sorted = [...filtered].sort((a, b) => { - let av, bv - // Sort by what's displayed in the column (same value regardless of device type) - switch (sortCol) { - case 'hostname': av = a.hostname || ''; bv = b.hostname || ''; break - case 'ip': av = a.ip_address || ''; bv = b.ip_address || ''; break - case 'mac': av = a.mac || ''; bv = b.mac || ''; break - case 'product': av = a.product || a.model || ''; bv = b.product || b.model || ''; break - case 'signal_60ghz': av = a.signal_60ghz || -999; bv = b.signal_60ghz || -999; break - case 'sta_60ghz': av = getSTASignal60GHz(a) || -999; bv = getSTASignal60GHz(b) || -999; break - case 'signal_5ghz': av = getSignal5GHz(a) || -999; bv = getSignal5GHz(b) || -999; break - case 'signal_5ghz_c0': av = getSignal5GHzChain(a, 0) || -999; bv = getSignal5GHzChain(b, 0) || -999; break - case 'signal_5ghz_c1': av = getSignal5GHzChain(a, 1) || -999; bv = getSignal5GHzChain(b, 1) || -999; break - case 'sta_5ghz': av = getSTASignal5GHz(a) || -999; bv = getSTASignal5GHz(b) || -999; break - case 'sta_5ghz_c0': av = getSTASignal5GHzChain(a, 0) || -999; bv = getSTASignal5GHzChain(b, 0) || -999; break - case 'sta_5ghz_c1': av = getSTASignal5GHzChain(a, 1) || -999; bv = getSTASignal5GHzChain(b, 1) || -999; break - case 'distance': av = a.distance || 0; bv = b.distance || 0; break - case 'capacity': av = a.capacity_60ghz || a.capacity_ltu || a.capacity_5ghz || 0; bv = b.capacity_60ghz || b.capacity_ltu || b.capacity_5ghz || 0; break - case 'firmware': av = a.firmware_version || a.firmware || ''; bv = b.firmware_version || b.firmware || ''; break - case 'site': av = a.site_name || ''; bv = b.site_name || ''; break - default: av = a.hostname || ''; bv = b.hostname || '' - } - if (typeof av === 'string') { - return sortDir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av) - } - return sortDir === 'asc' ? av - bv : bv - av - }) - } else { - // No column sort - use hierarchical grouping (root devices first, then their STAs). - // Managed devices (added via Add IP/Bulk) are treated as root-level even if they have a parent_id. - const aps = filtered.filter(d => !d.parent_id || d.managed) - const grouped = [] - - aps.forEach(ap => { - grouped.push(ap) - const stas = filtered.filter(d => d.parent_id === ap.id && !d.managed) - stas.sort((a, b) => (a.hostname || '').localeCompare(b.hostname || '')) - grouped.push(...stas) - }) - - // Add orphan STAs - const orphans = filtered.filter(d => d.parent_id && !d.managed && !aps.find(ap => ap.id === d.parent_id)) - grouped.push(...orphans) - - sorted = grouped - } - const sortIcon = (col) => sortCol === col ? (sortDir === 'asc' ? ' ▲' : ' ▼') : '' const cols = store.columns @@ -742,62 +676,29 @@ function renderDevicesRegular(container) { }) }) - // Row actions - container.querySelectorAll('.btn-refresh').forEach(btn => { + bindRegularRowActions(container, container) +} + +function bindRegularRowActions(root, renderContainer) { + root.querySelectorAll('.btn-refresh').forEach(btn => { btn.addEventListener('click', async e => { e.stopPropagation() - const id = parseInt(btn.dataset.id) - try { - btn.disabled = true - await api.refreshDevice(id) - showToast('Refreshing...', 'info') - setTimeout(async () => { - const devices = await api.devices() - store.set({ devices }) - renderTree() - renderDevices(container) - }, 2000) - } catch (e) { - showToast('Refresh failed: ' + e.message, 'error') - btn.disabled = false - } + await handleRefreshClick(parseInt(btn.dataset.id), btn) }) }) - - container.querySelectorAll('.btn-upgrade').forEach(btn => { + + root.querySelectorAll('.btn-upgrade').forEach(btn => { btn.addEventListener('click', e => { e.stopPropagation() const id = parseInt(btn.dataset.id) if (window.showUpgradeModal) window.showUpgradeModal(id) }) }) - - container.querySelectorAll('.btn-delete').forEach(btn => { + + root.querySelectorAll('.btn-delete').forEach(btn => { btn.addEventListener('click', async e => { e.stopPropagation() - const id = parseInt(btn.dataset.id) - const confirmed = await requestConfirmation( - 'This device and its WaveControl inventory record will be removed. Discovered child devices may also disappear from the current view.', - { - title: 'Delete device?', - eyebrow: 'Inventory change', - confirmText: 'Delete device', - tone: 'danger', - calloutTitle: 'This action removes the selected device from WaveControl.' - } - ) - if (!confirmed) return - - try { - await api.deleteDevice(id) - const devices = await api.devices() - store.set({ devices, selectedDevice: null }) - renderTree() - renderDevices(container) - showToast('Device deleted', 'success') - } catch (e) { - showToast('Delete failed: ' + e.message, 'error') - } + await handleDeleteClick(parseInt(btn.dataset.id), renderContainer) }) }) } @@ -927,6 +828,8 @@ function renderDevicesVirtual(container) { bufferSize: 100, renderRow: (d) => renderDeviceRowContent(d, cols), renderHeader: () => renderVirtualHeader(cols, sortCol, sortDir), + getRowClass: (d) => getDeviceRowClasses(d), + emptyMessage: 'No devices found', onRowClick: handleVirtualRowClick, getRowId: d => d.id, getRowIp: d => d.ip_address @@ -1033,7 +936,16 @@ function renderVirtualHeader(cols, sortCol, sortDir) { ` } -// Render row content (innerHTML without wrapper) for virtual table +function getDeviceRowClasses(device) { + const classes = [] + if (device.parent_id && !device.managed) classes.push('sta-row') + if (device.managed) classes.push('managed-row') + if (device.alertable === false) classes.push('not-alertable-row') + if (store.selectedDevice === device.id) classes.push('selected') + return classes.join(' ') +} + +// Render canonical row cells for both table modes. function renderDeviceRowContent(device, cols) { const isSTA = !!device.parent_id && !device.managed const isOnline = device.online @@ -1146,7 +1058,7 @@ function handleVirtualRowClick(device, tr, e) { const btn = e.target.closest('button') if (btn) { if (btn.classList.contains('btn-refresh')) { - handleRefreshClick(device.id) + handleRefreshClick(device.id, btn) } else if (btn.classList.contains('btn-upgrade')) { if (window.showUpgradeModal) window.showUpgradeModal(device.id) } else if (btn.classList.contains('btn-delete')) { @@ -1169,6 +1081,7 @@ function handleVirtualRowClick(device, tr, e) { detailPanel.classList.remove('hidden') } } + virtualTableInstance?.refreshRowClasses() } // Handle context menu in virtual table @@ -1220,8 +1133,7 @@ function setupColumnMenuHandlers(container) { }) } - const wrapper = container.querySelector('.device-table-wrapper') - wrapper?.addEventListener('scroll', closeAllColumnMenus, { passive: true }) + container.addEventListener('scroll', closeAllColumnMenus, { passive: true, capture: true }) window.addEventListener('resize', closeAllColumnMenus) container.addEventListener('click', (e) => { @@ -1270,17 +1182,20 @@ function cleanupVirtualTableInstance() { } // Handle refresh button click -async function handleRefreshClick(deviceId) { +async function handleRefreshClick(deviceId, button = null) { try { + if (button) button.disabled = true await api.refreshDevice(deviceId) showToast('Refreshing...', 'info') } catch (e) { showToast('Refresh failed: ' + e.message, 'error') + } finally { + if (button?.isConnected) button.disabled = false } } // Handle delete button click -async function handleDeleteClick(deviceId) { +async function handleDeleteClick(deviceId, renderContainer = null) { const confirmed = await requestConfirmation( 'This device and its WaveControl inventory record will be removed. Discovered child devices may also disappear from the current view.', { @@ -1307,9 +1222,13 @@ async function handleDeleteClick(deviceId) { const selectedStillExists = selected ? newDevices.some(d => d.id === selected) : true store.set({ devices: newDevices, selectedDevice: selectedStillExists ? selected : null }) - // Update tree + virtual table without tearing down the scroll container. try { renderTree() } catch (e) {} - if (virtualTableInstance) { + const container = renderContainer || + virtualTableInstance?.container?.closest('.devices-split-view')?.parentElement || + null + if (container) { + renderDevices(container) + } else if (virtualTableInstance) { virtualTableInstance.setData(getSortedFilteredDevices()) } @@ -1335,161 +1254,56 @@ export function cleanupVirtualTable() { // End Virtual Table Renderer // ========================================================================== -// Render a single device row +// Render a regular table row around the canonical cell renderer. function renderDeviceRow(device, cols = {}) { - const isSTA = !!device.parent_id && !device.managed - const isOnline = device.online - const status = isOnline ? 'online' : (device.db_status === 'offline' ? 'offline' : 'unknown') - - // Device name - prefer hostname, then product+IP combo, then just IP - const deviceName = escapeHTML(device.hostname || (device.product ? `${device.product} (${device.ip_address})` : device.ip_address) || device.mac || 'Unknown') - - // APs are already visually top-level, so only directly managed stations - // need role badges. Alertability is shown in the host pane rather than as a - // persistent dashboard pill; temporary silences remain visible here. - const inferredRole = String(device.role || (device.parent_id ? 'sta' : 'ap')).toLowerCase() - const directBadge = (device.managed && inferredRole !== 'ap') - ? `DIRECT` - : '' - const managedStaBadge = (device.managed && inferredRole === 'sta') ? `STA` : '' - const alertSilenced = device.alert_silenced_until && new Date(device.alert_silenced_until).getTime() > Date.now() - const alertSilencedBadge = alertSilenced ? `SILENCED` : '' - - // 60GHz signal (Wave devices) - prefer server-computed quality - const signal60 = device.signal_60ghz || 0 - const signal60Display = signal60 ? `${signal60} dBm` : '-' - const signal60Quality = device.radio_60ghz?.signal_quality - const signal60Class = signal60 ? getSignalClassFromQuality(signal60Quality, signal60, '60ghz') : '' - - // STA 60GHz signal (remote - what STA receives from AP) - const sta60 = getSTASignal60GHz(device) - const sta60Quality = device.radio_60ghz?.remote_signal_quality - const colSta60 = cols.sta60 ? (() => { - const display = sta60 ? `${sta60} dBm` : '-' - const cls = sta60 ? getSignalClassFromQuality(sta60Quality, sta60, '60ghz') : '' - return `${display}` - })() : '' - - // 5GHz signals (all platforms: Wave backup, airMAX, LTU) - prefer server-computed quality - const chains = getSignal5GHzChains(device) - const signal5Combined = getSignal5GHz(device) - const signal5Quality = device.radio_5ghz?.signal_quality || device.radio_ltu?.signal_quality - const signal5C0 = chains[0] || 0 - const signal5C1 = chains[1] || 0 - - // 5GHz combined column - const col5Combined = cols.signal5 ? (() => { - const display = signal5Combined ? `${signal5Combined} dBm` : '-' - const cls = signal5Combined ? getSignalClassFromQuality(signal5Quality, signal5Combined, '5ghz') : '' - return `${display}` - })() : '' - - // 5GHz C0 column (no server quality for per-chain) - const col5C0 = cols.signal5c0 ? (() => { - const display = signal5C0 ? `${signal5C0}` : '-' - const cls = signal5C0 ? getSignalClass5(signal5C0) : '' - return `${display}` - })() : '' - - // 5GHz C1 column (no server quality for per-chain) - const col5C1 = cols.signal5c1 ? (() => { - const display = signal5C1 ? `${signal5C1}` : '-' - const cls = signal5C1 ? getSignalClass5(signal5C1) : '' - return `${display}` - })() : '' - - // STA 5GHz signals (remote - what STA receives from AP) - const staChains = getSTASignal5GHzChains(device) - const sta5Combined = getSTASignal5GHz(device) - const sta5Quality = device.radio_5ghz?.remote_signal_quality || device.radio_ltu?.remote_signal_quality || device.remote_signal_quality - const sta5C0 = staChains[0] || 0 - const sta5C1 = staChains[1] || 0 - - // STA 5GHz combined column - const colSta5Combined = cols.sta5 ? (() => { - const display = sta5Combined ? `${sta5Combined} dBm` : '-' - const cls = sta5Combined ? getSignalClassFromQuality(sta5Quality, sta5Combined, '5ghz') : '' - return `${display}` - })() : '' - - // STA 5GHz C0 column (no server quality for per-chain) - const colSta5C0 = cols.sta5c0 ? (() => { - const display = sta5C0 ? `${sta5C0}` : '-' - const cls = sta5C0 ? getSignalClass5(sta5C0) : '' - return `${display}` - })() : '' - - // STA 5GHz C1 column (no server quality for per-chain) - const colSta5C1 = cols.sta5c1 ? (() => { - const display = sta5C1 ? `${sta5C1}` : '-' - const cls = sta5C1 ? getSignalClass5(sta5C1) : '' - return `${display}` - })() : '' - - // Directional diagnosis (DL/UL) - computed client-side from CINR/SNR/EVM if available - const colDir = cols.dir ? `${renderDirectionalCell(device)}` : '' - - // Health column - signal bars for primary signal - const healthCol = cols.health ? (() => { - const primarySignal = signal60 || signal5Combined || 0 - const band = signal60 ? '60ghz' : '5ghz' - return `${getSignalBars(primarySignal, band)}` - })() : '' - - // Site - escape for XSS protection - const siteName = escapeHTML(device.site_name || '-') - - // Distance - const distanceStr = device.distance ? `${(device.distance / 1000).toFixed(2)} km` : '-' - - // Capacity - const capacity = device.capacity_60ghz || device.capacity_ltu || device.capacity_5ghz || 0 - const capacityStr = capacity ? `${(capacity / 1e6).toFixed(0)} Mbps` : '-' - - // Firmware - use helper to extract clean version - const firmware = escapeHTML(getDisplayFirmware(device)) - - // Escape all device-controlled attribute values const escapedIP = escapeAttr(device.ip_address || '') - const escapedMAC = escapeHTML(device.mac || '-') - const escapedProduct = escapeHTML(device.product || device.model || '-') - return ` - - - ${cols.status !== false ? `` : ''} - ${cols.name !== false ? ` - - ${isSTA ? '+-' : ''} - ${deviceName}${directBadge}${managedStaBadge}${alertSilencedBadge} - - ` : ''} - ${cols.ip !== false ? `${escapeHTML(device.ip_address || '-')}` : ''} - ${cols.mac ? `${escapedMAC}` : ''} - ${cols.product !== false ? `${escapedProduct}` : ''} - ${cols.site !== false ? `${siteName}` : ''} - ${cols.signal60 !== false ? `${signal60Display}` : ''} - ${colSta60} - ${col5Combined} - ${col5C0} - ${col5C1} - ${colSta5Combined} - ${colSta5C0} - ${colSta5C1} - ${colDir} - ${healthCol} - ${cols.distance !== false ? `${distanceStr}` : ''} - ${cols.capacity !== false ? `${capacityStr}` : ''} - ${cols.firmware !== false ? `${firmware}` : ''} - - - - - + + ${renderDeviceRowContent(device, cols)} ` } +export function refreshDeviceTableRow(deviceId) { + const id = Number(deviceId) + if (!Number.isFinite(id)) return false + + const device = store.getDeviceById(id) + if (!device) return false + + // In virtual mode, keep using its batching/update path. + if (virtualTableInstance) { + virtualTableInstance.updateById(id, {}) + return true + } + + const row = document.querySelector(`.devices-split-view .device-table tbody tr[data-id="${id}"]`) + if (!row) return false + + const transient = ['highlighted', 'context-menu-target'].filter(cls => row.classList.contains(cls)) + const active = document.activeElement + let focusSelector = null + if (active && row.contains(active)) { + if (active.matches('input[type="checkbox"][data-id]')) { + focusSelector = `input[type="checkbox"][data-id="${active.dataset.id}"]` + } else if (active.matches('button[data-id]')) { + const actionClass = ['btn-refresh', 'btn-upgrade', 'btn-delete'].find(cls => active.classList.contains(cls)) + if (actionClass) focusSelector = `button.${actionClass}[data-id="${active.dataset.id}"]` + } + } + + row.dataset.ip = device.ip_address || '' + row.className = getDeviceRowClasses(device) + transient.forEach(cls => row.classList.add(cls)) + row.innerHTML = renderDeviceRowContent(device, store.columns) + + const renderContainer = row.closest('.devices-split-view')?.parentElement || document.getElementById('app') + if (renderContainer) bindRegularRowActions(row, renderContainer) + + if (focusSelector) row.querySelector(focusSelector)?.focus({ preventScroll: true }) + return true +} + // Signal thresholds - must match app.js SIGNAL_THRESHOLDS and Go store.go const SIGNAL_THRESHOLDS = { '60ghz': { good: -55, fair: -65 }, diff --git a/web/js/virtual-integration.js b/web/js/virtual-integration.js index 1477464..86040f7 100644 --- a/web/js/virtual-integration.js +++ b/web/js/virtual-integration.js @@ -9,7 +9,7 @@ // 4. Re-exports VirtualTable class // -import { VirtualTable } from './virtual-table.js?v=7' +import { VirtualTable } from './virtual-table.js?v=9' import { store } from './store.js?v=17' // Re-export for components.js @@ -50,11 +50,15 @@ export function setVirtualTableRef(vt) { // Scroll to and highlight a device by ID in the virtual table export function scrollToDeviceById(id) { if (virtualTableRef && typeof virtualTableRef.scrollToId === 'function') { - return virtualTableRef.scrollToId(id) // Returns true if found and scrolled + return virtualTableRef.scrollToId(id) } return false } +export function refreshVirtualRowClasses() { + virtualTableRef?.refreshRowClasses?.() +} + const wsBatcher = { // Use device id as the primary key to avoid conflating devices when IPs are reused. // (IP is only a fallback identifier when no MAC/ID exists.) @@ -68,6 +72,9 @@ const wsBatcher = { countsTimer: null, lastCountsAt: 0, countsMinIntervalMs: 1000, + derivedTimer: null, + lastDerivedAt: 0, + derivedMinIntervalMs: 500, add(id, updates) { if (id === undefined || id === null) return @@ -102,6 +109,7 @@ const wsBatcher = { } this.pending.clear() + scheduleVirtualBatchFlushCallback(this) // Trigger counts update (throttled) if (this.countsDirty && typeof updateCountsCallback === 'function') { @@ -133,6 +141,24 @@ export function setUpdateCountsCallback(fn) { updateCountsCallback = fn } +let virtualBatchFlushCallback = null +export function setVirtualBatchFlushCallback(fn) { + virtualBatchFlushCallback = fn +} + +function scheduleVirtualBatchFlushCallback(state) { + if (typeof virtualBatchFlushCallback !== 'function') return + const now = Date.now() + const elapsed = now - (state.lastDerivedAt || 0) + const run = () => { + state.lastDerivedAt = Date.now() + state.derivedTimer = null + try { virtualBatchFlushCallback() } catch (e) {} + } + if (elapsed >= state.derivedMinIntervalMs) run() + else if (!state.derivedTimer) state.derivedTimer = setTimeout(run, state.derivedMinIntervalMs - elapsed) +} + // Allow other modules (e.g. components.js) to trigger a counts refresh // without importing app.js. This calls the callback registered by app.js. export function triggerUpdateCounts() { @@ -298,7 +324,7 @@ export function getSortedFilteredDevices() { grouped.push(d) const kids = childrenByParent.get(d.id) if (kids && kids.length) { - // Maintain insertion order (AP, then its STAs) + kids.sort((a, b) => (a.hostname || '').localeCompare(b.hostname || '')) for (let k = 0; k < kids.length; k++) { grouped.push(kids[k]) } diff --git a/web/js/virtual-table.js b/web/js/virtual-table.js index 6194a30..28f9b67 100644 --- a/web/js/virtual-table.js +++ b/web/js/virtual-table.js @@ -42,9 +42,11 @@ export class VirtualTable { // Optional with defaults this.rowHeight = options.rowHeight || 40 this.bufferSize = options.bufferSize || 100 - this.onRowClick = options.onRowClick // (item, tr, event) => void + this.onRowClick = options.onRowClick this.getRowId = options.getRowId || (d => d.id) this.getRowIp = options.getRowIp || (d => d.ip_address) + this.getRowClass = options.getRowClass || (() => '') + this.emptyMessage = options.emptyMessage || 'No rows found' // Internal state this.data = [] @@ -59,11 +61,13 @@ export class VirtualTable { // DOM references (set in mount()) this.wrapper = null + this.headerViewport = null this.headerTable = null this.scrollContainer = null this.spacer = null this.bodyTable = null this.tbody = null + this.emptyState = null // Row cache: id -> { element, index } this.rowCache = new Map() @@ -103,25 +107,31 @@ export class VirtualTable { // Build DOM structure this.container.innerHTML = `
- - -
+
+ + +
+
+
` // Cache DOM references this.wrapper = this.container.querySelector('.virtual-table-wrapper') + this.headerViewport = this.container.querySelector('.virtual-header-viewport') this.headerTable = this.container.querySelector('.virtual-header-table') this.scrollContainer = this.container.querySelector('.virtual-scroll-container') this.spacer = this.container.querySelector('.virtual-spacer') this.bodyTable = this.container.querySelector('.virtual-body-table') this.tbody = this.container.querySelector('tbody') + this.emptyState = this.container.querySelector('.virtual-empty-state') + if (this.emptyState) this.emptyState.textContent = this.emptyMessage // Render header const thead = this.container.querySelector('thead') @@ -198,6 +208,7 @@ export class VirtualTable { this.data = data || [] this._rebuildIndexes() this._updateSpacerHeight() + this._syncEmptyState() // Data length changes can cause the vertical scrollbar to appear/disappear. // That changes scrollContainer.clientWidth without changing its border-box size, @@ -234,6 +245,18 @@ export class VirtualTable { this._log('Spacer height set to', totalHeight) } + _syncEmptyState() { + if (!this.emptyState) return + this.emptyState.classList.toggle('hidden', this.data.length !== 0) + } + + _syncRowClass(tr, item) { + if (!tr) return + const transient = ['highlighted', 'context-menu-target'].filter(cls => tr.classList.contains(cls)) + tr.className = this.getRowClass(item) || '' + transient.forEach(cls => tr.classList.add(cls)) + } + _updateViewportHeight() { if (!this.scrollContainer) return @@ -242,15 +265,23 @@ export class VirtualTable { } _syncHeaderWidth() { - if (!this.headerTable || !this.scrollContainer) return + if (!this.headerTable || !this.bodyTable || !this.scrollContainer) return - // clientWidth excludes the vertical scrollbar, which is exactly the width - // the body table is laid out against. - const w = this.scrollContainer.clientWidth - if (w && w > 0) { - this.headerTable.style.width = `${w}px` - } else { - this.headerTable.style.width = '100%' + const viewportWidth = this.scrollContainer.clientWidth + let minimumWidth = 0 + this.headerTable.querySelectorAll('thead th').forEach(cell => { + const min = parseFloat(getComputedStyle(cell).minWidth) + if (Number.isFinite(min) && min > 0) minimumWidth += min + }) + + const tableWidth = Math.max(viewportWidth || 0, Math.ceil(minimumWidth)) + const widthValue = tableWidth > 0 ? `${tableWidth}px` : '100%' + this.headerTable.style.width = widthValue + this.bodyTable.style.width = widthValue + if (this.spacer) this.spacer.style.width = widthValue + if (this.headerViewport) { + this.headerViewport.style.width = viewportWidth > 0 ? `${viewportWidth}px` : '100%' + this.headerViewport.scrollLeft = this.scrollContainer.scrollLeft } } @@ -352,6 +383,7 @@ export class VirtualTable { tr.dataset.id = id if (ip) tr.dataset.ip = ip + this._syncRowClass(tr, item) // Set styles inline - faster than multiple style assignments tr.style.cssText = 'position:absolute;top:0;left:0;right:0;height:' + this.rowHeight + 'px;transform:translateY(' + yPos + 'px)' @@ -407,8 +439,19 @@ export class VirtualTable { for (const [id, item] of this.pendingUpdates) { const cached = this.rowCache.get(id) if (cached) { - // Re-render row content + const active = document.activeElement + let focusSelector = null + if (active && cached.element.contains(active)) { + if (active.matches('input[type="checkbox"][data-id]')) { + focusSelector = `input[type="checkbox"][data-id="${active.dataset.id}"]` + } else if (active.matches('button[data-id]')) { + const actionClass = ['btn-refresh', 'btn-upgrade', 'btn-delete'].find(cls => active.classList.contains(cls)) + if (actionClass) focusSelector = `button.${actionClass}[data-id="${active.dataset.id}"]` + } + } cached.element.innerHTML = this.renderRow(item) + this._syncRowClass(cached.element, item) + if (focusSelector) cached.element.querySelector(focusSelector)?.focus({ preventScroll: true }) } } this.pendingUpdates.clear() @@ -419,7 +462,11 @@ export class VirtualTable { // =========================================================================== _onScroll() { - // Use RAF to batch scroll handling - prevents multiple renders per frame + // Horizontal movement must track immediately so the fixed header stays + // aligned with the body. Vertical row recycling remains RAF-batched. + if (this.headerViewport) { + this.headerViewport.scrollLeft = this.scrollContainer.scrollLeft + } if (this._scrollRAF) return this._scrollRAF = requestAnimationFrame(() => { this._scrollRAF = null @@ -438,6 +485,13 @@ export class VirtualTable { // PUBLIC METHODS // =========================================================================== + refreshRowClasses() { + for (const [id, cached] of this.rowCache) { + const item = this.dataById.get(id) + if (item) this._syncRowClass(cached.element, item) + } + } + scrollToId(id) { const item = this.dataById.get(id) if (!item || !this.scrollContainer) return false @@ -454,6 +508,7 @@ export class VirtualTable { this.scrollContainer.scrollTop = Math.max(0, targetY) this.scrollTop = this.scrollContainer.scrollTop this._renderViewport() + this.refreshRowClasses() // The target row is now in the rendered buffer. Highlight on the next frame // so the scroll position is painted before the animation begins.