diff --git a/luci-mod-dashboard/Makefile b/luci-mod-dashboard/Makefile index 4a9aec96f..bb85cb4a3 100644 --- a/luci-mod-dashboard/Makefile +++ b/luci-mod-dashboard/Makefile @@ -7,11 +7,15 @@ include $(TOPDIR)/rules.mk LUCI_TITLE:=LuCI Dashboard Pages -LUCI_DEPENDS:=+luci-base +libiwinfo +LUCI_DEPENDS:=+luci-base +libiwinfo +ucode-mod-uloop PKG_BUILD_DEPENDS:=iwinfo PKG_LICENSE:=Apache-2.0 +define Package/luci-mod-dashboard/conffiles +/etc/config/dashboard +endef + include $(TOPDIR)/feeds/luci/luci.mk # call BuildPackage - OpenWrt buildroot signature diff --git a/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/css/custom.css b/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/css/custom.css index b09a2fbc7..5fb8baec1 100644 --- a/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/css/custom.css +++ b/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/css/custom.css @@ -57,25 +57,31 @@ } /* ---- Layout ---- */ -.dashboard-kpi-row { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); - gap: var(--dashboard-gap); -} - +/* Every row fills the width: an item's basis is its row's share, set from + --dashboard-per-row, and flex-grow takes up the rest. On narrow screens + the min-width wraps the items, which still fill their rows. */ +.dashboard-kpi-row, .dashboard-charts { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + display: flex; + flex-wrap: wrap; gap: var(--dashboard-gap); - align-items: stretch; } -/* Grid gaps space the cards, not the theme's section margin. */ +/* The gaps space the cards, not the theme's section margin. */ .dashboard-kpi-row > .cbi-section, .dashboard-charts > .cbi-section { + flex: 1 1 calc(100% / var(--dashboard-per-row, 3) - var(--dashboard-gap)); + box-sizing: border-box; + min-width: 190px; margin: 0; } +.dashboard-charts > .cbi-section { + display: flex; + flex-direction: column; + min-width: 250px; +} + /* ---- Icon (mask keeps the SVG files, colour follows the theme) ---- */ .dashboard-icon { display: inline-block; @@ -129,17 +135,23 @@ } .dashboard-kpi-sub { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 6px; + display: block; + overflow-wrap: anywhere; +} + +.dashboard-kpi-line { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } /* ---- Donut ---- */ +/* Shorter than the bar charts it shares a row with, so centre it. */ .dashboard-donut { display: flex; align-items: center; gap: 20px; + margin-block: auto; } .dashboard-donut svg { @@ -362,6 +374,52 @@ .dashboard-legend-inline i.dashboard-bar-up { background: var(--dashboard-series-1); } .dashboard-legend-inline i.dashboard-bar-down { background: var(--dashboard-series-2); } +/* ---- Time series (shares the axis and grid of the bar chart) ---- */ +.dashboard-lines-plot { + position: relative; + height: var(--dashboard-plot-h); +} + +.dashboard-lines-plot svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: visible; +} + +.Dashboard .dashboard-lines-plot polyline { + fill: none; + stroke-width: 1.75; + stroke-linejoin: round; + stroke-linecap: round; + vector-effect: non-scaling-stroke; +} + +.Dashboard .dashboard-lines-plot polygon { + stroke: none; + opacity: 0.14; +} + +.dashboard-lines-plot polygon.dashboard-series-1 { fill: var(--dashboard-series-1); } +.dashboard-lines-plot polygon.dashboard-series-2 { fill: var(--dashboard-series-2); } + +.dashboard-lines-time { + grid-column: 2; + display: flex; + justify-content: space-between; + padding-top: 6px; + font-size: 10.5px; + color: var(--dashboard-text-muted); +} + +.dashboard-legend-inline i.dashboard-series-1 { background: var(--dashboard-series-1); } +.dashboard-legend-inline i.dashboard-series-2 { background: var(--dashboard-series-2); } + +.dashboard-legend-inline b { + color: var(--dashboard-text); +} + /* ---- Wireless network boxes (LuCI .ifacebox) ---- */ .dashboard-net-grid { display: grid; diff --git a/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/icons/cpu.svg b/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/icons/cpu.svg new file mode 100644 index 000000000..ca5bbad35 --- /dev/null +++ b/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/icons/cpu.svg @@ -0,0 +1 @@ + diff --git a/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/icons/load.svg b/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/icons/load.svg new file mode 100644 index 000000000..86c6b0735 --- /dev/null +++ b/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/icons/load.svg @@ -0,0 +1 @@ + diff --git a/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/icons/memory.svg b/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/icons/memory.svg new file mode 100644 index 000000000..b0c83ad95 --- /dev/null +++ b/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/icons/memory.svg @@ -0,0 +1 @@ + diff --git a/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/include/10_router.js b/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/include/10_router.js index 33fe3f86d..49150c8ae 100644 --- a/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/include/10_router.js +++ b/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/include/10_router.js @@ -5,16 +5,7 @@ 'require network'; 'require uci'; 'require view.dashboard.lib.charts as charts'; - -var callSystemBoard = rpc.declare({ - object: 'system', - method: 'board' -}); - -var callSystemInfo = rpc.declare({ - object: 'system', - method: 'info' -}); +'require view.dashboard.lib.system as system'; var callGetUnixtime = rpc.declare({ object: 'luci', @@ -26,32 +17,36 @@ return baseclass.extend({ params: [], + widgets: [ + { id: 'internet', slot: 'cards', title: _('Internet'), order: 10 }, + { id: 'uptime', slot: 'cards', title: _('Uptime'), order: 15 }, + { id: 'internet', slot: 'tabs', title: _('Internet'), order: 10 }, + { id: 'system', slot: 'tabs', title: _('System'), order: 20 } + ], + load() { return Promise.all([ network.getWANNetworks(), network.getWAN6Networks(), - L.resolveDefault(callSystemBoard(), {}), - L.resolveDefault(callSystemInfo(), {}), + system.board(), + system.info(), L.resolveDefault(callGetUnixtime(), 0), uci.load('system') ]); }, - renderValue(value, mono) { + renderValue(value) { if (Array.isArray(value)) - return E('span', {}, value.map(v => E('div', {}, [ mono ? E('code', {}, [ v ]) : v ]))); + return E('span', {}, value.map(v => E('div', {}, [ v ]))); - if (value == null || value === '') - return '-'; - - return mono ? E('code', {}, [ value ]) : value; + return (value == null || value === '') ? '-' : value; }, // Same markup as the status page's system table. renderKeyValueTable(rows) { return E('table', { 'class': 'table' }, rows.map(row => E('tr', { 'class': 'tr' }, [ E('td', { 'class': 'td left', 'width': '33%' }, [ row.title ]), - E('td', { 'class': 'td left' }, [ this.renderValue(row.value, row.mono) ]) + E('td', { 'class': 'td left' }, [ this.renderValue(row.value) ]) ]))); }, @@ -65,14 +60,10 @@ return baseclass.extend({ let value = group[key].value; - if (key == 'addrsv4' && Array.isArray(value)) + if (/^addrs/.test(key) && Array.isArray(value)) value = value.map(a => a.split('/')[0]); - rows.push({ - title: group[key].title, - value: value, - mono: /^(addrs|gateway|dns|ipprefix)/.test(key) - }); + rows.push({ title: group[key].title, value: value }); } return E('div', {}, [ @@ -95,11 +86,7 @@ return baseclass.extend({ const rows = []; for (const key in this.params.router) - rows.push({ - title: this.params.router[key].title, - value: this.params.router[key].value, - mono: (key == 'kernel' || key == 'localtime') - }); + rows.push({ title: this.params.router[key].title, value: this.params.router[key].value }); return this.renderKeyValueTable(rows); }, @@ -108,19 +95,15 @@ return baseclass.extend({ const v4 = this.params.internet.v4; const v6 = this.params.internet.v6; const connected = (v4.connected.value === true || v6.connected.value === true); - const sub = [ - charts.badge('IPv4', (v4.connected.value === true) ? 'success' : 'warning'), - charts.badge('IPv6', (v6.connected.value === true) ? 'success' : 'warning') - ]; - - if (v4.connected.value === true && Array.isArray(v4.addrsv4.value) && v4.addrsv4.value.length) - sub.push(E('code', {}, [ v4.addrsv4.value[0].split('/')[0] ])); + const address = (family, addrs) => (family.connected.value === true) + ? [ addrs.title, L.toArray(addrs.value)[0]?.split('/')[0] ].filter(part => part != null).join(' · ') : null; return charts.kpi({ icon: connected ? 'internet' : 'not-internet', title: _('Internet'), value: [ connected ? _('Connected') : _('Not connected') ], - sub: sub + sub: [ address(v4, v4.addrsv4), address(v6, v6.addrsv6) ], + stacked: true }); }, @@ -243,7 +226,7 @@ return baseclass.extend({ addrsv6: { title: _('IPv6'), - visible: false, + visible: true, value: [ '-' ] }, @@ -325,10 +308,13 @@ return baseclass.extend({ this.renderRouterBox(data); return { - kpi: [ this.renderInternetKpi(), this.renderSystemKpi() ], + cards: [ + { id: 'internet', node: () => this.renderInternetKpi() }, + { id: 'uptime', node: () => this.renderSystemKpi() } + ], tabs: [ - { id: 'internet', title: _('Internet'), content: this.renderInternetTab() }, - { id: 'system', title: _('System'), content: this.renderSystemTab() } + { id: 'internet', title: _('Internet'), content: () => this.renderInternetTab() }, + { id: 'system', title: _('System'), content: () => this.renderSystemTab() } ] }; } diff --git a/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/include/12_resources.js b/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/include/12_resources.js new file mode 100644 index 000000000..782df4f92 --- /dev/null +++ b/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/include/12_resources.js @@ -0,0 +1,205 @@ +'use strict'; +'require baseclass'; +'require view.dashboard.lib.charts as charts'; +'require view.dashboard.lib.history as history'; +'require view.dashboard.lib.system as system'; + +// The first eight fields of a /proc/stat cpu line. Guest time is already +// part of user and nice, so it must not be added again. +const CPUS = 2, MEMORY = 3; +const USER = 0, NICE = 1, SYSTEM = 2, IDLE = 3, IOWAIT = 4, IRQ = 5, SOFTIRQ = 6, STEAL = 7; + +// Shares of the time between two samples, in percent. Both are in jiffies, +// so no clock is involved. Waiting for I/O is idle time. The iowait counter +// may step back, hence the clamp. +function usage(prev, cur) { + const d = cur.map((value, i) => Math.max(0, value - (prev[i] || 0))); + const total = d.reduce((sum, value) => sum + value, 0); + + if (total <= 0) + return null; + + const share = value => 100 * value / total; + + return { + busy: share(total - d[IDLE] - d[IOWAIT]), + user: share(d[USER] + d[NICE]), + system: share(d[SYSTEM]), + iowait: share(d[IOWAIT]), + irq: share(d[IRQ]), + softirq: share(d[SOFTIRQ]), + steal: share(d[STEAL]) + }; +} + +function progressbar(percent, text) { + return E('div', { 'class': 'cbi-progressbar', 'title': text }, [ + E('div', { 'style': 'width:%.2f%%'.format(Math.max(0, Math.min(100, percent))) }) + ]); +} + +return baseclass.extend({ + widgets: [ + { id: 'cpu', slot: 'cards', title: _('CPU usage'), order: 20 }, + { id: 'memory', slot: 'cards', title: _('Memory'), order: 30 }, + { id: 'load', slot: 'cards', title: _('Load average'), order: 80, hidden: true }, + { id: 'system', slot: 'charts', title: _('System load'), order: 20 }, + { id: 'resources', slot: 'tabs', title: _('Resources'), order: 30 } + ], + + load() { + return Promise.all([ system.info(), history.load() ]); + }, + + // Per-CPU usage over the latest interval, keyed by the kernel's names so + // that a CPU going offline does not shift the others. + cpu() { + const pair = history.pair(); + const result = {}; + + if (pair == null) + return null; + + for (const name in pair[1][CPUS]) + if (pair[0][CPUS][name] != null) + result[name] = usage(pair[0][CPUS][name], pair[1][CPUS][name]); + + return (result.cpu != null) ? result : null; + }, + + memory(info) { + const mem = L.isObject(info.memory) ? info.memory : {}; + + if (!mem.total) + return null; + + // What the kernel reckons can be had without swapping. Counting + // everything but "free" as used would include the page cache. + const available = (mem.available != null) ? mem.available : (mem.free || 0) + (mem.buffered || 0) + (mem.cached || 0); + const used = Math.max(0, mem.total - available); + + return { total: mem.total, used: used, percent: 100 * used / mem.total, buffered: mem.buffered, cached: mem.cached }; + }, + + renderCpuCard(cpu, cores, load) { + return charts.kpi({ + icon: 'cpu', + title: _('CPU usage'), + value: [ cpu ? '%d%%'.format(Math.round(cpu.cpu.busy)) : '-' ], + sub: cpu ? [ N_(cores, '%d core', '%d cores').format(cores), load ? _('load %.2f').format(load[0]) : null ] + : [ E('em', {}, [ history.status() ]) ] + }); + }, + + renderMemoryCard(mem) { + return charts.kpi({ + icon: 'memory', + title: _('Memory'), + value: [ mem ? '%d%%'.format(Math.round(mem.percent)) : '-' ], + sub: [ mem ? '%1024.1mB / %1024.1mB'.format(mem.used, mem.total) : '' ] + }); + }, + + renderLoadCard(load) { + return charts.kpi({ + icon: 'load', + title: _('Load average'), + value: [ load ? '%.2f'.format(load[0]) : '-' ], + sub: load ? [ _('5 min: %.2f').format(load[1]), _('15 min: %.2f').format(load[2]) ] : [] + }); + }, + + renderChart(cores, mem) { + const busy = history.series((cur, prev) => { + const share = (prev != null && prev[CPUS].cpu != null && cur[CPUS].cpu != null) ? usage(prev[CPUS].cpu, cur[CPUS].cpu) : null; + + return share ? share.busy : null; + }); + const used = history.series(cur => (mem != null && cur[MEMORY] != null) ? 100 * (1 - cur[MEMORY] / mem.total) : null); + const last = points => points.length ? points[points.length - 1].v : null; + const percent = value => (value != null) ? '%d%%'.format(Math.round(value)) : '-'; + const desc = []; + + if (cores) + desc.push(N_(cores, '%d core', '%d cores').format(cores)); + + if (mem) + desc.push('%1024.0mB'.format(mem.total)); + + return charts.card({ + title: _('System load'), + desc: desc.join(' · '), + body: busy.length ? [ + charts.lines({ + span: history.span(), + max: 100, + ticks: [ 0, 25, 50, 75, 100 ].map(value => ({ value: value, label: '%d%%'.format(value) })), + ariaLabel: _('System load'), + series: [ { values: busy, area: true }, { values: used } ] + }), + charts.legend([ + { className: 'dashboard-series-1', label: _('CPU'), value: percent(last(busy)) }, + { className: 'dashboard-series-2', label: _('Memory'), value: percent(last(used)) } + ]) + ] : charts.empty(history.status()) + }); + }, + + renderTab(cpu, mem, load, swap) { + const rows = []; + const pending = () => E('em', {}, [ history.status() ]); + const bytes = (value, total) => progressbar(100 * value / total, '%1024.1mB / %1024.1mB (%d%%)'.format(value, total, Math.round(100 * value / total))); + + if (load) + rows.push([ _('Load average'), '%.2f, %.2f, %.2f'.format(load[0], load[1], load[2]) ]); + + const core = (title, share) => [ + E('span', { 'data-tooltip': 'user %.1f · system %.1f · iowait %.1f · irq %.1f · softirq %.1f · steal %.1f'.format( + share.user, share.system, share.iowait, share.irq, share.softirq, share.steal) }, [ title ]), + progressbar(share.busy, '%.1f%%'.format(share.busy)) + ]; + + rows.push(cpu ? core(_('CPU usage'), cpu.cpu) : [ _('CPU usage'), pending() ]); + + if (cpu && Object.keys(cpu).length > 2) + Object.keys(cpu).filter(name => name != 'cpu' && cpu[name] != null).forEach(name => { + rows.push(core(name.replace(/^cpu/, _('CPU') + ' '), cpu[name])); + }); + + if (mem) { + rows.push([ _('Memory used'), bytes(mem.used, mem.total) ]); + + if (mem.buffered) + rows.push([ _('Buffered'), bytes(mem.buffered, mem.total) ]); + + if (mem.cached) + rows.push([ _('Cached'), bytes(mem.cached, mem.total) ]); + } + + if (swap && swap.total > 0) + rows.push([ _('Swap used'), bytes(swap.total - swap.free, swap.total) ]); + + return E('table', { 'class': 'table' }, rows.map(row => E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td left', 'width': '33%' }, [ row[0] ]), + E('td', { 'class': 'td left' }, [ row[1] ]) + ]))); + }, + + render([info]) { + const cpu = this.cpu(); + const latest = history.samples[history.samples.length - 1]; + const cores = latest ? Math.max(1, Object.keys(latest[CPUS]).length - 1) : 0; + const mem = this.memory(info); + const load = Array.isArray(info.load) ? info.load.map(value => value / 65536) : null; + + return { + cards: [ + { id: 'cpu', node: () => this.renderCpuCard(cpu, cores, load) }, + { id: 'memory', node: () => this.renderMemoryCard(mem) }, + { id: 'load', node: () => this.renderLoadCard(load) } + ], + charts: [ { id: 'system', node: () => this.renderChart(cores, mem) } ], + tabs: [ { id: 'resources', title: _('Resources'), content: () => this.renderTab(cpu, mem, load, info.swap) } ] + }; + } +}); diff --git a/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/include/15_traffic.js b/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/include/15_traffic.js new file mode 100644 index 000000000..4f74e6174 --- /dev/null +++ b/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/include/15_traffic.js @@ -0,0 +1,81 @@ +'use strict'; +'require baseclass'; +'require network'; +'require view.dashboard.lib.charts as charts'; +'require view.dashboard.lib.history as history'; + +const NET = 4; + +return baseclass.extend({ + title: _('WAN traffic'), + + widgets: [ + { id: 'wan', slot: 'charts', title: _('WAN traffic'), order: 10 } + ], + + load() { + return Promise.all([ + network.getWANNetworks(), + network.getWAN6Networks(), + history.load() + ]); + }, + + uplink(wan, wan6) { + const pick = list => list.slice().sort((a, b) => a.getMetric() - b.getMetric())[0]; + + return pick(wan) || pick(wan6) || null; + }, + + // Rates come from the byte counters of two samples and the time between + // them on the router's clock. Counters that went backwards, as after the + // interface was brought up again, give no rate and leave a gap. + rates(name, index) { + return history.series((cur, prev, seconds) => { + const a = prev ? prev[NET][name] : null; + const b = cur[NET][name]; + + if (a == null || b == null || b[0] < a[0] || b[1] < a[1]) + return null; + + return (b[index] - a[index]) * 8 / seconds; + }); + }, + + render([wan, wan6]) { + const ifc = this.uplink(wan, wan6); + const dev = ifc ? ifc.getL3Device() : null; + + if (dev == null) + return { charts: [ { id: 'wan', node: charts.card({ title: this.title, body: charts.empty(_('Not connected')) }) } ] }; + + const down = this.rates(dev.getName(), 0); + const up = this.rates(dev.getName(), 1); + const last = points => points.length ? points[points.length - 1].v : null; + const scale = charts.rateScale(Math.max(1, ...down.concat(up).map(p => p.v || 0))); + const names = (ifc.getName() == dev.getName()) ? [ dev.getName() ] : [ ifc.getName(), dev.getName() ]; + + return { + charts: [ { + id: 'wan', + node: charts.card({ + title: this.title, + desc: names.concat([ scale.unit ]).join(' · '), + body: down.length ? [ + charts.lines({ + span: history.span(), + max: scale.max, + ticks: scale.ticks, + ariaLabel: this.title, + series: [ { values: down, area: true }, { values: up, area: true } ] + }), + charts.legend([ + { className: 'dashboard-series-1', label: _('Down.'), value: charts.formatRate(last(down)) }, + { className: 'dashboard-series-2', label: _('Up.'), value: charts.formatRate(last(up)) } + ]) + ] : charts.empty(history.status()) + }) + } ] + }; + } +}); 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 4892dd56a..4f3ce7542 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 @@ -15,6 +15,15 @@ return baseclass.extend({ params: {}, + widgets: [ + { id: 'dhcp', slot: 'cards', title: _('DHCP Devices'), order: 50, hidden: true }, + { id: 'dhcp', slot: 'tabs', title: _('DHCP Devices'), order: 40 } + ], + + available() { + return L.hasSystemFeature('dnsmasq') || L.hasSystemFeature('odhcpd'); + }, + load() { return Promise.all([ callLuciDHCPLeases(), @@ -37,11 +46,11 @@ return baseclass.extend({ head: [ _('Hostname'), _('IP Address'), _('MAC') ], rows: this.params.lan.devices.map(device => [ device.hostname, - E('code', {}, [ device.ipv4 ]), - E('code', {}, [ device.macaddr ]) + device.ipv4, + device.macaddr ]), emptyText: _('No active leases'), - foot: [ '', _('Total'), String(this.params.lan.devices.length) ] + foot: [ _('Total'), String(this.params.lan.devices.length) ] }); }, @@ -56,15 +65,12 @@ return baseclass.extend({ }, render([leases]) { - if (!L.hasSystemFeature('dnsmasq') && !L.hasSystemFeature('odhcpd')) - return null; - this.renderUpdateData([...leases.dhcp_leases]); return { - kpi: [ this.renderKpi() ], + cards: [ { id: 'dhcp', node: () => this.renderKpi() } ], tabs: [ - { id: 'dhcp', title: this.title, count: this.params.lan.devices.length, content: this.renderTable() } + { id: 'dhcp', title: this.title, count: this.params.lan.devices.length, content: () => this.renderTable() } ] }; } 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 2505fd36c..14f7da942 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 @@ -3,6 +3,7 @@ 'require dom'; 'require network'; 'require rpc'; +'require uci'; 'require view.dashboard.lib.charts as charts'; /* -67 dBm is the minimum RSSI Cisco specifies for voice-grade coverage; @@ -28,6 +29,19 @@ return baseclass.extend({ params: [], + widgets: [ + { id: 'wifi', slot: 'cards', title: _('Wireless clients'), order: 60, hidden: true }, + { id: 'distribution', slot: 'charts', title: _('Wireless client distribution'), order: 30, hidden: true }, + { id: 'signal', slot: 'charts', title: _('Signal Strength'), order: 40, hidden: true }, + { id: 'traffic', slot: 'charts', title: _('Wireless client data usage'), order: 50, hidden: true }, + { id: 'wifi', slot: 'tabs', title: _('Wireless'), order: 50, hidden: true } + ], + + // The "wifi" system feature only says /sbin/wifi exists. + available() { + return uci.load('wireless').then(() => uci.sections('wireless', 'wifi-device').length > 0); + }, + load() { return Promise.all([ network.getWifiDevices(), @@ -67,10 +81,10 @@ return baseclass.extend({ })); return charts.card({ - title: _('Client distribution'), + title: _('Wireless client distribution'), desc: _('by SSID'), body: devices.length - ? charts.donut({ series: series, centerLabel: _('clients'), ariaLabel: _('Client distribution') }) + ? charts.donut({ series: series, centerLabel: _('clients'), ariaLabel: _('Wireless client distribution') }) : charts.empty(_('No wireless clients connected')) }); }, @@ -142,7 +156,7 @@ return baseclass.extend({ const scale = this.byteScale(peak); return charts.card({ - title: _('Client traffic'), + title: _('Wireless client data usage'), desc: devices.length ? _('Transferred') : '', body: devices.length ? [ charts.barChart({ @@ -151,7 +165,8 @@ return baseclass.extend({ ticks: [ 0, 1, 2, 3, 4 ].map(n => ({ value: scale.step * n, label: scale.format(scale.step * n) })), items: devices.map(device => ({ label: device.hostname.value, - title: '%s · %s %s · %s %s'.format(device.hostname.value, _('Up.'), device.transferred.value.rx, _('Down.'), device.transferred.value.tx), + title: '%s · %s %s · %s %s'.format(device.hostname.value, _('Up.'), device.transferred.value.rx, _('Down.'), device.transferred.value.tx) + + (device.connected.value ? ' · %s %s'.format(_('Connected'), device.connected.value) : ''), values: [ { value: device.transferred.value.bytes.rx, className: 'dashboard-bar-up' }, { value: device.transferred.value.bytes.tx, className: 'dashboard-bar-down' } @@ -174,7 +189,7 @@ return baseclass.extend({ if (radio[key].visible) fields.push(E('div', {}, [ E('span', {}, [ radio[key].title ]), - E((key == 'bssid') ? 'code' : 'b', {}, [ radio[key].value ]) + E('b', {}, [ radio[key].value ]) ])); return E('div', { 'class': 'ifacebox' }, [ @@ -199,8 +214,9 @@ return baseclass.extend({ _('Hostname'), _('SSID'), '%s / %s'.format(_('Signal'), _('Noise floor')), - { text: _('Up.'), className: 'right' }, - { text: _('Down.'), className: 'right' } + _('Up.'), + _('Down.'), + _('Connected') ], rows: this.params.wifi.devices.map(device => [ device.hostname.value, @@ -212,11 +228,12 @@ return baseclass.extend({ ? E('small', {}, [ '/ %d %s'.format(device.signal.value.noise, _('dBm')) ]) : '' ]), - { text: device.transferred.value.rx, className: 'right' }, - { text: device.transferred.value.tx, className: 'right' } + device.transferred.value.rx, + device.transferred.value.tx, + device.connected.value || '-' ]), emptyText: _('No wireless clients connected'), - foot: [ '', _('Total'), String(this.params.wifi.devices.length), '', '' ] + foot: [ _('Total'), String(this.params.wifi.devices.length) ] }); }, @@ -326,6 +343,13 @@ return baseclass.extend({ tx: '%s'.format('%1024.2mB'.format(bss.tx.bytes)), bytes: { rx: bss.rx.bytes || 0, tx: bss.tx.bytes || 0 } } + }, + + // Connection duration is not reported by every driver. + connected: { + title: _('Connected'), + visible: true, + value: (typeof(bss.connected_time) == 'number') ? '%t'.format(bss.connected_time) : null } } ); @@ -346,10 +370,14 @@ return baseclass.extend({ return null; return { - kpi: [ this.renderKpi() ], - charts: [ this.renderDistributionChart(), this.renderSignalChart(), this.renderTrafficChart() ], + cards: [ { id: 'wifi', node: () => this.renderKpi() } ], + charts: [ + { id: 'distribution', node: () => this.renderDistributionChart() }, + { id: 'signal', node: () => this.renderSignalChart() }, + { id: 'traffic', node: () => this.renderTrafficChart() } + ], tabs: [ - { id: 'wifi', title: this.title, count: this.params.wifi.devices.length, content: this.renderTab() } + { id: 'wifi', title: this.title, count: this.params.wifi.devices.length, content: () => this.renderTab() } ] }; } 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 c567c9d60..5707919f5 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 @@ -1,25 +1,36 @@ 'use strict'; 'require view'; -'require dom'; 'require poll'; -'require fs'; 'require ui'; 'require network'; +'require view.dashboard.lib.widgets as widgets'; document.querySelector('head').appendChild(E('link', { 'rel': 'stylesheet', 'type': 'text/css', - 'href': L.resource('view/dashboard/css/custom.css') + 'href': L.resource('view/dashboard/css/custom.css') + (L.env.resource_version ? '?v=' + L.env.resource_version : '') })); -function invokeIncludesLoad(includes) { +const ROWS = { + cards: { max: 5, shortFirst: false }, + charts: { max: 3, shortFirst: true } +}; + +function invokeIncludesLoad(includes, layout) { const tasks = []; let has_load = false; for (let i = 0; i < includes.length; i++) { + const declared = includes[i].widgets || []; + includes[i].failed = false; - if (typeof(includes[i].load) == 'function') { + includes[i].skipped = (declared.length > 0 && !declared.some(widget => layout[widget.slot]?.includes(widget.id))); + + if (includes[i].skipped) { + tasks.push(null); + } + else if (typeof(includes[i].load) == 'function') { tasks.push(includes[i].load().catch(function(e) { console.error(e); this.failed = true; @@ -35,13 +46,14 @@ function invokeIncludesLoad(includes) { return has_load ? Promise.all(tasks) : Promise.resolve(null); } -function collectSections(includes, results) { - const sections = { kpi: [], charts: [], tabs: [], extra: [] }; +function collectSections(includes, results, layout) { + const sections = { cards: [], charts: [], tabs: [], extra: [] }; + const found = { cards: {}, charts: {}, tabs: {} }; for (let i = 0; i < includes.length; i++) { let content = null; - if (includes[i].failed) + if (includes[i].failed || includes[i].skipped) continue; try { @@ -49,37 +61,79 @@ function collectSections(includes, results) { content = includes[i].render(results ? results[i] : null); else if (includes[i].content != null) content = includes[i].content; + + if (content == null) + continue; + + if (content instanceof Node || Array.isArray(content)) { + sections.extra.push(content); + continue; + } + + // Items may defer building their node or tab content until selected. + // Includes without widget declarations keep their original behavior. + for (const slot of widgets.slots) + (content[slot] || (slot == 'cards' ? content.kpi : null) || []).forEach(item => { + if (item instanceof Node) + sections[slot].push(item); + else if (includes[i].widgets == null) + sections[slot].push((slot == 'tabs') ? item : item.node); + else if (layout[slot].includes(item.id)) { + const value = (slot == 'tabs') ? item.content : item.node; + const node = (typeof(value) == 'function') ? value() : value; + + found[slot][item.id] = (slot == 'tabs') ? Object.assign({}, item, { content: node }) : node; + } + }); } catch (e) { console.error(e); continue; } - - if (content == null) - continue; - - if (content instanceof Node || Array.isArray(content)) { - sections.extra.push(content); - continue; - } - - for (const key of [ 'kpi', 'charts', 'tabs' ]) - (content[key] || []).forEach(item => sections[key].push(item)); } + for (const slot of widgets.slots) + sections[slot] = layout[slot].map(id => found[slot][id]).filter(item => item != null).concat(sections[slot]); + return sections; } -function renderSections(sections) { +// Split n items into rows of at most max items, as evenly as possible. +function rowSizes(n, max, shortFirst) { + const rows = Math.ceil(n / max); + const base = Math.floor(n / rows); + const extra = n % rows; + const sizes = []; + + for (let i = 0; i < rows; i++) + sizes.push(base + ((shortFirst ? i >= rows - extra : i < extra) ? 1 : 0)); + + return sizes; +} + +function fillRows(nodes, slot) { + let i = 0; + + rowSizes(nodes.length, ROWS[slot].max, ROWS[slot].shortFirst).forEach(size => { + for (let n = 0; n < size; n++) + nodes[i++].style.setProperty('--dashboard-per-row', size); + }); + + return nodes; +} + +function renderSections(sections, keep) { const nodes = []; - if (sections.kpi.length) - nodes.push(E('div', { 'class': 'dashboard-kpi-row' }, sections.kpi)); + if (sections.cards.length) + nodes.push(E('div', { 'class': 'dashboard-kpi-row' }, fillRows(sections.cards, 'cards'))); if (sections.charts.length) - nodes.push(E('div', { 'class': 'dashboard-charts' }, sections.charts)); + nodes.push(E('div', { 'class': 'dashboard-charts' }, fillRows(sections.charts, 'charts'))); - if (sections.tabs.length) + 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', { 'class': 'cbi-section', @@ -112,18 +166,48 @@ function restoreChartScroll(root, positions) { }); } -function startPolling(includes, root) { +function startPolling(includes, layout, root, form) { + let loading = null; + + const showForm = () => { + loading ??= L.require('view.dashboard.lib.layout') + .then(layoutForm => layoutForm.render(includes)) + .then(node => L.dom.content(form, node)); + }; + const step = () => { return network.flushCache().then(() => { - return invokeIncludesLoad(includes); + return invokeIncludesLoad(includes, layout); }).then(results => { const positions = saveChartScroll(root); - dom.content(root, renderSections(collectSections(includes, results))); + const sections = collectSections(includes, results, layout); + + if (form) + sections.tabs.push({ id: 'layout', title: _('Layout'), content: form }); + + // 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; + + Array.from(root.childNodes).filter(node => node !== keep).forEach(node => root.removeChild(node)); + + renderSections(sections, keep).forEach(node => { + if (node === keep) + before = null; + else + root.insertBefore(node, before); + }); + + const group = keep ? null : root.querySelector('.cbi-map-tabbed'); + if (group) { + if (form) + form.parentNode.addEventListener('cbi-tab-active', showForm); - const group = root.querySelector('.cbi-map-tabbed'); - if (group) ui.tabs.initTabGroup(group.childNodes); + } restoreChartScroll(root, positions); root.classList.add('fade-in'); @@ -132,28 +216,34 @@ function startPolling(includes, root) { return step().then(() => { poll.add(step); + + // Only resume what was stopped here, not a poll the user has paused. + let paused = false; + + document.addEventListener('visibilitychange', () => { + if (document.hidden) + paused = poll.stop(); + else if (paused) + paused = !poll.start(); + }); }); } return view.extend({ load() { - return L.resolveDefault(fs.list('/www' + L.resource('view/dashboard/include')), []).then(entries => { - return Promise.all(entries.filter(e => { - return (e.type == 'file' && e.name.match(/\.js$/)); - }).map(e => { - return 'view.dashboard.include.' + e.name.replace(/\.js$/, ''); - }).sort().map(n => { - return L.require(n); - })); + return widgets.load().then(includes => { + return widgets.layout(includes).then(layout => [ includes, layout ]); }); }, - render(includes) { + render([ includes, layout ]) { const root = E('div', { 'class': 'Dashboard' }); - return startPolling(includes, root).then(() => { - return root; - }); + document.addEventListener('uci-applied', () => window.location.reload()); + + const form = L.hasViewPermission() ? E('div', {}, [ E('em', { 'class': 'spinning' }, [ _('Loading view…') ]) ]) : null; + + return startPolling(includes, layout, root, form).then(() => root); }, handleSaveApply: null, 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 19ddc921b..e8c5b191f 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 @@ -21,7 +21,44 @@ function scale(value, min, max) { return Math.max(0, Math.min(100, (value - min) / (max - min) * 100)); } +// Data rates take decimal prefixes, as link speeds and ISP plans do. +const RATE_UNITS = [ _('bit/s'), _('Kbit/s'), _('Mbit/s'), _('Gbit/s') ]; + +function rateUnit(value) { + let exp = 0; + + while (value >= 1000 && exp < RATE_UNITS.length - 1) { + value /= 1000; + exp++; + } + + return exp; +} + return baseclass.extend({ + formatRate(value) { + if (value == null) + return '-'; + + const exp = rateUnit(value); + + return '%.1f %s'.format(value / Math.pow(1000, exp), RATE_UNITS[exp]); + }, + + rateScale(peak) { + const exp = rateUnit(peak); + const unit = Math.pow(1000, exp); + const raw = Math.max(peak / unit, 1e-9) / 4; + const magnitude = Math.pow(10, Math.floor(Math.log10(raw))); + const step = [ 1, 2, 2.5, 5, 10 ].map(n => n * magnitude).find(candidate => candidate >= raw); + + return { + max: step * 4 * unit, + unit: RATE_UNITS[exp], + ticks: [ 0, 1, 2, 3, 4 ].map(n => ({ value: step * n * unit, label: String(+(step * n).toFixed(2)) })) + }; + }, + icon(name, className) { return E('span', { 'class': 'dashboard-icon ' + (className || ''), @@ -42,13 +79,17 @@ return baseclass.extend({ }, kpi(opts) { + const parts = (opts.sub || []).filter(part => part != null && part !== ''); + const sub = opts.stacked + ? parts.map(part => E('small', { 'class': 'dashboard-kpi-sub dashboard-kpi-line', 'title': part }, [ part ])) + : [ E('small', { 'class': 'dashboard-kpi-sub' }, parts.flatMap((part, i) => i ? [ ' · ', part ] : [ part ])) ]; + return E('div', { 'class': 'cbi-section dashboard-kpi ' + (opts.className || '') }, [ this.icon(opts.icon, 'dashboard-kpi-icon'), E('div', { 'class': 'dashboard-kpi-text' }, [ E('small', {}, [ opts.title ]), - E('strong', { 'class': 'dashboard-kpi-value' }, opts.value), - E('small', { 'class': 'dashboard-kpi-sub' }, opts.sub) - ]) + E('strong', { 'class': 'dashboard-kpi-value' }, opts.value) + ].concat(sub)) ]); }, @@ -56,26 +97,16 @@ return baseclass.extend({ return E('em', {}, [ text ]); }, - // A cell is a string, a DOM node or a descriptor `{ text, className }`. - cell(tag, cell, title) { - const descr = (cell != null && typeof(cell) == 'object' && !(cell instanceof Node)) ? cell : { text: cell }; - - return E(tag, { - 'class': tag + (descr.className ? ' ' + descr.className : ''), - 'data-title': (typeof(title) == 'string' && title !== '') ? title : null - }, [ (descr.text != null) ? descr.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) { - const titles = opts.head.map(cell => (cell != null && typeof(cell) == 'object' && !(cell instanceof Node)) ? cell.text : cell); + 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(cell => this.cell('th', cell))) + 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((cell, n) => this.cell('td', cell, titles[n])))); + table.appendChild(E('tr', { 'class': 'tr ' + (i % 2 ? 'cbi-rowstyle-2' : 'cbi-rowstyle-1') }, row.map((content, n) => cell('td', content, opts.head[n])))); }); if (!opts.rows.length && opts.emptyText) @@ -83,8 +114,12 @@ return baseclass.extend({ E('td', { 'class': 'td', 'colspan': opts.head.length }, [ E('em', {}, [ opts.emptyText ]) ]) ])); - if (opts.foot) - table.appendChild(E('tr', { 'class': 'tr' }, opts.foot.map(cell => this.cell('td', cell)))); + 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); + } return table; }, @@ -150,8 +185,59 @@ return baseclass.extend({ ]); }, + // A time series of points `{ t, v }`, placed by their time: the x axis + // spans `span` seconds up to the last point. A null `v` leaves a gap, and + // a point with gaps on both sides is drawn as a dot. + lines(opts) { + const max = opts.max || 1; + const shapes = []; + const end = opts.series.reduce((t, series) => Math.max(t, ...series.values.map(p => p.t)), 0); + + opts.series.forEach((series, n) => { + let run = []; + + series.values.concat([ { v: null } ]).forEach(p => { + if (p.v != null) { + run.push([ 100 - (end - p.t) / (opts.span * 10), 100 - scale(p.v, 0, max) ]); + return; + } + + if (run.length) { + if (run.length == 1) + run.push(run[0]); + + const points = run.map(p => '%.2f,%.2f'.format(p[0], p[1])).join(' '); + + if (series.area) + shapes.push(svg('polygon', { + 'class': 'dashboard-series-' + (n + 1), + 'points': '%.2f,100 %s %.2f,100'.format(run[0][0], points, run[run.length - 1][0]) + })); + + shapes.push(svg('polyline', { 'class': 'dashboard-series-' + (n + 1), 'points': points })); + } + + run = []; + }); + }); + + return E('div', { 'class': 'dashboard-bars' }, [ + E('div', { 'class': 'dashboard-bars-axis' }, opts.ticks.map(tick => + E('span', { 'style': 'top:%.2f%%'.format(100 - scale(tick.value, 0, max)) }, [ tick.label ]))), + E('div', { 'class': 'dashboard-lines-plot' }, [ + E('div', { 'class': 'dashboard-bars-grid' }, opts.ticks.map(tick => + E('i', { 'style': 'top:%.2f%%'.format(100 - scale(tick.value, 0, max)) }))), + svg('svg', { 'viewBox': '0 0 100 100', 'preserveAspectRatio': 'none', 'role': 'img', 'aria-label': opts.ariaLabel || '' }, shapes) + ]), + E('div', { 'class': 'dashboard-lines-time' }, [ + E('span', {}, [ (opts.span >= 60) ? _('%d min ago').format(Math.round(opts.span / 60)) : _('%d s ago').format(opts.span) ]), + E('span', {}, [ _('now') ]) + ]) + ]); + }, + legend(items) { return E('div', { 'class': 'dashboard-legend-inline' }, items.map(item => - E('span', {}, [ E('i', { 'class': item.className }), item.label ]))); + E('span', {}, [ E('i', { 'class': item.className }), item.label, (item.value != null) ? E('b', {}, [ item.value ]) : '' ]))); } }); diff --git a/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/lib/history.js b/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/lib/history.js new file mode 100644 index 000000000..a198dbc02 --- /dev/null +++ b/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/lib/history.js @@ -0,0 +1,87 @@ +'use strict'; +'require baseclass'; +'require rpc'; + +const callHistory = rpc.declare({ + object: 'luci.dashboard', + method: 'history', + params: [ 'since', 'id' ] +}); + +const SEQ = 0, TIME = 1; + +return baseclass.extend({ + samples: [], + step: 5000, + slots: 60, + + fetch() { + const last = this.samples[this.samples.length - 1]; + + return L.resolveDefault(callHistory(last ? last[SEQ] : 0, this.id || ''), null).then(res => { + this.failed = (!L.isObject(res) || !Array.isArray(res.samples)); + + if (this.failed) { + // Old samples must not appear as current CPU usage or live traffic. + this.samples = []; + return; + } + + if (res.id != this.id) + this.samples = []; + + this.id = res.id; + this.step = res.step; + this.slots = res.slots; + this.samples = this.samples.concat(res.samples).filter(sample => sample[TIME] > res.now - res.step * res.slots); + }); + }, + + load() { + const now = performance.now(); + + if (this.pending == null || now - this.asked > 1000) { + this.asked = now; + this.pending = this.fetch(); + } + + return this.pending; + }, + + status() { + return this.failed ? _('No data received') : _('Collecting data...'); + }, + + span() { + return this.slots * this.step / 1000; + }, + + near(prev, cur) { + return (prev != null && cur[TIME] - prev[TIME] <= 2.5 * this.step); + }, + + pair() { + const cur = this.samples[this.samples.length - 1]; + const prev = this.samples[this.samples.length - 2]; + + return this.near(prev, cur) ? [ prev, cur ] : null; + }, + + // Points `{ t, v }` for a chart. `fn(cur, prev, seconds)` gives a value or + // null; `prev` is null where sampling had paused, which leaves a gap. + series(fn) { + const points = []; + + this.samples.forEach((cur, i) => { + const prev = this.samples[i - 1]; + const near = this.near(prev, cur); + + if (prev != null && !near) + points.push({ t: cur[TIME] - 1, v: null }); + + points.push({ t: cur[TIME], v: fn(cur, near ? prev : null, near ? (cur[TIME] - prev[TIME]) / 1000 : 0) }); + }); + + return points; + } +}); diff --git a/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/lib/layout.js b/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/lib/layout.js new file mode 100644 index 000000000..a84b42a21 --- /dev/null +++ b/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/lib/layout.js @@ -0,0 +1,65 @@ +'use strict'; +'require baseclass'; +'require form'; +'require uci'; +'require ui'; +'require view.dashboard.lib.widgets as widgets'; + +return baseclass.extend({ + render(includes) { + const slots = { + cards: _('Status cards'), + charts: _('Live charts'), + tabs: _('Detail tabs') + }; + + const m = new form.Map('dashboard'); + const s = m.section(form.NamedSection, 'layout', 'dashboard', null, + _('The lists follow the page from top to bottom. What is in a list is shown, in that order: drag an entry to move it. An empty list shows the defaults.')); + + widgets.slots.forEach(slot => { + const choices = widgets.list(includes, slot); + + if (!choices.length) + return; + + const o = s.option(form.DynamicList, slot, slots[slot]); + + choices.forEach(widget => o.value(widget.id, widget.title)); + + // Show the defaults while the slot is not configured, without + // writing them. + o.cfgvalue = () => { + const known = choices.map(widget => widget.id); + const ids = L.toArray(uci.get('dashboard', 'layout', slot)).filter(id => known.includes(id)); + + return ids.length ? ids : widgets.defaults(includes, slot); + }; + + // The list hides a choice once it is picked, but not the ones it + // starts out with, and always offers a custom value. + o.renderWidget = function(section_id, option_index, cfgvalue) { + const node = form.DynamicList.prototype.renderWidget.apply(this, arguments); + const shown = L.toArray(cfgvalue); + + node.querySelectorAll('.cbi-dropdown ul > li[data-value]').forEach(li => { + if (shown.includes(li.getAttribute('data-value'))) + li.setAttribute('unselectable', ''); + }); + node.querySelector('.create-item-input').parentNode.remove(); + + return node; + }; + }); + + return m.render().then(node => E('div', {}, [ + node, + E('div', { 'class': 'cbi-page-actions' }, [ + E('button', { + 'class': 'cbi-button cbi-button-apply', + 'click': ui.createHandlerFn(this, () => m.save().then(() => ui.changes.apply(true))) + }, [ _('Save & Apply') ]) + ]) + ])); + } +}); diff --git a/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/lib/system.js b/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/lib/system.js new file mode 100644 index 000000000..c62c0cf46 --- /dev/null +++ b/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/lib/system.js @@ -0,0 +1,38 @@ +'use strict'; +'require baseclass'; +'require rpc'; + +const callSystemBoard = rpc.declare({ + object: 'system', + method: 'board' +}); + +const callSystemInfo = rpc.declare({ + object: 'system', + method: 'info' +}); + +return baseclass.extend({ + info() { + const now = performance.now(); + + if (this.pending == null || now - this.asked > 1000) { + this.asked = now; + this.pending = L.resolveDefault(callSystemInfo(), {}); + } + + return this.pending; + }, + + // The board does not change while the page is open. + board() { + if (this.boardinfo == null) + this.boardinfo = callSystemBoard().catch(() => { + this.boardinfo = null; + + return {}; + }); + + return this.boardinfo; + } +}); diff --git a/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/lib/widgets.js b/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/lib/widgets.js new file mode 100644 index 000000000..9a8b9c461 --- /dev/null +++ b/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/lib/widgets.js @@ -0,0 +1,60 @@ +'use strict'; +'require baseclass'; +'require fs'; +'require uci'; + +const SLOTS = [ 'cards', 'charts', 'tabs' ]; + +return baseclass.extend({ + slots: SLOTS, + + load() { + return L.resolveDefault(fs.list('/www' + L.resource('view/dashboard/include')), []).then(entries => { + return Promise.all(entries.filter(e => { + return (e.type == 'file' && e.name.match(/\.js$/)); + }).map(e => { + return 'view.dashboard.include.' + e.name.replace(/\.js$/, ''); + }).sort().map(n => { + return L.require(n); + })); + }).then(includes => { + return Promise.all(includes.map(include => new Promise(resolve => { + resolve((typeof(include.available) == 'function') ? include.available() : true); + }).catch(e => { + console.error(e); + return false; + }))).then(flags => includes.filter((include, i) => flags[i])); + }); + }, + + list(includes, slot) { + return includes.reduce((list, include) => list.concat(include.widgets || []), []) + .filter(widget => widget.slot == slot) + .sort((a, b) => (a.order || 0) - (b.order || 0)); + }, + + defaults(includes, slot) { + return this.list(includes, slot).filter(widget => !widget.hidden).map(widget => widget.id); + }, + + // The ids to show in each slot, in display order. A slot that is not + // configured shows its defaults: UCI cannot store an empty list, so + // emptying one brings them back. Ids this device does not know, from + // a restored config or typed by hand, are ignored. + layout(includes) { + const layout = {}; + + return L.resolveDefault(uci.load('dashboard')).then(() => { + SLOTS.forEach(slot => { + const known = this.list(includes, slot).map(widget => widget.id); + const ids = L.toArray(uci.get('dashboard', 'layout', slot)).filter((id, i, list) => { + return known.includes(id) && list.indexOf(id) == i; + }); + + layout[slot] = ids.length ? ids : this.defaults(includes, slot); + }); + + return layout; + }); + } +}); diff --git a/luci-mod-dashboard/root/etc/config/dashboard b/luci-mod-dashboard/root/etc/config/dashboard new file mode 100644 index 000000000..2e096985d --- /dev/null +++ b/luci-mod-dashboard/root/etc/config/dashboard @@ -0,0 +1 @@ +config dashboard 'layout' diff --git a/luci-mod-dashboard/root/usr/share/rpcd/acl.d/luci-mod-dashboard.json b/luci-mod-dashboard/root/usr/share/rpcd/acl.d/luci-mod-dashboard.json index 023122a3c..db827b3c7 100644 --- a/luci-mod-dashboard/root/usr/share/rpcd/acl.d/luci-mod-dashboard.json +++ b/luci-mod-dashboard/root/usr/share/rpcd/acl.d/luci-mod-dashboard.json @@ -17,8 +17,13 @@ "ubus": { "file": [ "list", "read" ], "luci": [ "getUnixtime" ], + "luci.dashboard": [ "history" ], "system": [ "board", "info" ] - } + }, + "uci": [ "dashboard" ] + }, + "write": { + "uci": [ "dashboard" ] } }, diff --git a/luci-mod-dashboard/root/usr/share/rpcd/ucode/luci.dashboard b/luci-mod-dashboard/root/usr/share/rpcd/ucode/luci.dashboard new file mode 100644 index 000000000..d87679d9b --- /dev/null +++ b/luci-mod-dashboard/root/usr/share/rpcd/ucode/luci.dashboard @@ -0,0 +1,80 @@ +'use strict'; + +import { readfile } from 'fs'; +import * as uloop from 'uloop'; + +const STEP = 5000, SLOTS = 60; + +let ring = [], seq = 0, timer; + +function now() { + const c = clock(true); + + return c[0] * 1000 + int(c[1] / 1000000); +} + +// Tells the instances apart, as the sequence starts over with rpcd. +const id = trim(readfile('/proc/sys/kernel/random/uuid')); + +// Counters as the kernel has them. Whoever draws them takes the differences, +// over the time between two samples on the clock they were read by. +function sample() { + const t = now(), cpus = {}, net = {}; + + for (let line in split(readfile('/proc/stat') ?? '', '\n')) { + if (substr(line, 0, 3) != 'cpu') + break; + + const fields = split(line, /\s+/); + + cpus[fields[0]] = map(slice(fields, 1, 9), v => +v); + } + + const mem = match(readfile('/proc/meminfo', 256) ?? '', /MemAvailable:\s+(\d+)/); + + for (let line in slice(split(readfile('/proc/net/dev') ?? '', '\n'), 2)) { + const fields = split(trim(line), /[: ]+/); + + if (length(fields) > 9 && fields[0] != 'lo') + net[fields[0]] = [ +fields[1], +fields[9] ]; + } + + push(ring, [ ++seq, t, cpus, mem ? +mem[1] * 1024 : null, net ]); + + while (length(ring) > SLOTS) + shift(ring); +} + +// Sampling never stops, so the charts are full whenever somebody looks. +function tick() { + timer.set(STEP); + + // An exception here would end rpcd's uloop, not just this timer. + try { + sample(); + } + catch (e) { + warn(`luci.dashboard: ${e}\n`); + } +} + +timer = uloop.timer(0, tick); + +const methods = { + history: { + args: { since: 0, id: 'id' }, + call: function(request) { + const since = (request.args?.id == id) ? +(request.args?.since ?? 0) : 0; + + return { + id, + now: now(), + step: STEP, + slots: SLOTS, + samples: filter(ring, entry => entry[0] > since) + }; + } + } +}; + +return { 'luci.dashboard': methods };