mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-31 07:25:13 +08:00
💐 Sync 2026-09-14 23:49:49
This commit is contained in:
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 ])));
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user