diff --git a/.github/diy/patches/dashboard.patch b/.github/diy/patches/dashboard.patch index b65218e0f..380db052b 100644 --- a/.github/diy/patches/dashboard.patch +++ b/.github/diy/patches/dashboard.patch @@ -32,3 +32,364 @@ "action": { "type": "view", "path": "dashboard/index" + +--- 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 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;