🛸 Sync 2026-09-22 19:25:09

This commit is contained in:
github-actions[bot] committed 2026-09-22 19:25:09 +08:00
1 parent 8197f31b41
commit 402920f93d
19 files changed
+1022 -139

No files matched your search

+5 -1
View File
@@ -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
@@ -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;
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="6" width="12" height="12" rx="2"/><rect x="9.5" y="9.5" width="5" height="5" rx=".5"/><path d="M9 3v3M12 3v3M15 3v3M9 18v3M12 18v3M15 18v3M3 9h3M3 12h3M3 15h3M18 9h3M18 12h3M18 15h3"/></svg>

After

Width:  |  Height:  |  Size: 358 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h4l2.5-6 4 12 2.5-6h5"/></svg>

After

Width:  |  Height:  |  Size: 197 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="2.5" y="7" width="19" height="9" rx="1.5"/><path d="M6.5 10v3M10 10v3M14 10v3M17.5 10v3M5 16v2.5M9 16v2.5M15 16v2.5M19 16v2.5"/></svg>

After

Width:  |  Height:  |  Size: 296 B

@@ -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() }
]
};
}
@@ -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) } ]
};
}
});
@@ -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())
})
} ]
};
}
});
@@ -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() }
]
};
}
@@ -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() }
]
};
}
@@ -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,
@@ -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 ]) : '' ])));
}
});
@@ -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;
}
});
@@ -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') ])
])
]));
}
});
@@ -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;
}
});
@@ -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;
});
}
});
@@ -0,0 +1 @@
config dashboard 'layout'
@@ -17,8 +17,13 @@
"ubus": {
"file": [ "list", "read" ],
"luci": [ "getUnixtime" ],
"luci.dashboard": [ "history" ],
"system": [ "board", "info" ]
}
},
"uci": [ "dashboard" ]
},
"write": {
"uci": [ "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 };