💐 Sync 2026-09-14 23:49:49

This commit is contained in:
github-actions[bot] committed 2026-09-14 23:49:49 +08:00
1 parent 06a2e88aee
commit 2bab511fb4
60 files changed
+11427 -1

No files matched your search

@@ -0,0 +1,450 @@
/**
* Dashboard styles. Cards, tabs, tables and badges are standard LuCI
* elements styled by the theme; this file only lays out the grids and
* draws the charts. Colours map to the LuCI theme variables where a theme
* defines them and fall back to a self-contained light / dark palette.
*/
.Dashboard {
--dashboard-surface-alt: var(--background-color-medium, #f2f4f7);
--dashboard-text: var(--text-color-high, #1f2933);
--dashboard-text-muted: var(--text-color-low, #6b7280);
--dashboard-border: var(--border-color-medium, rgba(0, 0, 0, 0.12));
--dashboard-primary: var(--primary-color-high, #1976d2);
--dashboard-success: var(--success-color-high, #00ac59);
--dashboard-warn: var(--warn-color-high, #e0a800);
--dashboard-series-1: var(--dashboard-primary);
--dashboard-series-2: var(--dashboard-success);
--dashboard-series-3: var(--dashboard-warn);
--dashboard-series-4: #8e5bd8;
--dashboard-series-5: #ef6c2f;
--dashboard-series-6: #19b5c8;
--dashboard-series-7: #b0367c;
--dashboard-series-8: #9c9a1c;
--dashboard-gap: 16px;
display: grid;
/* minmax(0, …): a wide tab menu must scroll, not widen every row. */
grid-template-columns: minmax(0, 1fr);
gap: var(--dashboard-gap);
color: var(--dashboard-text);
}
[data-darkmode="true"] .Dashboard {
--dashboard-surface-alt: var(--background-color-medium, #262b34);
--dashboard-text: var(--text-color-high, #e6e8eb);
--dashboard-text-muted: var(--text-color-low, #9aa3ad);
--dashboard-border: var(--border-color-medium, rgba(255, 255, 255, 0.12));
--dashboard-series-4: #a98cf0;
--dashboard-series-5: #e5652c;
--dashboard-series-6: #10a4b8;
--dashboard-series-7: #c2438a;
--dashboard-series-8: #a89f2a;
}
@media (prefers-color-scheme: dark) {
:root:not([data-darkmode="false"]) .Dashboard {
--dashboard-surface-alt: var(--background-color-medium, #262b34);
--dashboard-text: var(--text-color-high, #e6e8eb);
--dashboard-text-muted: var(--text-color-low, #9aa3ad);
--dashboard-border: var(--border-color-medium, rgba(255, 255, 255, 0.12));
--dashboard-series-4: #a98cf0;
--dashboard-series-5: #e5652c;
--dashboard-series-6: #10a4b8;
--dashboard-series-7: #c2438a;
--dashboard-series-8: #a89f2a;
}
}
/* ---- Layout ---- */
.dashboard-kpi-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: var(--dashboard-gap);
}
.dashboard-charts {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: var(--dashboard-gap);
align-items: stretch;
}
/* Grid gaps space the cards, not the theme's section margin. */
.dashboard-kpi-row > .cbi-section,
.dashboard-charts > .cbi-section {
margin: 0;
}
/* ---- Icon (mask keeps the SVG files, colour follows the theme) ---- */
.dashboard-icon {
display: inline-block;
width: 1em;
height: 1em;
background: currentColor;
-webkit-mask: var(--dashboard-icon) center / contain no-repeat;
mask: var(--dashboard-icon) center / contain no-repeat;
}
/* ---- KPI card ---- */
.Dashboard .dashboard-kpi {
display: flex;
align-items: center;
gap: 14px;
}
.dashboard-kpi-icon {
flex-shrink: 0;
width: 56px;
height: 56px;
border-radius: 14px;
background: var(--dashboard-surface-alt);
color: var(--dashboard-primary);
position: relative;
}
.dashboard-kpi-icon::after {
content: "";
position: absolute;
inset: 13px;
background: currentColor;
-webkit-mask: var(--dashboard-icon) center / contain no-repeat;
mask: var(--dashboard-icon) center / contain no-repeat;
}
.dashboard-kpi-icon.dashboard-icon {
-webkit-mask: none;
mask: none;
}
.dashboard-kpi-text {
min-width: 0;
}
.dashboard-kpi-value {
display: block;
font-size: 22px;
line-height: 1.15;
margin: 2px 0;
}
.dashboard-kpi-sub {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
/* ---- Donut ---- */
.dashboard-donut {
display: flex;
align-items: center;
gap: 20px;
}
.dashboard-donut svg {
width: 136px;
height: 136px;
flex-shrink: 0;
}
.dashboard-donut-track {
fill: none;
stroke: var(--dashboard-surface-alt);
stroke-width: 16;
}
.dashboard-donut-seg {
fill: none;
stroke: var(--dashboard-text-muted);
stroke-width: 16;
}
.dashboard-donut-value {
font-size: 22px;
font-weight: 700;
fill: var(--dashboard-text);
text-anchor: middle;
}
.dashboard-donut-label {
font-size: 9px;
fill: var(--dashboard-text-muted);
text-anchor: middle;
}
.Dashboard .dashboard-series-1 { stroke: var(--dashboard-series-1); background: var(--dashboard-series-1); }
.Dashboard .dashboard-series-2 { stroke: var(--dashboard-series-2); background: var(--dashboard-series-2); }
.Dashboard .dashboard-series-3 { stroke: var(--dashboard-series-3); background: var(--dashboard-series-3); }
.Dashboard .dashboard-series-4 { stroke: var(--dashboard-series-4); background: var(--dashboard-series-4); }
.Dashboard .dashboard-series-5 { stroke: var(--dashboard-series-5); background: var(--dashboard-series-5); }
.Dashboard .dashboard-series-6 { stroke: var(--dashboard-series-6); background: var(--dashboard-series-6); }
.Dashboard .dashboard-series-7 { stroke: var(--dashboard-series-7); background: var(--dashboard-series-7); }
.Dashboard .dashboard-series-8 { stroke: var(--dashboard-series-8); background: var(--dashboard-series-8); }
.Dashboard .dashboard-legend {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 8px;
flex: 1;
min-width: 0;
}
.Dashboard .dashboard-legend li {
display: grid;
grid-template-columns: 10px minmax(0, 1fr) auto auto;
gap: 8px;
align-items: start;
font-size: 12.5px;
line-height: 1.4;
}
.dashboard-legend i {
width: 10px;
height: 10px;
border-radius: 3px;
background: var(--dashboard-text-muted);
margin-top: calc((1.4em - 10px) / 2);
}
.dashboard-legend-name {
overflow-wrap: anywhere;
}
.dashboard-legend-name small {
display: block;
font-size: 11px;
}
.dashboard-legend li > small {
color: var(--dashboard-text-muted);
min-width: 3ch;
text-align: right;
font-size: 12px;
}
.dashboard-legend-inline {
display: flex;
flex-wrap: wrap;
gap: 6px 14px;
margin-top: 10px;
font-size: 11.5px;
color: var(--dashboard-text-muted);
}
.dashboard-legend-inline span {
display: inline-flex;
align-items: center;
gap: 6px;
}
.dashboard-legend-inline i {
width: 8px;
height: 8px;
border-radius: 2px;
}
/* ---- Bar chart (fixed axis, scrollable plot) ---- */
.dashboard-bars {
--dashboard-plot-h: 170px;
--dashboard-label-h: 40px;
--dashboard-slot: 56px;
display: grid;
grid-template-columns: 44px minmax(0, 1fr);
}
.dashboard-bars-many {
--dashboard-label-h: 58px;
}
.dashboard-bars-axis {
position: relative;
height: var(--dashboard-plot-h);
font-size: 10.5px;
color: var(--dashboard-text-muted);
}
.dashboard-bars-axis span {
position: absolute;
right: 8px;
transform: translateY(-50%);
white-space: nowrap;
}
.dashboard-bars-scroll {
overflow-x: auto;
overflow-y: hidden;
padding-bottom: 2px;
scrollbar-width: thin;
}
.dashboard-bars-plot {
position: relative;
display: flex;
align-items: stretch;
height: calc(var(--dashboard-plot-h) + var(--dashboard-label-h));
min-width: 100%;
}
.dashboard-bars-grid {
position: absolute;
top: 0;
left: 0;
right: 0;
height: var(--dashboard-plot-h);
pointer-events: none;
}
.dashboard-bars-grid i {
position: absolute;
left: 0;
right: 0;
border-top: 1px solid var(--dashboard-border);
}
.dashboard-bars-col {
flex: 1 0 var(--dashboard-slot);
display: grid;
grid-template-rows: var(--dashboard-plot-h) var(--dashboard-label-h);
min-width: 0;
}
.dashboard-bars-col:hover .dashboard-bars-stack i {
filter: brightness(0.9);
}
.dashboard-bars-stack {
display: flex;
align-items: flex-end;
justify-content: center;
gap: 3px;
padding: 0 6px;
height: var(--dashboard-plot-h);
}
.dashboard-bars-stack i {
display: block;
flex: 1 1 0;
max-width: 26px;
min-height: 2px;
border-radius: 4px 4px 0 0;
background: var(--dashboard-series-1);
}
.dashboard-bars-stack i.dashboard-bar-ok { background: var(--dashboard-success); }
.dashboard-bars-stack i.dashboard-bar-mid { background: var(--dashboard-warn); }
.dashboard-bars-stack i.dashboard-bar-up { background: var(--dashboard-series-1); }
.dashboard-bars-stack i.dashboard-bar-down { background: var(--dashboard-series-2); }
.dashboard-bars-stack i.dashboard-bar-none { background: var(--dashboard-text-muted); }
.dashboard-bars-label {
font-size: 10.5px;
color: var(--dashboard-text-muted);
text-align: center;
padding: 6px 2px 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dashboard-bars-many .dashboard-bars-label {
transform: rotate(-40deg) translate(-8px, 10px);
transform-origin: top right;
text-align: right;
overflow: visible;
max-width: var(--dashboard-slot);
}
.dashboard-legend-inline i.dashboard-bar-ok { background: var(--dashboard-success); }
.dashboard-legend-inline i.dashboard-bar-mid { background: var(--dashboard-warn); }
.dashboard-legend-inline i.dashboard-bar-up { background: var(--dashboard-series-1); }
.dashboard-legend-inline i.dashboard-bar-down { background: var(--dashboard-series-2); }
/* ---- Wireless network boxes (LuCI .ifacebox) ---- */
.dashboard-net-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 12px;
margin-bottom: 18px;
}
.Dashboard .ifacebox {
margin: 0;
}
.Dashboard .ifacebox-body {
display: grid;
gap: 10px;
padding: 12px 14px;
text-align: left;
white-space: normal;
}
.dashboard-net-count b {
display: block;
font-size: 24px;
font-weight: 700;
line-height: 1;
letter-spacing: -0.02em;
}
.dashboard-net-count small {
display: block;
margin-top: 2px;
font-size: 11.5px;
color: var(--dashboard-text-muted);
}
.dashboard-net-fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px 14px;
overflow-wrap: anywhere;
font-size: 12.5px;
}
.dashboard-net-fields span {
display: block;
font-size: 11.5px;
color: var(--dashboard-text-muted);
}
.dashboard-net-note {
font-size: 12.5px;
color: var(--dashboard-text-muted);
}
/* ---- Responsive ---- */
@media screen and (max-width: 760px) {
.dashboard-donut {
flex-direction: column;
align-items: stretch;
}
.dashboard-donut svg {
margin: 0 auto;
}
}
@media screen and (max-width: 640px) {
.Dashboard {
--dashboard-gap: 12px;
}
.dashboard-kpi-icon {
width: 46px;
height: 46px;
border-radius: 12px;
}
.dashboard-kpi-icon::after {
inset: 11px;
}
.dashboard-bars {
--dashboard-plot-h: 140px;
}
}
@@ -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="8" y="3" width="8" height="6" rx="1"/><rect x="2" y="15" width="8" height="6" rx="1"/><rect x="14" y="15" width="8" height="6" rx="1"/><path d="M12 9v3M6 15v-3h12v3"/></svg>

After

Width:  |  Height:  |  Size: 335 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"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c2.5 2.6 3.8 5.6 3.8 9s-1.3 6.4-3.8 9c-2.5-2.6-3.8-5.6-3.8-9S9.5 5.6 12 3z"/></svg>

After

Width:  |  Height:  |  Size: 289 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"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c2.5 2.6 3.8 5.6 3.8 9s-1.3 6.4-3.8 9c-2.5-2.6-3.8-5.6-3.8-9S9.5 5.6 12 3z"/><path d="M4 4l16 16"/></svg>

After

Width:  |  Height:  |  Size: 311 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="3" y="13" width="18" height="7" rx="2"/><path d="M6.5 16.5h.01M9.5 16.5h.01M12.5 16.5h.01M17 13V9M14.2 6.2a4 4 0 0 1 5.6 0M12.1 4.1a7 7 0 0 1 9.8 0"/></svg>

After

Width:  |  Height:  |  Size: 318 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="M2 8.5a15.5 15.5 0 0 1 20 0M5.2 12a11 11 0 0 1 13.6 0M8.5 15.5a6 6 0 0 1 7 0"/><circle cx="12" cy="19" r="1.2" fill="#000" stroke="none"/></svg>

After

Width:  |  Height:  |  Size: 306 B

@@ -0,0 +1,337 @@
'use strict';
'require baseclass';
'require fs';
'require rpc';
'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'
});
var callGetUnixtime = rpc.declare({
object: 'luci',
method: 'getUnixtime',
expect: { result: 0 }
});
return baseclass.extend({
params: [],
load() {
return Promise.all([
network.getWANNetworks(),
network.getWAN6Networks(),
L.resolveDefault(callSystemBoard(), {}),
L.resolveDefault(callSystemInfo(), {}),
L.resolveDefault(callGetUnixtime(), 0),
uci.load('system')
]);
},
renderValue(value, mono) {
if (Array.isArray(value))
return E('span', {}, value.map(v => E('div', {}, [ mono ? E('code', {}, [ v ]) : v ])));
if (value == null || value === '')
return '-';
return mono ? E('code', {}, [ 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) ])
])));
},
renderInternetColumn(group) {
const connected = group.connected.value === true;
const rows = [];
for (const key in group) {
if (key == 'title' || key == 'connected' || !group[key].visible)
continue;
let value = group[key].value;
if (key == 'addrsv4' && 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)
});
}
return E('div', {}, [
E('h3', {}, [
group.title, ' ',
charts.badge(connected ? _('Connected') : _('Not connected'), connected ? 'success' : 'warning')
]),
connected ? this.renderKeyValueTable(rows) : charts.empty(_('Not configured or no address acquired'))
]);
},
renderInternetTab() {
return E('div', {}, [
this.renderInternetColumn(this.params.internet.v4),
this.renderInternetColumn(this.params.internet.v6)
]);
},
renderSystemTab() {
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')
});
return this.renderKeyValueTable(rows);
},
renderInternetKpi() {
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] ]));
return charts.kpi({
className: 'internet-status-self',
icon: connected ? 'internet' : 'not-internet',
title: _('Internet'),
value: [ connected ? _('Connected') : _('Not connected') ],
sub: sub
});
},
renderSystemKpi() {
const router = this.params.router;
return charts.kpi({
className: 'router-status-self',
icon: 'router',
title: router.uptime.title,
value: [ router.uptime.value || '-' ],
sub: [ router.model.value || '' ]
});
},
renderUpdateWanData(data, v6) {
let min_metric = 2000000000;
let min_metric_i = 0;
for (let i = 0; i < data.length; i++) {
const metric = data[i].getMetric();
if (metric < min_metric) {
min_metric = metric;
min_metric_i = i;
}
}
const ifc = data[min_metric_i];
if(ifc){
if (v6) {
const uptime = ifc.getUptime();
this.params.internet.v6.uptime.value = (uptime > 0) ? '%t'.format(uptime) : '-';
this.params.internet.v6.ipprefixv6.value = ifc.getIP6Prefix() || '-';
this.params.internet.v6.gatewayv6.value = ifc.getGateway6Addr() || '-';
this.params.internet.v6.protocol.value= ifc.getI18n() || E('em', _('Not connected'));
this.params.internet.v6.addrsv6.value = ifc.getIP6Addrs() || [ '-' ];
this.params.internet.v6.dnsv6.value = ifc.getDNS6Addrs() || [ '-' ];
this.params.internet.v6.connected.value = ifc.isUp();
} else {
const uptime = ifc.getUptime();
this.params.internet.v4.uptime.value = (uptime > 0) ? '%t'.format(uptime) : '-';
this.params.internet.v4.protocol.value= ifc.getI18n() || E('em', _('Not connected'));
this.params.internet.v4.gatewayv4.value = ifc.getGatewayAddr() || '0.0.0.0';
this.params.internet.v4.connected.value = ifc.isUp();
this.params.internet.v4.addrsv4.value = ifc.getIPAddrs() || [ '-'];
this.params.internet.v4.dnsv4.value = ifc.getDNSAddrs() || [ '-' ];
}
}
},
renderInternetBox(data) {
this.params.internet = {
v4: {
title: _('IPv4 Internet'),
connected: {
title: _('Connected'),
visible: true,
value: false
},
uptime: {
title: _('Uptime'),
visible: true,
value: '-'
},
protocol: {
title: _('Protocol'),
visible: true,
value: '-'
},
addrsv4: {
title: _('IPv4'),
visible: true,
value: [ '-' ]
},
gatewayv4: {
title: _('GatewayV4'),
visible: true,
value: '-'
},
dnsv4: {
title: _('DNSv4'),
visible: true,
value: ['-']
}
},
v6: {
title: _('IPv6 Internet'),
connected: {
title: _('Connected'),
visible: true,
value: false
},
uptime: {
title: _('Uptime'),
visible: true,
value: '-'
},
protocol: {
title: _('Protocol'),
visible: true,
value: ' - '
},
ipprefixv6 : {
title: _('IPv6 prefix'),
visible: true,
value: ' - '
},
addrsv6: {
title: _('IPv6'),
visible: false,
value: [ '-' ]
},
gatewayv6: {
title: _('GatewayV6'),
visible: true,
value: '-'
},
dnsv6: {
title: _('DNSv6'),
visible: true,
value: [ '-' ]
}
}
};
this.renderUpdateWanData(data[0], false);
this.renderUpdateWanData(data[1], true);
},
renderRouterBox(data) {
const boardinfo = data[2];
const systeminfo = data[3];
const unixtime = data[4];
let datestr = null;
if (unixtime) {
const date = new Date(unixtime * 1000);
const zn = uci.get('system', '@system[0]', 'zonename')?.replaceAll(' ', '_') || 'UTC';
const ts = uci.get('system', '@system[0]', 'clock_timestyle') || 0;
const hc = uci.get('system', '@system[0]', 'clock_hourcycle') || 0;
datestr = new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: (ts == 0) ? 'long' : 'full',
hourCycle: (hc == 0) ? undefined : hc,
timeZone: zn
}).format(date);
}
this.params.router = {
uptime: {
title: _('Uptime'),
value: systeminfo.uptime ? '%t'.format(systeminfo.uptime) : null,
},
localtime: {
title: _('Local Time'),
value: datestr
},
kernel: {
title: _('Kernel Version'),
value: boardinfo.kernel
},
model: {
title: _('Model'),
value: boardinfo.model
},
system: {
title: _('Architecture'),
value: boardinfo.system
},
release: {
title: _('Firmware Version'),
value: boardinfo?.release?.description
}
};
},
render(data) {
this.renderInternetBox(data);
this.renderRouterBox(data);
return {
kpi: [ this.renderInternetKpi(), this.renderSystemKpi() ],
tabs: [
{ id: 'internet', title: _('Internet'), content: this.renderInternetTab() },
{ id: 'system', title: _('System'), content: this.renderSystemTab() }
]
};
}
});
@@ -0,0 +1,73 @@
'use strict';
'require baseclass';
'require rpc';
'require network';
'require view.dashboard.lib.charts as charts';
var callLuciDHCPLeases = rpc.declare({
object: 'luci-rpc',
method: 'getDHCPLeases',
expect: { '': {} }
});
return baseclass.extend({
title: _('DHCP Devices'),
params: {},
load() {
return Promise.all([
callLuciDHCPLeases(),
]);
},
renderKpi() {
const count = this.params.lan.devices.length;
return charts.kpi({
className: 'router-status-lan',
icon: 'devices',
title: this.title,
value: [ String(count) ],
sub: [ count ? _('Active leases') : _('No active leases') ]
});
},
renderTable() {
return charts.table({
className: 'assoclist devices-info',
head: [ _('Hostname'), _('IP Address'), _('MAC') ],
rows: this.params.lan.devices.map(device => [
device.hostname,
E('code', {}, [ device.ipv4 ]),
E('code', {}, [ device.macaddr ])
]),
emptyText: _('No active leases'),
foot: [ '', _('Total'), String(this.params.lan.devices.length) ]
});
},
renderUpdateData(leases) {
const dev_arr = [];
leases.forEach(({ hostname = '?', ipaddr: ipv4 = '-', macaddr = '00:00:00:00:00:00' }) => {
dev_arr.push({ hostname, ipv4, macaddr });
});
this.params.lan = { devices: dev_arr };
},
render([leases]) {
if (!L.hasSystemFeature('dnsmasq') && !L.hasSystemFeature('odhcpd'))
return null;
this.renderUpdateData([...leases.dhcp_leases]);
return {
kpi: [ this.renderKpi() ],
tabs: [
{ id: 'dhcp', title: this.title, count: this.params.lan.devices.length, content: this.renderTable() }
]
};
}
});
@@ -0,0 +1,361 @@
'use strict';
'require baseclass';
'require dom';
'require network';
'require rpc';
'require view.dashboard.lib.charts as charts';
/* -67 dBm is the minimum RSSI Cisco specifies for voice-grade coverage;
below it the client is still usable, just not at that level. */
const GRADES = [
{ min: -67, className: 'dashboard-bar-ok', kind: 'success' },
{ min: -Infinity, className: 'dashboard-bar-mid', kind: 'warning' }
];
const NO_SIGNAL = { className: 'dashboard-bar-none', kind: '' };
function signalGrade(rssi) {
return (rssi != null) ? GRADES.find(grade => rssi >= grade.min) : NO_SIGNAL;
}
function rssiOf(device) {
return device.signal.value.rssi ?? -Infinity;
}
return baseclass.extend({
title: _('Wireless'),
params: [],
load() {
return Promise.all([
network.getWifiDevices(),
network.getWifiNetworks(),
network.getHostHints()
]).then(radios_networks_hints => {
const tasks = [];
for (let i = 0; i < radios_networks_hints[1].length; i++)
tasks.push(L.resolveDefault(radios_networks_hints[1][i].getAssocList(), []).then(L.bind((net, list) => {
net.assoclist = list.sort((a, b) => a.mac.localeCompare(b.mac));
}, this, radios_networks_hints[1][i])));
return Promise.all(tasks).then(() => {
return radios_networks_hints;
});
});
},
renderKpi() {
const radios = this.params.wifi.radios;
const active = radios.filter(radio => radio.isactive.value === true).length;
return charts.kpi({
className: 'router-status-wifi',
icon: 'wireless',
title: _('Wireless clients'),
value: [ String(this.params.wifi.devices.length) ],
sub: [ _('%d of %d SSIDs active').format(active, radios.length) ]
});
},
renderDistributionChart() {
const devices = this.params.wifi.devices;
const series = this.params.wifi.radios.map(radio => ({
label: [ radio.ssid.value, ' ', E('small', {}, [ radio.chan.value ]) ],
value: (radio.isactive.value === true) ? (parseInt(radio.associations.value) || 0) : 0
}));
return charts.card({
className: 'router-status-wifi',
title: _('Client distribution'),
desc: _('by SSID'),
body: devices.length
? charts.donut({ series: series, centerLabel: _('clients'), ariaLabel: _('Client distribution') })
: charts.empty(_('No wireless clients connected'))
});
},
renderSignalChart() {
const devices = this.params.wifi.devices.slice().sort((a, b) => rssiOf(b) - rssiOf(a));
return charts.card({
className: 'router-status-wifi',
title: _('Signal Strength'),
desc: devices.length ? '%s · %s'.format(_('%d clients').format(devices.length), _('dBm')) : '',
body: devices.length ? [ charts.barChart({
id: 'signal',
// iwinfo's nl80211 backend clamps the signal to -110..-40 dBm
// (the cfg80211 wext compat range) before deriving quality.
min: -110,
max: -40,
ticks: [ -110, -100, -90, -80, -70, -60, -50, -40 ].map(v => ({ value: v, label: '%d'.format(v) })),
items: devices.map(device => ({
label: device.hostname.value,
title: [ device.hostname.value, device.ssid.value, this.signalText(device) ].join(' · '),
values: [ { value: device.signal.value.rssi, className: signalGrade(device.signal.value.rssi).className } ]
}))
}), charts.legend([
{ className: 'dashboard-bar-ok', label: '%s ≥ -67 %s'.format(_('Good'), _('dBm')) },
{ className: 'dashboard-bar-mid', label: '%s < -67 %s'.format(_('Weak'), _('dBm')) }
]) ] : charts.empty(_('No wireless clients connected'))
});
},
signalText(device) {
if (device.signal.value.rssi == null)
return _('No RX signal');
const rssi = '%d %s'.format(device.signal.value.rssi, _('dBm'));
if (device.signal.value.noise != null)
return '%d / %d %s'.format(device.signal.value.rssi, device.signal.value.noise, _('dBm'));
return rssi;
},
// Fixed five ticks in the unit the peak falls into; the interval is the
// smallest 1-2-5 step whose fourth multiple covers the peak.
byteScale(peak) {
const units = [ 'B', 'KiB', 'MiB', 'GiB', 'TiB' ];
let exp = 0;
while (peak >= 1024 && exp < units.length - 1) {
peak /= 1024;
exp++;
}
const raw = peak / 4;
const magnitude = Math.pow(10, Math.floor(Math.log10(Math.max(raw, 1e-9))));
const step = [ 1, 2, 5, 10 ].map(n => n * magnitude).find(candidate => candidate >= raw) * Math.pow(1024, exp);
const unit = Math.pow(1024, exp);
const decimals = (step / unit < 1) ? 1 : 0;
return {
step: step,
format: value => '%s %s'.format((value / unit).toFixed(decimals), units[exp])
};
},
renderTrafficChart() {
const devices = this.params.wifi.devices.slice().sort((a, b) =>
(b.transferred.value.bytes.rx + b.transferred.value.bytes.tx) - (a.transferred.value.bytes.rx + a.transferred.value.bytes.tx));
const peak = Math.max(1, ...devices.map(device => Math.max(device.transferred.value.bytes.rx, device.transferred.value.bytes.tx)));
const scale = this.byteScale(peak);
return charts.card({
className: 'router-status-wifi',
title: _('Client traffic'),
desc: devices.length ? _('Transferred') : '',
body: devices.length ? [
charts.barChart({
id: 'traffic',
max: scale.step * 4,
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),
values: [
{ value: device.transferred.value.bytes.rx, className: 'dashboard-bar-up' },
{ value: device.transferred.value.bytes.tx, className: 'dashboard-bar-down' }
]
}))
}),
charts.legend([
{ className: 'dashboard-bar-up', label: _('Up.') },
{ className: 'dashboard-bar-down', label: _('Down.') }
])
] : charts.empty(_('No wireless clients connected'))
});
},
renderNetworkCard(radio) {
const active = radio.isactive.value === true;
const fields = [];
for (const key of [ 'chan', 'rate', 'encryption', 'bssid' ])
if (radio[key].visible)
fields.push(E('div', {}, [
E('span', {}, [ radio[key].title ]),
E((key == 'bssid') ? 'code' : 'b', {}, [ radio[key].value ])
]));
return E('div', { 'class': 'ifacebox' }, [
E('div', { 'class': 'ifacebox-head' + (active ? ' active' : '') }, [
E('strong', {}, [ radio.ssid.value ])
]),
E('div', { 'class': 'ifacebox-body' }, [
E('div', { 'class': 'dashboard-net-count' }, [
E('b', {}, [ String(active ? radio.associations.value : 0) ]),
E('small', {}, [ radio.associations.title ])
]),
active
? E('div', { 'class': 'dashboard-net-fields' }, fields)
: E('div', { 'class': 'dashboard-net-note' }, [ _('Interface is disabled') ])
])
]);
},
renderClientTable() {
return charts.table({
className: 'assoclist devices-info',
head: [
_('Hostname'),
_('SSID'),
'%s / %s'.format(_('Signal'), _('Noise floor')),
{ text: _('Up.'), className: 'right' },
{ text: _('Down.'), className: 'right' }
],
rows: this.params.wifi.devices.map(device => [
device.hostname.value,
device.ssid.value,
E('span', { 'class': 'nowrap' }, [
charts.badge((device.signal.value.rssi != null) ? '%d %s'.format(device.signal.value.rssi, _('dBm')) : _('No RX signal'), signalGrade(device.signal.value.rssi).kind),
' ',
(device.signal.value.noise != null)
? E('small', {}, [ '/ %d %s'.format(device.signal.value.noise, _('dBm')) ])
: ''
]),
{ text: device.transferred.value.rx, className: 'right' },
{ text: device.transferred.value.tx, className: 'right' }
]),
emptyText: _('No wireless clients connected'),
foot: [ '', _('Total'), String(this.params.wifi.devices.length), '', '' ]
});
},
renderTab() {
return E('div', {}, [
E('div', { 'class': 'dashboard-net-grid' }, this.params.wifi.radios.map(radio => this.renderNetworkCard(radio))),
E('h3', {}, [ _('Wireless clients') ]),
this.renderClientTable()
]);
},
renderUpdateData(radios, networks, hosthints) {
for (let i = 0; i < radios.sort((a, b) => a.getName().localeCompare(b.getName())).length; i++) {
const network_items = networks.filter(net => { return net.getWifiDeviceName() == radios[i].getName() });
for (let j = 0; j < network_items.length; j++) {
const net = network_items[j];
const is_assoc = (net.getBSSID() != '00:00:00:00:00:00' && net.getChannel() && !net.isDisabled());
const chan = net.getChannel();
const freq = net.getFrequency();
const rate = net.getBitRate();
this.params.wifi.radios.push(
{
ssid : {
title: _('SSID'),
visible: true,
value: net.getActiveSSID() || '?'
},
isactive : {
title: _('Active'),
visible: true,
value: !net.isDisabled()
},
chan : {
title: _('Channel'),
visible: true,
value: chan ? '%d (%.3f %s)'.format(chan, freq, _('GHz')) : '-'
},
rate : {
title: _('Bitrate'),
visible: true,
value: rate ? '%d %s'.format(rate, _('Mbit/s')) : '-'
},
bssid : {
title: _('BSSID'),
visible: true,
value: is_assoc ? (net.getActiveBSSID() || '-') : '-'
},
encryption : {
title: _('Encryption'),
visible: true,
value: is_assoc ? net.getActiveEncryption() : '-'
},
associations : {
title: _('Devices Connected'),
visible: true,
value: is_assoc ? (net.assoclist.length || '0') : 0
}
}
);
}
}
for (let i = 0; i < networks.length; i++) {
for (let k = 0; k < networks[i].assoclist.length; k++) {
const bss = networks[i].assoclist[k];
const name = hosthints.getHostnameByMACAddr(bss.mac);
this.params.wifi.devices.push(
{
hostname : {
title: _('Hostname'),
visible: true,
value: name || '?'
},
ssid : {
title: _('SSID'),
visible: true,
value: networks[i].getActiveSSID()
},
signal : {
title: _('Signal Strength'),
visible: true,
value: {
rssi: (typeof(bss.signal) == 'number' && bss.signal < 0) ? bss.signal : null,
noise: (typeof(bss.noise) == 'number' && bss.noise != 0) ? bss.noise : null
}
},
// AP-side station counters: rx = received from the client (its
// upload), tx = sent to the client (its download)
transferred : {
title: _('Transferred'),
visible: true,
value: {
rx: '%s'.format('%1024.2mB'.format(bss.rx.bytes)),
tx: '%s'.format('%1024.2mB'.format(bss.tx.bytes)),
bytes: { rx: bss.rx.bytes || 0, tx: bss.tx.bytes || 0 }
}
}
}
);
}
}
},
render([radios, networks, hosthints]) {
this.params.wifi = {
radios: [],
devices: []
};
this.renderUpdateData(radios, networks, hosthints);
if (!this.params.wifi.radios.length)
return null;
return {
kpi: [ this.renderKpi() ],
charts: [ this.renderDistributionChart(), this.renderSignalChart(), this.renderTrafficChart() ],
tabs: [
{ id: 'wifi', title: this.title, count: this.params.wifi.devices.length, content: this.renderTab() }
]
};
}
});
@@ -0,0 +1,162 @@
'use strict';
'require view';
'require dom';
'require poll';
'require fs';
'require ui';
'require network';
document.querySelector('head').appendChild(E('link', {
'rel': 'stylesheet',
'type': 'text/css',
'href': L.resource('view/dashboard/css/custom.css')
}));
function invokeIncludesLoad(includes) {
const tasks = [];
let has_load = false;
for (let i = 0; i < includes.length; i++) {
includes[i].failed = false;
if (typeof(includes[i].load) == 'function') {
tasks.push(includes[i].load().catch(function(e) {
console.error(e);
this.failed = true;
}.bind(includes[i])));
has_load = true;
}
else {
tasks.push(null);
}
}
return has_load ? Promise.all(tasks) : Promise.resolve(null);
}
function collectSections(includes, results) {
const sections = { kpi: [], charts: [], tabs: [], extra: [] };
for (let i = 0; i < includes.length; i++) {
let content = null;
if (includes[i].failed)
continue;
try {
if (typeof(includes[i].render) == 'function')
content = includes[i].render(results ? results[i] : null);
else if (includes[i].content != null)
content = includes[i].content;
}
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));
}
return sections;
}
function renderSections(sections) {
const nodes = [];
if (sections.kpi.length)
nodes.push(E('div', { 'class': 'dashboard-kpi-row' }, sections.kpi));
if (sections.charts.length)
nodes.push(E('div', { 'class': 'dashboard-charts' }, sections.charts));
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',
'data-tab': tab.id,
'data-tab-title': (tab.count != null) ? '%s (%d)'.format(tab.title, tab.count) : tab.title
}, tab.content)))
]));
if (sections.extra.length)
nodes.push(E('div', {}, sections.extra));
return nodes;
}
function saveChartScroll(root) {
const positions = {};
root.querySelectorAll('[data-chart]').forEach(node => {
if (node.dataset.chart)
positions[node.dataset.chart] = node.scrollLeft;
});
return positions;
}
function restoreChartScroll(root, positions) {
root.querySelectorAll('[data-chart]').forEach(node => {
if (positions[node.dataset.chart])
node.scrollLeft = positions[node.dataset.chart];
});
}
function startPolling(includes, root) {
const step = () => {
return network.flushCache().then(() => {
return invokeIncludesLoad(includes);
}).then(results => {
const positions = saveChartScroll(root);
dom.content(root, renderSections(collectSections(includes, results)));
const group = root.querySelector('.cbi-map-tabbed');
if (group)
ui.tabs.initTabGroup(group.childNodes);
restoreChartScroll(root, positions);
root.classList.add('fade-in');
});
};
return step().then(() => {
poll.add(step);
});
}
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);
}));
});
},
render(includes) {
const root = E('div', { 'class': 'Dashboard' });
return startPolling(includes, root).then(() => {
return root;
});
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});
@@ -0,0 +1,157 @@
'use strict';
'require baseclass';
const SVG_NS = 'http://www.w3.org/2000/svg';
function svg(tag, attrs, children) {
const node = document.createElementNS(SVG_NS, tag);
for (const key in (attrs || {}))
node.setAttribute(key, attrs[key]);
(children || []).forEach(child => {
if (child != null)
node.appendChild(typeof(child) == 'string' ? document.createTextNode(child) : child);
});
return node;
}
function scale(value, min, max) {
return Math.max(0, Math.min(100, (value - min) / (max - min) * 100));
}
return baseclass.extend({
icon(name, className) {
return E('span', {
'class': 'dashboard-icon ' + (className || ''),
'style': '--dashboard-icon: url(%s)'.format(L.resource('view/dashboard/icons/' + name + '.svg'))
});
},
badge(text, kind) {
return E('span', { 'class': 'label ' + (kind || '') }, [ text ]);
},
// A card is a plain .cbi-section, so the theme draws it.
card(opts) {
return E('div', { 'class': 'cbi-section ' + (opts.className || '') }, [
opts.title ? E('h3', {}, [ opts.title ]) : '',
opts.desc ? E('div', { 'class': 'cbi-section-descr' }, [ opts.desc ]) : ''
].concat(opts.body));
},
kpi(opts) {
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)
])
]);
},
empty(text) {
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 table = E('table', { 'class': 'table ' + (opts.className || '') }, [
E('tr', { 'class': 'tr table-titles' }, opts.head.map(cell => this.cell('th', cell)))
]);
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]))));
});
if (!opts.rows.length && opts.emptyText)
table.appendChild(E('tr', { 'class': 'tr placeholder' }, [
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))));
return table;
},
donut(opts) {
const radius = 40;
const circumference = 2 * Math.PI * radius;
const total = opts.series.reduce((sum, item) => sum + item.value, 0);
const gap = (opts.series.filter(item => item.value > 0).length > 1) ? 3 : 0;
const circles = [ svg('circle', { 'class': 'dashboard-donut-track', 'cx': 50, 'cy': 50, 'r': radius }) ];
let offset = 0;
opts.series.forEach((item, i) => {
const length = total ? item.value / total * circumference : 0;
if (length > 0)
circles.push(svg('circle', {
'class': 'dashboard-donut-seg dashboard-series-' + (i + 1),
'cx': 50, 'cy': 50, 'r': radius,
'stroke-dasharray': '%.2f %.2f'.format(Math.max(0, length - gap), circumference - Math.max(0, length - gap)),
'stroke-dashoffset': '%.2f'.format(-offset),
'transform': 'rotate(-90 50 50)'
}));
offset += length;
});
circles.push(svg('text', { 'class': 'dashboard-donut-value', 'x': 50, 'y': 53 }, [ String(total) ]));
circles.push(svg('text', { 'class': 'dashboard-donut-label', 'x': 50, 'y': 64 }, [ opts.centerLabel || '' ]));
return E('div', { 'class': 'dashboard-donut' }, [
svg('svg', { 'viewBox': '0 0 100 100', 'role': 'img', 'aria-label': opts.ariaLabel || '' }, circles),
E('ul', { 'class': 'dashboard-legend' }, opts.series.map((item, i) => E('li', {}, [
E('i', { 'class': 'dashboard-series-' + (i + 1) }),
E('span', { 'class': 'dashboard-legend-name' }, item.label),
E('b', {}, [ String(item.value) ]),
E('small', {}, [ total ? '%d%%'.format(Math.round(item.value / total * 100)) : '0%' ])
])))
]);
},
barChart(opts) {
const min = opts.min || 0;
const max = (opts.max != null) ? opts.max : 1;
const many = opts.items.length > 10;
const yAxis = E('div', { 'class': 'dashboard-bars-axis' }, opts.ticks.map(tick =>
E('span', { 'style': 'top:%.2f%%'.format(100 - scale(tick.value, min, max)) }, [ tick.label ])));
const grid = E('div', { 'class': 'dashboard-bars-grid' }, opts.ticks.map(tick =>
E('i', { 'style': 'top:%.2f%%'.format(100 - scale(tick.value, min, max)) })));
const plot = E('div', { 'class': 'dashboard-bars-plot' }, [ grid ]);
opts.items.forEach(item => {
plot.appendChild(E('div', { 'class': 'dashboard-bars-col', 'title': item.title || item.label }, [
E('div', { 'class': 'dashboard-bars-stack' }, item.values.map(v =>
E('i', { 'class': v.className || '', 'style': 'height:%.2f%%'.format((v.value != null) ? scale(v.value, min, max) : 0) }))),
E('div', { 'class': 'dashboard-bars-label' }, [ item.label ])
]));
});
return E('div', { 'class': 'dashboard-bars ' + (many ? 'dashboard-bars-many' : '') }, [
yAxis,
E('div', { 'class': 'dashboard-bars-scroll', 'data-chart': opts.id || '' }, [ plot ])
]);
},
legend(items) {
return E('div', { 'class': 'dashboard-legend-inline' }, items.map(item =>
E('span', {}, [ E('i', { 'class': item.className }), item.label ])));
}
});