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 };