⛄ Sync 2026-04-23 20:32:35

This commit is contained in:
github-actions[bot] committed 2026-04-23 20:32:35 +08:00
1 parent ac97aa196d
commit 618f49f6ca
206 files changed
+725671

No files matched your search

@@ -0,0 +1,16 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" version="1.1" xmlns="http://www.w3.org/2000/svg">
<polyline id="rx" points="" style="fill:blue;fill-opacity:0.4;stroke:blue;stroke-width:1" />
<polyline id="tx" points="" style="fill:green;fill-opacity:0.4;stroke:green;stroke-width:1" />
<line x1="0" y1="25%" x2="100%" y2="25%" style="stroke:black;stroke-width:0.1" />
<text id="label_75" x="20" y="24%" style="fill:#eee; font-size:9pt; font-family:sans-serif; text-shadow:1px 1px 1px #000"> </text>
<line x1="0" y1="50%" x2="100%" y2="50%" style="stroke:black;stroke-width:0.1" />
<text id="label_50" x="20" y="49%" style="fill:#eee; font-size:9pt; font-family:sans-serif; text-shadow:1px 1px 1px #000"> </text>
<line x1="0" y1="75%" x2="100%" y2="75%" style="stroke:black;stroke-width:0.1" />
<text id="label_25" x="20" y="74%" style="fill:#eee; font-size:9pt; font-family:sans-serif; text-shadow:1px 1px 1px #000"> </text>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,36 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" version="1.1" xmlns="http://www.w3.org/2000/svg">
<polyline id="rx" points="" style="fill:blue;fill-opacity:0.4;stroke:blue;stroke-width:1" />
<polyline id="tx" points="" style="fill:green;fill-opacity:0.4;stroke:green;stroke-width:1" />
<line x1="0" y1="25%" x2="100%" y2="25%" style="stroke:black;stroke-width:0.1" />
<text id="label_75" x="10" y="24%" style="fill:#eee; font-size:9pt; font-family:sans-serif; text-shadow:1px 1px 1px #000">-25 dbm</text>
<line x1="0" y1="50%" x2="100%" y2="50%" style="stroke:black;stroke-width:0.1" />
<text id="label_50" x="10" y="49%" style="fill:#eee; font-size:9pt; font-family:sans-serif; text-shadow:1px 1px 1px #000">-50 dbm</text>
<line x1="0" y1="75%" x2="100%" y2="75%" style="stroke:black;stroke-width:0.1" />
<text id="label_25" x="10" y="74%" style="fill:#eee; font-size:9pt; font-family:sans-serif; text-shadow:1px 1px 1px #000">-75 dbm</text>
<line x1="0" y1="90%" x2="100%" y2="90%" style="stroke:black;stroke-width:0.1" />
<text id="label_10" x="10" y="89%" style="fill:#eee; font-size:9pt; font-family:sans-serif; text-shadow:1px 1px 1px #000">-90 dbm</text>
<defs>
<linearGradient id="GradientVerticalCenteredBlack">
<stop class="stopinvis" offset="0%" />
<stop class="stopblack" offset="50%" />
<stop class="stopinvis" offset="100%" />
</linearGradient>
</defs>
<style>
.stopinvis {
stop-color: "transparent";
stop-opacity: 0;
}
.stopblack {
stop-color: "black";
stop-opacity: .1;
}
</style>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1,17 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" version="1.1" xmlns="http://www.w3.org/2000/svg">
<polyline id="tcp" points="" style="fill:green;fill-opacity:0.4;stroke:green;stroke-width:1" />
<polyline id="udp" points="" style="fill:blue;fill-opacity:0.4;stroke:blue;stroke-width:1" />
<polyline id="other" points="" style="fill:red;fill-opacity:0.4;stroke:red;stroke-width:1" />
<line x1="0" y1="25%" x2="100%" y2="25%" style="stroke:black;stroke-width:0.1" />
<text id="label_75" x="20" y="24%" style="fill:#eee; font-size:9pt; font-family:sans-serif; text-shadow:1px 1px 1px #000"> </text>
<line x1="0" y1="50%" x2="100%" y2="50%" style="stroke:black;stroke-width:0.1" />
<text id="label_50" x="20" y="49%" style="fill:#eee; font-size:9pt; font-family:sans-serif; text-shadow:1px 1px 1px #000"> </text>
<line x1="0" y1="75%" x2="100%" y2="75%" style="stroke:black;stroke-width:0.1" />
<text id="label_25" x="20" y="74%" style="fill:#eee; font-size:9pt; font-family:sans-serif; text-shadow:1px 1px 1px #000"> </text>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,17 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" version="1.1" xmlns="http://www.w3.org/2000/svg">
<polyline id="load01" points="" style="fill:#ff0000;fill-opacity:0.4;stroke:#ff0000;stroke-width:1" />
<polyline id="load05" points="" style="fill:#ff6600;fill-opacity:0.4;stroke:#ff6600;stroke-width:1" />
<polyline id="load15" points="" style="fill:#ffaa00;fill-opacity:0.4;stroke:#ffaa00;stroke-width:1" />
<line x1="0" y1="25%" x2="100%" y2="25%" style="stroke:black;stroke-width:0.1" />
<text id="label_75" x="20" y="24%" style="fill:#eee; font-size:9pt; font-family:sans-serif; text-shadow:1px 1px 1px #000"> </text>
<line x1="0" y1="50%" x2="100%" y2="50%" style="stroke:black;stroke-width:0.1" />
<text id="label_50" x="20" y="49%" style="fill:#eee; font-size:9pt; font-family:sans-serif; text-shadow:1px 1px 1px #000"> </text>
<line x1="0" y1="75%" x2="100%" y2="75%" style="stroke:black;stroke-width:0.1" />
<text id="label_25" x="20" y="74%" style="fill:#eee; font-size:9pt; font-family:sans-serif; text-shadow:1px 1px 1px #000"> </text>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,15 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" version="1.1" xmlns="http://www.w3.org/2000/svg">
<polyline id="rate" points="" style="fill:green;fill-opacity:0.4;stroke:green;stroke-width:1" />
<line x1="0" y1="25%" x2="100%" y2="25%" style="stroke:black;stroke-width:0.1" />
<text id="label_75" x="20" y="24%" style="fill:#eee; font-size:9pt; font-family:sans-serif; text-shadow:1px 1px 1px #000"> </text>
<line x1="0" y1="50%" x2="100%" y2="50%" style="stroke:black;stroke-width:0.1" />
<text id="label_50" x="20" y="49%" style="fill:#eee; font-size:9pt; font-family:sans-serif; text-shadow:1px 1px 1px #000"> </text>
<line x1="0" y1="75%" x2="100%" y2="75%" style="stroke:black;stroke-width:0.1" />
<text id="label_25" x="20" y="74%" style="fill:#eee; font-size:9pt; font-family:sans-serif; text-shadow:1px 1px 1px #000"> </text>
</svg>

After

Width:  |  Height:  |  Size: 973 B

@@ -0,0 +1,16 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" version="1.1" xmlns="http://www.w3.org/2000/svg">
<polyline id="rssi" points="" style="fill:blue;fill-opacity:0.4;stroke:blue;stroke-width:1" />
<polyline id="noise" points="" style="fill:red;fill-opacity:0.4;stroke:red;stroke-width:1" />
<line x1="0" y1="25%" x2="100%" y2="25%" style="stroke:black;stroke-width:0.1" />
<text id="label_75" x="20" y="24%" style="fill:#eee; font-size:9pt; font-family:sans-serif; text-shadow:1px 1px 1px #000"> </text>
<line x1="0" y1="50%" x2="100%" y2="50%" style="stroke:black;stroke-width:0.1" />
<text id="label_50" x="20" y="49%" style="fill:#eee; font-size:9pt; font-family:sans-serif; text-shadow:1px 1px 1px #000"> </text>
<line x1="0" y1="75%" x2="100%" y2="75%" style="stroke:black;stroke-width:0.1" />
<text id="label_25" x="20" y="74%" style="fill:#eee; font-size:9pt; font-family:sans-serif; text-shadow:1px 1px 1px #000"> </text>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,320 @@
'use strict';
'require view';
'require dom';
'require poll';
'require request';
'require ui';
'require rpc';
'require network';
const callLuciRealtimeStats = rpc.declare({
object: 'luci',
method: 'getRealtimeStats',
params: [ 'mode', 'device' ],
expect: { result: [] }
});
const graphPolls = [];
const pollInterval = 3;
Math.log2 = Math.log2 || function(x) { return Math.log(x) * Math.LOG2E; };
function rate(n, br) {
n = (n || 0).toFixed(2);
return [ '%1024.2mbit/s'.format(n * 8), br ? E('br') : ' ', '(%1024.2mB/s)'.format(n) ]
}
return view.extend({
load() {
return Promise.all([
this.loadSVG(L.resource('svg/bandwidth.svg')),
network.getDevices()
]);
},
updateGraph(ifname, svg, lines, cb) {
const G = svg.firstElementChild;
const view = document.querySelector('#view');
const width = view.offsetWidth - 2;
const height = 300 - 2;
const step = 5;
const data_wanted = Math.floor(width / step);
const data_values = [];
for (const line of lines)
if (line)
data_values.push([]);
const info = {
line_current: [],
line_average: [],
line_peak: []
};
/* prefill datasets */
for (let i = 0; i < data_values.length; i++)
for (let j = 0; j < data_wanted; j++)
data_values[i][j] = 0;
/* plot horizontal time interval lines */
for (let i = width % (step * 60); i < width; i += step * 60) {
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line.setAttribute('x1', i);
line.setAttribute('y1', 0);
line.setAttribute('x2', i);
line.setAttribute('y2', '100%');
line.setAttribute('style', 'stroke:black;stroke-width:0.1');
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
text.setAttribute('x', i + 5);
text.setAttribute('y', 15);
text.setAttribute('style', 'fill:#eee; font-size:9pt; font-family:sans-serif; text-shadow:1px 1px 1px #000');
text.appendChild(document.createTextNode(Math.round((width - i) / step / 60) + 'm'));
G.appendChild(line);
G.appendChild(text);
}
info.interval = pollInterval;
info.timeframe = data_wanted / 60;
graphPolls.push({
ifname: ifname,
svg: svg,
lines: lines,
cb: cb,
info: info,
width: width,
height: height,
step: step,
values: data_values,
timestamp: 0,
fill: 1
});
},
pollData() {
poll.add(L.bind(function() {
const tasks = [];
for (const ctx of graphPolls) {
tasks.push(L.resolveDefault(callLuciRealtimeStats('interface', ctx.ifname), []));
}
return Promise.all(tasks).then(L.bind(function(datasets) {
for (let gi = 0; gi < graphPolls.length; gi++) {
let ctx = graphPolls[gi];
const data = datasets[gi];
const values = ctx.values;
const lines = ctx.lines;
const info = ctx.info;
let data_scale = 0;
const data_wanted = Math.floor(ctx.width / ctx.step);
let last_timestamp = NaN;
for (let i = 0, di = 0; di < lines.length; di++) {
if (lines[di] == null)
continue;
const multiply = (lines[di].multiply != null) ? lines[di].multiply : 1;
const offset = (lines[di].offset != null) ? lines[di].offset : 0;
for (let j = ctx.timestamp ? 0 : 1; j < data.length; j++) {
/* skip overlapping entries */
if (data[j][0] <= ctx.timestamp)
continue;
if (i == 0) {
ctx.fill++;
last_timestamp = data[j][0];
}
if (lines[di].counter) {
/* normalize difference against time interval */
if (j > 0) {
var time_delta = data[j][0] - data[j - 1][0];
if (time_delta) {
info.line_current[i] = (data[j][di + 1] * multiply - data[j - 1][di + 1] * multiply) / time_delta;
info.line_current[i] -= Math.min(info.line_current[i], offset);
values[i].push(info.line_current[i]);
}
}
}
else {
info.line_current[i] = data[j][di + 1] * multiply;
info.line_current[i] -= Math.min(info.line_current[i], offset);
values[i].push(info.line_current[i]);
}
}
i++;
}
/* cut off outdated entries */
ctx.fill = Math.min(ctx.fill, data_wanted);
for (let i = 0; i < values.length; i++) {
const len = values[i].length;
values[i] = values[i].slice(len - data_wanted, len);
/* find peaks, averages */
info.line_peak[i] = NaN;
info.line_average[i] = 0;
for (let j = 0; j < values[i].length; j++) {
info.line_peak[i] = isNaN(info.line_peak[i]) ? values[i][j] : Math.max(info.line_peak[i], values[i][j]);
info.line_average[i] += values[i][j];
}
info.line_average[i] = info.line_average[i] / ctx.fill;
}
info.peak = Math.max.apply(Math, info.line_peak);
/* remember current timestamp, calculate horizontal scale */
if (!isNaN(last_timestamp))
ctx.timestamp = last_timestamp;
const size = Math.floor(Math.log2(info.peak));
const div = Math.pow(2, size - (size % 10));
const p_o_d = info.peak / div;
const mult = (p_o_d < 5) ? 2 : ((p_o_d < 50) ? 10 : ((p_o_d < 500) ? 100 : 1000));
info.peak = info.peak + (mult * div) - (info.peak % (mult * div));
data_scale = ctx.height / info.peak;
/* plot data */
for (let i = 0, di = 0; di < lines.length; di++) {
if (lines[di] == null)
continue;
const el = ctx.svg.firstElementChild.getElementById(lines[di].line);
let pt = `0,${ctx.height}`;
let y = 0;
if (!el)
continue;
for (let j = 0; j < values[i].length; j++) {
const x = j * ctx.step;
y = ctx.height - Math.floor(values[i][j] * data_scale);
//y -= Math.floor(y % (1 / data_scale));
y = isNaN(y) ? ctx.height : y;
pt += ` ${x},${y}`;
}
pt += ` ${ctx.width},${y} ${ctx.width},${ctx.height}`;
el.setAttribute('points', pt);
i++;
}
info.label_25 = 0.25 * info.peak;
info.label_50 = 0.50 * info.peak;
info.label_75 = 0.75 * info.peak;
if (typeof(ctx.cb) == 'function')
ctx.cb(ctx.svg, info);
}
}, this));
}, this), pollInterval);
},
loadSVG(src) {
return request.get(src).then(function(response) {
if (!response.ok)
throw new Error(response.statusText);
return E('div', {
'style': 'width:100%;height:300px;border:1px solid #000;background:#fff'
}, E(response.text()));
});
},
render([svg, devs]) {
const v = E('div', { 'class': 'cbi-map', 'id': 'map' }, E('div'));
for (const dev of devs) {
const ifname = dev.getName();
const ssid = dev.wif?.getSSID?.() || null;
if (!ifname || !dev.isUp() || dev.wif?.isDisabled())
continue;
const csvg = svg.cloneNode(true);
v.firstElementChild.appendChild(E('div', { 'class': 'cbi-section', 'data-tab': ifname, 'data-tab-title': ssid ? `${ifname} ${ssid}` : ifname }, [
csvg,
E('div', { 'class': 'right' }, E('small', { 'id': 'scale' }, '-')),
E('br'),
E('table', { 'class': 'table', 'style': 'width:100%;table-layout:fixed' }, [
E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td right top' }, E('strong', { 'style': 'border-bottom:2px solid blue' }, [ _('Inbound:') ])),
E('td', { 'class': 'td', 'id': 'rx_bw_cur' }, rate(0, true)),
E('td', { 'class': 'td right top' }, E('strong', {}, [ _('Average:') ])),
E('td', { 'class': 'td', 'id': 'rx_bw_avg' }, rate(0, true)),
E('td', { 'class': 'td right top' }, E('strong', {}, [ _('Peak:') ])),
E('td', { 'class': 'td', 'id': 'rx_bw_peak' }, rate(0, true))
]),
E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td right top' }, E('strong', { 'style': 'border-bottom:2px solid green' }, [ _('Outbound:') ])),
E('td', { 'class': 'td', 'id': 'tx_bw_cur' }, rate(0, true)),
E('td', { 'class': 'td right top' }, E('strong', {}, [ _('Average:') ])),
E('td', { 'class': 'td', 'id': 'tx_bw_avg' }, rate(0, true)),
E('td', { 'class': 'td right top' }, E('strong', {}, [ _('Peak:') ])),
E('td', { 'class': 'td', 'id': 'tx_bw_peak' }, rate(0, true))
])
]),
E('div', {'class': 'cbi-section-create'})
]));
this.updateGraph(ifname, csvg, [ { line: 'rx', counter: true }, null, { line: 'tx', counter: true } ], function(svg, info) {
const G = svg.firstElementChild, tab = svg.parentNode;
G.getElementById('label_25').firstChild.data = rate(info.label_25).join('');
G.getElementById('label_50').firstChild.data = rate(info.label_50).join('');
G.getElementById('label_75').firstChild.data = rate(info.label_75).join('');
tab.querySelector('#scale').firstChild.data = _('(%d minute window, %d second interval)').format(info.timeframe, info.interval);
dom.content(tab.querySelector('#rx_bw_cur'), rate(info.line_current[0], true));
dom.content(tab.querySelector('#rx_bw_avg'), rate(info.line_average[0], true));
dom.content(tab.querySelector('#rx_bw_peak'), rate(info.line_peak[0], true));
dom.content(tab.querySelector('#tx_bw_cur'), rate(info.line_current[1], true));
dom.content(tab.querySelector('#tx_bw_avg'), rate(info.line_average[1], true));
dom.content(tab.querySelector('#tx_bw_peak'), rate(info.line_peak[1], true));
});
}
ui.tabs.initTabGroup(v.firstElementChild.childNodes);
this.pollData();
return E([], [
E('h2', _('Bandwidth')),
E('div', {'class': 'cbi-map-descr'}, _('This page displays the bandwidth used for all available physical interfaces.')),
v
]);
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});
@@ -0,0 +1,907 @@
'use strict';
'require view';
'require poll';
'require request';
'require network';
'require fs';
'require ui';
'require rpc';
'require tools.prng as random';
function parseIwDevMap(stdout) {
const byPhy = {};
let currentPhy = null;
for (const rawLine of String(stdout || '').split(/\n/)) {
const phyMatch = rawLine.match(/^\s*phy#(\d+)\s*$/);
const ifMatch = rawLine.match(/^\s*Interface\s+(\S+)\s*$/);
if (phyMatch) {
currentPhy = `phy${phyMatch[1]}`;
byPhy[currentPhy] ??= [];
continue;
}
if (currentPhy && ifMatch)
byPhy[currentPhy].push(ifMatch[1]);
}
return Object.entries(byPhy).reduce((map, entry) => {
const phy = entry[0];
const ifaces = entry[1];
const preferred = ifaces.find((ifname) => !/^wifi\d+$/.test(ifname)) || ifaces[0];
const temp = `tmpsta${phy.replace(/^phy/, '')}`;
ifaces.forEach((ifname) => {
map[ifname] = {
phy: phy,
preferred: preferred,
temp: temp
};
});
return map;
}, {});
}
function decodeIwSSID(raw) {
const value = String(raw || '');
const bytes = [];
let hasEscapes = false;
let hasWideChars = false;
if (value === '')
return null;
for (let i = 0; i < value.length;) {
if (value[i] == '\\' && value[i + 1] == 'x' && i + 3 < value.length) {
const hex = value.substr(i + 2, 2);
const byte = parseInt(hex, 16);
if (!isNaN(byte)) {
bytes.push(byte);
i += 4;
hasEscapes = true;
continue;
}
}
const code = value.charCodeAt(i++);
if (code > 0xff) {
hasWideChars = true;
continue;
}
if (code !== 0)
bytes.push(code & 0xff);
}
if (hasWideChars)
return value.replace(/\u0000+/g, '').trim() || null;
if (!bytes.length)
return null;
try {
if ((hasEscapes || bytes.some((byte) => byte >= 0x80)) && typeof(TextDecoder) == 'function')
return new TextDecoder('utf-8', { fatal: false }).decode(new Uint8Array(bytes)).trim() || null;
}
catch (e) {
}
return String.fromCharCode.apply(null, bytes).replace(/\u0000+/g, '').trim() || null;
}
function frequencyToChannel(freq) {
freq = +freq;
if (isNaN(freq) || freq <= 0)
return null;
if (freq == 2484)
return 14;
if (freq >= 2412 && freq <= 2472)
return Math.round((freq - 2407) / 5);
if (freq >= 5000 && freq <= 5895)
return Math.round((freq - 5000) / 5);
if (freq >= 5955 && freq <= 7115)
return Math.round((freq - 5950) / 5);
return null;
}
function deriveBand(res) {
const freq = +res.mhz;
const channel = +res.channel;
if (!isNaN(freq) && freq >= 5955)
return 6;
if (!isNaN(freq) && freq >= 5000)
return 5;
if (!isNaN(freq) && freq >= 2400)
return 2;
if (!isNaN(channel) && channel >= 36)
return 5;
if (!isNaN(channel) && channel >= 1 && channel <= 14)
return 2;
return null;
}
function normalizeScanResult(res) {
const out = Object.assign({}, res);
out.ssid = decodeIwSSID(out.ssid);
out.channel = +out.channel || frequencyToChannel(out.mhz);
out.mhz = +out.mhz || null;
out.band = deriveBand(out);
out.mode = out.mode || 'Master';
return out;
}
function parseChannelWidth(widthText) {
const value = String(widthText || '');
if (/80\+80/i.test(value))
return 8080;
if (/160/i.test(value))
return 160;
if (/80/i.test(value))
return 80;
if (/20 or 40/i.test(value))
return 40;
return 20;
}
function parseIwScan(stdout) {
const results = [];
const lines = String(stdout || '').split(/\n/);
let current = null;
let section = null;
function finishCurrent() {
if (!current?.bssid || current.signal == null)
return;
results.push(normalizeScanResult(current));
}
for (const rawLine of lines) {
const line = rawLine.trim();
const bssMatch = line.match(/^BSS\s+([0-9a-f:]{17})\b/i);
if (bssMatch) {
finishCurrent();
current = {
bssid: bssMatch[1].toUpperCase(),
mode: 'Master'
};
section = null;
continue;
}
if (!current || line === '')
continue;
const freqMatch = line.match(/^freq:\s*(\d+)/i);
if (freqMatch) {
current.mhz = +freqMatch[1];
if (current.channel == null)
current.channel = frequencyToChannel(current.mhz);
continue;
}
const signalMatch = line.match(/^signal:\s*(-?\d+(?:\.\d+)?)\s*dBm/i);
if (signalMatch) {
current.signal = Math.round(parseFloat(signalMatch[1]));
continue;
}
const ssidMatch = line.match(/^SSID:\s*(.*)$/);
if (ssidMatch) {
current.ssid = decodeIwSSID(ssidMatch[1]);
continue;
}
const capabilityMatch = line.match(/^capability:\s*([A-Z-]+)/i);
if (capabilityMatch) {
if (/IBSS/i.test(capabilityMatch[1]))
current.mode = 'Ad-Hoc';
else if (/ESS/i.test(capabilityMatch[1]))
current.mode = 'Master';
continue;
}
if (/^HT operation:/i.test(line)) {
current.ht_operation ??= {};
section = 'ht';
continue;
}
if (/^VHT operation:/i.test(line)) {
current.vht_operation ??= {};
section = 'vht';
continue;
}
if (/^HE operation:/i.test(line)) {
current.he_operation ??= {};
section = 'he';
continue;
}
if (/^EHT operation:/i.test(line)) {
current.eht_operation ??= {};
section = 'eht';
continue;
}
if (section == 'ht') {
const primaryMatch = line.match(/primary channel:\s*(\d+)/i);
const offsetMatch = line.match(/secondary channel offset:\s*(below|above|no secondary)/i);
const widthMatch = line.match(/STA channel width:\s*(20 MHz|any)/i);
if (primaryMatch) {
current.channel = +primaryMatch[1];
continue;
}
if (offsetMatch) {
current.ht_operation.secondary_channel_offset = offsetMatch[1] == 'no secondary' ? 'none' : offsetMatch[1];
if (offsetMatch[1] == 'above' || offsetMatch[1] == 'below')
current.ht_operation.channel_width = 2040;
continue;
}
if (widthMatch && widthMatch[1] == '20 MHz')
current.ht_operation.channel_width = 20;
}
else if (section == 'vht') {
const widthMatch = line.match(/channel width:\s*([0-9]+)\s*\(([^)]+)\)/i);
const center1Match = line.match(/center freq segment 1:\s*(\d+)/i);
const center2Match = line.match(/center freq segment 2:\s*(\d+)/i);
if (widthMatch) {
current.vht_operation.channel_width = parseChannelWidth(widthMatch[2]);
continue;
}
if (center1Match) {
current.vht_operation.center_freq_1 = +center1Match[1];
continue;
}
if (center2Match) {
current.vht_operation.center_freq_2 = +center2Match[1];
continue;
}
}
else if (section == 'he') {
const widthMatch = line.match(/channel width:\s*(20|40|80|160|320)\s*MHz/i);
const center1Match = line.match(/center freq segment 1:\s*(\d+)/i);
const center2Match = line.match(/center freq segment 2:\s*(\d+)/i);
if (widthMatch) {
current.he_operation.channel_width = +widthMatch[1];
continue;
}
if (center1Match) {
current.he_operation.center_freq_1 = +center1Match[1];
continue;
}
if (center2Match) {
current.he_operation.center_freq_2 = +center2Match[1];
continue;
}
}
else if (section == 'eht') {
const widthMatch = line.match(/channel width:\s*(320)\s*MHz/i);
const center2Match = line.match(/center freq segment 2:\s*(\d+)/i);
if (widthMatch) {
current.eht_operation.channel_width = +widthMatch[1];
continue;
}
if (center2Match)
current.eht_operation.center_freq_2 = +center2Match[1];
}
}
finishCurrent();
return results;
}
function getSignalPercent(res) {
const qv = +res.quality;
const qm = +res.quality_max;
if (qv > 0 && qm > 0)
return Math.floor((100 / qm) * qv);
if (res.signal != null)
return Math.max(0, Math.min(100, Math.round(((+res.signal) + 110) / 70 * 100)));
return 0;
}
return view.extend({
callFrequencyList: rpc.declare({
object: 'iwinfo',
method: 'freqlist',
params: [ 'device' ],
expect: { results: [] }
}),
callNetworkDevices: rpc.declare({
object: 'luci-rpc',
method: 'getNetworkDevices',
expect: { devices: [] }
}),
cachedNetworkDevices: null,
cachedNetworkDevicesPromise: null,
scanViaIw(scanIfname) {
const attempts = [
[ 'dev', scanIfname, 'scan' ],
[ 'dev', scanIfname, 'scan', 'ap-force' ],
[ 'dev', scanIfname, 'scan', 'dump' ]
];
let index = 0;
const tryNext = () => {
if (index >= attempts.length)
return [];
return L.resolveDefault(fs.exec('/usr/sbin/iw', attempts[index++]), null).then((res) => {
const parsed = parseIwScan(res?.stdout);
if (parsed.length)
return parsed;
return tryNext();
}).catch(() => tryNext());
};
return tryNext();
},
cleanupTemporaryScanIface(tempIfname) {
return L.resolveDefault(fs.exec('/usr/sbin/ip', [ 'link', 'set', tempIfname, 'down' ]), null)
.then(() => L.resolveDefault(fs.exec('/usr/sbin/iw', [ 'dev', tempIfname, 'del' ]), null));
},
scanViaTemporaryIface(phy, tempIfname) {
return this.cleanupTemporaryScanIface(tempIfname)
.then(() => fs.exec('/usr/sbin/iw', [ 'phy', phy, 'interface', 'add', tempIfname, 'type', 'station' ]))
.then(() => fs.exec('/usr/sbin/ip', [ 'link', 'set', tempIfname, 'up' ]))
.then(() => this.scanViaIw(tempIfname))
.finally(L.bind(this.cleanupTemporaryScanIface, this, tempIfname));
},
loadNetworkDevices(forceReload) {
if (forceReload) {
this.cachedNetworkDevices = null;
this.cachedNetworkDevicesPromise = null;
}
if (this.cachedNetworkDevices)
return Promise.resolve(this.cachedNetworkDevices);
if (this.cachedNetworkDevicesPromise)
return this.cachedNetworkDevicesPromise;
this.cachedNetworkDevicesPromise = this.callNetworkDevices().then((devices) => {
this.cachedNetworkDevices = devices || [];
this.cachedNetworkDevicesPromise = null;
return this.cachedNetworkDevices;
}).catch((err) => {
this.cachedNetworkDevicesPromise = null;
throw err;
});
return this.cachedNetworkDevicesPromise;
},
resolveScanDevice(radioDev, forceReload) {
return this.loadNetworkDevices(forceReload).then((devices) => {
const apDevice = devices.find((dev) => dev.wireless &&
dev.wireless.radio == radioDev.getName() &&
dev.type !== 'wifi' &&
dev.type !== 'radio');
return apDevice?.device || radioDev.getName();
});
},
getScanResultsForRadio(radioDev) {
return this.resolveScanDevice(radioDev).then((scanIfname) => {
const iwDev = this.iwDevMap?.[scanIfname] || this.iwDevMap?.[radioDev.getName()];
const scanTasks = [];
if (iwDev?.phy)
scanTasks.push(() => this.scanViaTemporaryIface(iwDev.phy, iwDev.temp));
scanTasks.push(() => this.scanViaIw(scanIfname));
const tryNext = (index) => {
if (index >= scanTasks.length)
return [];
return Promise.resolve(scanTasks[index]()).then((results) => {
if (Array.isArray(results) && results.length)
return results;
return tryNext(index + 1);
}).catch(() => tryNext(index + 1));
};
return tryNext(0);
});
},
render_signal_badge(signalPercent, signalValue) {
let icon, title, value;
if (signalPercent < 0)
icon = L.resource('icons/signal-none.svg');
else if (signalPercent == 0)
icon = L.resource('icons/signal-000-000.svg');
else if (signalPercent < 25)
icon = L.resource('icons/signal-000-025.svg');
else if (signalPercent < 50)
icon = L.resource('icons/signal-025-050.svg');
else if (signalPercent < 75)
icon = L.resource('icons/signal-050-075.svg');
else
icon = L.resource('icons/signal-075-100.svg');
value = '%d\xa0%s'.format(signalValue, _('dBm'));
title = '%s: %d %s'.format(_('Signal'), signalValue, _('dBm'));
return E('div', {
'class': 'ifacebadge',
'title': title,
'data-signal': signalValue
}, [
E('img', { 'src': icon }),
value
]);
},
add_wifi_to_graph(chan_analysis, res, scanCache, channels, channel_width) {
const offset_tbl = chan_analysis.offset_tbl;
const height = chan_analysis.graph.offsetHeight - 2;
const step = chan_analysis.col_width;
const height_diff = (height-(height-(res.signal*-4)));
if (scanCache[res.bssid].color == null)
scanCache[res.bssid].color = random.derive_color(res.bssid);
if (scanCache[res.bssid].graph == null || scanCache[res.bssid].graph === undefined) {
const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
const line = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
const color = scanCache[res.bssid].color;
line.setAttribute('style', 'fill:'+color+'4f'+';stroke:'+color+';stroke-width:0.5');
text.setAttribute('style', 'fill:'+color+';font-size:9pt; font-family:sans-serif; text-shadow:1px 1px 1px #000');
text.appendChild(document.createTextNode(res.ssid || res.bssid));
group.appendChild(line);
group.appendChild(text);
chan_analysis.graph.firstElementChild.appendChild(group);
scanCache[res.bssid].graph = { group : group, line : line, text : text };
}
channels.forEach(function(channel) {
if (channel_width > 2) {
if (!("main" in scanCache[res.bssid].graph)) {
const main = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
main.setAttribute('style', 'fill:url(#GradientVerticalCenteredBlack)');
scanCache[res.bssid].graph.group.appendChild(main);
chan_analysis.graph.firstElementChild.lastElementChild.appendChild(main);
scanCache[res.bssid].graph["main"] = main;
}
const main_offset = offset_tbl[res.channel];
const points = [
(main_offset-(step*(2 )))+','+height,
(main_offset-(step*(2-1)))+','+height_diff,
(main_offset+(step*(2-1)))+','+height_diff,
(main_offset+(step*(2 )))+','+height
];
scanCache[res.bssid].graph.main.setAttribute('points', points);
}
const chan_offset = offset_tbl[channel];
const points = [
(chan_offset-(step*(channel_width )))+','+height,
(chan_offset-(step*(channel_width-1)))+','+height_diff,
(chan_offset+(step*(channel_width-1)))+','+height_diff,
(chan_offset+(step*(channel_width )))+','+height
];
scanCache[res.bssid].graph.text.setAttribute('x', offset_tbl[res.channel]-step);
scanCache[res.bssid].graph.text.setAttribute('y', height_diff - 2);
scanCache[res.bssid].graph.line.setAttribute('points', points);
scanCache[res.bssid].graph.group.style.zIndex = res.signal*-1;
scanCache[res.bssid].graph.group.style.opacity = res.stale ? '0.5' : null;
})
},
create_channel_graph(chan_analysis, freq_tbl, band) {
const columns = (band != 2) ? freq_tbl.length * 4 : freq_tbl.length + 3;
const chan_graph = chan_analysis.graph;
const G = chan_graph.firstElementChild;
const step = (chan_graph.offsetWidth - 2) / columns;
let curr_offset = step;
function createGraphHLine(graph, pos, width, dash) {
const elem = document.createElementNS('http://www.w3.org/2000/svg', 'line');
elem.setAttribute('x1', pos);
elem.setAttribute('y1', 0);
elem.setAttribute('x2', pos);
elem.setAttribute('y2', '100%');
elem.setAttribute('style', 'stroke:black;stroke-width:'+width+';stroke-dasharray:'+dash);
graph.appendChild(elem);
}
function createGraphText(graph, pos, text) {
const elem = document.createElementNS('http://www.w3.org/2000/svg', 'text');
elem.setAttribute('y', 15);
elem.setAttribute('style', 'fill:#eee; font-size:9pt; font-family:sans-serif; text-shadow:1px 1px 1px #000');
elem.setAttribute('x', pos + 5);
elem.appendChild(document.createTextNode(text));
graph.appendChild(elem);
}
chan_analysis.col_width = step;
createGraphHLine(G,curr_offset, 0.1, 1);
for (let i=0; i< freq_tbl.length;i++) {
const channel = freq_tbl[i]
chan_analysis.offset_tbl[channel] = curr_offset+step;
if (band != 2) {
createGraphHLine(G,curr_offset+step, 0.1, 3);
if (channel < 100)
createGraphText(G,curr_offset-(step/2), channel);
else
createGraphText(G,curr_offset-step, channel);
} else {
createGraphHLine(G,curr_offset+step, 0.1, 0);
createGraphText(G,curr_offset+step, channel);
}
curr_offset += step;
if ((band != 2) && freq_tbl[i+1]) {
const next_channel = freq_tbl[i+1];
/* Check if we are transitioning to another 5/6Ghz band range */
if ((next_channel - channel) == 4) {
for (let j=1; j < 4; j++) {
chan_analysis.offset_tbl[channel+j] = curr_offset+step;
if (j == 2)
createGraphHLine(G,curr_offset+step, 0.1, 0);
else
createGraphHLine(G,curr_offset+step, 0.1, 1);
curr_offset += step;
}
} else {
chan_analysis.offset_tbl[channel+1] = curr_offset+step;
createGraphHLine(G,curr_offset+step, 0.1, 1);
curr_offset += step;
chan_analysis.offset_tbl[next_channel-2] = curr_offset+step;
createGraphHLine(G,curr_offset+step, 0.5, 0);
curr_offset += step;
chan_analysis.offset_tbl[next_channel-1] = curr_offset+step;
createGraphHLine(G,curr_offset+step, 0.1, 1);
curr_offset += step;
}
}
}
createGraphHLine(G,curr_offset+step, 0.1, 1);
chan_analysis.tab.addEventListener('cbi-tab-active', L.bind(function(ev) {
this.active_tab = ev.detail.tab;
if (!this.radios[this.active_tab].loadedOnce)
this.handleScanRefresh();
}, this));
},
handleScanRefresh() {
if (!this.active_tab)
return;
const radio = this.radios[this.active_tab];
let q;
return this.getScanResultsForRadio(radio.dev).then(L.bind(function(results) {
const table = radio.table;
const chan_analysis = radio.graph;
const scanCache = radio.scanCache;
const band = radio.band;
const rows = [];
for (let res of results) {
if (scanCache[res.bssid] == null)
scanCache[res.bssid] = {};
scanCache[res.bssid].data = res;
scanCache[res.bssid].data.stale = false;
}
for (let k in scanCache)
if (scanCache[k].data.stale)
results.push(scanCache[k].data);
results.sort(function(a, b) {
const channelDiff = (+a.channel || 0) - (+b.channel || 0);
const ssidA = a.ssid || '';
const ssidB = b.ssid || '';
const bssidA = a.bssid || '';
const bssidB = b.bssid || '';
if (channelDiff)
return channelDiff;
if (ssidA < ssidB)
return -1;
if (ssidA > ssidB)
return 1;
if (bssidA < bssidB)
return -1;
if (bssidA > bssidB)
return 1;
return 0;
});
for (let res of results) {
q = getSignalPercent(res);
const s = res.stale ? 'opacity:0.5' : '';
const center_channels = [res.channel];
let chan_width = 2;
/* Skip WiFi not supported by the current band */
if (band != res.band)
continue;
if (chan_analysis.offset_tbl[res.channel] == null)
continue;
res.channel_width = "20 MHz";
if (res.ht_operation != null) {
/* Detect 40 MHz operation by looking for the presence of
* a secondary channel. */
if (res.ht_operation.secondary_channel_offset == "below") {
res.channel_width = "40 MHz";
chan_width = 4; /* 40 MHz Channel Used */
center_channels[0] -= 2;
} else if (res.ht_operation.secondary_channel_offset == "above") {
res.channel_width = "40 MHz";
chan_width = 4; /* 40 MHz Channel Used */
center_channels[0] += 2;
} else {
/* Fallback to 20 MHz due to discovery of other APs on the
* same channel (802.11n coexistence mechanism). */
if (res.ht_operation.channel_width == 2040)
res.channel_width = "20 MHz (40 MHz Intolerant)";
}
}
/* if channel_width <= 40, refer to HT (above) for actual channel width,
* as vht_operation.channel_width == 40 really only means that the used
* bandwidth is <= 40 and could be 20 Mhz as well */
if (res.vht_operation?.channel_width > 40) {
center_channels[0] = res.vht_operation.center_freq_1;
if (res.vht_operation.channel_width == 80) {
chan_width = 8;
res.channel_width = "80 MHz";
/* If needed, adjust based on the 802.11ac Wave 2 interop workaround. */
if (res.vht_operation.center_freq_2) {
const diff = Math.abs(res.vht_operation.center_freq_2 -
res.vht_operation.center_freq_1);
if (diff == 8) {
chan_width = 16;
res.channel_width = "160 MHz";
center_channels.push(res.vht_operation.center_freq_2);
} else if (diff > 8) {
chan_width = 8;
res.channel_width = "80+80 MHz";
center_channels.push(res.vht_operation.center_freq_2);
}
}
} else if (res.vht_operation.channel_width == 8080) {
res.channel_width = "80+80 MHz";
chan_width = 8;
center_channels.push(res.vht_operation.center_freq_2);
} else if (res.vht_operation.channel_width == 160) {
res.channel_width = "160 MHz";
chan_width = 16;
}
}
if (res.he_operation?.channel_width > 20) {
center_channels[0] = res.he_operation.center_freq_1;
chan_width = res.he_operation.channel_width / 10;
switch (res.he_operation.channel_width) {
case 40:
res.channel_width = "40 MHz";
break;
case 80:
res.channel_width = "80 MHz";
break;
case 160:
res.channel_width = "160 MHz";
center_channels.push(res.he_operation.center_freq_2);
break;
}
}
if (res.eht_operation?.channel_width == 320) {
chan_width = 32;
res.channel_width = "320 MHz";
center_channels.push(res.eht_operation.center_freq_2);
}
this.add_wifi_to_graph(chan_analysis, res, scanCache, center_channels, chan_width);
rows.push([
E('span', { 'style': s }, this.render_signal_badge(q, res.signal)),
E('span', { 'style': s }, [
E('span', { 'style': 'color:'+scanCache[res.bssid].color }, '⬤ '),
(res.ssid != null) ? '%h'.format(res.ssid) : E('em', _('hidden'))
]),
E('span', { 'style': s }, '%d'.format(res.channel)),
E('span', { 'style': s }, '%h'.format(res.channel_width)),
E('span', { 'style': s }, '%h'.format(res.mode)),
E('span', { 'style': s }, '%h'.format(res.bssid))
]);
scanCache[res.bssid].data.stale = true;
}
cbi_update_table(table, rows);
if (!radio.loadedOnce) {
radio.loadedOnce = true;
poll.stop();
}
}, this))
},
radios: {},
loadSVG(src) {
return request.get(src).then(function(response) {
if (!response.ok)
throw new Error(response.statusText);
return E('div', {
'id': 'channel_graph',
'style': 'width:100%;height:400px;border:1px solid #000;background:#fff'
}, E(response.text()));
});
},
load() {
return Promise.all([
this.loadSVG(L.resource('svg/channel_analysis.svg')),
L.resolveDefault(fs.exec('/usr/sbin/iw', [ 'dev' ]), null),
network.getWifiDevices().then(L.bind(function(data) {
const tasks = [], ret = [];
for (let d of data) {
ret[d.getName()] = { dev : d };
tasks.push(this.callFrequencyList(d.getName())
.then(L.bind(function(radio, data) {
ret[radio.getName()].freq = data;
}, this, d)));
}
return Promise.all(tasks).then(function() { return ret; })
}, this))
]).then((data) => {
this.iwDevMap = parseIwDevMap(data[1]?.stdout);
return [ data[0], data[2] ];
});
},
render([svg, wifiDevs]) {
const h2 = E('div', {'class' : 'cbi-title-section'}, [
E('h2', {'class': 'cbi-title-field'}, [ _('Channel Analysis') ]),
E('div', {'class': 'cbi-title-buttons' }, [
E('button', {
'class': 'cbi-button cbi-button-edit',
'click': ui.createHandlerFn(this, 'handleScanRefresh')
}, [ _('Refresh Channels') ])])
]);
const tabs = E('div', {}, E('div'));
for (let ifname in wifiDevs) {
const bands = {
[2] : { title: '2.4GHz', channels: [] },
[5] : { title: '5GHz', channels: [] },
[6] : { title: '6GHz', channels: [] },
};
/* Split FrequencyList in Bands */
wifiDevs[ifname].freq.forEach(function(freq) {
const band = deriveBand(freq);
if (bands[band])
bands[band].channels.push(freq.channel);
});
for (let band in bands) {
if (bands[band].channels.length == 0)
continue;
const csvg = svg.cloneNode(true);
const table = E('table', { 'class': 'table' }, [
E('tr', { 'class': 'tr table-titles' }, [
E('th', { 'class': 'th col-2 middle center' }, _('Signal')),
E('th', { 'class': 'th col-4 middle left' }, _('SSID')),
E('th', { 'class': 'th col-2 middle center hide-xs' }, _('Channel')),
E('th', { 'class': 'th col-3 middle left' }, _('Channel Width')),
E('th', { 'class': 'th col-2 middle left hide-xs' }, _('Mode')),
E('th', { 'class': 'th col-3 middle left hide-xs' }, _('BSSID'))
])
]),
tab = E('div', { 'data-tab': ifname+band, 'data-tab-title': ifname+' ('+bands[band].title+')' },
[E('br'),csvg,E('br'),table,E('br')]),
graph_data = {
graph: csvg,
offset_tbl: {},
col_width: 0,
tab: tab,
};
this.radios[ifname+band] = {
dev: wifiDevs[ifname].dev,
band: band,
graph: graph_data,
table: table,
scanCache: {},
loadedOnce: false,
};
cbi_update_table(table, [], E('em', { class: 'spinning' }, _('Starting wireless scan...')));
tabs.firstElementChild.appendChild(tab)
requestAnimationFrame(L.bind(this.create_channel_graph, this, graph_data, bands[band].channels, band));
}
}
ui.tabs.initTabGroup(tabs.firstElementChild.childNodes);
const activePane = Array.from(tabs.firstElementChild.childNodes).find((pane) => pane.getAttribute('data-tab-active') == 'true');
this.active_tab = activePane?.getAttribute('data-tab') || null;
this.pollFn = L.bind(this.handleScanRefresh, this);
poll.add(this.pollFn);
if (this.active_tab)
poll.start();
return E('div', {}, [h2, tabs]);
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});
@@ -0,0 +1,556 @@
'use strict';
'require view';
'require poll';
'require request';
'require rpc';
'require fs';
const callLuciRealtimeStats = rpc.declare({
object: 'luci',
method: 'getRealtimeStats',
params: [ 'mode', 'device' ],
expect: { result: [] }
});
const callLuciConntrackList = rpc.declare({
object: 'luci',
method: 'getConntrackList',
expect: { result: [] }
});
const callNetworkRrdnsLookup = rpc.declare({
object: 'network.rrdns',
method: 'lookup',
params: [ 'addrs', 'timeout', 'limit' ],
expect: { '': {} }
});
const callLuciRpcGetHostHints = rpc.declare({
object: 'luci-rpc',
method: 'getHostHints',
expect: { '': {} }
});
const callLuciRpcGetNetworkDevices = rpc.declare({
object: 'luci-rpc',
method: 'getNetworkDevices',
expect: { '': {} }
});
const callLuciRpcGetDHCPLeases = rpc.declare({
object: 'luci-rpc',
method: 'getDHCPLeases',
expect: { '': {} }
});
const graphPolls = [];
const pollInterval = 3;
const dns_cache = Object.create(null);
const service_cache = Object.create(null);
const ethers_cache = Object.create(null);
let ethers_cache_is_loaded = false;
let enableLookups = false;
let filterText = '';
const recheck_lookup_queue = {};
Math.log2 = Math.log2 || function(x) { return Math.log(x) * Math.LOG2E; };
return view.extend({
load() {
return Promise.all([
this.loadSVG(L.resource('svg/connections.svg'))
]);
},
updateGraph(svg, lines, cb) {
const G = svg.firstElementChild;
const view = document.querySelector('#view');
const width = view.offsetWidth - 2;
const height = 300 - 2;
const step = 5;
const data_wanted = Math.floor(width / step);
const data_values = [];
for (let line of lines)
if (line != null)
data_values.push([]);
const info = {
line_current: [],
line_average: [],
line_peak: []
};
/* prefill datasets */
for (let dv of data_values)
for (let j = 0; j < data_wanted; j++)
dv[j] = 0;
/* plot horizontal time interval lines */
for (let i = width % (step * 60); i < width; i += step * 60) {
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line.setAttribute('x1', i);
line.setAttribute('y1', 0);
line.setAttribute('x2', i);
line.setAttribute('y2', '100%');
line.setAttribute('style', 'stroke:black;stroke-width:0.1');
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
text.setAttribute('x', i + 5);
text.setAttribute('y', 15);
text.setAttribute('style', 'fill:#eee; font-size:9pt; font-family:sans-serif; text-shadow:1px 1px 1px #000');
text.appendChild(document.createTextNode(Math.round((width - i) / step / 60) + 'm'));
G.appendChild(line);
G.appendChild(text);
}
info.interval = pollInterval;
info.timeframe = data_wanted / 60;
graphPolls.push({
svg: svg,
lines: lines,
cb: cb,
info: info,
width: width,
height: height,
step: step,
values: data_values,
timestamp: 0,
fill: 1
});
},
async updateConntrack(conn) {
async function fetchServices() {
if (!enableLookups) return;
if (Object.keys(service_cache).length > 0) return;
try {
const rawData = await fs.read('/etc/services');
const lines = rawData.split('\n');
for (const line of lines) {
const match = line.match(/^([\w-]+)\s+(\d+)\/(\w+)/);
if (match) {
const service = match[1].toUpperCase();
const port = match[2];
const protocol = match[3].toUpperCase();
if (!(port in service_cache)) service_cache[port] = {};
service_cache[port][protocol] = service;
}
}
} catch (err) {
console.error('Error fetching services:', err);
}
}
function joinAddressWithPortOrServiceName(address, port, protocol) {
if (!port) return address;
if (enableLookups) {
const service = service_cache[port]?.[protocol];
if (service) return `${address}:${service}`;
}
return `${address}:${port}`;
}
await fetchServices();
const lookup_queue = new Set();
const rows = [];
conn.sort((a, b) => b.bytes - a.bytes);
for (const c of conn) {
if ((c.src === '127.0.0.1' && c.dst === '127.0.0.1') ||
(c.src === '::1' && c.dst === '::1'))
continue;
if (enableLookups) {
if (!(c.src in dns_cache)) lookup_queue.add(c.src);
if (!(c.dst in dns_cache)) lookup_queue.add(c.dst);
}
const src = enableLookups && (c.src in dns_cache) ? dns_cache[c.src] : (c.layer3 === 'ipv6' ? `[${c.src}]` : c.src);
const dst = enableLookups && (c.dst in dns_cache) ? dns_cache[c.dst] : (c.layer3 === 'ipv6' ? `[${c.dst}]` : c.dst);
const network = c.layer3.toUpperCase();
const protocol = c.layer4.toUpperCase();
const source = '%h'.format(joinAddressWithPortOrServiceName(src, c.sport, protocol));
const destination = '%h'.format(joinAddressWithPortOrServiceName(dst, c.dport, protocol));
const transfer = [c.bytes, '%1024.2mB (%d %s)'.format(c.bytes, c.packets, _('Pkts.'))];
if (filterText) {
const filterTextExpressions = filterText.split(' ');
if (filterTextExpressions.some(el => el.toUpperCase() !== network && el.toUpperCase() !== protocol
&& !c.src.includes(el) && !source.includes(el)
&& !c.dst.includes(el) && !destination.includes(el))) {
continue;
}
}
rows.push([network, protocol, source, destination, transfer]);
}
cbi_update_table('#connections', rows, E('em', _('No information available')));
if (!enableLookups || lookup_queue.size === 0) return;
const reduced_lookup_queue = lookup_queue.size > 100
? new Set([...lookup_queue].slice(0, 100))
: new Set(lookup_queue);
async function softFailure(fn) {
try {
return await fn();
} catch (err) {
console.debug('Lookup failed:', err);
return null;
}
}
function updateDnsCache(addr, name) {
if (!addr || !name) return;
if (!(addr in dns_cache)) dns_cache[addr] = name;
lookup_queue.delete(addr);
reduced_lookup_queue.delete(addr);
}
// 1. DHCP Leases
const leases = await softFailure(() => callLuciRpcGetDHCPLeases());
if (leases && Object.keys(leases).length !== 0) {
for (const lease of [...(leases.dhcp_leases || []), ...(leases.dhcp6_leases || [])]) {
const addr = lease.ipaddr || lease.ip6addr;
updateDnsCache(addr, lease.hostname);
}
}
if (reduced_lookup_queue.size > 0) {
// 2. Reverse DNS Lookup
const dnsReplies = await softFailure(() => callNetworkRrdnsLookup([...reduced_lookup_queue], 5000, 1000));
if (dnsReplies && Object.keys(dnsReplies).length !== 0) {
for (const addr of Object.keys(dnsReplies)) updateDnsCache(addr, dnsReplies[addr]);
}
}
if (reduced_lookup_queue.size > 0) {
// 3. Resolve names via hints
const hints = ethers_cache_is_loaded ? ethers_cache : await softFailure(() => callLuciRpcGetHostHints());
if (hints && Object.keys(hints).length !== 0) {
for (const [ether, obj] of Object.entries(hints)) {
if (!ether || !obj?.name) continue;
if (!(ether in ethers_cache) && Object.keys(obj).length !== 0) ethers_cache[ether] = obj;
for (const addr of [...(obj.ipaddrs || []), ...(obj.ip6addrs || [])])
updateDnsCache(addr, obj.name);
}
if (Object.keys(ethers_cache).length) ethers_cache_is_loaded = true;
}
}
if (reduced_lookup_queue.size > 0) {
// 4. Network devices
const devices = await softFailure(() => callLuciRpcGetNetworkDevices());
if (devices) {
for (const device of Object.values(devices)) {
if (!device.mac) continue;
const name = ethers_cache[device.mac]?.name;
if (!name) continue;
for (const item of [...(device.ipaddrs || []), ...(device.ip6addrs || [])])
updateDnsCache(item.address, name);
}
}
}
// Final cleanup for unresolved addresses
for (const address of reduced_lookup_queue)
if ((recheck_lookup_queue[address] || 0) > 2)
dns_cache[address] = address.includes(':') ? `[${address}]` : address;
else
recheck_lookup_queue[address] = (recheck_lookup_queue[address] || 0) + 1;
const btn = document.querySelector('.btn.toggle-lookups');
if (btn) {
btn.firstChild.data = enableLookups ? _('Disable DNS lookups') : _('Enable DNS lookups');
btn.classList.remove('spinning');
btn.disabled = false;
}
},
async pollData() {
poll.add(L.bind(async function() {
const tasks = [
L.resolveDefault(callLuciConntrackList(), [])
];
graphPolls.forEach(() => {
tasks.push(L.resolveDefault(callLuciRealtimeStats('conntrack'), []));
});
const datasets = await Promise.all(tasks);
await this.updateConntrack(datasets[0]);
for (let gi = 0; gi < graphPolls.length; gi++) {
const ctx = graphPolls[gi];
const data = datasets[gi + 1];
const values = ctx.values;
const lines = ctx.lines;
const info = ctx.info;
let data_scale = 0;
let data_wanted = Math.floor(ctx.width / ctx.step);
let last_timestamp = NaN;
for (let i = 0, di = 0; di < lines.length; di++) {
if (lines[di] == null)
continue;
const multiply = (lines[di].multiply != null) ? lines[di].multiply : 1;
const offset = (lines[di].offset != null) ? lines[di].offset : 0;
for (let j = ctx.timestamp ? 0 : 1; j < data.length; j++) {
/* skip overlapping entries */
if (data[j][0] <= ctx.timestamp)
continue;
if (i == 0) {
ctx.fill++;
last_timestamp = data[j][0];
}
info.line_current[i] = data[j][di + 1] * multiply;
info.line_current[i] -= Math.min(info.line_current[i], offset);
values[i].push(info.line_current[i]);
}
i++;
}
/* cut off outdated entries */
ctx.fill = Math.min(ctx.fill, data_wanted);
for (let i = 0; i < values.length; i++) {
const len = values[i].length;
values[i] = values[i].slice(len - data_wanted, len);
/* find peaks, averages */
info.line_peak[i] = NaN;
info.line_average[i] = 0;
for (let j = 0; j < values[i].length; j++) {
info.line_peak[i] = isNaN(info.line_peak[i]) ? values[i][j] : Math.max(info.line_peak[i], values[i][j]);
info.line_average[i] += values[i][j];
}
info.line_average[i] = info.line_average[i] / ctx.fill;
}
info.peak = Math.max.apply(Math, info.line_peak);
/* remember current timestamp, calculate horizontal scale */
if (!isNaN(last_timestamp))
ctx.timestamp = last_timestamp;
const size = Math.floor(Math.log2(info.peak));
const div = Math.pow(2, size - (size % 10));
let mult = info.peak / div;
mult = (mult < 5) ? 2 : ((mult < 50) ? 10 : ((mult < 500) ? 100 : 1000));
info.peak = info.peak + (mult * div) - (info.peak % (mult * div));
data_scale = ctx.height / info.peak;
/* plot data */
for (let i = 0, di = 0; di < lines.length; di++) {
if (lines[di] == null)
continue;
const el = ctx.svg.firstElementChild.getElementById(lines[di].line);
let pt = '0,' + ctx.height;
let y = 0;
if (!el)
continue;
for (let j = 0; j < values[i].length; j++) {
let x = j * ctx.step;
y = ctx.height - Math.floor(values[i][j] * data_scale);
//y -= Math.floor(y % (1 / data_scale));
y = isNaN(y) ? ctx.height : y;
pt += ' ' + x + ',' + y;
}
pt += ' ' + ctx.width + ',' + y + ' ' + ctx.width + ',' + ctx.height;
el.setAttribute('points', pt);
i++;
}
info.label_25 = 0.25 * info.peak;
info.label_50 = 0.50 * info.peak;
info.label_75 = 0.75 * info.peak;
if (typeof(ctx.cb) == 'function')
ctx.cb(ctx.svg, info);
}
}, this), pollInterval);
},
loadSVG(src) {
return request.get(src).then(function(response) {
if (!response.ok)
throw new Error(response.statusText);
return E('div', {
'style': 'width:100%;height:300px;border:1px solid #000;background:#fff'
}, E(response.text()));
});
},
render([svg]) {
const v = E('div', { 'class': 'cbi-map', 'id': 'map' }, [
E('h2', _('Connections')),
E('div', {'class': 'cbi-map-descr'}, _('This page displays the active connections via this device.')),
E('div', { 'class': 'cbi-section' }, [
svg,
E('div', { 'class': 'right' }, E('small', { 'id': 'scale' }, '-')),
E('br'),
E('table', { 'class': 'table', 'style': 'width:100%;table-layout:fixed' }, [
E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td right top' }, E('strong', { 'style': 'border-bottom:2px solid blue' }, [ _('UDP:') ])),
E('td', { 'class': 'td', 'id': 'lb_udp_cur' }, [ '0' ]),
E('td', { 'class': 'td right top' }, E('strong', {}, [ _('Average:') ])),
E('td', { 'class': 'td', 'id': 'lb_udp_avg' }, [ '0' ]),
E('td', { 'class': 'td right top' }, E('strong', {}, [ _('Peak:') ])),
E('td', { 'class': 'td', 'id': 'lb_udp_peak' }, [ '0' ])
]),
E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td right top' }, E('strong', { 'style': 'border-bottom:2px solid green' }, [ _('TCP:') ])),
E('td', { 'class': 'td', 'id': 'lb_tcp_cur' }, [ '0' ]),
E('td', { 'class': 'td right top' }, E('strong', {}, [ _('Average:') ])),
E('td', { 'class': 'td', 'id': 'lb_tcp_avg' }, [ '0' ]),
E('td', { 'class': 'td right top' }, E('strong', {}, [ _('Peak:') ])),
E('td', { 'class': 'td', 'id': 'lb_tcp_peak' }, [ '0' ])
]),
E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td right top' }, E('strong', { 'style': 'border-bottom:2px solid red' }, [ _('Other:') ])),
E('td', { 'class': 'td', 'id': 'lb_otr_cur' }, [ '0' ]),
E('td', { 'class': 'td right top' }, E('strong', {}, [ _('Average:') ])),
E('td', { 'class': 'td', 'id': 'lb_otr_avg' }, [ '0' ]),
E('td', { 'class': 'td right top' }, E('strong', {}, [ _('Peak:') ])),
E('td', { 'class': 'td', 'id': 'lb_otr_peak' }, [ '0' ])
])
]),
E('br'),
E('div', {
id: 'settings-row',
style: 'display: flex',
}, [
E('span', {
class: 'filter',
style: 'flex: auto',
}, [
E('input', {
id: 'filter-connections',
type: 'text',
placeholder: '192.168.1.15 / UDP 1.15 / :443',
class: 'cbi-input-text',
style: 'width: 100%',
keyup: function(ev) {
filterText = this.value;
}
})
]),
E('span', {
class: 'right',
style: 'flex: auto',
}, [
E('button', {
'class': 'btn cbi-button cbi-button-apply toggle-lookups',
'click': function(ev) {
if (!enableLookups) {
ev.currentTarget.classList.add('spinning');
ev.currentTarget.disabled = true;
enableLookups = true;
}
else {
ev.currentTarget.firstChild.data = _('Enable DNS lookups');
enableLookups = false;
}
this.blur();
}
}, [ enableLookups ? _('Disable DNS lookups') : _('Enable DNS lookups') ])
]),
]),
E('br'),
E('div', { 'class': 'cbi-section-node' }, [
E('table', { 'class': 'table', 'id': 'connections' }, [
E('tr', { 'class': 'tr table-titles' }, [
E('th', { 'class': 'th col-2 hide-xs' }, [ _('Network') ]),
E('th', { 'class': 'th col-2' }, [ _('Protocol') ]),
E('th', { 'class': 'th col-7' }, [ _('Source') ]),
E('th', { 'class': 'th col-7' }, [ _('Destination') ]),
E('th', { 'class': 'th col-4' }, [ _('Transfer') ])
]),
E('tr', { 'class': 'tr placeholder' }, [
E('td', { 'class': 'td' }, [
E('em', {}, [ _('Collecting data...') ])
])
])
])
])
])
]);
this.updateGraph(svg, [ { line: 'udp' }, { line: 'tcp' }, { line: 'other' } ], function(svg, info) {
const G = svg.firstElementChild, tab = svg.parentNode;
G.getElementById('label_25').firstChild.data = '%d'.format(info.label_25);
G.getElementById('label_50').firstChild.data = '%d'.format(info.label_50);
G.getElementById('label_75').firstChild.data = '%d'.format(info.label_75);
tab.querySelector('#scale').firstChild.data = _('(%d minute window, %d second interval)').format(info.timeframe, info.interval);
tab.querySelector('#lb_udp_cur').firstChild.data = '%d'.format(info.line_current[0]);
tab.querySelector('#lb_udp_avg').firstChild.data = '%d'.format(info.line_average[0]);
tab.querySelector('#lb_udp_peak').firstChild.data = '%d'.format(info.line_peak[0]);
tab.querySelector('#lb_tcp_cur').firstChild.data = '%d'.format(info.line_current[1]);
tab.querySelector('#lb_tcp_avg').firstChild.data = '%d'.format(info.line_average[1]);
tab.querySelector('#lb_tcp_peak').firstChild.data = '%d'.format(info.line_peak[1]);
tab.querySelector('#lb_otr_cur').firstChild.data = '%d'.format(info.line_current[2]);
tab.querySelector('#lb_otr_avg').firstChild.data = '%d'.format(info.line_average[2]);
tab.querySelector('#lb_otr_peak').firstChild.data = '%d'.format(info.line_peak[2]);
});
this.pollData();
return v;
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});
@@ -0,0 +1,296 @@
'use strict';
'require view';
'require fs';
'require poll';
'require ui';
return view.extend({
logFilterFrom: '0',
logFilterTo: '',
invertLogRangeFilter: false,
minSeverity: '',
invertMinSeverity: false,
sortLogsDescending: false,
logTextFilter: '',
invertLogTextSearch: false,
severity: [
['', 'KERN_DEFAULT', _('Default')],
// ['0', 'KERN_EMERG'], // unusable kernels tend to halt ergo no running system
['1', 'KERN_ALERT', _('Alert')],
['2', 'KERN_CRIT', _('Critical')],
['3', 'KERN_ERR', _('Error')],
['4', 'KERN_WARNING', _('Warning')],
['5', 'KERN_NOTICE', _('Notice')],
['6', 'KERN_INFO', _('Info')],
['7', 'KERN_DEBUG', _('Debug')],
// ['c', 'KERN_CONT'], // for follow-on printed lines lacking newline
/*
As of 24.10 there appear to be kernel log lines printed with severity 14-15
which seems like a bug in ubox. So we must structure the filter in an
'at least' fashion to include those.
*/
],
async retrieveLog() {
return fs.exec_direct('/bin/dmesg', [ '-r' ]).then(logdata => {
let loglines = [];
let lastSeverity = null;
logdata.trim().split(/\n/).forEach(line => {
const priorityMatch = line.match(/^<(\w+)>/);
if (!priorityMatch) return;
const tag = priorityMatch[1];
const isCont = tag === 'c';
const cleanLine = line.replace(/^<\w+>/, '');
const timeMatch = cleanLine.match(/^\[\s*(\d+(?:\.\d+)?)\]/);
const time = timeMatch ? parseFloat(timeMatch[1]) : null;
if (!isCont) {
lastSeverity = parseInt(tag, 10); // update severity
}
loglines.push({
severity: isCont ? lastSeverity : parseInt(tag, 10),
isCont,
time,
text: cleanLine
});
});
// Filter by time
const hasStart = this.logFilterFrom;
const hasEnd = this.logFilterTo;
if (hasStart || hasEnd) {
loglines = loglines.filter(({ time }) => {
if (time == null) return false;
let inRange = true;
if (hasStart && hasEnd)
inRange = time >= this.logFilterFrom && time <= this.logFilterTo;
else if (hasStart)
inRange = time >= this.logFilterFrom;
else if (hasEnd)
inRange = time <= this.logFilterTo;
return this.invertLogRangeFilter ? !inRange : inRange;
});
}
// Filter by severity
loglines = loglines.filter(entry => {
if (!entry.isCont) {
if (!this.invertMinSeverity)
return (entry.severity >= this.minSeverity);
else
return (entry.severity < this.minSeverity);
}
});
// Filter by text
if (this.logTextFilter) {
loglines = loglines.filter(({ text }) => {
const match = text.includes(this.logTextFilter);
return this.invertLogTextSearch ? !match : match;
});
}
// Sort by time
if (this.sortLogsDescending) loglines.reverse();
return {
value: loglines.map(l => l.text).join('\n'),
rows: loglines.length + 1
};
}).catch(function(err) {
ui.addNotification(null, E('p', {}, _('Unable to load log data: ' + err.message)));
return '';
});
},
async pollLog() {
const element = document.getElementById('syslog');
if (element) {
const log = await this.retrieveLog();
element.value = log.value;
element.rows = log.rows;
}
},
async load() {
poll.add(this.pollLog.bind(this));
return await this.retrieveLog();
},
render(loglines) {
const scrollDownButton = E('button', {
'id': 'scrollDownButton',
'class': 'cbi-button cbi-button-neutral',
}, _('Scroll to tail', 'scroll to bottom (the tail) of the log file')
);
scrollDownButton.addEventListener('click', () => {
scrollUpButton.scrollIntoView();
scrollDownButton.blur();
});
const scrollUpButton = E('button', {
'id' : 'scrollUpButton',
'class': 'cbi-button cbi-button-neutral',
}, _('Scroll to head', 'scroll to top (the head) of the log file')
);
scrollUpButton.addEventListener('click', () => {
scrollDownButton.scrollIntoView();
scrollUpButton.blur();
});
const self = this;
// Create range invert checkbox
const rangeTimeInvert = E('input', {
'id': 'invertLogRangeTime',
'type': 'checkbox',
'class': 'cbi-input-checkbox',
});
// Create from time filter
const fromTimeFilter = E('input', {
'id': 'logFromTime',
'class': 'cbi-input-text',
'style': 'margin-bottom:10px',
'type': 'number',
'min': '0',
'step': '0.1',
'placeholder': '0.000000',
});
// Create to time filter
const toTimeFilter = E('input', {
'id': 'logToTime',
'class': 'cbi-input-text',
'style': 'margin-bottom:10px',
'type': 'number',
'min': '0',
'step': '0.1',
'placeholder': '0.000000',
});
// Create range invert checkbox
const severityInvert = E('input', {
'id': 'invertSeverity',
'type': 'checkbox',
'class': 'cbi-input-checkbox',
});
// Create severity select-dropdown from severity map
const severitySelect = E('select', {
'id': 'logSeveritySelect',
'class': 'cbi-input-select',
},
this.severity.map(([val, , label]) =>
E('option', { value: val }, label)
));
// Create range invert checkbox
const descendingSort = E('input', {
'id': 'invertAscendingSort',
'type': 'checkbox',
'class': 'cbi-input-checkbox',
});
// Create raw text search invert checkbox
const filterTextInvert = E('input', {
'id': 'invertLogTextSearch',
'type': 'checkbox',
'class': 'cbi-input-checkbox',
});
// Create raw text search text input
const filterTextInput = E('input', {
'id': 'logTextFilter',
'class': 'cbi-input-text',
});
function handleLogFilterChange() {
// time
self.invertLogRangeFilter = rangeTimeInvert.checked;
self.logFilterFrom = fromTimeFilter.value;
self.logFilterTo = toTimeFilter.value;
// severity
self.minSeverity = severitySelect.value;
self.invertMinSeverity = severityInvert.checked;
// sort
self.sortLogsDescending = descendingSort.checked;
// text
self.logTextFilter = filterTextInput.value;
self.invertLogTextSearch = filterTextInvert.checked;
self.pollLog();
}
// time
rangeTimeInvert.addEventListener('change', handleLogFilterChange);
fromTimeFilter.addEventListener('input', handleLogFilterChange);
toTimeFilter.addEventListener('input', handleLogFilterChange);
// severity
severitySelect.addEventListener('change', handleLogFilterChange);
severityInvert.addEventListener('change', handleLogFilterChange);
// sort
descendingSort.addEventListener('change', handleLogFilterChange);
// text
filterTextInput.addEventListener('input', handleLogFilterChange);
filterTextInvert.addEventListener('change', handleLogFilterChange);
return E([], [
E('h2', {}, [ _('Kernel Log') ]),
E('div', { 'id': 'content_syslog' }, [
E('div', { 'style': 'margin-bottom:10px' }, [
E('label', { 'for': 'invertLogFacilitySearch', 'style': 'margin-right:5px' }, _('Not')),
rangeTimeInvert,
E('label', { 'for': 'logFacilitySelect', 'style': 'margin: 0 5px' }, _('between:')),
fromTimeFilter,
E('label', { 'for': 'logSeveritySelect', 'style': 'margin: 0 5px' }, _('and:')),
toTimeFilter,
]),
E('div', { 'style': 'margin-bottom:10px' }, [
E('label', { 'for': 'invertLogSeveritySearch', 'style': 'margin-right:5px' }, _('Not')),
severityInvert,
'\xa0',
severitySelect,
E('label', { 'for': 'logSeveritySelect', 'style': 'margin: 0 5px' }, _('and above')),
]),
E('div', { 'style': 'margin-bottom:10px' }, [
E('label', { 'for': 'invertAscendingSort', 'style': 'margin-right:5px' }, _('Reverse sort')),
descendingSort,
]),
E('div', { 'style': 'margin-bottom:10px' }, [
E('label', { 'for': 'invertLogTextSearch', 'style': 'margin-right:5px' }, _('Not')),
filterTextInvert,
E('label', { 'for': 'logTextFilter', 'style': 'margin: 0 5px' }, _('including:')),
filterTextInput,
]),
E('div', {'style': 'padding-bottom: 20px'}, [scrollDownButton]),
E('textarea', {
'id': 'syslog',
'style': 'font-size:12px',
'readonly': 'readonly',
'wrap': 'off',
'rows': loglines.rows
}, [ loglines.value ]),
E('div', {'style': 'padding-bottom: 20px'}, [scrollUpButton])
])
]);
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});
@@ -0,0 +1,119 @@
'use strict';
'require baseclass';
'require rpc';
var callGetUnixtime = rpc.declare({
object: 'luci',
method: 'getUnixtime',
expect: { result: 0 }
});
var callLuciVersion = rpc.declare({
object: 'luci',
method: 'getVersion'
});
var callSystemBoard = rpc.declare({
object: 'system',
method: 'board'
});
var callSystemInfo = rpc.declare({
object: 'system',
method: 'info'
});
var callCPUBench = rpc.declare({
object: 'luci',
method: 'getCPUBench'
});
var callCPUInfo = rpc.declare({
object: 'luci',
method: 'getCPUInfo'
});
var callTempInfo = rpc.declare({
object: 'luci',
method: 'getTempInfo'
});
return baseclass.extend({
title: _('System'),
disableCache: true,
load: function() {
return Promise.all([
L.resolveDefault(callSystemBoard(), {}),
L.resolveDefault(callSystemInfo(), {}),
L.resolveDefault(callCPUBench(), {}),
L.resolveDefault(callCPUInfo(), {}),
L.resolveDefault(callTempInfo(), {}),
L.resolveDefault(callLuciVersion(), { revision: _('unknown version'), branch: 'LuCI' }),
L.resolveDefault(callGetUnixtime(), 0)
]);
},
render: function(data) {
var boardinfo = data[0],
systeminfo = data[1],
cpubench = data[2],
cpuinfo = data[3],
tempinfo = data[4],
luciversion = data[5],
unixtime = data[6];
luciversion = luciversion.branch;
var datestr = null;
if (unixtime) {
var date = new Date(unixtime * 1000);
datestr = new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'long'
}).format(date);
}
var fields = [
_('Hostname'), boardinfo.hostname,
_('Architecture'), (cpuinfo.cpuinfo || boardinfo.system) + ' ' + cpubench.cpubench,
_('Target Platform'), (L.isObject(boardinfo.release) ? boardinfo.release.target : ''),
_('Firmware Version'), (L.isObject(boardinfo.release)
? '%s%s / '.format(
boardinfo.release.description || '',
boardinfo.release.revision ? boardinfo.release.revision : ''
)
: '') + (luciversion || ''),
_('Kernel Version'), boardinfo.kernel,
_('Local Time'), datestr,
_('Uptime'), systeminfo.uptime ? '%t'.format(systeminfo.uptime) : null,
_('Load Average'), Array.isArray(systeminfo.load) ? '%.2f, %.2f, %.2f'.format(
systeminfo.load[0] / 65535.0,
systeminfo.load[1] / 65535.0,
systeminfo.load[2] / 65535.0
) : null
];
if (tempinfo.tempinfo) {
fields.splice(6, 0, _('Temperature'));
fields.splice(7, 0, tempinfo.tempinfo);
}
if (boardinfo.model != "Default string Default string") {
fields.splice(2, 0, _('Model'));
fields.splice(3, 0, boardinfo.model);
}
var table = E('table', { 'class': 'table' });
for (var i = 0; i < fields.length; i += 2) {
table.appendChild(E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td left', 'width': '33%' }, [ fields[i] ]),
E('td', { 'class': 'td left' }, [ (fields[i + 1] != null) ? fields[i + 1] : '?' ])
]));
}
return table;
}
});
@@ -0,0 +1,777 @@
'use strict';
'require baseclass';
'require fs';
'require ui';
'require uci';
'require rpc';
'require network';
'require firewall';
const callGetBuiltinEthernetPorts = rpc.declare({
object: 'luci',
method: 'getBuiltinEthernetPorts',
expect: { result: [] }
});
const callSwconfigPortState = rpc.declare({
object: 'luci',
method: 'getSwconfigPortState',
params: [ 'switch' ],
expect: { result: [] }
});
const callNetworkDeviceStatus = rpc.declare({
object: 'network.device',
method: 'status',
params: [ 'name' ],
expect: { '': {} }
});
function isString(v)
{
return typeof(v) === 'string' && v !== '';
}
function parseBoardPortList(value)
{
if (Array.isArray(value))
return value.filter(isString);
if (isString(value))
return value.trim().split(/[\s,]+/).filter(isString);
return [];
}
function addKnownPort(knownPorts, seenPorts, role, device, extra)
{
if (!isString(device) || seenPorts[device])
return;
seenPorts[device] = true;
const port = Object.assign({
role,
device,
label: device
}, extra || {});
if (!port.netdev && !port.swstate)
port.netdev = network.instantiateDevice(device);
knownPorts.push(port);
}
function parseSwitchPortToken(token)
{
if (!isString(token))
return null;
const match = token.match(/^(\d+)([a-z*]+)?$/i);
return match ? { port: +match[1], flags: (match[2] || '').toLowerCase() } : null;
}
function appendSwitchMemberPorts(sw, ports, cpuPorts, memberPorts)
{
for (const value of ports) {
const token = parseSwitchPortToken(value);
if (!token)
continue;
for (const port of sw.ports) {
if (port.num != token.port)
continue;
if (isString(port.device))
cpuPorts[port.device] = true;
else
memberPorts.push('%s:%d'.format(sw['.name'] || sw.name || '', token.port));
break;
}
}
}
function buildLegacySwitchMappings(mapping, board)
{
const switchVlans = uci.sections('network', 'switch_vlan');
if (!L.isObject(board) || !L.isObject(board.switch))
return;
for (const section of switchVlans) {
const swname = section.device;
const sw = board.switch[swname];
const vid = section.vid || section.vlan;
if (!L.isObject(sw) || !/^[0-9]{1,4}$/.test(vid) || +vid > 4095)
continue;
const ports = L.toArray(section.ports);
const cpuPorts = {};
let memberPorts = [];
sw['.name'] = swname;
appendSwitchMemberPorts(sw, ports, cpuPorts, memberPorts);
memberPorts = memberPorts.filter((port, index) => memberPorts.indexOf(port) === index);
for (const cpuDev in cpuPorts)
mapping['%s.%s'.format(cpuDev, vid)] = memberPorts;
}
for (const swname in board.switch) {
const sw = board.switch[swname];
if (!L.isObject(sw) || !Array.isArray(sw.roles) || !Array.isArray(sw.ports))
continue;
sw['.name'] = swname;
for (const role of sw.roles) {
if (!L.isObject(role) || !isString(role.device))
continue;
const cpuPorts = {};
let memberPorts = [];
appendSwitchMemberPorts(sw, parseBoardPortList(role.ports), cpuPorts, memberPorts);
if (!cpuPorts[role.device])
cpuPorts[role.device] = true;
memberPorts = memberPorts.filter((port, index) => memberPorts.indexOf(port) === index);
if (!mapping[role.device])
mapping[role.device] = [];
for (const port of memberPorts)
if (mapping[role.device].indexOf(port) === -1)
mapping[role.device].push(port);
}
}
}
function getSwitchPortLabel(board, switchName, portNum)
{
const sw = L.isObject(board) && L.isObject(board.switch) ? board.switch[switchName] : null;
if (!L.isObject(sw) || !Array.isArray(sw.ports))
return '%s:%d'.format(switchName, portNum);
for (let i = 0; i < sw.ports.length; i++) {
if (sw.ports[i].num != portNum)
continue;
if (isString(sw.ports[i].label))
return sw.ports[i].label;
if (isString(sw.ports[i].role)) {
let index = 1;
for (let j = 0; j < i; j++)
if (sw.ports[j].role == sw.ports[i].role)
index++;
return sw.ports[i].role + index;
}
break;
}
return '%s:%d'.format(switchName, portNum);
}
function addResolvedPort(knownPorts, seenPorts, role, device, mapping, board, swstate)
{
const resolved = resolveVLANPorts(device, mapping);
for (const value of resolved) {
const match = value.match(/^([^:]+):(\d+)$/);
if (match) {
const switchName = match[1];
const portNum = +match[2];
const switchPorts = swstate[switchName] || {};
addKnownPort(knownPorts, seenPorts, role, value, {
label: getSwitchPortLabel(board, switchName, portNum),
swstate: switchPorts[portNum] || {
port: portNum,
link: false,
speed: 0,
duplex: false,
rx_bytes: 0,
tx_bytes: 0,
rx_packets: 0,
tx_packets: 0
}
});
}
else {
addKnownPort(knownPorts, seenPorts, role, value);
}
}
}
function addBoardNetworkPorts(knownPorts, seenPorts, role, entry, mapping, board, swstate)
{
if (!L.isObject(entry))
return;
const values = parseBoardPortList(entry.ports).concat(parseBoardPortList(entry.ifname));
if (isString(entry.device))
values.push(entry.device);
for (const value of values)
addResolvedPort(knownPorts, seenPorts, role, value, mapping, board, swstate);
}
function resolveVLANChain(ifname, bridges, mapping)
{
while (!mapping[ifname]) {
const m = ifname.match(/^(.+)\.([^.]+)$/);
if (!m)
break;
if (bridges[m[1]]) {
if (bridges[m[1]].vlan_filtering)
mapping[ifname] = bridges[m[1]].vlans[m[2]];
else
mapping[ifname] = bridges[m[1]].ports;
}
else if (/^[0-9]{1,4}$/.test(m[2]) && m[2] <= 4095) {
mapping[ifname] = [ m[1] ];
}
else {
break;
}
ifname = m[1];
}
}
function buildVLANMappings(mapping, board)
{
const bridge_vlans = uci.sections('network', 'bridge-vlan');
const vlan_devices = uci.sections('network', 'device');
const interfaces = uci.sections('network', 'interface');
const bridges = {};
/* find bridge VLANs */
for (let i = 0, s; (s = bridge_vlans[i]) != null; i++) {
if (!isString(s.device) || !/^[0-9]{1,4}$/.test(s.vlan) || +s.vlan > 4095)
continue;
const aliases = L.toArray(s.alias);
const ports = L.toArray(s.ports);
const br = bridges[s.device] = (bridges[s.device] || { ports: [], vlans: {}, vlan_filtering: true });
br.vlans[s.vlan] = [];
for (let p of ports) {
const port = p.replace(/:[ut*]+$/, '');
if (br.ports.indexOf(port) === -1)
br.ports.push(port);
br.vlans[s.vlan].push(port);
}
for (let a of aliases)
if (a != s.vlan)
br.vlans[a] = br.vlans[s.vlan];
}
/* find bridges, VLAN devices */
for (let i = 0, s; (s = vlan_devices[i]) != null; i++) {
if (s.type == 'bridge') {
if (!isString(s.name))
continue;
const ports = L.toArray(s.ports);
const br = bridges[s.name] || (bridges[s.name] = { ports: [], vlans: {}, vlan_filtering: false });
if (s.vlan_filtering == '0')
br.vlan_filtering = false;
else if (s.vlan_filtering == '1')
br.vlan_filtering = true;
for (let p of ports)
if (br.ports.indexOf(p) === -1)
br.ports.push(p);
mapping[s.name] = br.ports;
}
else if (s.type == '8021q' || s.type == '8021ad') {
if (!isString(s.name) || !isString(s.vid) || !isString(s.ifname))
continue;
/* parent device is a bridge */
if (bridges[s.ifname]) {
/* parent bridge is VLAN enabled, device refers to VLAN ports */
if (bridges[s.ifname].vlan_filtering)
mapping[s.name] = bridges[s.ifname].vlans[s.vid];
/* parent bridge is not VLAN enabled, device refers to all bridge ports */
else
mapping[s.name] = bridges[s.ifname].ports;
}
/* parent is a simple netdev */
else {
mapping[s.name] = [ s.ifname ];
}
resolveVLANChain(s.ifname, bridges, mapping);
}
}
/* resolve VLAN tagged interfaces in bridge ports */
for (let brname in bridges) {
for (let bp of bridges[brname].ports)
resolveVLANChain(bp, bridges, mapping);
for (let vid in bridges[brname].vlans)
for (let v of bridges[brname].vlans[vid])
resolveVLANChain(v, bridges, mapping);
}
buildLegacySwitchMappings(mapping, board);
/* find implicit VLAN devices */
for (let i = 0, s; (s = interfaces[i]) != null; i++) {
if (!isString(s.device))
continue;
resolveVLANChain(s.device, bridges, mapping);
}
}
function resolveVLANPorts(ifname, mapping, seen)
{
const ports = [];
if (!seen)
seen = {};
if (mapping[ifname]) {
for (let m of mapping[ifname]) {
if (!seen[m]) {
seen[m] = true;
ports.push.apply(ports, resolveVLANPorts(m, mapping, seen));
}
}
}
else {
ports.push(ifname);
}
return ports.sort(L.naturalCompare);
}
function buildInterfaceMapping(zones, networks, board) {
const vlanmap = {};
const portmap = {};
const netmap = {};
buildVLANMappings(vlanmap, board);
for (let net of networks) {
const l3dev = net.getDevice();
if (!l3dev)
continue;
const ports = resolveVLANPorts(l3dev.getName(), vlanmap);
for (let p of ports) {
portmap[p] = portmap[p] || { networks: [], zones: [] };
portmap[p].networks.push(net);
}
netmap[net.getName()] = net;
}
for (let z of zones) {
const networknames = z.getNetworks();
for (let nn of networknames) {
if (!netmap[nn])
continue;
const l3dev = netmap[nn].getDevice();
if (!l3dev)
continue;
const ports = resolveVLANPorts(l3dev.getName(), vlanmap);
for (let p of ports) {
portmap[p] = portmap[p] || { networks: [], zones: [] };
if (portmap[p].zones.indexOf(z) === -1)
portmap[p].zones.push(z);
}
}
}
return portmap;
}
function formatSpeed(carrier, speed, duplex) {
if ((speed > 0) && duplex) {
const d = (duplex == 'half') ? '\u202f(H)' : '';
const e = E('span', { 'title': _('Speed: %d Mbit/s, Duplex: %s').format(speed, duplex) });
switch (true) {
case (speed < 1000):
e.innerText = '%d\u202fM%s'.format(speed, d);
break;
case (speed == 1000):
e.innerText = '1\u202fGbE' + d;
break;
case (speed >= 1e6 && speed < 1e9):
e.innerText = '%f\u202fTbE'.format(speed / 1e6);
break;
case (speed >= 1e9):
e.innerText = '%f\u202fPbE'.format(speed / 1e9);
break;
default: e.innerText = '%f\u202fGbE'.format(speed / 1000);
}
return e;
}
return carrier ? _('Connected') : _('no link');
}
function getPSEStatus(pse) {
if (!pse)
return null;
const status = pse['c33-power-status'] || pse['podl-power-status'],
power = pse['c33-actual-power'];
return {
status: status,
power: power,
isDelivering: status === 'delivering' && power > 0
};
}
function formatPSEPower(pse) {
if (!pse)
return null;
const status = pse['c33-power-status'] || pse['podl-power-status'],
power = pse['c33-actual-power'];
if (status === 'delivering' && power) {
const watts = (power / 1000).toFixed(1);
/* Format: "⚡ 15.4 W" - lightning bolt + narrow space + watts + narrow space + W */
return E('span', { 'style': 'color:#000' },
[ '\u26a1\ufe0e\u202f%s\u202fW'.format(watts) ]);
}
else if (status === 'searching') {
return E('span', { 'style': 'color:#000' },
[ '\u26a1\ufe0e\u202f' + _('searching') ]);
}
else if (status === 'fault' || status === 'otherfault' || status === 'error') {
return E('span', { 'style': 'color:#d9534f' },
[ '\u26a1\ufe0e\u202f' + _('fault') ]);
}
else if (status === 'disabled') {
return E('span', { 'style': 'color:#888' },
[ '\u26a1\ufe0e\u202f' + _('off') ]);
}
return null;
}
function formatStats(port, pse) {
const stats = port.netdev
? (port.netdev._devstate('stats') || {})
: {
rx_bytes: port.swstate ? port.swstate.rx_bytes : null,
rx_packets: port.swstate ? port.swstate.rx_packets : null,
multicast: null,
rx_errors: null,
rx_dropped: null,
tx_bytes: port.swstate ? port.swstate.tx_bytes : null,
tx_packets: port.swstate ? port.swstate.tx_packets : null,
tx_errors: null,
tx_dropped: null,
collisions: null
};
const items = [
_('Received bytes'), '%1024mB'.format(stats.rx_bytes),
_('Received packets'), '%1000mPkts.'.format(stats.rx_packets),
_('Received multicast'), '%1000mPkts.'.format(stats.multicast),
_('Receive errors'), '%1000mPkts.'.format(stats.rx_errors),
_('Receive dropped'), '%1000mPkts.'.format(stats.rx_dropped),
_('Transmitted bytes'), '%1024mB'.format(stats.tx_bytes),
_('Transmitted packets'), '%1000mPkts.'.format(stats.tx_packets),
_('Transmit errors'), '%1000mPkts.'.format(stats.tx_errors),
_('Transmit dropped'), '%1000mPkts.'.format(stats.tx_dropped),
_('Collisions seen'), stats.collisions
];
if (pse) {
const status = pse['c33-power-status'] || pse['podl-power-status'],
power = pse['c33-actual-power'],
powerClass = pse['c33-power-class'],
powerLimit = pse['c33-available-power-limit'];
items.push(_('PoE status'), status || _('unknown'));
if (power)
items.push(_('PoE power'), '%.1f W'.format(power / 1000));
if (powerClass)
items.push(_('PoE class'), powerClass);
if (powerLimit)
items.push(_('PoE limit'), '%.1f W'.format(powerLimit / 1000));
}
return ui.itemlist(E('span'), items);
}
function getPortSpeed(port)
{
return port.netdev ? port.netdev.getSpeed() : (port.swstate ? port.swstate.speed : null);
}
function getPortDuplex(port)
{
if (port.netdev)
return port.netdev.getDuplex();
return (port.swstate && port.swstate.link) ? (port.swstate.duplex ? 'full' : 'half') : null;
}
function getPortCarrier(port)
{
return port.netdev ? port.netdev.getCarrier() : !!(port.swstate && port.swstate.link);
}
function getPortTXBytes(port)
{
return port.netdev ? port.netdev.getTXBytes() : (port.swstate ? port.swstate.tx_bytes : null);
}
function getPortRXBytes(port)
{
return port.netdev ? port.netdev.getRXBytes() : (port.swstate ? port.swstate.rx_bytes : null);
}
function renderNetworkBadge(network, zonename) {
const l3dev = network.getDevice();
const span = E('span', { 'class': 'ifacebadge', 'style': 'margin:.125em 0' }, [
E('span', {
'class': 'zonebadge',
'title': zonename ? _('Part of zone %q').format(zonename) : _('No zone assigned'),
'style': firewall.getZoneColorStyle(zonename)
}, '\u202f'),
'\u202f', network.getName(), ': '
]);
if (l3dev)
span.appendChild(E('img', {
'title': l3dev.getI18n(),
'src': L.resource('icons/%s%s.svg'.format(l3dev.getType(), l3dev.isUp() ? '' : '_disabled'))
}));
else
span.appendChild(E('em', _('(no interfaces attached)')));
return span;
}
function renderNetworksTooltip(pmap) {
const res = [ null ];
const zmap = {};
const zones = (pmap && Array.isArray(pmap.zones)) ? pmap.zones : [];
const networks = (pmap && Array.isArray(pmap.networks)) ? pmap.networks : [];
for (let pmz of zones) {
const networknames = pmz.getNetworks();
for (let nn of networknames)
zmap[nn] = pmz.getName();
}
for (let pmn of networks)
res.push(E('br'), renderNetworkBadge(pmn, zmap[pmn.getName()]));
if (res.length > 1)
res[0] = N_((res.length - 1) / 2, 'Part of network:', 'Part of networks:');
else
res[0] = _('Port is not part of any network');
return E([], res);
}
return baseclass.extend({
title: _('Port status'),
deferFirstLoad: true,
disableCache: true,
load() {
return Promise.all([
L.resolveDefault(callGetBuiltinEthernetPorts(), []),
L.resolveDefault(fs.read('/etc/board.json'), '{}'),
firewall.getZones(),
network.getNetworks(),
uci.load('network')
]).then((data) => {
const builtinPorts = data[0] || [];
const board = JSON.parse(data[1] || '{}');
const allPorts = new Set();
const swstate = {};
const tasks = [];
builtinPorts.forEach((port) => {
if (port.device)
allPorts.add(port.device);
});
if (allPorts.size === 0 && board.network) {
['lan', 'wan'].forEach((role) => {
if (board.network[role]) {
if (Array.isArray(board.network[role].ports))
board.network[role].ports.forEach((p) => allPorts.add(p));
else if (board.network[role].device)
allPorts.add(board.network[role].device);
}
});
}
if (L.isObject(board) && L.isObject(board.switch)) {
for (const switchName in board.switch) {
tasks.push(L.resolveDefault(callSwconfigPortState(switchName), []).then((ports) => {
swstate[switchName] = ports.reduce((map, port) => {
map[port.port] = port;
return map;
}, {});
}));
}
}
const psePromises = Array.from(allPorts).map((devname) => {
return L.resolveDefault(callNetworkDeviceStatus(devname), {}).then((status) => {
return { name: devname, pse: status.pse || null };
});
});
return Promise.all(tasks).then(() => Promise.all(psePromises)).then((pseResults) => {
const pseMap = {};
pseResults.forEach((result) => {
if (result.pse)
pseMap[result.name] = result.pse;
});
data.push(swstate);
data.push(pseMap);
return data;
});
});
},
render(data) {
const board = JSON.parse(data[1]),
swstate = data[5] || {},
port_map = buildInterfaceMapping(data[2], data[3], board),
pseMap = data[6] || {};
let known_ports = [];
const seenPorts = {};
const vlanMap = {};
buildVLANMappings(vlanMap, board);
if (Array.isArray(data[0]) && data[0].length > 0) {
known_ports = data[0].reduce((ports, port) => {
addResolvedPort(ports, seenPorts, port.role, port.device, vlanMap, board, swstate);
return ports;
}, []);
}
if (L.isObject(board) && L.isObject(board.network)) {
for (let k = 'lan'; k != null; k = (k == 'lan') ? 'wan' : null) {
if (!L.isObject(board.network[k]))
continue;
addBoardNetworkPorts(known_ports, seenPorts, k, board.network[k], vlanMap, board, swstate);
}
}
if (!known_ports.length)
return null;
known_ports.sort(function(a, b) {
return L.naturalCompare(a.label || a.device, b.label || b.device);
});
return E('div', { 'style': 'display:grid;grid-template-columns:repeat(auto-fit, minmax(100px, 1fr));margin-bottom:1em;align-items:center;justify-items:center;text-align:center' }, known_ports.map(function(port) {
const speed = getPortSpeed(port);
const duplex = getPortDuplex(port);
const carrier = getPortCarrier(port);
const pmap = port_map[port.device];
const pzones = (pmap && pmap.zones.length) ? pmap.zones.sort((a, b) => L.naturalCompare(a.getName(), b.getName())) : [ null ];
const pse = pseMap[port.device];
const pseInfo = getPSEStatus(pse);
const psePower = formatPSEPower(pse);
/* Select port icon based on carrier and PSE status */
let portIcon;
if (pseInfo && pseInfo.isDelivering) {
portIcon = carrier ? 'pse_up' : 'pse_down';
} else {
portIcon = carrier ? 'up' : 'down';
}
const statsContent = [
'\u25b2\u202f%1024.1mB'.format(getPortTXBytes(port)),
E('br'),
'\u25bc\u202f%1024.1mB'.format(getPortRXBytes(port))
];
if (psePower) {
statsContent.push(E('br'));
statsContent.push(psePower);
}
statsContent.push(E('span', { 'class': 'cbi-tooltip' }, formatStats(port, pse)));
return E('div', { 'class': 'ifacebox', 'style': 'margin:.25em;width:100px' }, [
E('div', { 'class': 'ifacebox-head', 'style': 'font-weight:bold' }, [ port.label || port.device ]),
E('div', { 'class': 'ifacebox-body' }, [
E('img', { 'src': L.resource('icons/port_%s.svg').format(portIcon) }),
E('br'),
formatSpeed(carrier, speed, duplex)
]),
E('div', { 'class': 'ifacebox-head cbi-tooltip-container', 'style': 'display:flex' }, [
E([], pzones.map(function(zone) {
return E('div', {
'class': 'zonebadge',
'style': 'cursor:help;flex:1;height:3px;opacity:' + (carrier ? 1 : 0.25) + ';' + firewall.getZoneColorStyle(zone)
});
})),
E('span', { 'class': 'cbi-tooltip left' }, [ renderNetworksTooltip(pmap) ])
]),
E('div', { 'class': 'ifacebox-body' }, [
E('div', { 'class': 'cbi-tooltip-container', 'style': 'text-align:left;font-size:80%' }, statsContent)
])
]);
}));
}
});
@@ -0,0 +1,60 @@
'use strict';
'require baseclass';
'require rpc';
var callSystemInfo = rpc.declare({
object: 'system',
method: 'info'
});
function progressbar(value, max, byte) {
var vn = parseInt(value) || 0,
mn = parseInt(max) || 100,
fv = byte ? String.format('%1024.2mB', value) : value,
fm = byte ? String.format('%1024.2mB', max) : max,
pc = Math.floor((100 / mn) * vn);
return E('div', {
'class': 'cbi-progressbar',
'title': '%s / %s (%d%%)'.format(fv, fm, pc)
}, E('div', { 'style': 'width:%.2f%%'.format(pc) }));
}
return baseclass.extend({
title: _('Memory'),
load: function() {
return L.resolveDefault(callSystemInfo(), {});
},
render: function(systeminfo) {
var mem = L.isObject(systeminfo.memory) ? systeminfo.memory : {},
swap = L.isObject(systeminfo.swap) ? systeminfo.swap : {};
var fields = [
_('Used'), (mem.total && mem.available) ? (mem.total - mem.free - mem.buffered - mem.cached) : null, mem.total,
];
if (mem.buffered)
fields.push(_('Buffered'), mem.buffered, mem.total);
if (mem.cached)
fields.push(_('Cached'), mem.cached, mem.total);
if (swap.total > 0)
fields.push(_('Swap used'), swap.total - swap.free, swap.total);
var table = E('table', { 'class': 'table' });
for (var i = 0; i < fields.length; i += 3) {
table.appendChild(E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td left', 'width': '33%' }, [ fields[i] ]),
E('td', { 'class': 'td left' }, [
(fields[i + 1] != null) ? progressbar(fields[i + 1], fields[i + 2], true) : '?'
])
]));
}
return table;
}
});
@@ -0,0 +1,66 @@
'use strict';
'require baseclass';
'require rpc';
var callMountPoints = rpc.declare({
object: 'luci',
method: 'getMountPoints',
expect: { result: [] }
});
function progressbar(value, max, byte) {
var vn = parseInt(value) || 0,
mn = parseInt(max) || 100,
fv = byte ? String.format('%1024.2mB', value) : value,
fm = byte ? String.format('%1024.2mB', max) : max,
pc = Math.floor((100 / mn) * vn);
return E('div', {
'class': 'cbi-progressbar',
'title': '%s / %s (%d%%)'.format(fv, fm, pc)
}, E('div', { 'style': 'width:%.2f%%'.format(pc) }));
}
return baseclass.extend({
title: _('Storage'),
load: function() {
return Promise.all([
L.resolveDefault(callMountPoints(), {}),
]);
},
render: function(data) {
var mounts = data[0],
overlay = null;
for (var i = 0; i < mounts.length; i++) {
if (mounts[i].mount == '/overlay') {
overlay = mounts[i];
break;
}
}
if (!overlay) {
for (var j = 0; j < mounts.length; j++) {
if (mounts[j].mount == '/') {
overlay = mounts[j];
break;
}
}
}
var table = E('table', { 'class': 'table' });
if (overlay) {
table.appendChild(E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td left', 'width': '33%' }, [ overlay.mount ]),
E('td', { 'class': 'td left' }, [
progressbar(overlay.size - overlay.free, overlay.size, true)
])
]));
}
return table;
}
});
@@ -0,0 +1,137 @@
'use strict';
'require baseclass';
'require fs';
'require network';
'require rpc';
'require ui';
/* returns per odhcp6c active interface JSON like:
{"result":{"eth1":{"dhcp_solicit":3,"dhcp_advertise":3,"dhcp_request":3,...}}} */
const callOdhcp6cStats = rpc.declare({
object: 'luci',
method: 'getOdhcp6cStats',
expect: { '': {} },
});
var callOnlineUsers = rpc.declare({
object: 'luci',
method: 'getOnlineUsers'
});
function progressbar(value, max, byte) {
const vn = parseInt(value) || 0;
const mn = parseInt(max) || 100;
const fv = byte ? String.format('%1024.2mB', value) : value;
const fm = byte ? String.format('%1024.2mB', max) : max;
const pc = Math.floor((100 / mn) * vn);
return E('div', {
'class': 'cbi-progressbar',
'title': '%s / %s (%d%%)'.format(fv, fm, pc)
}, E('div', { 'style': 'width:%.2f%%'.format(pc) }));
}
function renderbox(ifc, ipv6, dhcpv6_stats) {
const dev = ifc.getL3Device();
const active = (dev && ifc.getProtocol() != 'none');
const addrs = (ipv6 ? ifc.getIP6Addrs() : ifc.getIPAddrs()) || [];
const dnssrv = (ipv6 ? ifc.getDNS6Addrs() : ifc.getDNSAddrs()) || [];
const expires = ifc.getExpiry();
const uptime = ifc.getUptime();
function addEntries(label, array) {
return Array.isArray(array) ? array.flatMap((item) => [label, item]) : [label, null];
}
function addDhcpv6Stats() {
if (ipv6 && ifc.getProtocol() === 'dhcpv6' && dhcpv6_stats && dhcpv6_stats[dev.device]) {
const arr = [];
for (const [pkt_type, count] of Object.entries(dhcpv6_stats[dev.device]))
arr.push(pkt_type.replace('dhcp_', _('DHCPv6') + ' '), `${count} ${_('pkts', 'packets, abbreviated')}`);
return [_('DHCPv6 Statistics'), E('span', { 'class': 'cbi-tooltip-container'}, [
'📊',
E('span', { 'class': 'cbi-tooltip' }, ui.itemlist(E('span'), arr))
])];
}
return ['', null];
}
return E('div', { class: 'ifacebox' }, [
E('div', { class: 'ifacebox-head center ' + (active ? 'active' : '') },
E('strong', ipv6 ? _('IPv6 Upstream') : _('IPv4 Upstream'))),
E('div', { class: 'ifacebox-body left' }, [
L.itemlist(E('span'), [
_('Protocol'), ifc.getI18n() || E('em', _('Not connected')),
...addEntries(_('Prefix Delegated'), ipv6 ? ifc.getIP6Prefixes?.() : null),
...addEntries(_('Address'), addrs),
_('Gateway'), ipv6 ? (ifc.getGateway6Addr() || '::') : (ifc.getGatewayAddr() || '0.0.0.0'),
...addEntries(_('DNS'), dnssrv),
_('Expires'), (expires != null && expires > -1) ? '%t'.format(expires) : null,
_('Connected'), (uptime > 0) ? '%t'.format(uptime) : null,
...addDhcpv6Stats(),
]),
E('div', {}, renderBadge(
L.resource('icons/%s.svg').format(dev ? dev.getType() : 'ethernet_disabled'), null,
_('Device'), dev ? dev.getI18n() : '-',
_('MAC address'), dev.getMAC())
)
])
]);
}
return baseclass.extend({
title: _('Network'),
load() {
return Promise.all([
fs.trimmed('/proc/sys/net/netfilter/nf_conntrack_count'),
fs.trimmed('/proc/sys/net/netfilter/nf_conntrack_max'),
network.getWANNetworks(),
network.getWAN6Networks(),
callOdhcp6cStats(),
L.resolveDefault(callOnlineUsers(), {})
]);
},
render([ct_count, ct_max, wan_nets, wan6_nets, dhcpv6_stats, onlineusers]) {
const fields = [
{ label: _('Active Connections'), value: ct_max ? ct_count : null },
{ label: _('Online Users'), value: onlineusers ? onlineusers.onlineusers : null }
];
const ctstatus = E('table', { 'class': 'table' });
for (const { label, value } of fields) {
if (label == _('Online Users')) {
ctstatus.appendChild(E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td left', 'width': '33%' }, [ label ]),
E('td', { 'class': 'td left' }, [
(value != null) ? value : '?'
])
]));
} else {
ctstatus.appendChild(E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td left', 'width': '33%' }, [ label ]),
E('td', { 'class': 'td left' }, [
(value != null) ? progressbar(value, ct_max) : '?'
])
]));
}
}
const netstatus = E('div', { 'class': 'network-status-table' });
for (const wan_net of wan_nets)
netstatus.appendChild(renderbox(wan_net, false));
for (const wan6_net of wan6_nets)
netstatus.appendChild(renderbox(wan6_net, true, dhcpv6_stats?.result));
return E([
netstatus,
ctstatus
]);
}
});
@@ -0,0 +1,243 @@
'use strict';
'require baseclass';
'require rpc';
'require uci';
'require network';
'require validation';
const callLuciDHCPLeases = rpc.declare({
object: 'luci-rpc',
method: 'getDHCPLeases',
expect: { '': {} }
});
const callUfpList = rpc.declare({
object: 'fingerprint',
method: 'fingerprint',
});
return baseclass.extend({
title: _('DHCP Leases'),
deferFirstLoad: true,
disableCache: true,
isMACStatic: {},
isDUIDStatic: {},
isDUIDIAIDStatic: {},
load() {
return Promise.all([
callLuciDHCPLeases(),
network.getHostHints(),
L.hasSystemFeature('ufpd') ? callUfpList() : null,
L.resolveDefault(uci.load('dhcp'))
]);
},
render([dhcp_leases, host_hints, ufp_list]) {
if (L.hasSystemFeature('dnsmasq') || L.hasSystemFeature('odhcpd'))
return this.renderLeases(dhcp_leases, host_hints, ufp_list);
return null;
},
handleCreateStaticLease(lease, ev) {
ev.currentTarget.classList.add('spinning');
ev.currentTarget.disabled = true;
ev.currentTarget.blur();
const cfg = uci.add('dhcp', 'host');
uci.set('dhcp', cfg, 'name', lease.hostname);
uci.set('dhcp', cfg, 'ip', lease.ipaddr);
uci.set('dhcp', cfg, 'mac', [lease.macaddr.toLowerCase()]);
return uci.save()
.then(L.bind(L.ui.changes.init, L.ui.changes))
.then(L.bind(L.ui.changes.displayChanges, L.ui.changes));
},
handleCreateStaticLease6(lease, ev) {
ev.currentTarget.classList.add('spinning');
ev.currentTarget.disabled = true;
ev.currentTarget.blur();
const cfg = uci.add('dhcp', 'host');
const ip6addr = lease.ip6addrs?.[0]?.replace(/\/128$/, '');
const ip6arr = ip6addr ? validation.parseIPv6(ip6addr) : null;
// Combine DUID and IAID if both available
// (note that we know that lease.duid is set here)
let duid_iaid = lease.duid.toLowerCase();
if (lease.iaid)
duid_iaid += `%${lease.iaid}`.toLowerCase();
uci.set('dhcp', cfg, 'name', lease.hostname);
uci.set('dhcp', cfg, 'duid', [duid_iaid]);
if (lease.macaddr)
uci.set('dhcp', cfg, 'mac', [lease.macaddr.toLowerCase()]);
if (ip6arr)
uci.set('dhcp', cfg, 'hostid', (ip6arr[6] * 0xFFFF + ip6arr[7]).toString(16));
return uci.save()
.then(L.bind(L.ui.changes.init, L.ui.changes))
.then(L.bind(L.ui.changes.displayChanges, L.ui.changes));
},
renderLeases(dhcp_leases, host_hints, macaddr) {
const leases = Array.isArray(dhcp_leases.dhcp_leases) ? dhcp_leases.dhcp_leases : [];
const leases6 = Array.isArray(dhcp_leases.dhcp6_leases) ? dhcp_leases.dhcp6_leases : [];
if (leases.length == 0 && leases6.length == 0)
return E('em', _('No active leases found'));
const machints = host_hints.getMACHints(false);
const isReadonlyView = !L.hasViewPermission();
for (const host of uci.sections('dhcp', 'host')) {
for (const mac of L.toArray(host.mac).map(m => m.toLowerCase()))
this.isMACStatic[mac] = true;
for (const duid_iaid of L.toArray(host.duid).map(m => m.toLowerCase())) {
const parts = duid_iaid.split('%').length;
if (parts == 1)
this.isDUIDStatic[duid_iaid] = true;
else if (parts == 2)
this.isDUIDIAIDStatic[duid_iaid] = true;
}
};
const table = E('table', { 'id': 'status_leases', 'class': 'table leases' }, [
E('tr', { 'class': 'tr table-titles' }, [
L.hasSystemFeature('odhcpd', 'dhcpv4') ? E('th', { 'class': 'th' }, _('Interface')) : E([]),
E('th', { 'class': 'th' }, _('Hostname')),
E('th', { 'class': 'th' }, _('IPv4 address')),
E('th', { 'class': 'th' }, _('MAC address')),
E('th', { 'class': 'th' }, _('DUID')),
E('th', { 'class': 'th' }, _('IAID')),
E('th', { 'class': 'th' }, _('Remaining time')),
isReadonlyView ? E([]) : E('th', { 'class': 'th cbi-section-actions' }, _('Static Lease'))
])
]);
cbi_update_table(table, leases.map(L.bind(function(lease) {
let exp;
let vendor;
if (lease.expires === false)
exp = E('em', _('unlimited'));
else if (lease.expires <= 0)
exp = E('em', _('expired'));
else
exp = '%t'.format(lease.expires);
const hint = lease.macaddr ? machints.filter(function(h) { return h[0] == lease.macaddr })[0] : null;
let host = null;
if (hint && lease.hostname && lease.hostname != hint[1])
host = '%s (%s)'.format(lease.hostname, hint[1]);
else if (lease.hostname)
host = lease.hostname;
if (macaddr)
vendor = macaddr[lease.macaddr.toLowerCase()]?.vendor ?? null;
const columns = [
host || '-',
lease.ipaddr,
vendor ? lease.macaddr + ` (${vendor})` : lease.macaddr,
lease.duid || '-',
lease.iaid || '-',
exp,
];
if (L.hasSystemFeature('odhcpd', 'dhcpv4'))
columns.unshift(lease.interface || '-');
if (!isReadonlyView && lease.macaddr != null) {
columns.push(E('button', {
'class': 'cbi-button cbi-button-apply',
'click': L.bind(this.handleCreateStaticLease, this, lease),
'data-tooltip': _('Reserve a specific IP address for this device'),
'disabled': this.isMACStatic[lease.macaddr.toLowerCase()]
}, [ _('Reserve IP') ]));
}
return columns;
}, this)), E('em', _('No active leases found')));
const table6 = E('table', { 'id': 'status_leases6', 'class': 'table leases6' }, [
E('tr', { 'class': 'tr table-titles' }, [
L.hasSystemFeature('odhcpd', 'dhcpv6') ? E('th', { 'class': 'th' }, _('Interface')) : E([]),
E('th', { 'class': 'th' }, _('Hostname')),
E('th', { 'class': 'th' }, _('IPv6 addresses')),
E('th', { 'class': 'th' }, _('DUID')),
E('th', { 'class': 'th' }, _('IAID')),
E('th', { 'class': 'th' }, _('Remaining time')),
isReadonlyView ? E([]) : E('th', { 'class': 'th cbi-section-actions' }, _('Static Lease'))
])
]);
cbi_update_table(table6, leases6.map(L.bind(function(lease) {
let exp;
if (lease.expires === false)
exp = E('em', _('unlimited'));
else if (lease.expires <= 0)
exp = E('em', _('expired'));
else
exp = '%t'.format(lease.expires);
const hint = lease.macaddr ? machints.filter(function(h) { return h[0] == lease.macaddr })[0] : null;
let host = null;
if (hint && lease.hostname && lease.hostname != hint[1] && lease.ip6addr != hint[1])
host = '%s (%s)'.format(lease.hostname, hint[1]);
else if (lease.hostname)
host = lease.hostname;
else if (hint)
host = hint[1];
const duid = lease.duid?.toLowerCase();
const iaid = lease.iaid?.toLowerCase();
// Note: "disabled: false" doesn't work
let disabled = null;
if (!duid)
disabled = true;
else if (duid && this.isDUIDStatic[duid])
disabled = true;
else if (duid && iaid && this.isDUIDIAIDStatic[`${duid}%${iaid}`])
disabled = true;
const columns = [
host || '-',
lease.ip6addrs ? lease.ip6addrs.join('<br />') : lease.ip6addr,
duid || '-',
iaid || '-',
exp
];
if (L.hasSystemFeature('odhcpd', 'dhcpv6'))
columns.unshift(lease.interface || '-');
if (!isReadonlyView && lease.duid) {
columns.push(E('button', {
'class': 'cbi-button cbi-button-apply',
'click': L.bind(this.handleCreateStaticLease6, this, lease),
'data-tooltip': _('Reserve a specific IP address for this device'),
'disabled': disabled
}, [ _('Reserve IP') ]));
}
return columns;
}, this)), E('em', _('No active leases found')));
return E([
E('h3', _('Active DHCPv4 Leases')),
table,
E('h3', _('Active DHCPv6 Leases')),
table6
]);
},
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,353 @@
'use strict';
'require view';
'require dom';
'require poll';
'require network';
'require ui';
return view.extend({
overviewCacheTtl: 30000,
wrapDeferredInclude: function(spec) {
return {
title: spec.title,
deferFirstLoad: true,
_moduleName: spec.name,
_include: null,
_loading: null,
resolveModule: function() {
if (this._include != null)
return Promise.resolve(this._include);
if (this._loading != null)
return this._loading;
this._loading = L.resolveDefault(L.require(this._moduleName), null).then(L.bind(function(include) {
this._include = include;
this._loading = null;
if (include != null && include.title != null)
this.title = include.title;
return include;
}, this)).catch(L.bind(function(err) {
this._loading = null;
return Promise.reject(err);
}, this));
return this._loading;
},
load: function() {
return this.resolveModule().then(function(include) {
return (include != null && typeof(include.load) == 'function') ? include.load() : null;
});
},
render: function(result) {
return (this._include != null && typeof(this._include.render) == 'function')
? this._include.render(result)
: null;
}
};
},
getIncludeCacheKey: function(include) {
return 'luci.status.include.%s'.format(include.id || '');
},
restoreIncludeCache: function(include, container) {
var cached;
var now = Date.now();
if (include.disableCache || !window.sessionStorage || !include.id)
return false;
try {
cached = JSON.parse(window.sessionStorage.getItem(this.getIncludeCacheKey(include)));
}
catch (err) {
cached = null;
}
if (!cached || typeof(cached.html) != 'string' || typeof(cached.ts) != 'number')
return false;
if ((now - cached.ts) > this.overviewCacheTtl) {
window.sessionStorage.removeItem(this.getIncludeCacheKey(include));
return false;
}
container.innerHTML = cached.html;
container.setAttribute('data-cached', '1');
return true;
},
cacheIncludeContent: function(include, container) {
if (include.disableCache || !window.sessionStorage || !include.id)
return;
try {
window.sessionStorage.setItem(this.getIncludeCacheKey(include), JSON.stringify({
ts: Date.now(),
html: container.innerHTML
}));
}
catch (err) {
}
},
clearIncludeCache: function(include) {
if (include.disableCache || !window.sessionStorage || !include.id)
return;
window.sessionStorage.removeItem(this.getIncludeCacheKey(include));
},
renderIncludePlaceholder: function(container) {
dom.content(container,
E('p', {}, E('em', { 'class': 'spinning' },
[ _('Collecting data...') ])
)
);
},
applyIncludeResult: function(include, container, result, first_load) {
var content = null;
if (include.hide && !first_load)
return;
if (typeof(include.render) == 'function')
content = include.render(result);
else if (include.content != null)
content = include.content;
if (typeof(include.oneshot) == 'function') {
include.oneshot(result);
include.oneshot = null;
}
if (content != null) {
container.parentNode.style.display = '';
container.parentNode.classList.add('fade-in');
if (!include.hide) {
dom.content(container, content);
container.removeAttribute('data-cached');
this.cacheIncludeContent(include, container);
}
}
else if (first_load) {
container.parentNode.style.display = 'none';
this.clearIncludeCache(include);
}
},
loadInclude: function(include, container, first_load) {
if (include.hide && !first_load)
return Promise.resolve(null);
if (!include.hide && !container.hasAttribute('data-cached'))
this.renderIncludePlaceholder(container);
if (typeof(include.load) == 'function') {
return include.load().then(L.bind(function(result) {
this.applyIncludeResult(include, container, result, first_load);
return result;
}, this)).catch(function() {
return null;
});
}
this.applyIncludeResult(include, container, null, first_load);
return Promise.resolve(null);
},
handleToggleSection: function(include, container, ev) {
var btn = ev.currentTarget;
include.hide = !include.hide;
btn.setAttribute('data-style', include.hide ? 'active' : 'inactive');
btn.setAttribute('class', include.hide ? 'label notice' : 'label');
btn.firstChild.data = include.hide ? _('Show') : _('Hide');
btn.blur();
container.style.display = include.hide ? 'none' : 'block';
if (include.hide) {
localStorage.setItem(include.id, 'hide');
} else {
if (!this.restoreIncludeCache(include, container))
this.renderIncludePlaceholder(container);
localStorage.removeItem(include.id);
this.loadInclude(include, container, false);
}
},
invokeIncludesLoad: function(includes, first_load) {
var tasks = [];
for (var i = 0; i < includes.length; i++) {
if (includes[i].hide && !first_load) {
tasks.push(Promise.resolve(null));
continue;
}
if (typeof(includes[i].load) == 'function') {
tasks.push(Promise.resolve(includes[i].load()).catch(function() {
return null;
}));
}
else {
tasks.push(Promise.resolve(null));
}
}
return Promise.all(tasks);
},
poll_status: function(includes, containers, first_load) {
var fetch = first_load ? Promise.resolve(null) : network.flushCache();
return fetch.then(L.bind(
this.invokeIncludesLoad, this, includes, first_load
)).then(L.bind(function(results) {
for (var i = 0; i < includes.length; i++) {
this.applyIncludeResult(includes[i], containers[i], results ? results[i] : null, first_load);
}
var ssi = document.querySelector('div.includes');
if (ssi) {
ssi.style.display = '';
ssi.classList.add('fade-in');
}
}, this));
},
loadIncludeGroup: function(includes, containers, deferred, first_load) {
var tasks = [];
for (var i = 0; i < includes.length; i++) {
if (!!includes[i].deferFirstLoad != !!deferred)
continue;
tasks.push(this.loadInclude(includes[i], containers[i], first_load));
}
return Promise.all(tasks);
},
prime_status: function(includes, containers) {
var ssi = document.querySelector('div.includes');
var deferredLoader = L.bind(function() {
this.loadIncludeGroup(includes, containers, true, true);
}, this);
for (var i = 0; i < includes.length; i++) {
if (includes[i].hide)
continue;
containers[i].parentNode.style.display = '';
if (!this.restoreIncludeCache(includes[i], containers[i]))
this.renderIncludePlaceholder(containers[i]);
}
if (ssi)
ssi.style.display = '';
this.loadIncludeGroup(includes, containers, false, true);
if (window.requestAnimationFrame)
window.requestAnimationFrame(function() { window.setTimeout(deferredLoader, 0); });
else
window.setTimeout(deferredLoader, 0);
},
startOverviewRefresh: function(includes, containers) {
/* Defer startup until after the view DOM has been inserted to keep
* first paint and poll initialization from racing each other. */
window.setTimeout(L.bind(function() {
this.prime_status(includes, containers);
poll.add(L.bind(this.poll_status, this, includes, containers, false));
poll.start();
}, this), 0);
},
load: function() {
var includeModules = [
{ name: 'view.status.include.10_system' },
{ name: 'view.status.include.19_cpu' },
{ name: 'view.status.include.15_ports', title: _('Port status'), deferFirstLoad: true },
{ name: 'view.status.include.20_memory' },
{ name: 'view.status.include.25_storage' },
{ name: 'view.status.include.30_network' },
{ name: 'view.status.include.40_dhcp', title: _('DHCP Leases'), deferFirstLoad: true },
{ name: 'view.status.include.50_dsl' },
{ name: 'view.status.include.60_wifi' }
];
return Promise.all(includeModules.map(L.bind(function(spec) {
if (spec.deferFirstLoad)
return this.wrapDeferredInclude(spec);
return L.resolveDefault(L.require(spec.name), null);
}, this))).then(function(includes) {
return includes.filter(function(include) {
return include != null;
});
});
},
render: function(includes) {
var rv = E([]), containers = [];
for (var i = 0; i < includes.length; i++) {
var title = null;
if (includes[i].title != null)
title = includes[i].title;
else
title = String(includes[i]).replace(/^\[ViewStatusInclude\d+_(.+)Class\]$/,
function(m, n) { return n.replace(/(^|_)(.)/g,
function(m, s, c) { return (s ? ' ' : '') + c.toUpperCase() })
});
includes[i].id = title;
includes[i].hide = localStorage.getItem(includes[i].id) == 'hide';
var container = E('div');
rv.appendChild(E('div', { 'class': 'cbi-section', 'style': 'display: none' }, [
E('div', { 'class': 'cbi-title' },[
E('h3', { 'style': 'display: flex; justify-content: space-between' }, [
title || '-',
E('span', {
'class': includes[i].hide ? 'label notice' : 'label',
'style': 'display: flex; align-items: center; justify-content: center; min-width: 4em',
'data-style': includes[i].hide ? 'active' : 'inactive',
'data-indicator': 'poll-status',
'data-clickable': 'true',
'click': ui.createHandlerFn(this, 'handleToggleSection',
includes[i], container)
}, [ _(includes[i].hide ? 'Show' : 'Hide') ])
]),
]),
container
]));
containers.push(container);
}
this.startOverviewRefresh(includes, containers);
return rv;
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});
@@ -0,0 +1,374 @@
'use strict';
'require view';
'require poll';
'require fs';
'require ui';
const table_names = [ 'Filter', 'NAT', 'Mangle', 'Raw' ];
const raw_style = 'font-family:monospace;font-size:smaller;text-align:right';
return view.extend({
load() {
return L.resolveDefault(fs.stat('/usr/sbin/ip6tables'));
},
createTableSection(is_ipv6, table) {
const idiv = document.querySelector('div[data-tab="%s"]'.format(is_ipv6 ? 'ip6tables' : 'iptables'));
let tdiv = idiv.querySelector('[data-table="%s-%s"]'.format(is_ipv6 ? 'ipv6' : 'ipv4', table));
const title = '%s: %s'.format(_('Table'), table);
if (!tdiv) {
tdiv = E('div', { 'data-table': '%s-%s'.format(is_ipv6 ? 'ipv6' : 'ipv4', table) }, [
E('h3', {}, title),
E('div')
]);
if (idiv.firstElementChild.nodeName.toLowerCase() === 'p')
idiv.removeChild(idiv.firstElementChild);
let added = false, thisIdx = table_names.indexOf(table);
idiv.querySelectorAll('[data-table]').forEach(function(child) {
const childIdx = table_names.indexOf(child.getAttribute('data-table').split(/-/)[1]);
if (added === false && childIdx > thisIdx) {
idiv.insertBefore(tdiv, child);
added = true;
}
});
if (added === false)
idiv.appendChild(tdiv);
}
return tdiv.lastElementChild;
},
createChainSection(is_ipv6, table, chain, policy, packets, bytes, references) {
const tdiv = this.createTableSection(is_ipv6, table);
let cdiv = tdiv.querySelector('[data-chain="%s"]'.format(chain));
let title;
if (policy)
title = '%s <em>%s</em> <span>(%s: <em>%s</em>, %d %s, %.2mB %s)</span>'
.format(_('Chain'), chain, _('Policy'), policy, packets, _('Packets'), bytes, _('Traffic'));
else
title = '%s <em>%s</em> <span class="references">(%d %s)</span>'
.format(_('Chain'), chain, references, _('References'));
if (!cdiv) {
cdiv = E('div', { 'data-chain': chain }, [
E('h4', { 'id': 'rule_%s-%s_%s'.format(is_ipv6 ? 'ipv6' : 'ipv4', table.toLowerCase(), chain) }, title),
E('table', { 'class': 'table' }, [
E('tr', { 'class': 'tr table-titles' }, [
E('th', { 'class': 'th' }, _('Pkts.')),
E('th', { 'class': 'th' }, _('Traffic')),
E('th', { 'class': 'th' }, _('Target')),
E('th', { 'class': 'th' }, _('Prot.')),
E('th', { 'class': 'th' }, _('In')),
E('th', { 'class': 'th' }, _('Out')),
E('th', { 'class': 'th' }, _('Source')),
E('th', { 'class': 'th' }, _('Destination')),
E('th', { 'class': 'th' }, _('Options')),
E('th', { 'class': 'th' }, _('Comment'))
])
])
]);
tdiv.appendChild(cdiv);
}
else {
cdiv.firstElementChild.innerHTML = title;
}
return cdiv.lastElementChild;
},
updateChainSection(chaintable, rows) {
if (!chaintable)
return;
cbi_update_table(chaintable, rows, _('No rules in this chain.'));
if (rows.length === 0 && document.querySelector('[data-hide-empty="true"]'))
chaintable.parentNode.style.display = 'none';
else
chaintable.parentNode.style.display = '';
chaintable.parentNode.setAttribute('data-empty', rows.length === 0);
},
parseIptablesDump(is_ipv6, table, s) {
let current_chain = null;
let current_rules = [];
const seen_chains = {};
const chain_refs = {};
const re = /([^\n]*)\n/g;
let m, m2;
const raw = document.querySelector('[data-raw-counters="true"]');
while ((m = re.exec(s)) != null) {
if (m[1].match(/^Chain (.+) \(policy (\w+) (\d+) packets, (\d+) bytes\)$/)) {
const chain = RegExp.$1;
const policy = RegExp.$2;
const packets = +RegExp.$3;
const bytes = +RegExp.$4;
this.updateChainSection(current_chain, current_rules);
seen_chains[chain] = true;
current_chain = this.createChainSection(is_ipv6, table, chain, policy, packets, bytes);
current_rules = [];
}
else if (m[1].match(/^Chain (.+) \((\d+) references\)$/)) {
const chain = RegExp.$1;
const references = +RegExp.$2;
this.updateChainSection(current_chain, current_rules);
seen_chains[chain] = true;
current_chain = this.createChainSection(is_ipv6, table, chain, null, null, null, references);
current_rules = [];
}
else if (m[1].match(/^num /)) {
continue;
}
else if ((m2 = m[1].match(/^(\d+) +(\d+) +(\d+) +(.*?) +(\S+) +(\S*) +(\S+) +(\S+) +(!?[a-f0-9:.]+(?:\/[a-f0-9:.]+)?) +(!?[a-f0-9:.]+(?:\/[a-f0-9:.]+)?) +(.+)$/)) !== null) {
const num = +m2[1];
const pkts = +m2[2];
const bytes = +m2[3];
const target = m2[4];
const proto = m2[5];
const indev = m2[7];
const outdev = m2[8];
const srcnet = m2[9];
const dstnet = m2[10];
let options = m2[11] || '-';
let comment = '-';
options = options.trim().replace(/(?:^| )\/\* (.+) \*\//,
function(m1, m2) {
comment = m2.replace(/^!fw3(: |$)/, '').trim() || '-';
return '';
}) || '-';
current_rules.push([
E('div', {
'class': 'nowrap',
'style': raw ? raw_style : null,
'data-format': '%.2m',
'data-value': pkts
}, (raw ? '%d' : '%.2m').format(pkts)),
E('div', {
'class': 'nowrap',
'style': raw ? raw_style : null,
'data-format': '%.2mB',
'data-value': bytes
}, (raw ? '%d' : '%.2mB').format(bytes)),
target ? '<span class="target">%s</span>'.format(target) : '-',
proto,
(indev !== '*') ? '<span class="ifacebadge nowrap">%s</span>'.format(indev) : '*',
(outdev !== '*') ? '<span class="ifacebadge nowrap">%s</span>'.format(outdev) : '*',
srcnet,
dstnet,
options,
[ comment, '%h'.format(comment) ]
]);
if (target) {
chain_refs[target] = chain_refs[target] || [];
chain_refs[target].push([ current_chain, num ]);
}
}
}
this.updateChainSection(current_chain, current_rules);
document.querySelectorAll('[data-table="%s-%s"] [data-chain]'.format(is_ipv6 ? 'ipv6' : 'ipv4', table)).forEach(L.bind(function(cdiv) {
if (!seen_chains[cdiv.getAttribute('data-chain')]) {
cdiv.parentNode.removeChild(cdiv);
return;
}
cdiv.querySelectorAll('.target').forEach(L.bind(function(tspan) {
if (seen_chains[tspan.textContent]) {
tspan.classList.add('jump');
tspan.addEventListener('click', this.handleJumpTarget);
}
}, this));
cdiv.querySelectorAll('.references').forEach(L.bind(function(rspan) {
const refs = chain_refs[cdiv.getAttribute('data-chain')];
if (refs && refs.length) {
rspan.classList.add('cbi-tooltip-container');
rspan.appendChild(E('small', { 'class': 'cbi-tooltip ifacebadge', 'style': 'top:1em; left:auto' }, [ E('ul') ]));
refs.forEach(L.bind(function(ref) {
const chain = ref[0].parentNode.getAttribute('data-chain');
const num = ref[1];
rspan.lastElementChild.lastElementChild.appendChild(E('li', {}, [
_('Chain'), ' ',
E('span', {
'class': 'jump',
'data-num': num,
'click': this.handleJumpTarget
}, chain),
', %s #%d'.format(_('Rule'), num)
]));
}, this));
}
}, this));
}, this));
},
pollFirewallLists(has_ip6tables) {
const cmds = [ '/usr/sbin/iptables' ];
if (has_ip6tables)
cmds.push('/usr/sbin/ip6tables');
poll.add(L.bind(function() {
const tasks = [];
for (let i = 0; i < cmds.length; i++) {
for (let tn of table_names) {
tasks.push(L.resolveDefault(
fs.exec_direct(cmds[i], [ '--line-numbers', '-w', '-nvxL', '-t', tn.toLowerCase() ])
.then(this.parseIptablesDump.bind(this, i > 0, tn))));
}
}
return Promise.all(tasks);
}, this));
},
handleJumpTarget(ev) {
const link = ev.target;
const table = findParent(link, '[data-table]').getAttribute('data-table');
const chain = link.textContent;
const num = +link.getAttribute('data-num');
const elem = document.getElementById('rule_%s_%s'.format(table.toLowerCase(), chain));
if (elem) {
if (elem.scrollIntoView) {
elem.scrollIntoView();
} else {
(document.documentElement || document.body.parentNode || document.body).scrollTop = elem.offsetTop - 40;
}
elem.classList.remove('flash');
void elem.offsetWidth;
elem.classList.add('flash');
if (num) {
const rule = elem.nextElementSibling.childNodes[num];
if (rule) {
rule.classList.remove('flash');
void rule.offsetWidth;
rule.classList.add('flash');
}
}
}
},
handleRawCounters(ev) {
const btn = ev.currentTarget;
const raw = (btn.getAttribute('data-raw-counters') === 'false');
btn.setAttribute('data-raw-counters', raw);
btn.firstChild.data = raw ? _('Human-readable counters') : _('Show raw counters');
btn.blur();
document.querySelectorAll('[data-value]')
.forEach(function(div) {
const fmt = raw ? '%d' : div.getAttribute('data-format');
div.style = raw ? raw_style : '';
div.innerText = fmt.format(div.getAttribute('data-value'));
});
},
handleHideEmpty(ev) {
const btn = ev.currentTarget;
const hide = (btn.getAttribute('data-hide-empty') === 'false');
btn.setAttribute('data-hide-empty', hide);
btn.firstChild.data = hide ? _('Show empty chains') : _('Hide empty chains');
btn.blur();
document.querySelectorAll('[data-chain][data-empty="true"]')
.forEach(function(chaintable) {
chaintable.style.display = hide ? 'none' : '';
});
},
handleCounterReset(has_ip6tables, ev) {
return Promise.all([
fs.exec('/usr/sbin/iptables', [ '-Z' ])
.catch(function(err) { ui.addNotification(null, E('p', {}, _('Unable to reset iptables counters: %s').format(err.message))) }),
has_ip6tables ? fs.exec('/usr/sbin/ip6tables', [ '-Z' ])
.catch(function(err) { ui.addNotification(null, E('p', {}, _('Unable to reset ip6tables counters: %s').format(err.message))) }) : null
]);
},
handleRestart(ev) {
return fs.exec_direct('/etc/init.d/firewall', [ 'restart' ])
.catch(function(err) { ui.addNotification(null, E('p', {}, _('Unable to restart firewall: %s').format(err.message))) });
},
render(has_ip6tables) {
const view = E([], [
E('style', { 'type': 'text/css' }, [
'.cbi-tooltip-container, span.jump { border-bottom:1px dotted #00f;cursor:pointer }',
'ul { list-style:none }',
'.references { position:relative }',
'.references .cbi-tooltip { left:0!important;top:1.5em!important }',
'h4>span { font-size:90% }'
]),
E('h2', {}, [ _('Firewall Status') ]),
E('div', { 'class': 'right', 'style': 'margin-bottom:-1.5em' }, [
E('button', {
'class': 'cbi-button',
'data-hide-empty': false,
'click': ui.createHandlerFn(this, 'handleHideEmpty')
}, [ _('Hide empty chains') ]),
' ',
E('button', {
'class': 'cbi-button',
'data-raw-counters': false,
'click': ui.createHandlerFn(this, 'handleRawCounters')
}, [ _('Show raw counters') ]),
' ',
E('button', {
'class': 'cbi-button',
'click': ui.createHandlerFn(this, 'handleCounterReset', has_ip6tables)
}, [ _('Reset Counters') ]),
' ',
E('button', {
'class': 'cbi-button',
'click': ui.createHandlerFn(this, 'handleRestart')
}, [ _('Restart Firewall') ])
]),
E('div', {}, [
E('div', { 'data-tab': 'iptables', 'data-tab-title': has_ip6tables ? _('IPv4 Firewall') : null, 'data-tab-active': has_ip6tables ? null : true }, [
E('p', {}, E('em', { 'class': 'spinning' }, [ _('Collecting data...') ]))
]),
has_ip6tables ? E('div', { 'data-tab': 'ip6tables', 'data-tab-title': _('IPv6 Firewall') }, [
E('p', {}, E('em', { 'class': 'spinning' }, [ _('Collecting data...') ]))
]) : E([])
])
]);
if (has_ip6tables)
ui.tabs.initTabGroup(view.lastElementChild.childNodes);
this.pollFirewallLists(has_ip6tables);
return view;
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});
@@ -0,0 +1,295 @@
'use strict';
'require view';
'require poll';
'require request';
'require rpc';
const callLuciRealtimeStats = rpc.declare({
object: 'luci',
method: 'getRealtimeStats',
params: [ 'mode', 'device' ],
expect: { result: [] }
});
const graphPolls = [];
const pollInterval = 3;
Math.log2 = Math.log2 || function(x) { return Math.log(x) * Math.LOG2E; };
return view.extend({
load() {
return Promise.all([
this.loadSVG(L.resource('svg/load.svg'))
]);
},
updateGraph(svg, lines, cb) {
const G = svg.firstElementChild;
const view = document.querySelector('#view');
const width = view.offsetWidth - 2;
const height = 300 - 2;
const step = 5;
const data_wanted = Math.floor(width / step);
const data_values = [];
for (let line of lines)
if (line != null)
data_values.push([]);
const info = {
line_current: [],
line_average: [],
line_peak: []
};
/* prefill datasets */
for (let dv of data_values)
for (let j = 0; j < data_wanted; j++)
dv[j] = 0;
/* plot horizontal time interval lines */
for (let i = width % (step * 60); i < width; i += step * 60) {
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line.setAttribute('x1', i);
line.setAttribute('y1', 0);
line.setAttribute('x2', i);
line.setAttribute('y2', '100%');
line.setAttribute('style', 'stroke:black;stroke-width:0.1');
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
text.setAttribute('x', i + 5);
text.setAttribute('y', 15);
text.setAttribute('style', 'fill:#eee; font-size:9pt; font-family:sans-serif; text-shadow:1px 1px 1px #000');
text.appendChild(document.createTextNode(Math.round((width - i) / step / 60) + 'm'));
G.appendChild(line);
G.appendChild(text);
}
info.interval = pollInterval;
info.timeframe = data_wanted / 60;
graphPolls.push({
svg: svg,
lines: lines,
cb: cb,
info: info,
width: width,
height: height,
step: step,
values: data_values,
timestamp: 0,
fill: 1
});
},
pollData() {
poll.add(L.bind(function() {
const tasks = [];
graphPolls.forEach(() => {
tasks.push(L.resolveDefault(callLuciRealtimeStats('load'), []));
});
return Promise.all(tasks).then(L.bind(function(datasets) {
for (let gi = 0; gi < graphPolls.length; gi++) {
const ctx = graphPolls[gi];
const data = datasets[gi];
const values = ctx.values;
const lines = ctx.lines;
const info = ctx.info;
let data_scale = 0;
let data_wanted = Math.floor(ctx.width / ctx.step);
let last_timestamp = NaN;
for (let i = 0, di = 0; di < lines.length; di++) {
if (lines[di] == null)
continue;
const multiply = (lines[di].multiply != null) ? lines[di].multiply : 1;
const offset = (lines[di].offset != null) ? lines[di].offset : 0;
for (let j = ctx.timestamp ? 0 : 1; j < data.length; j++) {
/* skip overlapping entries */
if (data[j][0] <= ctx.timestamp)
continue;
if (i == 0) {
ctx.fill++;
last_timestamp = data[j][0];
}
info.line_current[i] = data[j][di + 1] * multiply;
info.line_current[i] -= Math.min(info.line_current[i], offset);
values[i].push(info.line_current[i]);
}
i++;
}
/* cut off outdated entries */
ctx.fill = Math.min(ctx.fill, data_wanted);
for (let i = 0; i < values.length; i++) {
const len = values[i].length;
values[i] = values[i].slice(len - data_wanted, len);
/* find peaks, averages */
info.line_peak[i] = NaN;
info.line_average[i] = 0;
for (let j = 0; j < values[i].length; j++) {
info.line_peak[i] = isNaN(info.line_peak[i]) ? values[i][j] : Math.max(info.line_peak[i], values[i][j]);
info.line_average[i] += values[i][j];
}
info.line_average[i] = info.line_average[i] / ctx.fill;
}
info.peak = Math.max.apply(Math, info.line_peak);
/* remember current timestamp, calculate horizontal scale */
if (!isNaN(last_timestamp))
ctx.timestamp = last_timestamp;
const size = Math.floor(Math.log2(info.peak));
const div = Math.pow(2, size - (size % 10));
let mult = info.peak / div;
mult = (mult < 5) ? 2 : ((mult < 50) ? 10 : ((mult < 500) ? 100 : 1000));
info.peak = info.peak + (mult * div) - (info.peak % (mult * div));
data_scale = ctx.height / info.peak;
/* plot data */
for (let i = 0, di = 0; di < lines.length; di++) {
if (lines[di] == null)
continue;
const el = ctx.svg.firstElementChild.getElementById(lines[di].line);
let pt = '0,' + ctx.height;
let y = 0;
if (!el)
continue;
for (let j = 0; j < values[i].length; j++) {
let x = j * ctx.step;
y = ctx.height - Math.floor(values[i][j] * data_scale);
//y -= Math.floor(y % (1 / data_scale));
y = isNaN(y) ? ctx.height : y;
pt += ' ' + x + ',' + y;
}
pt += ' ' + ctx.width + ',' + y + ' ' + ctx.width + ',' + ctx.height;
el.setAttribute('points', pt);
i++;
}
info.label_25 = 0.25 * info.peak;
info.label_50 = 0.50 * info.peak;
info.label_75 = 0.75 * info.peak;
if (typeof(ctx.cb) == 'function')
ctx.cb(ctx.svg, info);
}
}, this));
}, this), pollInterval);
},
loadSVG(src) {
return request.get(src).then(function(response) {
if (!response.ok)
throw new Error(response.statusText);
return E('div', {
'style': 'width:100%;height:300px;border:1px solid #000;background:#fff'
}, E(response.text()));
});
},
render([svg]) {
const v = E('div', { 'class': 'cbi-map', 'id': 'map' }, [
E('h2', _('System load')),
E('div', {'class': 'cbi-map-descr'}, _('Load Average is a metric that is used by Linux to keep track of system resources.')),
E('div', { 'class': 'cbi-section' }, [
svg,
E('div', { 'class': 'right' }, E('small', { 'id': 'scale' }, '-')),
E('br'),
E('table', { 'class': 'table', 'style': 'width:100%;table-layout:fixed' }, [
E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td right top' }, E('strong', { 'style': 'border-bottom:2px solid #f00' }, [ _('1 Minute Load:') ])),
E('td', { 'class': 'td', 'id': 'lb_load01_cur' }, [ '0.00' ]),
E('td', { 'class': 'td right top' }, E('strong', {}, [ _('Average:') ])),
E('td', { 'class': 'td', 'id': 'lb_load01_avg' }, [ '0.00' ]),
E('td', { 'class': 'td right top' }, E('strong', {}, [ _('Peak:') ])),
E('td', { 'class': 'td', 'id': 'lb_load01_peak' }, [ '0.00' ])
]),
E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td right top' }, E('strong', { 'style': 'border-bottom:2px solid #f60' }, [ _('5 Minute Load:') ])),
E('td', { 'class': 'td', 'id': 'lb_load05_cur' }, [ '0.00' ]),
E('td', { 'class': 'td right top' }, E('strong', {}, [ _('Average:') ])),
E('td', { 'class': 'td', 'id': 'lb_load05_avg' }, [ '0.00' ]),
E('td', { 'class': 'td right top' }, E('strong', {}, [ _('Peak:') ])),
E('td', { 'class': 'td', 'id': 'lb_load05_peak' }, [ '0.00' ])
]),
E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td right top' }, E('strong', { 'style': 'border-bottom:2px solid #fa0' }, [ _('15 Minute Load:') ])),
E('td', { 'class': 'td', 'id': 'lb_load15_cur' }, [ '0.00' ]),
E('td', { 'class': 'td right top' }, E('strong', {}, [ _('Average:') ])),
E('td', { 'class': 'td', 'id': 'lb_load15_avg' }, [ '0.00' ]),
E('td', { 'class': 'td right top' }, E('strong', {}, [ _('Peak:') ])),
E('td', { 'class': 'td', 'id': 'lb_load15_peak' }, [ '0.00' ])
])
])
])
]);
this.updateGraph(svg, [ { line: 'load01' }, { line: 'load05' }, { line: 'load15' } ], function(svg, info) {
const G = svg.firstElementChild, tab = svg.parentNode;
G.getElementById('label_25').firstChild.data = '%.2f'.format(info.label_25 / 100);
G.getElementById('label_50').firstChild.data = '%.2f'.format(info.label_50 / 100);
G.getElementById('label_75').firstChild.data = '%.2f'.format(info.label_75 / 100);
tab.querySelector('#scale').firstChild.data = _('(%d minute window, %d second interval)').format(info.timeframe, info.interval);
tab.querySelector('#lb_load01_cur').firstChild.data = '%.2f'.format(info.line_current[0] / 100);
tab.querySelector('#lb_load01_avg').firstChild.data = '%.2f'.format(info.line_average[0] / 100);
tab.querySelector('#lb_load01_peak').firstChild.data = '%.2f'.format(info.line_peak[0] / 100);
tab.querySelector('#lb_load05_cur').firstChild.data = '%.2f'.format(info.line_current[1] / 100);
tab.querySelector('#lb_load05_avg').firstChild.data = '%.2f'.format(info.line_average[1] / 100);
tab.querySelector('#lb_load05_peak').firstChild.data = '%.2f'.format(info.line_peak[1] / 100);
tab.querySelector('#lb_load15_cur').firstChild.data = '%.2f'.format(info.line_current[2] / 100);
tab.querySelector('#lb_load15_avg').firstChild.data = '%.2f'.format(info.line_average[2] / 100);
tab.querySelector('#lb_load15_peak').firstChild.data = '%.2f'.format(info.line_peak[2] / 100);
});
this.pollData();
return v;
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});
@@ -0,0 +1,813 @@
'use strict';
'require view';
'require poll';
'require fs';
'require ui';
'require dom';
const expr_translations = {
'meta.iifname': _('Ingress device name', 'nft meta iifname'),
'meta.oifname': _('Egress device name', 'nft meta oifname'),
'meta.iif': _('Ingress device id', 'nft meta iif'),
'meta.oif': _('Egress device id', 'nft meta oif'),
'meta.l4proto': _('IP protocol', 'nft meta l4proto'),
'meta.l4proto.tcp': 'TCP',
'meta.l4proto.udp': 'UDP',
'meta.l4proto.icmp': 'ICMP',
'meta.l4proto.icmpv6': 'ICMPv6',
'meta.l4proto.ipv6-icmp': 'ICMPv6',
'meta.nfproto': _('Address family', 'nft meta nfproto'),
'meta.nfproto.ipv4': 'IPv4',
'meta.nfproto.ipv6': 'IPv6',
'meta.mark': _('Packet mark', 'nft meta mark'),
'meta.time': _('Packet receive time', 'nft meta time'),
'meta.hour': _('Current time', 'nft meta hour'),
'meta.day': _('Current weekday', 'nft meta day'),
'ct.state': _('Conntrack state', 'nft ct state'),
'ct.status': _('Conntrack status', 'nft ct status'),
'ct.status.dnat': 'DNAT',
'ip.dscp': 'DSCP',
'ip6.dscp': 'DSCP',
'ether.saddr': _('Source MAC address', 'nft ether saddr'),
'ether.daddr': _('Destination MAC address', 'nft ether daddr'),
'ip.protocol': _('IP protocol', 'nft ip protocol'),
'ip.protocol.tcp': 'TCP',
'ip.protocol.udp': 'UDP',
'ip.protocol.icmp': 'ICMP',
'ip.protocol.icmpv6': 'ICMPv6',
'ip.protocol.ipv6-icmp': 'ICMPv6',
'ip.saddr': _('Source IP', 'nft ip saddr'),
'ip.daddr': _('Destination IP', 'nft ip daddr'),
'ip.sport': _('Source port', 'nft ip sport'),
'ip.dport': _('Destination port', 'nft ip dport'),
'ip6.saddr': _('Source IPv6', 'nft ip6 saddr'),
'ip6.daddr': _('Destination IPv6', 'nft ip6 daddr'),
'icmp.code': _('ICMP code', 'nft icmp code'),
'icmp.type': _('ICMP type', 'nft icmp type'),
'icmpv6.code': _('ICMPv6 code', 'nft icmpv6 code'),
'icmpv6.type': _('ICMPv6 type', 'nft icmpv6 type'),
'tcp.sport': _('TCP source port', 'nft tcp sport'),
'tcp.dport': _('TCP destination port', 'nft tcp dport'),
'udp.sport': _('UDP source port', 'nft udp sport'),
'udp.dport': _('UDP destination port', 'nft udp dport'),
'tcp.flags': _('TCP flags', 'nft tcp flags'),
'th.sport': _('Transport header source port', 'nft th sport'),
'th.dport': _('Transport header destination port', 'nft th dport'),
'natflag.random': _('Randomize source port mapping', 'nft nat flag random'),
'natflag.fully-random': _('Full port randomization', 'nft nat flag fully-random'),
'natflag.persistent': _('Use same source and destination for each connection', 'nft nat flag persistent'),
'rt.mtu': _('Effective route MTU', 'nft rt mtu'),
'tcpoption.maxseg.size': _('TCP MSS', 'nft tcp option maxseg size'),
'unit.packets': _('packets', 'nft unit'),
'unit.mbytes': _('MiB', 'nft unit'),
'unit.kbytes': _('KiB', 'nft unit'),
'unit.week': _('week', 'nft unit'),
'unit.day': _('day', 'nft unit'),
'unit.hour': _('hour', 'nft unit'),
'unit.minute': _('minute', 'nft unit'),
'payload.ll': _('Link layer header bits %d-%d', 'nft @ll,off,len'),
'payload.nh': _('Network header bits %d-%d', 'nft @nh,off,len'),
'payload.th': _('Transport header bits %d-%d', 'nft @th,off,len')
};
const op_translations = {
'==': _('<var>%s</var> is <strong>%s</strong>', 'nft relational "==" operator expression'),
'!=': _('<var>%s</var> not <strong>%s</strong>', 'nft relational "!=" operator expression'),
'>=': _('<var>%s</var> greater than or equal to <strong>%s</strong>', 'nft relational ">=" operator expression'),
'<=': _('<var>%s</var> lower than or equal to <strong>%s</strong>', 'nft relational "<=" operator expression'),
'>': _('<var>%s</var> greater than <strong>%s</strong>', 'nft relational ">" operator expression'),
'<': _('<var>%s</var> lower than <strong>%s</strong>', 'nft relational "<" operator expression'),
'in': _('<var>%s</var> is one of <strong>%s</strong>', 'nft relational "in" operator expression'),
'in_set': _('<var>%s</var> in set <strong>%s</strong>', 'nft set match expression'),
'not_in_set': _('<var>%s</var> not in set <strong>%s</strong>', 'nft not in set match expression'),
};
const action_translations = {
'accept': _('Accept packet', 'nft accept action'),
'notrack': _('Do not track', 'nft notrack action'),
'drop': _('Drop packet', 'nft drop action'),
'jump': _('Continue in <strong><a href="#%q.%q">%h</a></strong>', 'nft jump action'),
'goto': _('Goto chain <strong><a href="#%q.%q">%h</a></strong>', 'nft goto action'),
'continue': _('Continue to next rule', 'nft continue action'),
'log': _('Log event', 'nft log action'),
'log.prefix': _('Log event "<strong>%h</strong>…"', 'nft log action'),
'reject.tcp reset': _('Reject packet with <strong>TCP reset</strong>', 'nft reject with tcp reset'),
'reject.icmp': _('Reject IPv4 packet with <strong>ICMP type %h</strong>', 'nft reject with icmp type'),
'reject.icmpv6': _('Reject packet with <strong>ICMPv6 type %h</strong>', 'nft reject with icmpv6 type'),
'reject.icmpx': _('Reject packet with <strong>ICMP type %h</strong>', 'nft reject with icmpx type'),
'snat.ip.addr': _('Rewrite source to <strong>%h</strong>', 'nft snat ip to addr'),
'snat.ip.addr.port': _('Rewrite source to <strong>%h</strong>, port <strong>%h</strong>', 'nft snat ip to addr:port'),
'snat.ip6.addr': _('Rewrite source to <strong>%h</strong>', 'nft snat ip6 to addr'),
'snat.ip6.addr.port': _('Rewrite source to <strong>%h</strong>, port <strong>%h</strong>', 'nft snat ip6 to addr:port'),
'dnat.ip.addr': _('Rewrite destination to <strong>%h</strong>', 'nft dnat ip to addr'),
'dnat.ip.addr.port': _('Rewrite destination to <strong>%h</strong>, port <strong>%h</strong>', 'nft dnat ip to addr:port'),
'dnat.ip6.addr': _('Rewrite destination to <strong>%h</strong>', 'nft dnat ip6 to addr'),
'dnat.ip6.addr.port': _('Rewrite destination to <strong>%h</strong>, port <strong>%h</strong>', 'nft dnat ip6 to addr:port'),
'redirect': _('Redirect to local system', 'nft redirect'),
'redirect.port': _('Redirect to local port <strong>%h</strong>', 'nft redirect to port'),
'masquerade': _('Rewrite to egress device address'),
'mangle': _('Set header field <var>%s</var> to <strong>%s</strong>', 'nft mangle'),
'limit': _('At most <strong>%h</strong> per <strong>%h</strong>, burst of <strong>%h</strong>'),
'limit.burst': _('At most <strong>%h</strong> per <strong>%h</strong>, burst of <strong>%h</strong>'),
'limit.inv': _('At least <strong>%h</strong> per <strong>%h</strong>, burst of <strong>%h</strong>'),
'limit.inv.burst': _('At least <strong>%h</strong> per <strong>%h</strong>, burst of <strong>%h</strong>'),
'return': _('Continue in calling chain'),
'ct helper': _('Utilise <strong>%h</strong> conntrack helper'),
'flow': _('Utilize flow table <strong>%h</strong>')
};
return view.extend({
load() {
return Promise.all([
L.resolveDefault(fs.exec_direct('/usr/sbin/nft', [ '--terse', '--json', 'list', 'ruleset' ], 'json'), {}),
fs.stat('/usr/sbin/iptables-legacy-save').then(function() {
return L.resolveDefault(fs.exec_direct('/usr/sbin/iptables-legacy-save'), '');
}).catch(function() {
return L.resolveDefault(fs.exec_direct('/usr/sbin/iptables-save'), '');
}),
fs.stat('/usr/sbin/ip6tables-legacy-save').then(function() {
return L.resolveDefault(fs.exec_direct('/usr/sbin/ip6tables-legacy-save'), '');
}).catch(function() {
return L.resolveDefault(fs.exec_direct('/usr/sbin/ip6tables-save'), '');
})
]);
},
isActionExpression(expr) {
for (let k in expr) {
if (expr.hasOwnProperty(k)) {
switch (k) {
case 'accept':
case 'notrack':
case 'reject':
case 'drop':
case 'jump':
case 'goto':
case 'continue':
case 'snat':
case 'dnat':
case 'redirect':
case 'mangle':
case 'masquerade':
case 'return':
case 'flow':
case 'ct helper':
case 'log':
return true;
}
}
}
return false;
},
exprToKey(expr) {
let kind, spec;
if (!Array.isArray(expr) && typeof(expr) == 'object') {
for (let k in expr) {
if (expr.hasOwnProperty(k)) {
kind = k;
spec = expr[k];
break;
}
}
}
switch (kind || '-') {
case 'meta':
case 'ct':
case 'rt':
return '%h.%h'.format(kind, spec.key);
case 'tcp option':
return 'tcpoption.%h.%h'.format(spec.name, spec.field);
case 'reject':
return 'reject.%h'.format(spec.type);
}
return null;
},
exprToString(expr, hint) {
let kind, spec;
if (typeof(expr) != 'object') {
let s;
if (hint)
s = expr_translations['%s.%h'.format(hint, expr)];
return s || '%h'.format(expr);
}
if (Array.isArray(expr)) {
kind = 'list';
spec = expr;
}
else {
for (let k in expr) {
if (expr.hasOwnProperty(k)) {
kind = k;
spec = expr[k];
}
}
}
if (!kind)
return '';
const items = [];
const lis = [];
let tpl, k;
switch (kind) {
case 'prefix':
return '%h/%d'.format(spec.addr, spec.len);
case 'set':
case 'list':
for (var i = 0; i < spec.length; i++) {
items.push('<span class="nft-set-item">%s</span>'.format(this.exprToString(spec[i])));
lis.push('<span class="ifacebadge">%s</span>'.format(this.exprToString(spec[i])));
}
if (kind == 'set')
tpl = '<div class="nft-set cbi-tooltip-container">{ <span class="nft-set-items">%s</span> }<div class="cbi-tooltip">%s</div></div>';
else
tpl = '<div class="nft-list cbi-tooltip-container"><span class="nft-list-items">%s</span><div class="cbi-tooltip">%s</div></div>';
return tpl.format(items.join(', '), lis.join('<br />'));
case 'concat':
for (let s of spec)
items.push(this.exprToString(s));
return items.join('+');
case 'range':
return '%s-%s'.format(this.exprToString(spec[0], hint), this.exprToString(spec[1], hint));
case 'payload':
if (spec.protocol && spec.field) {
k = '%h.%h'.format(spec.protocol, spec.field);
return expr_translations[k] || '<em>%s</em>'.format(k);
}
else if (spec.base && spec.offset != null && spec.len != null) {
k = 'payload.%h'.format(spec.base);
return (expr_translations[k] || '<em>@%s,%%d,%%d</em>'.format(spec.base)).format(spec.offset + 1, spec.offset + spec.len + 1);
}
return 'payload: %s'.format(kind, JSON.stringify(spec));
case '&':
case '|':
case '^':
return '%s %h %s'.format(
this.exprToString(spec[0], hint),
kind,
Array.isArray(spec[1]) ? '(%h)'.format(spec[1].join('|')) : this.exprToString(spec[1], hint));
default:
k = this.exprToKey(expr);
if (k)
return expr_translations[k] || '<em>%s</em>'.format(k);
return '%s: %s'.format(kind, JSON.stringify(spec));
}
},
renderVMap(spec, table) {
// spec: { key: {...}, data: { set: [ [mapkey, actionSpec], ... ] } }
const matchElem = E('span', { 'class': 'ifacebadge' },
_('Verdict map: <var>%h</var> is').format(this.exprToString(spec.key)));
const actions = [];
const keys = [];
if (spec && spec.data && Array.isArray(spec.data.set)) {
// For each mapping entry, render key and the action
for (var i = 0; i < spec.data.set.length; i++) {
const entry = spec.data.set[i];
const mapKey = entry[0];
const actionSpec = entry[1];
// if actionSpec is an action (accept/drop/jump/...), render it as action element
if (typeof(actionSpec) == 'object' && this.isActionExpression(actionSpec)) {
// renderExpr expects an expr object like { accept: null } or { jump: {...} }
const renderedAction = this.renderExpr(actionSpec, table);
actions.push(E('div', { }, [
E('span', [ this.exprToString(mapKey) + ':' ]),
' ',
renderedAction
]));
keys.push('<strong>%s</strong>'.format(this.exprToString(mapKey)));
} else {
// fallback: render the actionSpec as generic JSON tooltip
actions.push(E('span', { 'class': 'ifacebadge', 'data-tooltip': JSON.stringify(actionSpec) },
this.exprToString(mapKey) + ':' + this.exprToString(actionSpec)));
keys.push('<strong>%s</strong>'.format(this.exprToString(mapKey)));
}
}
dom.append(matchElem, [ ' ', E('span', {}, keys.join(_(' or '))) ]);
}
return { match: matchElem, actions: actions };
},
renderMatchExpr(spec) {
switch (spec.op) {
case '==':
case '!=':
if ((typeof(spec.right) == 'object' && spec.right.set) ||
(typeof(spec.right) == 'string' && spec.right.charAt(0) == '@'))
spec.op = (spec.op == '==') ? 'in_set' : 'not_in_set';
break;
case 'in':
if (typeof(spec.right) != 'object')
spec.op = '==';
break;
}
return E('span', { 'class': 'ifacebadge' },
(op_translations[spec.op] || '<var>%%s</var> %h <strong>%%s</strong>'.format(spec.op)).format(
this.exprToString(spec.left),
this.exprToString(spec.right, this.exprToKey(spec.left))
)
);
},
renderNatFlags(spec) {
const f = [];
if (spec && Array.isArray(spec.flags)) {
for (let sf of spec.flags)
f.push(expr_translations['natflag.%h'.format(sf)] || sf);
}
return f.length ? E('small', { 'class': 'cbi-tooltip-container' }, [
' (',
N_(f.length, '1 flag', '%d flags', 'nft amount of flags').format(f.length),
')',
E('span', { 'class': 'cbi-tooltip' }, f.join('<br />'))
]) : E([]);
},
renderRateUnit(value, unit) {
if (!unit)
unit = 'packets';
return '%d\xa0%s'.format(
value,
expr_translations['unit.%h'.format(unit)] || unit
);
},
renderExpr(expr, table) {
let kind, spec;
for (let k in expr) {
if (expr.hasOwnProperty(k)) {
kind = k;
spec = expr[k];
}
}
if (!kind)
return E([]);
let k;
let a = [];
switch (kind) {
case 'match':
return this.renderMatchExpr(spec);
case 'ct helper':
return E('span', {
'class': 'ifacebadge'
}, action_translations[kind].format(spec));
case 'reject':
k = 'reject.%s'.format(spec.type);
return E('span', {
'class': 'ifacebadge'
}, (action_translations[k] || k).format(this.exprToString(spec.expr)));
case 'accept':
case 'notrack':
case 'drop':
return E('span', {
'class': 'ifacebadge'
}, action_translations[kind] || '<em>%h</em>'.format(kind));
case 'jump':
return E('span', {
'class': 'ifacebadge'
}, action_translations.jump.format(table, spec.target, spec.target));
case 'goto':
return E('span', {
'class': 'ifacebadge'
}, action_translations.goto.format(table, spec.target, spec.target));
case 'continue':
return E('span', {
'class': 'ifacebadge'
}, action_translations.continue);
case 'return':
return E('span', {
'class': 'ifacebadge'
}, action_translations.return);
case 'snat':
case 'dnat':
k = '%h.%h'.format(kind, spec.family);
if (spec.addr) {
k += '.addr';
a.push(this.exprToString(spec.addr));
}
if (spec.port) {
k += '.port';
a.push(this.exprToString(spec.port));
}
return E('span', { 'class': 'ifacebadge' }, [
E('span', ''.format.apply(action_translations[k] || k, a)),
this.renderNatFlags(spec)
]);
case 'redirect':
k = 'redirect';
if (spec && spec.port) {
k += '.port';
a.push(this.exprToString(spec.port));
}
return E('span', { 'class': 'ifacebadge' }, [
E('span', ''.format.apply(action_translations[k] || k, a)),
this.renderNatFlags(spec)
]);
case 'masquerade':
return E('span', { 'class': 'ifacebadge' }, [
E('span', action_translations.masquerade),
this.renderNatFlags(spec)
]);
case 'mangle':
return E('span', { 'class': 'ifacebadge' },
action_translations.mangle.format(
this.exprToString(spec.key),
this.exprToString(spec.value)
));
case 'limit':
k = 'limit';
a = [
this.renderRateUnit(spec.rate, spec.rate_unit),
expr_translations['unit.%h'.format(spec.per)] || spec.per
];
if (spec.inv)
k += '.inv';
if (spec.burst) {
k += '.burst';
a.push(this.renderRateUnit(spec.burst, spec.burst_unit));
}
return E('span', { 'class': 'ifacebadge', 'cbi-tooltip': JSON.stringify(spec) },
''.format.apply(action_translations[k] || k, a));
case 'flow':
return E('span', {
'class': 'ifacebadge'
}, action_translations.flow.format(spec.flowtable.replace(/^@/, '')));
case 'log':
return E('span', {
'class': 'ifacebadge'
}, spec?.prefix ?
''.format.apply(action_translations['log.prefix'], [spec?.prefix])
: action_translations.log);
default:
return E('span', {
'class': 'ifacebadge',
'data-tooltip': JSON.stringify(spec)
}, [ '{ ', E('strong', {}, [ kind ]), ' }' ]);
}
},
renderCounter(data) {
return E('span', { 'class': 'ifacebadge cbi-tooltip-container nft-counter' }, [
E('var', [ '%.1024mB'.format(data.bytes) ]),
E('div', { 'class': 'cbi-tooltip' }, [
_('Traffic matched by rule: %.1000mPackets, %.1024mBytes', 'nft counter').format(data.packets, data.bytes)
])
]);
},
renderComment(comment) {
return E('span', { 'class': 'ifacebadge cbi-tooltip-container nft-comment' }, [
E('var', [ '#' ]),
E('div', { 'class': 'cbi-tooltip' }, [
_('Rule comment: %s', 'nft comment').format(comment.replace(/^!fw4: /, ''))
])
]);
},
renderRule(data, spec) {
let empty = true;
const row = E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td', 'style': 'width:60%' }),
E('td', { 'class': 'td', 'style': 'width:40%' })
]);
if (Array.isArray(spec.expr)) {
for (let se of spec.expr) {
// nftables JSON format bug, `flow` targets are currently not properly serialized
if (typeof(se) == 'string' && se.match(/^flow add (@\S+)$/))
se = { flow: { op: "add", flowtable: RegExp.$1 } };
// vmap special handling
if (se && se.vmap) {
const vm = this.renderVMap(se.vmap, spec.table);
// add match summary to left column
dom.append(row.childNodes[0], [ vm.match ]);
empty = false;
if (typeof(se) == 'object' && se.counter) {
row.childNodes[0].appendChild(
this.renderCounter(se.counter));
}
// append each mapped action to the actions column
for (let vma of vm.actions)
dom.append(row.childNodes[1], [ vma ]);
continue;
}
const res = this.renderExpr(se, spec.table);
if (typeof(se) == 'object' && se.counter) {
row.childNodes[0].insertBefore(
this.renderCounter(se.counter),
row.childNodes[0].firstChild);
}
else if (this.isActionExpression(se)) {
dom.append(row.childNodes[1], [ res ]);
}
else {
dom.append(row.childNodes[0], [ res ]);
empty = false;
}
}
}
if (spec.comment) {
row.childNodes[0].insertBefore(
this.renderComment(spec.comment),
row.childNodes[0].firstChild);
}
if (empty)
dom.append(row.childNodes[0], E('span', { 'class': 'ifacebadge' }, '<em>%h</em>'.format(_('Any packet', 'nft match any traffic'))));
return row;
},
renderChain(data, spec) {
let title, policy, hook;
switch (spec.type) {
case 'filter':
title = _('Traffic filter chain "%h"').format(spec.name);
break;
case 'route':
title = _('Route action chain "%h"').format(spec.name);
break;
case 'nat':
title = _('NAT action chain "%h"').format(spec.name);
break;
default:
title = _('Rule container chain "%h"').format(spec.name);
break;
}
switch (spec.policy) {
case 'drop':
policy = _('Drop unmatched packets', 'Chain policy: drop');
break;
default:
policy = _('Continue processing unmatched packets', 'Chain policy: accept');
break;
}
switch (spec.hook) {
case 'ingress':
hook = _('Capture packets directly after the NIC received them', 'Chain hook: ingress');
break;
case 'prerouting':
hook = _('Capture incoming packets before any routing decision', 'Chain hook: prerouting');
break;
case 'input':
hook = _('Capture incoming packets routed to the local system', 'Chain hook: input');
break;
case 'forward':
hook = _('Capture incoming packets addressed to other hosts', 'Chain hook: forward');
break;
case 'output':
hook = _('Capture outgoing packets originating from the local system', 'Chain hook: output');
break;
case 'postrouting':
hook = _('Capture outgoing packets after any routing decision', 'Chain hook: postrouting');
break;
default:
hook = _('Chain hook "%h"', 'Yet unknown nftables chain hook').format(spec.hook);
break;
}
const node = E('div', { 'class': 'nft-chain' }, [
E('h4', {
'id': '%h.%h'.format(spec.table, spec.name)
}, [ title ])
]);
if (spec.hook) {
node.appendChild(E('div', { 'class': 'nft-chain-hook' }, [
E('ul', {}, [
E('li', {}, _('Hook: <strong>%h</strong> (%h), Priority: <strong>%d</strong>', 'Chain hook description').format(spec.hook, hook, spec.prio)),
E('li', {}, _('Policy: <strong>%h</strong> (%h)', 'Chain hook policy').format(spec.policy, policy))
])
]));
}
node.appendChild(E('table', { 'class': 'nft-rules table cbi-section-table' }, [
E('tr', { 'class': 'tr table-titles' }, [
E('th', { 'class': 'th', 'style': 'width:60%' }, [ _('Rule matches') ]),
E('th', { 'class': 'th', 'style': 'width:40%' }, [ _('Rule actions') ])
])
]));
for (let d of data)
if (typeof(d.rule) == 'object' && d.rule.table == spec.table && d.rule.chain == spec.name && d.rule.family == spec.family)
node.lastElementChild.appendChild(this.renderRule(data, d.rule));
if (node.lastElementChild.childNodes.length == 1)
node.lastElementChild.appendChild(E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td center', 'colspan': 3 }, [
E('em', [ _('No rules in this chain', 'nft chain is empty') ])
])
]));
return node;
},
renderTable(data, spec) {
let title;
switch (spec.family) {
case 'ip':
title = _('IPv4 traffic table "%h"').format(spec.name);
break;
case 'ip6':
title = _('IPv6 traffic table "%h"').format(spec.name);
break;
case 'inet':
title = _('IPv4/IPv6 traffic table "%h"').format(spec.name);
break;
case 'arp':
title = _('ARP traffic table "%h"').format(spec.name);
break;
case 'bridge':
title = _('Bridge traffic table "%h"').format(spec.name);
break;
case 'netdev':
title = _('Network device table "%h"').format(spec.name);
break;
default:
title = _('"%h" table "%h"', 'Yet unknown nftables table family ("family" table "name")').format(spec.family, spec.name);
break;
}
const node = E([], [
E('style', { 'type': 'text/css' }, [
'.nft-rules .ifacebadge { margin: .125em }',
'.nft-rules tr > td { padding: .25em !important }',
'.nft-set, .nft-list { display: inline-block; vertical-align: middle }',
'.nft-set-items, .nft-list-items { display: inline-block; vertical-align: middle; max-width: 200px; overflow: hidden; text-overflow: ellipsis }',
'.ifacebadge.cbi-tooltip-container { cursor: help }',
'.ifacebadge.cbi-tooltip-container .cbi-tooltip { padding: .5em }'
]),
E('div', { 'class': 'nft-table' }, [
E('h3', [ title ]),
E('div', { 'class': 'nft-chains' })
])
]);
for (let d of data)
if (typeof(d.chain) == 'object' && d.chain.table == spec.name && d.chain.family == spec.family)
node.lastElementChild.lastElementChild.appendChild(this.renderChain(data, d.chain));
return node;
},
checkLegacyRules(ipt4save, ipt6save) {
if (ipt4save.match(/\n-A /) || ipt6save.match(/\n-A /)) {
ui.addNotification(_('Legacy rules detected'), [
E('button', {
'class': 'btn cbi-button',
'click': function() { location.href = 'nftables/iptables' }
}, _('Open iptables rules overview…'))
], 'warning');
}
},
render([nft, ipt, ipt6]) {
const view = E('div');
let has_tables = false;
this.checkLegacyRules(ipt, ipt6);
if (Array.isArray(nft.nftables))
for (let t of nft.nftables)
if (t.hasOwnProperty('table'))
has_tables = true;
if (!has_tables) {
window.location.replace(L.url('admin/status/nftables/iptables'));
return E('em', _('Redirecting to iptables rules overview...'));
}
for (let t of nft.nftables)
if (t.hasOwnProperty('table'))
view.appendChild(this.renderTable(nft.nftables, t.table));
return view;
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});
@@ -0,0 +1,87 @@
'use strict';
'require view';
'require fs';
'require ui';
'require rpc';
var callLuciProcessList = rpc.declare({
object: 'luci',
method: 'getProcessList',
expect: { result: [] }
});
return view.extend({
load: function() {
return callLuciProcessList();
},
handleSignal: function(signum, pid, ev) {
return fs.exec('/bin/kill', ['-%d'.format(signum), '%s'.format(pid)]).then(L.bind(function() {
return callLuciProcessList().then(L.bind(function(processes) {
this.updateTable('.table', processes);
}, this));
}, this)).catch(function(e) { ui.addNotification(null, E('p', e.message)) });
},
updateTable: function(table, processes) {
var rows = [];
processes.sort(function(a, b) {
return (a.PID - b.PID);
});
for (var i = 0; i < processes.length; i++) {
var proc = processes[i];
rows.push([
proc.PID,
proc.USER,
E('span', {'style': 'word-break: break-word'}, proc.COMMAND),
proc['%CPU'],
proc['%MEM'],
E('div', {}, [
E('button', {
'class': 'btn cbi-button-action',
'click': ui.createHandlerFn(this, 'handleSignal', 1, proc.PID)
}, _('Hang Up')), ' ',
E('button', {
'class': 'btn cbi-button-negative',
'click': ui.createHandlerFn(this, 'handleSignal', 15, proc.PID)
}, _('Terminate')), ' ',
E('button', {
'class': 'btn cbi-button-negative',
'click': ui.createHandlerFn(this, 'handleSignal', 9, proc.PID)
}, _('Kill'))
])
]);
}
cbi_update_table(table, rows, E('em', _('No information available')));
},
render: function(processes) {
var v = E([], [
E('h2', _('Processes')),
E('div', { 'class': 'cbi-map-descr' }, _('This list gives an overview over currently running system processes and their status.')),
E('table', { 'class': 'table' }, [
E('tr', { 'class': 'tr table-titles' }, [
E('th', { 'class': 'th' }, _('PID')),
E('th', { 'class': 'th' }, _('Owner')),
E('th', { 'class': 'th' }, _('Command')),
E('th', { 'class': 'th' }, _('CPU usage (%)')),
E('th', { 'class': 'th' }, _('Memory usage (%)')),
E('th', { 'class': 'th center nowrap cbi-section-actions' })
])
])
]);
this.updateTable(v.lastElementChild, processes);
return v;
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});
@@ -0,0 +1,282 @@
'use strict';
'require fs';
'require rpc';
'require ui';
'require validation';
'require view';
const callNetworkInterfaceDump = rpc.declare({
object: 'network.interface',
method: 'dump',
expect: { interface: [] }
});
const callUfpList = rpc.declare({
object: 'fingerprint',
method: 'fingerprint',
expect: { '': {} }
});
function applyMask(addr, mask, v6) {
const words = v6 ? validation.parseIPv6(addr) : validation.parseIPv4(addr);
const bword = v6 ? 0xffff : 0xff;
const bwlen = v6 ? 16 : 8;
if (!words || mask < 0 || mask > (v6 ? 128 : 32))
return null;
for (let i = 0; i < words.length; i++) {
const b = Math.min(mask, bwlen);
words[i] &= (bword << (bwlen - b)) & bword;
mask -= b;
}
return String.prototype.format.apply(
v6 ? '%x:%x:%x:%x:%x:%x:%x:%x' : '%d.%d.%d.%d', words);
}
return view.extend({
load() {
return Promise.all([
callNetworkInterfaceDump(),
L.resolveDefault(fs.exec('/sbin/ip', [ '-4', 'neigh', 'show' ]), { stdout: '' }),
L.resolveDefault(fs.exec('/sbin/ip', [ '-4', 'route', 'show', 'table', 'all' ]), { stdout: '' }),
L.resolveDefault(fs.exec('/sbin/ip', [ '-4', 'rule', 'show' ]), { stdout: '' }),
L.resolveDefault(fs.exec('/sbin/ip', [ '-6', 'neigh', 'show' ]), { stdout: '' }),
L.resolveDefault(fs.exec('/sbin/ip', [ '-6', 'route', 'show', 'table', 'all' ]), { stdout: '' }),
L.resolveDefault(fs.exec('/sbin/ip', [ '-6', 'rule', 'show' ]), { stdout: '' }),
L.hasSystemFeature('ufpd') ? callUfpList() : null
]);
},
getNetworkByDevice(networks, dev, addr, mask, v6) {
const addr_arrays = [ 'ipv4-address', 'ipv6-address', 'ipv6-prefix', 'ipv6-prefix-assignment', 'route' ];
let matching_iface = null;
let matching_prefix = -1;
for (const net of networks) {
if (!L.isObject(net) || (net.l3_device !== dev && net.device !== dev))
continue;
for (const key of addr_arrays) {
const list = net[key];
if (!Array.isArray(list)) continue;
for (const { address, target, mask: cmp_mask } of list) {
const cmp_addr = address || target;
if (!cmp_addr) continue;
if (applyMask(cmp_addr, cmp_mask, v6) !== applyMask(addr, cmp_mask, v6) || mask < cmp_mask)
continue;
if (cmp_mask > matching_prefix) {
matching_iface = net.interface;
matching_prefix = cmp_mask;
}
}
}
}
return matching_iface;
},
parseNeighbs(nbs, macs, networks, v6) {
if (!nbs) return [];
const res = [];
for (const line of nbs.trim().split(/\n/)) {
const [, addr = null, f = [], state = null] = line.match(/^([0-9a-f:.]+) (.+) (\S+) *$/);
const flags = f?.trim?.().split?.(/\s+/);
let vendor;
if (!addr || !state || addr.match(/^fe[89a-f][0-9a-f]:/))
continue;
for (let j = 0; j < flags.length; j += 2)
flags[flags[j]] = flags[j + 1];
if (!flags.lladdr)
continue;
for (let mac in macs) {
if (flags.lladdr === mac)
vendor = macs[mac].vendor;
}
const net = this.getNetworkByDevice(networks, flags.dev, addr, v6 ? 128 : 32, v6);
res.push([
addr,
vendor ? flags.lladdr.toUpperCase() + ` (${vendor})` : flags.lladdr.toUpperCase(),
E('span', { 'class': 'ifacebadge' }, [ net ? net : '(%s)'.format(flags.dev) ]),
]);
}
return res;
},
parseRoutes(routes, networks, v6) {
if (!routes) return [];
const res = [];
for (const line of routes.trim().split(/\n/)) {
const [, type = 'unicast', d, f = [] ] = line.match(/^(?:([a-z_]+|\d+) )?(default|[0-9a-f:./]+) (.+)$/);
const dest = d == 'default' ? (v6 ? '::/0' : '0.0.0.0/0') : d;
const flags = f?.trim?.().split?.(/\s+/);
if (!dest || type != 'unicast' || dest == 'fe80::/64' || dest == 'ff00::/8')
continue;
for (let j = 0; j < flags.length; j += 2)
flags[flags[j]] = flags[j + 1];
const [addr, bits = (v6 ? 128 : 32)] = dest.split('/');
const net = this.getNetworkByDevice(networks, flags.dev, addr, bits, v6);
res.push([
E('span', { 'class': 'ifacebadge' }, [ net ? net : '(%s)'.format(flags.dev) ]),
dest,
flags.via || '-',
flags.src || flags.from || '-',
String(flags.metric || 0),
flags.table || 'main',
flags.proto,
]);
}
return res;
},
parseRules: rules => rules?.trim()?.split('\n')?.map(l => {
const [, prio=null, rule=null] = l.match(/^(\d+):\s+(.+)$/) || [];
return [prio, rule];
}),
render([
networks,
{ stdout: ip4neigh = '' } = {},
{ stdout: ip4route = '' } = {},
{ stdout: ip4rule = '' } = {},
{ stdout: ip6neigh = '' } = {},
{ stdout: ip6route = '' } = {},
{ stdout: ip6rule = '' } = {},
macdata,
]) {
const device_title = _('Which is used to access this %s').format(_('Target'));
const target_title = _('Network and its mask that define the size of the destination');
const gateway_title = _('The address through which this %s is reachable').format(_('Target'));
const metric_title = _('Quantifies the cost or distance to a destination in a way that allows routers to make informed decisions about the optimal path to forward data packets');
const table_title = _('Common name or numeric ID of the %s in which this route is found').format(_('Table'));
const proto_title = _('The routing protocol identifier of this route');
const source_title = _('Network and its mask that define which source addresses use this route');
const neigh4tbl = E('table', { 'class': 'table' }, [
E('tr', { 'class': 'tr table-titles' }, [
E('th', { 'class': 'th' }, [ _('IP address') ]),
E('th', { 'class': 'th' }, [ _('MAC address') ]),
E('th', { 'class': 'th' }, [ _('Interface') ]),
])
]);
const route4tbl = E('table', { 'class': 'table' }, [
E('tr', { 'class': 'tr table-titles' }, [
E('th', { 'class': 'th', 'title': device_title }, [ _('Device') ]),
E('th', { 'class': 'th', 'title': target_title }, [ _('Target') ]),
E('th', { 'class': 'th', 'title': gateway_title }, [ _('Gateway') ]),
E('th', { 'class': 'th', 'title': source_title }, [ _('Source') ]),
E('th', { 'class': 'th', 'title': metric_title }, [ _('Metric') ]),
E('th', { 'class': 'th', 'title': table_title }, [ _('Table') ]),
E('th', { 'class': 'th', 'title': proto_title }, [ _('Protocol') ]),
])
]);
const rule4tbl = E('table', { 'class': 'table' }, [
E('tr', { 'class': 'tr table-titles' }, [
E('th', { 'class': 'th' }, [ _('Priority') ]),
E('th', { 'class': 'th' }, [ _('Rule') ]),
])
]);
const neigh6tbl = E('table', { 'class': 'table' }, [
E('tr', { 'class': 'tr table-titles' }, [
E('th', { 'class': 'th' }, [ _('IP address') ]),
E('th', { 'class': 'th' }, [ _('MAC address') ]),
E('th', { 'class': 'th' }, [ _('Interface') ]),
])
]);
const route6tbl = E('table', { 'class': 'table' }, [
E('tr', { 'class': 'tr table-titles' }, [
E('th', { 'class': 'th', 'title': device_title }, [ _('Device') ]),
E('th', { 'class': 'th', 'title': target_title }, [ _('Target') ]),
E('th', { 'class': 'th', 'title': gateway_title }, [ _('Gateway') ]),
E('th', { 'class': 'th', 'title': source_title }, [ _('Source') ]),
E('th', { 'class': 'th', 'title': metric_title }, [ _('Metric') ]),
E('th', { 'class': 'th', 'title': table_title }, [ _('Table') ]),
E('th', { 'class': 'th', 'title': proto_title }, [ _('Protocol') ]),
])
]);
const rule6tbl = E('table', { 'class': 'table' }, [
E('tr', { 'class': 'tr table-titles' }, [
E('th', { 'class': 'th' }, [ _('Priority') ]),
E('th', { 'class': 'th' }, [ _('Rule') ]),
])
]);
cbi_update_table(neigh4tbl, this.parseNeighbs(ip4neigh, macdata, networks, false),
E('em', _('No entries available'))
);
cbi_update_table(route4tbl, this.parseRoutes(ip4route, networks, false),
E('em', _('No entries available'))
);
cbi_update_table(rule4tbl, this.parseRules(ip4rule),
E('em', _('No entries available'))
);
cbi_update_table(neigh6tbl, this.parseNeighbs(ip6neigh, macdata, networks, true),
E('em', _('No entries available'))
);
cbi_update_table(route6tbl, this.parseRoutes(ip6route, networks, true),
E('em', _('No entries available'))
);
cbi_update_table(rule6tbl, this.parseRules(ip6rule),
E('em', _('No entries available'))
);
const view = E([], [
E('h2', {}, [ _('Routing') ]),
E('p', {}, [ _('The following rules are currently active on this system.') ]),
E('div', {}, [
E('div', { 'class': 'cbi-section', 'data-tab': 'ipv4routing', 'data-tab-title': _('IPv4 Routing') }, [
E('h3', {}, [ _('IPv4 Neighbours') ]),
neigh4tbl,
E('h3', {}, [ _('Active IPv4 Routes') ]),
route4tbl,
E('h3', {}, [ _('Active IPv4 Rules') ]),
rule4tbl,
]),
E('div', { 'class': 'cbi-section', 'data-tab': 'ipv6routing', 'data-tab-title': _('IPv6 Routing') }, [
E('h3', {}, [ _('IPv6 Neighbours') ]),
neigh6tbl,
E('h3', {}, [ _('Active IPv6 Routes') ]),
route6tbl,
E('h3', {}, [ _('Active IPv6 Rules') ]),
rule6tbl,
])
])
]);
ui.tabs.initTabGroup(view.lastElementChild.childNodes);
return view;
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});
@@ -0,0 +1,334 @@
'use strict';
'require fs';
'require rpc';
'require tools.network as tn';
'require ui';
'require validation';
'require view';
const callNetworkInterfaceDump = rpc.declare({
object: 'network.interface',
method: 'dump',
expect: { interface: [] }
});
const callUfpList = rpc.declare({
object: 'fingerprint',
method: 'fingerprint',
expect: { '': {} }
});
function applyMask(addr, mask, v6) {
const words = v6 ? validation.parseIPv6(addr) : validation.parseIPv4(addr);
const bword = v6 ? 0xffff : 0xff;
const bwlen = v6 ? 16 : 8;
if (!words || mask < 0 || mask > (v6 ? 128 : 32))
return null;
for (let i = 0; i < words.length; i++) {
const b = Math.min(mask, bwlen);
words[i] &= (bword << (bwlen - b)) & bword;
mask -= b;
}
return String.prototype.format.apply(
v6 ? '%x:%x:%x:%x:%x:%x:%x:%x' : '%d.%d.%d.%d', words);
}
return view.extend({
load() {
return Promise.all([
callNetworkInterfaceDump(),
L.resolveDefault(fs.exec('/sbin/ip', [ '-4', '-j', 'neigh', 'show' ]), {}),
L.resolveDefault(fs.exec('/sbin/ip', [ '-4', '-j', 'route', 'show', 'table', 'all' ]), {}),
L.resolveDefault(fs.exec('/sbin/ip', [ '-4', '-j', 'rule', 'show' ]), {}),
L.resolveDefault(fs.exec('/sbin/ip', [ '-6', '-j', 'neigh', 'show' ]), {}),
L.resolveDefault(fs.exec('/sbin/ip', [ '-6', '-j', 'route', 'show', 'table', 'all' ]), {}),
L.resolveDefault(fs.exec('/sbin/ip', [ '-6', '-j', 'rule', 'show' ]), {}),
L.hasSystemFeature('ufpd') ? callUfpList() : null,
]);
},
getNetworkByDevice(networks, dev, addr, mask, v6) {
const addr_arrays = [ 'ipv4-address', 'ipv6-address', 'ipv6-prefix', 'ipv6-prefix-assignment', 'route' ];
let matching_iface = null;
let matching_prefix = -1;
for (const net of networks) {
if (!L.isObject(net) || (net.l3_device !== dev && net.device !== dev))
continue;
for (const key of addr_arrays) {
const list = net[key];
if (!Array.isArray(list)) continue;
for (const { address, target, mask: cmp_mask } of list) {
const cmp_addr = address || target;
if (!cmp_addr) continue;
if (applyMask(cmp_addr, cmp_mask, v6) !== applyMask(addr, cmp_mask, v6) || mask < cmp_mask)
continue;
if (cmp_mask > matching_prefix) {
matching_iface = net.interface;
matching_prefix = cmp_mask;
}
}
}
}
return matching_iface;
},
parseJSON(string) {
try {
return JSON.parse(string);
} catch (e) {
return [];
}
},
parseNeighbs(nbs, macs, networks, v6) {
const res = [];
for (const n of this.parseJSON(nbs)) {
let vendor;
if (n.dst.match(/^fe[89a-f][0-9a-f]:/))
continue;
if (n.state.find(f => {return f == 'FAILED'}))
continue;
for (let mac in macs) {
if (n?.lladdr === mac)
vendor = macs[mac].vendor;
}
const net = this.getNetworkByDevice(networks, n?.dev, n?.dst, v6 ? 128 : 32, v6);
res.push([
E('div', { 'data-tooltip': JSON.stringify(n) }, [
'#',
n?.nud ? `; ${_('NUD')}: ${n?.nud}` : '',
n?.proxy === null ? `; ${_('Proxy')}: ✅` : '',
n?.nomaster === null ? `; ${_('No master')} : ✅` : '',
n?.vrf ? `; ${_('VRF')}: ${n?.vrf}` : '',
]),
n?.dst,
n?.lladdr?.toUpperCase() + (vendor ? ` (${vendor})` : ''),
E('span', { 'class': 'ifacebadge' }, [ net ? net : '(%s)'.format(n?.dev) ]),
]);
}
return res;
},
parseRoutes(routes, networks, v6) {
const res = [];
for (const rt of this.parseJSON(routes)) {
const dest = rt.dst == 'default' ? (v6 ? '::/0' : '0.0.0.0/0') : rt.dst;
if (dest == 'fe80::/64' || dest == 'ff00::/8')
continue;
const [addr, bits = (v6 ? 128 : 32)] = dest.split('/');
const net = this.getNetworkByDevice(networks, rt.dev, addr, bits, v6);
res.push([
E('span', { 'class': 'ifacebadge' }, [ net ? net : '(%s)'.format(rt.dev) ]),
dest,
rt?.gateway || '-',
rt?.prefsrc || rt?.from || '-',
String(rt?.metric || '-'),
rt?.table || 'main',
rt?.protocol,
]);
}
return res;
},
parseRules(rules) {
const r = [];
for (const rl of this.parseJSON(rules)) {
r.push([
E('div', { 'data-tooltip': JSON.stringify(rl) }, [
'#',
rl?.not === null ? `; ${_('Not')}: ✅` : '',
rl?.nop === null ? `; ${_('No-op')}: ✅` : '',
rl?.l3mdev === null ? `; ${_('L3Mdev')}: ✅` : '',
rl?.fwmark ? `; ${_('Fwmark')}:${rl?.fwmark}` : '',
rl?.from ? `; ${_('From')}:${rl?.from}` : '',
rl?.to ? `; ${_('To')}:${rl?.to}` : '',
rl?.tos ? `; ${_('ToS')}:${rl?.tos}` : '',
rl?.dsfield ? `; ${_('DSCP')}:${rl?.dsfield}` : '',
rl?.uidrange ? `; ${_('UID-range')}:${rl?.uidrange}` : '',
rl?.goto ? `; ${_('goto')}:${rl?.goto}` : '',
rl?.nat ? `; ${_('NAT')}:${rl?.nat}` : '',
]),
rl?.priority,
rl?.iif ? E('span', { 'class': 'ifacebadge' }, [ rl?.iif ]) : '-',
rl?.src ? (rl?.srclen ? rl?.src + '/' + rl?.srclen : rl?.src) : _('any'),
rl?.sport || '-',
rl?.action || '-',
tn.protocols.find(f => {return f.i == rl?.ipproto?.split?.('-')[1] })?.d || '-',
rl?.oif ? E('span', { 'class': 'ifacebadge' }, [ rl?.oif ]) : '-',
rl?.dst ? (rl?.dstlen ? rl?.dst + '/' + rl?.dstlen : rl?.dst) : _('any'),
rl?.dport || '-',
rl?.table || '-',
]);
}
return r;
},
render([
networks,
{ stdout: ip4neighbs = '' } = {},
{ stdout: ip4routes = '' } = {},
{ stdout: ip4rules = '' } = {},
{ stdout: ip6neighbs = '' } = {},
{ stdout: ip6routes = '' } = {},
{ stdout: ip6rules = '' } = {},
macdata,
]) {
const device_title = _('Which is used to access this %s').format(_('Target'));
const target_title = _('Network and its mask that define the size of the destination');
const gateway_title = _('The address through which this %s is reachable').format(_('Target'));
const metric_title = _('Quantifies the cost or distance to a destination in a way that allows routers to make informed decisions about the optimal path to forward data packets');
const table_title = _('Common name or numeric ID of the %s in which this route is found').format(_('Table'));
const proto_title = _('The routing protocol identifier of this route');
const source_title = _('Network and its mask that define which source addresses use this route');
const neigh4tbl = E('table', { 'class': 'table' }, [
E('tr', { 'class': 'tr table-titles' }, [
E('th', { 'class': 'th' }, [ _('Entry') ]),
E('th', { 'class': 'th' }, [ _('IP address') ]),
E('th', { 'class': 'th' }, [ _('MAC address') ]),
E('th', { 'class': 'th' }, [ _('Interface') ]),
])
]);
const route4tbl = E('table', { 'class': 'table' }, [
E('tr', { 'class': 'tr table-titles' }, [
E('th', { 'class': 'th', 'title': device_title }, [ _('Device') ]),
E('th', { 'class': 'th', 'title': target_title }, [ _('Target') ]),
E('th', { 'class': 'th', 'title': gateway_title }, [ _('Gateway') ]),
E('th', { 'class': 'th', 'title': source_title }, [ _('Source') ]),
E('th', { 'class': 'th', 'title': metric_title }, [ _('Metric') ]),
E('th', { 'class': 'th', 'title': table_title }, [ _('Table') ]),
E('th', { 'class': 'th', 'title': proto_title }, [ _('Protocol') ]),
])
]);
const rule4tbl = E('table', { 'class': 'table' }, [
E('tr', { 'class': 'tr table-titles' }, [
E('th', { 'class': 'th' }, [ _('Rule') ]),
E('th', { 'class': 'th' }, [ _('Priority') ]),
E('th', { 'class': 'th' }, [ _('Ingress') ]),
E('th', { 'class': 'th' }, [ _('Source') ]),
E('th', { 'class': 'th' }, [ _('Src Port') ]),
E('th', { 'class': 'th' }, [ _('Action') ]),
E('th', { 'class': 'th' }, [ _('IP Protocol') ]),
E('th', { 'class': 'th' }, [ _('Egress') ]),
E('th', { 'class': 'th' }, [ _('Destination') ]),
E('th', { 'class': 'th' }, [ _('Dest Port') ]),
E('th', { 'class': 'th' }, [ _('Table') ]),
])
]);
const neigh6tbl = E('table', { 'class': 'table' }, [
E('tr', { 'class': 'tr table-titles' }, [
E('th', { 'class': 'th' }, [ _('Entry') ]),
E('th', { 'class': 'th' }, [ _('IP address') ]),
E('th', { 'class': 'th' }, [ _('MAC address') ]),
E('th', { 'class': 'th' }, [ _('Interface') ]),
])
]);
const route6tbl = E('table', { 'class': 'table' }, [
E('tr', { 'class': 'tr table-titles' }, [
E('th', { 'class': 'th', 'title': device_title }, [ _('Device') ]),
E('th', { 'class': 'th', 'title': target_title }, [ _('Target') ]),
E('th', { 'class': 'th', 'title': gateway_title }, [ _('Gateway') ]),
E('th', { 'class': 'th', 'title': source_title }, [ _('Source') ]),
E('th', { 'class': 'th', 'title': metric_title }, [ _('Metric') ]),
E('th', { 'class': 'th', 'title': table_title }, [ _('Table') ]),
E('th', { 'class': 'th', 'title': proto_title }, [ _('Protocol') ]),
])
]);
const rule6tbl = E('table', { 'class': 'table' }, [
E('tr', { 'class': 'tr table-titles' }, [
E('th', { 'class': 'th' }, [ _('Rule') ]),
E('th', { 'class': 'th' }, [ _('Priority') ]),
E('th', { 'class': 'th' }, [ _('Ingress') ]),
E('th', { 'class': 'th' }, [ _('Source') ]),
E('th', { 'class': 'th' }, [ _('Src Port') ]),
E('th', { 'class': 'th' }, [ _('Action') ]),
E('th', { 'class': 'th' }, [ _('IP Protocol') ]),
E('th', { 'class': 'th' }, [ _('Egress') ]),
E('th', { 'class': 'th' }, [ _('Destination') ]),
E('th', { 'class': 'th' }, [ _('Dest Port') ]),
E('th', { 'class': 'th' }, [ _('Table') ]),
])
]);
cbi_update_table(neigh4tbl, this.parseNeighbs(ip4neighbs, macdata, networks, false),
E('em', _('No entries available'))
);
cbi_update_table(route4tbl, this.parseRoutes(ip4routes, networks, false),
E('em', _('No entries available'))
);
cbi_update_table(rule4tbl, this.parseRules(ip4rules),
E('em', _('No entries available'))
);
cbi_update_table(neigh6tbl, this.parseNeighbs(ip6neighbs, macdata, networks, true),
E('em', _('No entries available'))
);
cbi_update_table(route6tbl, this.parseRoutes(ip6routes, networks, true),
E('em', _('No entries available'))
);
cbi_update_table(rule6tbl, this.parseRules(ip6rules),
E('em', _('No entries available'))
);
const view = E([], [
E('h2', {}, [ _('Routing') ]),
E('p', {}, [ _('The following rules are currently active on this system.') ]),
E('div', {}, [
E('div', { 'class': 'cbi-section', 'data-tab': 'ipv4routing', 'data-tab-title': _('IPv4 Routing') }, [
E('h3', {}, [ _('IPv4 Neighbours') ]),
neigh4tbl,
E('h3', {}, [ _('Active IPv4 Routes') ]),
route4tbl,
E('h3', {}, [ _('Active IPv4 Rules') ]),
rule4tbl,
]),
E('div', { 'class': 'cbi-section', 'data-tab': 'ipv6routing', 'data-tab-title': _('IPv6 Routing') }, [
E('h3', {}, [ _('IPv6 Neighbours') ]),
neigh6tbl,
E('h3', {}, [ _('Active IPv6 Routes') ]),
route6tbl,
E('h3', {}, [ _('Active IPv6 Rules') ]),
rule6tbl,
])
])
]);
ui.tabs.initTabGroup(view.lastElementChild.childNodes);
return view;
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});
@@ -0,0 +1,4 @@
'use strict';
'require tools.views as views';
return views.LogreadBox(null, _('System Log'));
@@ -0,0 +1,350 @@
'use strict';
'require view';
'require poll';
'require request';
'require ui';
'require rpc';
'require network';
const callLuciRealtimeStats = rpc.declare({
object: 'luci',
method: 'getRealtimeStats',
params: [ 'mode', 'device' ],
expect: { result: [] }
});
const graphPolls = [], pollInterval = 3;
Math.log2 = Math.log2 || function(x) { return Math.log(x) * Math.LOG2E; };
return view.extend({
load() {
return Promise.all([
this.loadSVG(L.resource('svg/wireless.svg')),
this.loadSVG(L.resource('svg/wifirate.svg')),
network.getWifiDevices().then((radios) => {
const tasks = [], all_networks = [];
for (const radio of radios) {
if (radio.isDisabled()) continue;
tasks.push(radio.getWifiNetworks().then((networks) => {
for (const net of networks) {
if (net.isDisabled()) continue;
all_networks.push(net);
}
}));
}
return Promise.all(tasks).then(() => {
return all_networks;
});
})
]);
},
updateGraph(ifname, svg, lines, cb) {
const G = svg.firstElementChild;
const view = document.querySelector('#view');
const width = view.offsetWidth - 2;
const height = 300 - 2;
const step = 5;
const data_wanted = Math.floor(width / step);
const data_values = [];
for (const line of lines)
if (line)
data_values.push([]);
const info = {
line_current: [],
line_average: [],
line_peak: []
};
/* prefill datasets */
for (let i = 0; i < data_values.length; i++)
for (let j = 0; j < data_wanted; j++)
data_values[i][j] = 0;
/* plot horizontal time interval lines */
for (let i = width % (step * 60); i < width; i += step * 60) {
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line.setAttribute('x1', i);
line.setAttribute('y1', 0);
line.setAttribute('x2', i);
line.setAttribute('y2', '100%');
line.setAttribute('style', 'stroke:black;stroke-width:0.1');
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
text.setAttribute('x', i + 5);
text.setAttribute('y', 15);
text.setAttribute('style', 'fill:#eee; font-size:9pt; font-family:sans-serif; text-shadow:1px 1px 1px #000');
text.appendChild(document.createTextNode(Math.round((width - i) / step / 60) + 'm'));
G.appendChild(line);
G.appendChild(text);
}
info.interval = pollInterval;
info.timeframe = data_wanted / 60;
graphPolls.push({
ifname: ifname,
svg: svg,
lines: lines,
cb: cb,
info: info,
width: width,
height: height,
step: step,
values: data_values,
timestamp: 0,
fill: 1
});
},
pollData() {
poll.add(L.bind(function() {
const tasks = [];
for (const ctx of graphPolls) {
tasks.push(L.resolveDefault(callLuciRealtimeStats('wireless', ctx.ifname), []));
}
return Promise.all(tasks).then(L.bind(function(datasets) {
for (let gi = 0; gi < graphPolls.length; gi++) {
let ctx = graphPolls[gi];
const data = datasets[gi];
const values = ctx.values;
const lines = ctx.lines;
const info = ctx.info;
let data_scale = 0;
const data_wanted = Math.floor(ctx.width / ctx.step);
let last_timestamp = NaN;
for (let i = 0, di = 0; di < lines.length; di++) {
if (lines[di] == null)
continue;
const multiply = (lines[di].multiply != null) ? lines[di].multiply : 1;
const offset = (lines[di].offset != null) ? lines[di].offset : 0;
for (let j = ctx.timestamp ? 0 : 1; j < data.length; j++) {
/* skip overlapping entries */
if (data[j][0] <= ctx.timestamp)
continue;
if (i == 0) {
ctx.fill++;
last_timestamp = data[j][0];
}
info.line_current[i] = data[j][di + 1] * multiply;
info.line_current[i] -= Math.min(info.line_current[i], offset);
values[i].push(info.line_current[i]);
}
i++;
}
/* cut off outdated entries */
ctx.fill = Math.min(ctx.fill, data_wanted);
for (let i = 0; i < values.length; i++) {
const len = values[i].length;
values[i] = values[i].slice(len - data_wanted, len);
/* find peaks, averages */
info.line_peak[i] = NaN;
info.line_average[i] = 0;
for (let j = 0; j < values[i].length; j++) {
info.line_peak[i] = isNaN(info.line_peak[i]) ? values[i][j] : Math.max(info.line_peak[i], values[i][j]);
info.line_average[i] += values[i][j];
}
info.line_average[i] = info.line_average[i] / ctx.fill;
}
info.peak = Math.max.apply(Math, info.line_peak);
/* remember current timestamp, calculate horizontal scale */
if (!isNaN(last_timestamp))
ctx.timestamp = last_timestamp;
const size = Math.floor(Math.log2(info.peak));
const div = Math.pow(2, size - (size % 10));
const p_o_d = info.peak / div;
const mult = (p_o_d < 5) ? 2 : ((p_o_d < 50) ? 10 : ((p_o_d < 500) ? 100 : 1000));
info.peak = info.peak + (mult * div) - (info.peak % (mult * div));
data_scale = ctx.height / info.peak;
/* plot data */
for (let i = 0, di = 0; di < lines.length; di++) {
if (lines[di] == null)
continue;
const el = ctx.svg.firstElementChild.getElementById(lines[di].line);
let pt = `0,${ctx.height}`;
let y = 0;
if (!el)
continue;
for (let j = 0; j < values[i].length; j++) {
const x = j * ctx.step;
y = ctx.height - Math.floor(values[i][j] * data_scale);
//y -= Math.floor(y % (1 / data_scale));
y = isNaN(y) ? ctx.height : y;
pt += ` ${x},${y}`;
}
pt += ` ${ctx.width},${y} ${ctx.width},${ctx.height}`;
el.setAttribute('points', pt);
i++;
}
info.label_25 = 0.25 * info.peak;
info.label_50 = 0.50 * info.peak;
info.label_75 = 0.75 * info.peak;
if (typeof(ctx.cb) == 'function')
ctx.cb(ctx.svg, info);
}
}, this));
}, this), pollInterval);
},
loadSVG(src) {
return request.get(src).then(function(response) {
if (!response.ok)
throw new Error(response.statusText);
return E('div', {
'style': 'width:100%;height:300px;border:1px solid #000;background:#fff'
}, E(response.text()));
});
},
render([svg1, svg2, wifidevs]) {
const v = E('div', { 'class': 'cbi-map', 'id': 'map' }, E('div'));
for (const wifidev of wifidevs) {
const ifname = wifidev.getIfname();
const ssid = wifidev.getSSID();
if (!ifname)
continue;
const csvg1 = svg1.cloneNode(true);
const csvg2 = svg2.cloneNode(true);
v.firstElementChild.appendChild(E('div', { 'class': 'cbi-section', 'data-tab': ifname, 'data-tab-title': `${ifname} ${ssid}` }, [
csvg1,
E('div', { 'class': 'right' }, E('small', { 'id': 'scale' }, '-')),
E('br'),
E('table', { 'class': 'table', 'style': 'width:100%;table-layout:fixed' }, [
E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td right top' }, E('strong', { 'style': 'border-bottom:2px solid blue' }, [ _('Signal:') ])),
E('td', { 'class': 'td', 'id': 'rssi_bw_cur' }, [ '0 ' + _('dBm') ]),
E('td', { 'class': 'td right top' }, E('strong', {}, [ _('Average:') ])),
E('td', { 'class': 'td', 'id': 'rssi_bw_avg' }, [ '0 ' + _('dBm') ]),
E('td', { 'class': 'td right top' }, E('strong', {}, [ _('Peak:') ])),
E('td', { 'class': 'td', 'id': 'rssi_bw_peak' }, [ '0 ' + _('dBm') ])
]),
E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td right top' }, E('strong', { 'style': 'border-bottom:2px solid red' }, [ _('Noise:') ])),
E('td', { 'class': 'td', 'id': 'noise_bw_cur' }, [ '0 ' + _('dBm') ]),
E('td', { 'class': 'td right top' }, E('strong', {}, [ _('Average:') ])),
E('td', { 'class': 'td', 'id': 'noise_bw_avg' }, [ '0 ' + _('dBm') ]),
E('td', { 'class': 'td right top' }, E('strong', {}, [ _('Peak:') ])),
E('td', { 'class': 'td', 'id': 'noise_bw_peak' }, [ '0 ' + _('dBm') ])
])
]),
E('br'),
csvg2,
E('div', { 'class': 'right' }, E('small', { 'id': 'scale2' }, '-')),
E('br'),
E('table', { 'class': 'table', 'style': 'width:100%;table-layout:fixed' }, [
E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td right top' }, E('strong', { 'style': 'border-bottom:2px solid green' }, [ _('Phy Rate:') ])),
E('td', { 'class': 'td', 'id': 'rate_bw_cur' }, [ '0 Mbit/s' ]),
E('td', { 'class': 'td right top' }, E('strong', {}, [ _('Average:') ])),
E('td', { 'class': 'td', 'id': 'rate_bw_avg' }, [ '0 Mbit/s' ]),
E('td', { 'class': 'td right top' }, E('strong', {}, [ _('Peak:') ])),
E('td', { 'class': 'td', 'id': 'rate_bw_peak' }, [ '0 Mbit/s' ])
])
]),
E('div', {'class': 'cbi-section-create'})
]));
this.updateGraph(ifname, csvg1, [ null, { line: 'rssi', offset: 155 }, { line: 'noise', offset: 155 } ], function(svg, info) {
var G = svg.firstElementChild, tab = svg.parentNode;
G.getElementById('label_25').firstChild.data = '%d %s'.format(info.label_25 - 100, _('dBm'));
G.getElementById('label_50').firstChild.data = '%d %s'.format(info.label_50 - 100, _('dBm'));
G.getElementById('label_75').firstChild.data = '%d %s'.format(info.label_75 - 100, _('dBm'));
tab.querySelector('#scale').firstChild.data = _('(%d minute window, %d second interval)').format(info.timeframe, info.interval);
tab.querySelector('#rssi_bw_cur').firstChild.data = '%d %s'.format(info.line_current[0] - 100, _('dBm'));
tab.querySelector('#rssi_bw_avg').firstChild.data = '%d %s'.format(info.line_average[0] - 100, _('dBm'));
tab.querySelector('#rssi_bw_peak').firstChild.data = '%d %s'.format(info.line_peak[0] - 100, _('dBm'));
tab.querySelector('#noise_bw_cur').firstChild.data = '%d %s'.format(info.line_current[1] - 100, _('dBm'));
tab.querySelector('#noise_bw_avg').firstChild.data = '%d %s'.format(info.line_average[1] - 100, _('dBm'));
tab.querySelector('#noise_bw_peak').firstChild.data = '%d %s'.format(info.line_peak[1] - 100, _('dBm'));
});
this.updateGraph(ifname, csvg2, [ { line: 'rate', multiply: 0.001 } ], function(svg, info) {
var G = svg.firstElementChild, tab = svg.parentNode;
G.getElementById('label_25').firstChild.data = '%.2f %s'.format(info.label_25, _('Mbit/s'));
G.getElementById('label_50').firstChild.data = '%.2f %s'.format(info.label_50, _('Mbit/s'));
G.getElementById('label_75').firstChild.data = '%.2f %s'.format(info.label_75, _('Mbit/s'));
tab.querySelector('#scale2').firstChild.data = _('(%d minute window, %d second interval)').format(info.timeframe, info.interval);
tab.querySelector('#rate_bw_cur').firstChild.data = '%d %s'.format(info.line_current[0], _('Mbit/s'));
tab.querySelector('#rate_bw_avg').firstChild.data = '%d %s'.format(info.line_average[0], _('Mbit/s'));
tab.querySelector('#rate_bw_peak').firstChild.data = '%d %s'.format(info.line_peak[0], _('Mbit/s'));
});
}
ui.tabs.initTabGroup(v.firstElementChild.childNodes);
this.pollData();
return E([], [
E('h2', _('Wireless')),
E('div', {'class': 'cbi-map-descr'}, _('This page displays the wireless metrics for each available radio interface.')),
v
]);
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});