diff --git a/.github/diy/patches/dashboard.patch b/.github/diy/patches/dashboard.patch index 380db052b..b0f277f52 100644 --- a/.github/diy/patches/dashboard.patch +++ b/.github/diy/patches/dashboard.patch @@ -43,353 +43,3 @@ 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 232241aca403..c081c073f717 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'), -@@ -242,11 +243,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 5707919f514f..6a8e755bfc0d 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); -+ -+ desired.forEach((node, i) => { -+ if (root.children[i] !== node) -+ root.insertBefore(node, root.children[i] || null); -+ }); -+ -+ while (root.children.length > desired.length) -+ root.removeChild(root.lastChild); -+} -+ -+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')); - -- if (sections.cards.length) -- nodes.push(E('div', { 'class': 'dashboard-kpi-row' }, fillRows(sections.cards, 'cards'))); -+ container.querySelectorAll('[data-chart]').forEach(el => { -+ if (el.dataset.chart && scrollPositions[el.dataset.chart] != null) -+ el.scrollLeft = scrollPositions[el.dataset.chart]; -+ }); -+} - -- if (sections.charts.length) -- nodes.push(E('div', { 'class': 'dashboard-charts' }, fillRows(sections.charts, 'charts'))); -+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); - -- 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', { -+ 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)); -- -- return nodes; --} -+ if (tab.id === activeId) -+ pane.setAttribute('data-tab-active', 'true'); - --function saveChartScroll(root) { -- const positions = {}; -+ tabGroup.appendChild(pane); -+ }); - -- root.querySelectorAll('[data-chart]').forEach(node => { -- if (node.dataset.chart) -- positions[node.dataset.chart] = node.scrollLeft; -- }); -+ ui.tabs.initTabGroup(tabGroup.childNodes); -+ } -+ else { -+ items.forEach(tab => { -+ const pane = tabGroup.querySelector(':scope > [data-tab="' + tab.id + '"]'); -+ if (!pane) -+ return; -+ -+ 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 e8c5b191ff7a..96e629fa799c 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))) -- ]); -- -- 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])))); -- }); -+ 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'); -+ -+ 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;