🛸 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,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() }
]
};
}