diff --git a/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/include/20_lan.js b/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/include/20_lan.js index 4f3ce7542..e870f6328 100644 --- a/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/include/20_lan.js +++ b/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/include/20_lan.js @@ -43,6 +43,7 @@ return baseclass.extend({ renderTable() { return charts.table({ + id: 'dashboard-lan-table', head: [ _('Hostname'), _('IP Address'), _('MAC') ], rows: this.params.lan.devices.map(device => [ device.hostname, diff --git a/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/include/30_wifi.js b/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/include/30_wifi.js index 48bb46dec..ebb772d35 100644 --- a/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/include/30_wifi.js +++ b/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/include/30_wifi.js @@ -210,6 +210,7 @@ return baseclass.extend({ renderClientTable() { return charts.table({ + id: 'dashboard-wifi-table', head: [ _('Hostname'), _('IP Address'), @@ -272,11 +273,23 @@ return baseclass.extend({ }, renderTab() { - return E('div', {}, [ - E('div', { 'class': 'dashboard-net-grid' }, this.params.wifi.radios.map(radio => this.renderNetworkCard(radio))), + const netGrid = E('div', { 'class': 'dashboard-net-grid' }, this.params.wifi.radios.map(radio => this.renderNetworkCard(radio))); + const clientTable = this.renderClientTable(); + + if (this.tabNode && this.tabNode.parentNode) { + const oldGrid = this.tabNode.querySelector('.dashboard-net-grid'); + if (oldGrid) + this.tabNode.replaceChild(netGrid, oldGrid); + return this.tabNode; + } + + this.tabNode = E('div', {}, [ + netGrid, E('h3', {}, [ _('Wireless clients') ]), - this.renderClientTable() + clientTable ]); + + return this.tabNode; }, clientAddresses(ipv4, hint) { diff --git a/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/index.js b/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/index.js index 5707919f5..6a8e755bf 100644 --- a/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/index.js +++ b/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/index.js @@ -122,48 +122,98 @@ function fillRows(nodes, slot) { return nodes; } -function renderSections(sections, keep) { - const nodes = []; +function updateShell(root, sections, containers) { + const desired = [ + sections.cards.length ? containers.cards : null, + sections.charts.length ? containers.charts : null, + sections.tabs.length ? containers.tabs : null, + sections.extra.length ? containers.extra : null + ].filter(Boolean); - if (sections.cards.length) - nodes.push(E('div', { 'class': 'dashboard-kpi-row' }, fillRows(sections.cards, 'cards'))); + desired.forEach((node, i) => { + if (root.children[i] !== node) + root.insertBefore(node, root.children[i] || null); + }); - if (sections.charts.length) - nodes.push(E('div', { 'class': 'dashboard-charts' }, fillRows(sections.charts, 'charts'))); + while (root.children.length > desired.length) + root.removeChild(root.lastChild); +} - if (keep) - nodes.push(keep); - else if (sections.tabs.length) - nodes.push(E('div', { 'class': 'cbi-map' }, [ - E('div', { 'class': 'cbi-map-tabbed' }, sections.tabs.map(tab => E('div', { +function updateCharts(container, chartNodes) { + const scrollPositions = {}; + container.querySelectorAll('[data-chart]').forEach(el => { + if (el.dataset.chart) + scrollPositions[el.dataset.chart] = el.scrollLeft; + }); + + L.dom.content(container, fillRows(chartNodes, 'charts')); + + container.querySelectorAll('[data-chart]').forEach(el => { + if (el.dataset.chart && scrollPositions[el.dataset.chart] != null) + el.scrollLeft = scrollPositions[el.dataset.chart]; + }); +} + +function updateTabs(tabMap, tabGroup, items, form) { + const menu = tabMap.querySelector('.cbi-tabmenu'); + const existingPanes = Array.from(tabGroup.children); + const existingIds = existingPanes.map(pane => pane.dataset.tab); + const itemIds = items.map(tab => tab.id); + + const changed = !tabGroup.hasAttribute('data-initialized') || + existingIds.length !== itemIds.length || + existingIds.some((id, i) => id !== itemIds[i]); + + if (changed) { + if (menu) + menu.remove(); + tabGroup.removeAttribute('data-initialized'); + + const activePane = existingPanes.find(p => p.getAttribute('data-tab-active') === 'true'); + const activeId = activePane ? activePane.dataset.tab : null; + + while (tabGroup.firstChild) + tabGroup.removeChild(tabGroup.firstChild); + + items.forEach(tab => { + const pane = (tab.id === 'layout' && form) ? form : E('div', { 'class': 'cbi-section', 'data-tab': tab.id, 'data-tab-title': (tab.count != null) ? '%s (%d)'.format(tab.title, tab.count) : tab.title - }, tab.content))) - ])); + }, (typeof(tab.content) == 'function') ? tab.content() : tab.content); - if (sections.extra.length) - nodes.push(E('div', {}, sections.extra)); + if (tab.id === activeId) + pane.setAttribute('data-tab-active', 'true'); - return nodes; -} + tabGroup.appendChild(pane); + }); -function saveChartScroll(root) { - const positions = {}; + ui.tabs.initTabGroup(tabGroup.childNodes); + } + else { + items.forEach(tab => { + const pane = tabGroup.querySelector(':scope > [data-tab="' + tab.id + '"]'); + if (!pane) + return; - root.querySelectorAll('[data-chart]').forEach(node => { - if (node.dataset.chart) - positions[node.dataset.chart] = node.scrollLeft; - }); + const newTitle = (tab.count != null) ? '%s (%d)'.format(tab.title, tab.count) : tab.title; + if (pane.dataset.tabTitle !== newTitle) { + pane.dataset.tabTitle = newTitle; + const link = menu ? menu.querySelector('li[data-tab="' + tab.id + '"] > a') : null; + if (link && link.textContent !== newTitle) + link.textContent = newTitle; + } - return positions; -} + if (tab.id === 'layout') + return; -function restoreChartScroll(root, positions) { - root.querySelectorAll('[data-chart]').forEach(node => { - if (positions[node.dataset.chart]) - node.scrollLeft = positions[node.dataset.chart]; - }); + const contentNode = (typeof(tab.content) == 'function') ? tab.content() : tab.content; + if (contentNode) { + if (pane.childNodes.length !== 1 || pane.firstChild !== contentNode) + L.dom.content(pane, contentNode); + } + }); + } } function startPolling(includes, layout, root, form) { @@ -175,41 +225,45 @@ function startPolling(includes, layout, root, form) { .then(node => L.dom.content(form, node)); }; + if (form) + form.addEventListener('cbi-tab-active', showForm); + + const cardsContainer = E('div', { 'class': 'dashboard-kpi-row' }); + const chartsContainer = E('div', { 'class': 'dashboard-charts' }); + const tabGroup = E('div', { 'class': 'cbi-map-tabbed' }); + const tabMap = E('div', { 'class': 'cbi-map' }, [ tabGroup ]); + const extraContainer = E('div', {}); + + const containers = { + cards: cardsContainer, + charts: chartsContainer, + tabs: tabMap, + extra: extraContainer + }; + const step = () => { return network.flushCache().then(() => { return invokeIncludesLoad(includes, layout); }).then(results => { - const positions = saveChartScroll(root); - const sections = collectSections(includes, results, layout); if (form) - sections.tabs.push({ id: 'layout', title: _('Layout'), content: form }); + sections.tabs.push({ id: 'layout', title: _('Layout'), content: [] }); - // The tabs are left alone while the layout form is looked at, - // for a redraw not to take away what is being edited. - const tabs = root.querySelector(':scope > .cbi-map'); - const keep = (tabs && tabs.querySelector('[data-tab="layout"][data-tab-active="true"]')) ? tabs : null; - let before = keep; + updateShell(root, sections, containers); - Array.from(root.childNodes).filter(node => node !== keep).forEach(node => root.removeChild(node)); + if (sections.cards.length) + L.dom.content(cardsContainer, fillRows(sections.cards, 'cards')); - renderSections(sections, keep).forEach(node => { - if (node === keep) - before = null; - else - root.insertBefore(node, before); - }); + if (sections.charts.length) + updateCharts(chartsContainer, sections.charts); - const group = keep ? null : root.querySelector('.cbi-map-tabbed'); - if (group) { - if (form) - form.parentNode.addEventListener('cbi-tab-active', showForm); + if (sections.tabs.length) + updateTabs(tabMap, tabGroup, sections.tabs, form); - ui.tabs.initTabGroup(group.childNodes); - } + if (sections.extra.length) + L.dom.content(extraContainer, sections.extra); - restoreChartScroll(root, positions); root.classList.add('fade-in'); }); }; @@ -241,7 +295,7 @@ return view.extend({ document.addEventListener('uci-applied', () => window.location.reload()); - const form = L.hasViewPermission() ? E('div', {}, [ E('em', { 'class': 'spinning' }, [ _('Loading view…') ]) ]) : null; + const form = L.hasViewPermission() ? E('div', { 'class': 'cbi-section', 'data-tab': 'layout', 'data-tab-title': _('Layout') }, [ E('em', { 'class': 'spinning' }, [ _('Loading view…') ]) ]) : null; return startPolling(includes, layout, root, form).then(() => root); }, diff --git a/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/lib/charts.js b/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/lib/charts.js index e8c5b191f..96e629fa7 100644 --- a/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/lib/charts.js +++ b/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/lib/charts.js @@ -97,33 +97,77 @@ return baseclass.extend({ return E('em', {}, [ text ]); }, - // Same markup as ui.Table: flat .table > .tr, header row .table-titles, - // data-title on the cells so themes can label them on phones. - table(opts) { + updateTable(table, opts) { const cell = (tag, content, title) => E(tag, { 'class': tag, 'data-title': title || null }, [ content ?? '' ]); - const table = E('table', { 'class': 'table ' + (opts.className || '') }, [ - E('tr', { 'class': 'tr table-titles' }, opts.head.map(title => cell('th', title))) - ]); + const head = opts.head || []; + const rows = opts.rows || []; + const tbody = (table.tBodies && table.tBodies[0]) ? table.tBodies[0] : table; + const currentRows = tbody.querySelectorAll('tr:not(.table-titles):not(.table-foot):not(.placeholder)'); + const placeholder = tbody.querySelector('tr.placeholder'); + const foot = tbody.querySelector('tr.table-foot'); - opts.rows.forEach((row, i) => { - table.appendChild(E('tr', { 'class': 'tr ' + (i % 2 ? 'cbi-rowstyle-2' : 'cbi-rowstyle-1') }, row.map((content, n) => cell('td', content, opts.head[n])))); - }); + for (let i = 0; i < rows.length; i++) { + const tr = E('tr', { 'class': 'tr ' + (i % 2 ? 'cbi-rowstyle-2' : 'cbi-rowstyle-1') }, + rows[i].map((content, n) => cell('td', content, head[n]))); + if (currentRows[i]) + tbody.replaceChild(tr, currentRows[i]); + else if (foot) + tbody.insertBefore(tr, foot); + else + tbody.appendChild(tr); + } - if (!opts.rows.length && opts.emptyText) - table.appendChild(E('tr', { 'class': 'tr placeholder' }, [ - E('td', { 'class': 'td', 'colspan': opts.head.length }, [ E('em', {}, [ opts.emptyText ]) ]) - ])); + for (let i = rows.length; i < currentRows.length; i++) + tbody.removeChild(currentRows[i]); + + if (!rows.length && opts.emptyText) { + if (!placeholder) { + const tr = E('tr', { 'class': 'tr placeholder' }, [ + E('td', { 'class': 'td', 'colspan': head.length }, [ E('em', {}, [ opts.emptyText ]) ]) + ]); + if (foot) + tbody.insertBefore(tr, foot); + else + tbody.appendChild(tr); + } + } + else if (placeholder) { + tbody.removeChild(placeholder); + } if (opts.foot) { - const foot = E('tr', { 'class': 'tr' }, opts.foot.map(content => cell('td', content))); - - foot.lastChild.setAttribute('colspan', opts.head.length - opts.foot.length + 1); - table.appendChild(foot); + if (!foot) { + const tr = E('tr', { 'class': 'tr table-foot' }, opts.foot.map(content => cell('td', content))); + tr.lastChild.setAttribute('colspan', head.length - opts.foot.length + 1); + tbody.appendChild(tr); + } + else { + while (foot.firstChild) + foot.removeChild(foot.firstChild); + opts.foot.map(content => cell('td', content)).forEach(td => foot.appendChild(td)); + foot.lastChild.setAttribute('colspan', head.length - opts.foot.length + 1); + } } return table; }, + // Same markup as ui.Table: flat .table > .tr, header row .table-titles, + // data-title on the cells so themes can label them on phones. + table(opts) { + const existing = opts.node || (opts.id ? document.getElementById(opts.id) : null); + if (existing && existing.parentNode) + return this.updateTable(existing, opts); + + const cell = (tag, content, title) => E(tag, { 'class': tag, 'data-title': title || null }, [ content ?? '' ]); + const head = opts.head || []; + const table = E('table', { 'id': opts.id || null, 'class': 'table ' + (opts.className || '') }, [ + E('tr', { 'class': 'tr table-titles' }, head.map(title => cell('th', title))) + ]); + + return this.updateTable(table, opts); + }, + donut(opts) { const radius = 40; const circumference = 2 * Math.PI * radius;