mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-30 20:15:13 +08:00
🛸 Sync 2026-09-22 19:25:09
This commit is contained in:
1 parent
8197f31b41
commit
402920f93d
19 files changed
+1022
-139
No files matched your search
@@ -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 |
+28
-42
@@ -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() }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
+205
@@ -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 };
|
||||
Reference in new issue
Block a user