⛅ Sync 2026-08-24 23:49:27

This commit is contained in:
github-actions[bot] committed 2026-08-24 23:49:27 +08:00
1 parent e187a41f8d
commit 1bd7b2c053
48 files changed
+4980 -4592

No files matched your search

@@ -1,537 +0,0 @@
'use strict';
'require baseclass';
'require ui';
/* Shared between the Test page's recent-history block and the full History
* page, so the two can never draw the same data differently. */
/* Labels are translated here, where the extractor can see them; consumers
* take them display-ready, the way RANGES and PHASES do. */
const METRICS = [
[ 'download_mbps', _('Download'), 'Mbps' ],
[ 'upload_mbps', _('Upload'), 'Mbps' ],
[ 'ping_ms', _('Ping'), 'ms' ],
[ 'jitter_ms', _('Jitter'), 'ms' ]
];
/* Series that share a unit share an axis; mixing Mbps and ms on one scale
* would flatten the milliseconds into the floor. */
const GROUPS = {
speed: [ 'download_mbps', 'upload_mbps' ],
latency: [ 'ping_ms', 'jitter_ms' ]
};
/* Labels are translated here, where the extractor can see them: switcher()
* takes them display-ready. The key is the seconds, never the label. */
const RANGES = [
[ _('24h'), 86400 ],
[ _('7d'), 604800 ],
[ _('30d'), 2592000 ],
[ _('1y'), 31536000 ],
[ _('All'), 0 ]
];
function esc(s) {
return String(s == null ? '' : s).replace(/"/g, '""');
}
function download(name, mime, text) {
const blob = new Blob([ text ], { type: mime }),
url = window.URL.createObjectURL(blob),
link = E('a', { 'style': 'display:none', 'href': url, 'download': name });
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
}
function metricOf(key) {
return METRICS.find(m => m[0] == key);
}
/* A metric keeps one color everywhere: the first of its group wears the
* theme's primary color, the second the fixed companion shade. */
function colorIndexOf(key) {
for (const g in GROUPS) {
const i = GROUPS[g].indexOf(key);
if (i >= 0)
return i;
}
return 0;
}
/* One scale shared by the renderer and the hover layer: both must agree on
* where a sample sits, or the tooltip would point beside the line. Spans
* every requested series, and the day's min/max spread when aggregated. */
function chartScale(entries, metrics, resolution) {
const W = 600, H = 240, L = 46, R = 10, T = 14, B = 24;
if (!entries || entries.length < 1)
return null;
const vals = [];
entries.forEach(e => {
metrics.forEach(k => {
if (typeof e[k] == 'number')
vals.push(e[k]);
if (resolution == '1d') {
if (typeof e[k + '_min'] == 'number')
vals.push(e[k + '_min']);
if (typeof e[k + '_max'] == 'number')
vals.push(e[k + '_max']);
}
});
});
if (!vals.length)
return null;
let min = Math.min.apply(null, vals),
max = Math.max.apply(null, vals);
/* Vertical padding: 10% of the spread, but never less than 2% of the
* value itself -- a connection steady within tenths of a Mbps must not
* be zoomed into dramatic noise. A flat zero line still needs a
* nonzero span to be drawable. */
const pad = Math.max((max - min) * 0.1, Math.abs(max) * 0.02) || 1;
min -= pad; max += pad;
return {
W: W, H: H, L: L, R: R, T: T, B: B,
min: min, max: max,
xs: i => entries.length > 1
? L + i * (W - L - R) / (entries.length - 1)
: (L + W - R) / 2,
ys: v => T + (H - T - B) * (1 - (v - min) / (max - min))
};
}
/* Timestamps for humans: daily aggregates carry a plain date, everything else
* becomes the locale's date and time; garbage shows as itself. */
function chartStampFull(e, resolution) {
if (!e || !e.timestamp)
return '?';
if (resolution == '1d' || e.timestamp.indexOf('T') < 0)
return e.timestamp;
const d = new Date(e.timestamp);
return isNaN(d.getTime()) ? e.timestamp : d.toLocaleString();
}
function chartStampShort(e, resolution) {
if (!e || !e.timestamp)
return '';
if (resolution == '1d' || e.timestamp.indexOf('T') < 0)
return e.timestamp;
const d = new Date(e.timestamp);
return isNaN(d.getTime()) ? e.timestamp : d.toLocaleDateString();
}
function fmtVal(v, unit) {
return (typeof v == 'number')
? (v.toFixed(unit == 'Mbps' ? 2 : 1) + ' ' + unit) : '–';
}
return baseclass.extend({
METRICS: METRICS,
GROUPS: GROUPS,
RANGES: RANGES,
/* Linked plainly, the way the other applications do: uhttpd serves
* static resources with ETag revalidation, so an upgraded stylesheet
* reaches the browser without a cache-busting version to maintain. */
cssLink() {
return E('link', {
'rel': 'stylesheet',
'href': L.resource('librespeed/librespeed.css')
});
},
/* One token->label table for the schedule intervals: Settings builds
* its choices from it and the Test page looks the status label up, so
* the two cannot drift. The init script accepts more shapes than these
* six, so readers must fall back to the raw token. */
INTERVALS: {
'15m': _('Every 15 minutes'),
'30m': _('Every 30 minutes'),
'1h': _('Hourly'),
'6h': _('Every 6 hours'),
'12h': _('Every 12 hours'),
'1d': _('Daily')
},
/* Exported alongside the chart: every timestamp a page prints should
* come through here, or the date-only guard gets lost in a copy. */
chartStampFull: chartStampFull,
/* Plain figures of one series: what is normal, how much it wobbles, and
* where it stands now. The story sentences are built from these. On
* daily aggregates the extremes come from the _min/_max columns the
* chart also draws -- min/max of the daily means would understate the
* spread and contradict the band directly above the sentence. */
seriesStats(entries, metric, resolution) {
const vals = (entries || []).map(e => e[metric]).filter(v => typeof v == 'number');
if (!vals.length)
return null;
const avg = vals.reduce((a, b) => a + b, 0) / vals.length;
const lows = (resolution == '1d')
? (entries || []).map(e => typeof e[metric + '_min'] == 'number'
? e[metric + '_min'] : e[metric]).filter(v => typeof v == 'number')
: vals;
const highs = (resolution == '1d')
? (entries || []).map(e => typeof e[metric + '_max'] == 'number'
? e[metric + '_max'] : e[metric]).filter(v => typeof v == 'number')
: vals;
return {
count: vals.length,
avg: avg,
min: Math.min.apply(null, lows),
max: Math.max.apply(null, highs),
current: vals[vals.length - 1]
};
},
/* A row of toggle buttons; the active one is highlighted. The state is
* also spoken: colour alone says nothing to a screen reader, so each
* button carries aria-pressed and the group a name for what it picks. */
switcher(view, items, current, onpick, label) {
return E('div', { 'role': 'group', 'aria-label': label || null },
items.map(it =>
E('button', {
'class': it[0] == current ? 'cbi-button cbi-button-action' : 'cbi-button',
'style': 'margin-right:.3em',
'aria-pressed': it[0] == current ? 'true' : 'false',
'click': ui.createHandlerFn(view, function() {
return onpick.call(view, it[0]);
})
/* Callers hand in display-ready labels: a runtime _() here
* would both miss the extractor and translate twice. */
}, [ it[1] != null ? it[1] : it[0] ])));
},
/* The one chart component: hand-built SVG, the way the realtime status
* graphs do it, colored by the stylesheet so it wears the active theme.
*
* opts.series keys drawn as lines (one unit family per chart)
* opts.resolution 'raw' or '1d'; aggregates draw their min-max band
* opts.hover guide line + a full-measurement tooltip
* opts.legend { all, onToggle }: checkbox legend that switches
* series on and off -- a control, not a caption
*
* The x axis is always time: a time series must never be reordered, or
* the story it tells dissolves. Sorting belongs to the table.
* Returns false when there is nothing to draw. */
renderChart(container, entries, opts) {
container.innerHTML = '';
const series = (opts.series || []).filter(k => metricOf(k));
const sc = chartScale(entries, series, opts.resolution);
if (!sc) {
if (opts.legend)
this.renderLegend(container, entries, opts);
return false;
}
const W = sc.W, H = sc.H, L = sc.L, R = sc.R, T = sc.T, B = sc.B;
const grid = [ 0.25, 0.5, 0.75 ].map(f => {
const y = T + (H - T - B) * f,
v = sc.max - (sc.max - sc.min) * f;
return '<line x1="%d" y1="%f" x2="%d" y2="%f" stroke="currentColor" stroke-opacity="0.15"/>'
.format(L, y, W - R, y) +
'<text x="%d" y="%f" font-size="10" fill="currentColor" fill-opacity="0.6" text-anchor="end">%s</text>'
.format(L - 4, y + 3, v.toFixed(1));
}).join('');
let body = '';
series.forEach(k => {
const ci = colorIndexOf(k);
const valid = entries
.map((e, i) => (typeof e[k] == 'number') ? [ sc.xs(i), sc.ys(e[k]), e[k] ] : null)
.filter(p => p != null);
if (!valid.length)
return;
const pts = valid.map(p => '%f,%f'.format(p[0], p[1])).join(' ');
/* Aggregated entries carry the day's spread beside the mean,
* drawn as a band under the mean line. */
if (opts.resolution == '1d') {
const lo = entries.map((e, i) => (typeof e[k + '_min'] == 'number')
? '%f,%f'.format(sc.xs(i), sc.ys(e[k + '_min'])) : null).filter(p => p != null);
const hi = entries.map((e, i) => (typeof e[k + '_max'] == 'number')
? '%f,%f'.format(sc.xs(i), sc.ys(e[k + '_max'])) : null).filter(p => p != null);
if (lo.length)
body += '<polygon class="librespeed-band librespeed-fill-%d" points="%s %s"/>'
.format(ci, lo.join(' '), hi.reverse().join(' '));
}
/* The soft fill reads well under one line; under two it is mud. */
if (series.length == 1 && valid.length > 1)
body += '<polygon class="librespeed-area librespeed-fill-%d" points="%s %f,%f %f,%f"/>'
.format(ci, pts, valid[valid.length - 1][0], H - B, valid[0][0], H - B);
/* The dashed average is the series' "what is normal" reference. */
const avg = valid.reduce((a, p) => a + p[2], 0) / valid.length;
body += '<line class="librespeed-avgline librespeed-stroke-%d" x1="%d" y1="%f" x2="%d" y2="%f"/>'
.format(ci, L, sc.ys(avg), W - R, sc.ys(avg));
body += '<text x="%d" y="%f" font-size="9" fill="currentColor" fill-opacity=".55" text-anchor="end">%h %s</text>'
.format(W - R, sc.ys(avg) - 4, _('avg'),
avg.toFixed(avg >= 100 ? 0 : 1));
body += (valid.length > 1)
? '<polyline class="librespeed-line librespeed-stroke-%d" points="%s"/>'.format(ci, pts)
: '<circle class="librespeed-dot librespeed-fill-%d" cx="%f" cy="%f" r="3"/>'
.format(ci, valid[0][0], valid[0][1]);
/* Dots make a sparse series readable; with hundreds of points
* they would just thicken the line, so they bow out. */
if (valid.length > 1 && valid.length <= 60)
body += valid.map(p =>
'<circle class="librespeed-dot librespeed-fill-%d" cx="%f" cy="%f" r="2"/>'
.format(ci, p[0], p[1])).join('');
});
/* A date-only timestamp names a local calendar day; new Date() would
* read it as UTC midnight and shift it in negative offsets. */
const first = entries[0],
last = entries[entries.length - 1],
mid = entries[Math.floor((entries.length - 1) / 2)];
const s1 = chartStampShort(first, opts.resolution),
s2 = chartStampShort(mid, opts.resolution),
s3 = chartStampShort(last, opts.resolution);
const midLabel = (entries.length > 2 && s2 != s1 && s2 != s3)
? '<text x="%f" y="%d" font-size="10" fill="currentColor" fill-opacity="0.6" text-anchor="middle">%h</text>'
.format((L + W - R) / 2, H - 6, s2)
: '';
const unit = metricOf(series[0]) ? metricOf(series[0])[2] : '';
const chart = E('div', {});
chart.innerHTML =
'<svg viewBox="0 0 %d %d" style="width:100%%; max-height:280px" xmlns="http://www.w3.org/2000/svg">'
.format(W, H) +
grid + body +
'<text x="%d" y="%d" font-size="10" fill="currentColor" fill-opacity="0.6">%h</text>'
.format(L, H - 6, s1) +
midLabel +
'<text x="%d" y="%d" font-size="10" fill="currentColor" fill-opacity="0.6" text-anchor="end">%h (%s)</text>'
.format(W - R, H - 6, s3, unit) +
'</svg>';
container.appendChild(chart);
if (opts.hover !== false)
this.attachChartHover(chart, entries, series, opts.resolution, sc);
if (opts.legend)
this.renderLegend(container, entries, opts);
return true;
},
/* The legend doubles as the series switch: each entry is a checkbox with
* the series' color and its average over the visible range. */
renderLegend(container, entries, opts) {
const leg = E('div', { 'class': 'librespeed-legend' });
opts.legend.all.forEach(k => {
const m = metricOf(k);
const st = this.seriesStats(entries, k, opts.resolution);
const cb = E('input', { 'type': 'checkbox' });
cb.checked = opts.series.indexOf(k) >= 0;
cb.addEventListener('change', () => opts.legend.onToggle(k, cb.checked));
leg.appendChild(E('label', { 'class': 'librespeed-legend-item' }, [
cb,
E('span', { 'class': 'librespeed-swatch librespeed-bg-%d'.format(colorIndexOf(k)) }),
E('span', {}, [ m[1] ]),
st ? E('span', { 'class': 'librespeed-muted' },
[ ' ' + fmtVal(st.avg, m[2]) + ' ' + _('avg') ]) : ''
]));
});
container.appendChild(leg);
},
/* Hover layer: a guide line, a highlighted sample per series, and an HTML
* tooltip carrying the whole measurement -- every metric, the server and
* how the test travelled -- because whoever inspects one point wants to
* know about that test, not one number of it. Daily aggregates show the
* day's spread of the drawn series instead. */
attachChartHover(container, entries, series, resolution, sc) {
const svg = container.querySelector('svg');
if (!svg || !sc)
return;
container.classList.add('librespeed-chart-holder');
const NS = 'http://www.w3.org/2000/svg';
const guide = document.createElementNS(NS, 'line');
guide.setAttribute('class', 'librespeed-guide');
guide.setAttribute('y1', sc.T);
guide.setAttribute('y2', sc.H - sc.B);
guide.style.display = 'none';
const dots = series.map(k => {
const d = document.createElementNS(NS, 'circle');
d.setAttribute('class', 'librespeed-dot librespeed-fill-%d'.format(colorIndexOf(k)));
d.setAttribute('r', '3.5');
d.style.display = 'none';
return d;
});
const hit = document.createElementNS(NS, 'rect');
hit.setAttribute('x', sc.L);
hit.setAttribute('y', sc.T);
hit.setAttribute('width', sc.W - sc.L - sc.R);
hit.setAttribute('height', sc.H - sc.T - sc.B);
hit.setAttribute('fill', 'transparent');
svg.appendChild(guide);
dots.forEach(d => svg.appendChild(d));
svg.appendChild(hit);
const tip = E('div', { 'class': 'librespeed-tooltip' });
container.appendChild(tip);
const row = (lab, txt) => E('div', { 'class': 'librespeed-tooltip-row' }, [
E('span', { 'class': 'librespeed-muted' }, [ lab ]),
E('span', {}, [ txt ])
]);
const hide = () => {
guide.style.display = 'none';
dots.forEach(d => (d.style.display = 'none'));
tip.style.display = 'none';
};
hit.addEventListener('mousemove', ev => {
const rect = svg.getBoundingClientRect();
const x = (ev.clientX - rect.left) * sc.W / rect.width;
const step = entries.length > 1
? (sc.W - sc.L - sc.R) / (entries.length - 1) : 1;
const i = Math.max(0, Math.min(entries.length - 1,
Math.round((x - sc.L) / step)));
const e = entries[i];
const px = sc.xs(i);
let anchorY = null;
guide.setAttribute('x1', px);
guide.setAttribute('x2', px);
guide.style.display = '';
series.forEach((k, si) => {
if (typeof e[k] == 'number') {
const py = sc.ys(e[k]);
dots[si].setAttribute('cx', px);
dots[si].setAttribute('cy', py);
dots[si].style.display = '';
anchorY = (anchorY == null) ? py : Math.min(anchorY, py);
}
else
dots[si].style.display = 'none';
});
if (anchorY == null)
return hide();
tip.innerHTML = '';
tip.appendChild(E('div', { 'style': 'font-weight:600; margin-bottom:.2em' },
[ chartStampFull(e, resolution) ]));
if (resolution == '1d') {
series.forEach(k => {
const m = metricOf(k);
if (typeof e[k] != 'number')
return;
let txt = fmtVal(e[k], m[2]);
if (typeof e[k + '_min'] == 'number' &&
typeof e[k + '_max'] == 'number')
txt += ' (%s – %s)'.format(
e[k + '_min'].toFixed(m[2] == 'Mbps' ? 0 : 1),
e[k + '_max'].toFixed(m[2] == 'Mbps' ? 0 : 1));
tip.appendChild(row(m[1], txt));
});
}
else {
METRICS.forEach(m => {
if (typeof e[m[0]] == 'number')
tip.appendChild(row(m[1], fmtVal(e[m[0]], m[2])));
});
if (e.server && e.server.name)
tip.appendChild(E('div', { 'style': 'margin-top:.2em' },
[ e.server.name ]));
const meta = [];
if (e.interface)
meta.push(e.interface);
if (e.family)
meta.push(e.family == 'ipv6' ? 'IPv6' : 'IPv4');
if (e.proto)
meta.push(e.proto.toUpperCase());
if (meta.length)
tip.appendChild(E('div', { 'class': 'librespeed-muted' },
[ meta.join(' · ') ]));
}
tip.style.display = 'block';
const cr = container.getBoundingClientRect();
const pxs = rect.left - cr.left + px * rect.width / sc.W;
const pys = rect.top - cr.top + anchorY * rect.height / sc.H;
let left = pxs - tip.offsetWidth / 2;
left = Math.max(0, Math.min(cr.width - tip.offsetWidth, left));
tip.style.left = left + 'px';
tip.style.top = Math.max(0, pys - tip.offsetHeight - 14) + 'px';
});
hit.addEventListener('mouseleave', hide);
},
/* Daily aggregates carry each metric's spread beside the mean; the CSV
* must not lose it, so aggregated exports gain the _min/_max columns. */
exportCSV(entries, resolution) {
const agg = (resolution == '1d');
const keys = METRICS.map(m => m[0]);
const n = v => (typeof v == 'number') ? v : '';
let head = 'timestamp,resolution,interface,family,proto,server,' + keys.join(',');
if (agg)
head += ',' + keys.map(k => k + '_min,' + k + '_max').join(',');
const lines = [ head ];
(entries || []).forEach(e => {
/* Every text field is quoted, so esc()'s quote-doubling is correct
* everywhere and embedded commas cannot shift columns. */
let row = '"%s","%s","%s","%s","%s","%s",'.format(
esc(e.timestamp), agg ? '1d' : 'raw', esc(e.interface),
esc(e.family), esc(e.proto), esc(e.server && e.server.name)) +
keys.map(k => n(e[k])).join(',');
if (agg)
row += ',' + keys.map(k => n(e[k + '_min']) + ',' + n(e[k + '_max'])).join(',');
lines.push(row);
});
download('librespeed-history.csv', 'text/csv', lines.join('\n') + '\n');
},
exportJSON(entries, resolution) {
download('librespeed-history.json', 'application/json',
JSON.stringify({ resolution: resolution || 'raw', entries: entries || [] }, null, '\t') + '\n');
}
});
@@ -1,344 +0,0 @@
/* The theme's accent where the theme publishes one: --primary-color-medium
* exists only in luci-theme-bootstrap. Every other in-tree theme sees the
* fallback, which must clear 3:1 against a light page -- the arc and the
* history line are the only graphical encoding of the data. */
:root {
--librespeed-accent: var(--primary-color-medium, #3c8dbc);
}
/* Dark is an attribute in LuCI, not a media preference: bootstrap stamps
* data-darkmode on the root and pins it for its light/dark variants, while
* the themes that actually reach the fallback have no dark mode at all --
* an OS-level preference must not darken their light pages. */
:root[data-darkmode="true"] {
--librespeed-accent: var(--primary-color-medium, #6ab0de);
}
/* The one piece of CSS this application owns: layout and type scale. Colors,
* borders, buttons and spacing inside sections stay with the theme. */
.librespeed-columns {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(18rem, 1fr);
gap: 1.5rem;
align-items: start;
}
@media (max-width: 768px) {
.librespeed-columns {
grid-template-columns: 1fr;
}
}
.librespeed-metrics {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(7.5rem, 1fr));
gap: 1rem;
text-align: center;
margin: 1rem 0;
}
.librespeed-value {
font-size: 2.2em;
font-weight: 500;
line-height: 1.2;
}
.librespeed-value-sm {
font-size: 1.5em;
line-height: 1.2;
}
/* Muted through opacity rather than a hardcoded grey, so it dims whatever
* foreground color the active theme uses. */
.librespeed-muted {
opacity: .65;
}
.librespeed-caps {
font-size: .85em;
letter-spacing: .08em;
text-transform: uppercase;
}
.librespeed-center {
text-align: center;
padding: 1.5em 1em;
}
.librespeed-progress {
width: 60%;
max-width: 20em;
display: inline-block;
vertical-align: middle;
accent-color: var(--librespeed-accent);
}
/* The chart takes its color from the theme where one is exposed, and falls
* back to a neutral accent where none is. */
.librespeed-chart-line {
stroke: var(--librespeed-accent);
fill: none;
stroke-width: 1.5;
}
.librespeed-chart-fill {
fill: var(--librespeed-accent);
fill-opacity: .1;
stroke: none;
}
.librespeed-chart-band {
fill: var(--librespeed-accent);
fill-opacity: .18;
stroke: none;
}
.librespeed-chart-dot {
fill: var(--librespeed-accent);
}
.librespeed-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: .5em;
margin: .4em 0;
}
.librespeed-push {
margin-left: auto;
}
.librespeed-footer {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: .5em;
margin-top: .75em;
}
.librespeed-steps {
display: flex;
gap: 1.75em;
justify-content: center;
margin: .5em 0 0;
}
.librespeed-step { opacity: .35; }
.librespeed-step-active { opacity: 1; font-weight: 600; }
.librespeed-step-done { opacity: .65; }
.librespeed-spark {
width: 100%;
height: 72px;
margin: .75em 0 0;
display: block;
}
.librespeed-gauge {
width: 250px;
max-width: 100%;
margin: .5em auto 0;
display: block;
}
.librespeed-gauge-bg {
fill: none;
stroke: currentColor;
stroke-opacity: .12;
stroke-width: 12;
stroke-linecap: round;
}
.librespeed-gauge-fill {
fill: none;
stroke: var(--librespeed-accent);
stroke-width: 12;
stroke-linecap: round;
}
.librespeed-gauge text { fill: currentColor; }
.librespeed-gauge-tick {
stroke: currentColor;
stroke-opacity: .25;
stroke-width: 1.5;
}
.librespeed-gauge-needle {
stroke: currentColor;
stroke-width: 2.5;
stroke-linecap: round;
opacity: .85;
}
.librespeed-gauge-hub { fill: currentColor; }
.librespeed-chart-holder { position: relative; }
.librespeed-guide {
stroke: currentColor;
stroke-opacity: .3;
stroke-width: 1;
}
.librespeed-avg {
stroke: currentColor;
stroke-opacity: .45;
stroke-width: 1;
stroke-dasharray: 4 3;
}
.librespeed-tooltip {
position: absolute;
display: none;
pointer-events: none;
background: var(--background-color-high, #fff);
color: var(--text-color-high, #333);
border: 1px solid rgba(128, 128, 128, .4);
border-radius: 3px;
padding: .3em .6em;
font-size: .85em;
white-space: nowrap;
box-shadow: 0 1px 4px rgba(0, 0, 0, .2);
z-index: 50;
}
.librespeed-tooltip-row {
display: flex;
justify-content: space-between;
gap: 1.25em;
}
.librespeed-stats {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 1.5em;
margin-top: .5em;
}
.librespeed-line { fill: none; stroke-width: 1.5; }
.librespeed-area { stroke: none; fill-opacity: .1; }
.librespeed-band { stroke: none; fill-opacity: .16; }
.librespeed-dot { stroke: none; }
.librespeed-avgline {
fill: none;
stroke-width: 1;
stroke-dasharray: 4 3;
stroke-opacity: .55;
}
/* One theme color for every series: the second one differs by pattern, not
* by a hardcoded hue -- a dashed line and a striped swatch survive any theme
* and read for color-blind users too. */
.librespeed-stroke-0 { stroke: var(--librespeed-accent); }
.librespeed-stroke-1 { stroke: var(--librespeed-accent); }
polyline.librespeed-stroke-1 { stroke-dasharray: 6 3; }
.librespeed-avgline.librespeed-stroke-1 { stroke-dasharray: 2 4; }
.librespeed-fill-0 { fill: var(--librespeed-accent); }
.librespeed-fill-1 { fill: var(--librespeed-accent); }
circle.librespeed-fill-1 { fill-opacity: .55; }
.librespeed-bg-0 { background: var(--librespeed-accent); }
.librespeed-bg-1 {
background: repeating-linear-gradient(90deg,
var(--librespeed-accent) 0 4px, transparent 4px 7px);
}
.librespeed-legend {
display: flex;
flex-wrap: wrap;
gap: 1.5em;
margin: .35em 0 0;
}
.librespeed-legend-item {
display: inline-flex;
align-items: center;
gap: .4em;
cursor: pointer;
}
.librespeed-swatch {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 2px;
}
.librespeed-result-gauges {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 1.5em;
margin: .5em 0 0;
}
.librespeed-result-gauges .librespeed-gauge {
width: 200px;
margin: 0 auto;
}
.librespeed-fg-0 { color: var(--librespeed-accent); }
.librespeed-fg-1 { color: var(--librespeed-accent); }
.librespeed-cards {
display: flex;
flex-wrap: wrap;
gap: .75em;
margin: .35em 0 1em;
}
.librespeed-metric-card {
border: 1px solid rgba(128, 128, 128, .25);
border-top-width: 3px;
border-radius: 4px;
padding: .45em .9em;
min-width: 11em;
cursor: pointer;
/* Dim, still legible: .5 with the muted name inside multiplied down to
* an unreadable third. */
opacity: .78;
user-select: none;
}
.librespeed-metric-card-on { opacity: 1; }
.librespeed-metric-card:hover { border-color: rgba(128, 128, 128, .45); }
.librespeed-accent-0 { border-top-color: var(--librespeed-accent); }
.librespeed-accent-1 { border-top-color: var(--librespeed-accent); border-top-style: dashed; }
.librespeed-card-value {
font-size: 1.3em;
font-weight: 600;
line-height: 1.35;
}
.librespeed-hint {
border-bottom: 1px dotted currentColor;
cursor: help;
}
.librespeed-meta {
display: grid;
grid-template-columns: auto 1fr;
gap: .15em 1.25em;
width: fit-content;
margin: 1em auto 0;
text-align: left;
font-size: .95em;
}
/* Off-screen but reachable: the live region announcing the running phase,
* and the gauge figures repeated as plain text for browse mode. */
.librespeed-visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@@ -1,465 +0,0 @@
'use strict';
'require view';
'require rpc';
'require ui';
'require librespeed.common as lscommon';
/* The alias comes from the require above; the repo eslint config only
* knows the stock module names. */
/* global lscommon */
/* The chart wants every point in the range, but the table must not put
* thousands of rows into the DOM: a 15-minute schedule kept for 30 days is
* nearly 3000 measurements. Paging keeps the page responsive without a
* second fetch, since the entries are in memory anyway. */
const pageSize = 50;
const callConfig = rpc.declare({
object: 'librespeed',
method: 'config',
expect: { '': {} }
});
const callHistory = rpc.declare({
object: 'librespeed',
method: 'history',
params: [ 'from', 'to', 'limit' ],
expect: { '': {} }
});
return view.extend({
entries: [],
prevEntries: [],
resolution: 'raw',
group: 'speed',
active: null,
range: 86400,
server: '',
iface: '',
page: 0,
/* Which entries come back -- and at which resolution -- is the server's
* decision, so every range switch is a fetch rather than a client-side
* filter over one download. The previous window of the same length is
* fetched alongside, so the summary can say how this period compares. */
fetch() {
const now = Math.floor(Date.now() / 1000);
/* Zero, not undefined: the rpcd argument is typed as an integer and a
* null would fail validation rather than mean "everything". */
const from = this.range ? now - this.range : 0;
return Promise.all([
callHistory(from).catch(() => ({})),
this.range
? callHistory(now - 2 * this.range, now - this.range).catch(() => ({}))
: Promise.resolve({})
]).then(L.bind(function(data) {
this.entries = Array.isArray(data[0].entries) ? data[0].entries : [];
this.resolution = data[0].resolution || 'raw';
this.prevEntries = Array.isArray(data[1].entries) ? data[1].entries : [];
this.prevResolution = data[1].resolution || 'raw';
}, this));
},
load() {
/* Both series of a group start visible; the legend narrows it. */
this.active = {
speed: lscommon.GROUPS.speed.slice(),
latency: lscommon.GROUPS.latency.slice()
};
/* The config rides along for one purpose: with history keeping off,
* the empty state must not send the user to run a test that will
* never produce an entry. */
return Promise.all([
this.fetch(),
callConfig().then(c => { this.config = c; }, () => {})
]);
},
/* The server/interface filters narrow the fetched range on the client;
* daily aggregates carry neither field, so there the filters offer only
* "All" and match everything. */
applyFilters(entries) {
return (entries || []).filter(e =>
(!this.server || (e.server && e.server.name) == this.server) &&
(!this.iface || e.interface == this.iface));
},
visible() {
return this.applyFilters(this.entries);
},
filterSelect(title, values, current, onpick) {
const sel = E('select', { 'class': 'cbi-input-select' },
[ E('option', { 'value': '' }, [ _('All') ]) ].concat(
values.map(v => E('option', { 'value': v }, [ v ]))));
sel.value = values.indexOf(current) >= 0 ? current : '';
sel.addEventListener('change', ui.createHandlerFn(this, function() {
return onpick.call(this, sel.value);
}));
return E('label', {}, [
E('span', { 'class': 'librespeed-muted' }, [ title + ' ' ]), sel ]);
},
/* The header above the chart doubles as the legend: per series a checkbox
* with its color, its average, and the change against the previous period
* -- one place to read the numbers and switch the lines, right where the
* eye already is. Below it, one sentence on how the period behaved. */
renderSummary() {
const entries = this.visible(),
prev = this.applyFilters(this.prevEntries),
act = this.active[this.group],
states = [];
this.summaryNode.innerHTML = '';
const row = E('div', { 'class': 'librespeed-cards' });
lscommon.GROUPS[this.group].forEach(L.bind(function(k, ki) {
const m = lscommon.METRICS.find(x => x[0] == k),
st = lscommon.seriesStats(entries, k, this.resolution),
ps = lscommon.seriesStats(prev, k, this.prevResolution),
on = act.indexOf(k) >= 0;
let diff = ' ';
/* Only comparable windows get compared: an average of raw samples
* against an average of daily aggregates is not the same number,
* and the two fetches may straddle the raw/archive boundary. */
if (on && st && ps && ps.avg > 0 && this.prevResolution == this.resolution) {
const pct = (st.avg - ps.avg) / ps.avg * 100;
diff = '%s%d %% %s'.format(pct >= 0 ? '+' : '−',
Math.abs(Math.round(pct)), _('vs previous period'));
}
/* The card is a label around a real checkbox, so it works from the
* keyboard exactly like the control it is; the dimming and the
* accent stripe say what is drawn. */
const cb = E('input', { 'type': 'checkbox' });
cb.checked = on;
cb.addEventListener('change', L.bind(function() {
if (cb.checked && act.indexOf(k) < 0)
act.push(k);
else if (!cb.checked) {
if (act.length < 2) {
cb.checked = true;
return;
}
act.splice(act.indexOf(k), 1);
}
this.redraw();
}, this));
row.appendChild(E('label', {
'class': 'librespeed-metric-card librespeed-accent-%d'.format(ki) +
(on ? ' librespeed-metric-card-on' : ''),
'title': on ? _('Hide this series') : _('Show this series')
}, [
/* Not muted: the card's own off-state opacity already dims
* it, and two factors multiply into illegibility. */
E('div', { 'class': 'librespeed-caps' },
[ cb, ' ', m[1] ]),
E('div', { 'class': 'librespeed-card-value' }, [
st ? '%.1f'.format(st.avg) + ' ' + m[2] : '–',
E('span', { 'class': 'librespeed-muted', 'style': 'font-size:.6em; font-weight:400' },
[ ' ' + _('avg') ])
]),
E('div', { 'class': 'librespeed-muted', 'style': 'font-size:.85em' }, [ diff ])
]));
if (on && st) {
const varPct = (st.max - st.min) / st.avg * 100,
offPct = (st.current - st.avg) / st.avg * 100;
const bad = (k == 'ping_ms' || k == 'jitter_ms') ? offPct > 15 : offPct < -15;
states.push([ m[1],
bad ? _('declining') : (varPct > 30 ? _('highly variable') : _('stable')) ]);
}
}, this));
this.summaryNode.appendChild(row);
if (states.length) {
const allStable = states.every(s => s[1] == _('stable'));
this.summaryNode.appendChild(E('p', { 'class': 'librespeed-muted', 'style': 'margin:0 0 .25em' }, [
allStable
? _('Stable over the selected period.')
: states.map(s => '%s: %s'.format(s[0], s[1])).join(' · ')
]));
}
},
redraw() {
const entries = this.visible();
/* Without data the empty state IS the page: everything that only
* makes sense with measurements -- cards, chart, table, export --
* stays out of the way entirely, and the one useful action is a
* button to go run a test. Filters that merely exclude everything
* get their own wording and no button: data exists, go widen them. */
const noneAtAll = !this.entries.length;
if (!entries.length) {
/* Strict, matching test.js: the backend emits a real boolean
* (derived from UCI, never its raw '0'/'1'), and an absent or
* failed config must read as "history on", not off. */
const histOff = noneAtAll && this.config &&
this.config.history && this.config.history.enabled === false;
/* With history keeping off, a test would never write an entry:
* the way out is Settings, not the Start button. */
this.emptyText.textContent = histOff
? _('History keeping is switched off, so measurements are not recorded.')
: (noneAtAll
? _('Run a speed test to start building your connection history.')
: _('No measurements match the current filters.'));
this.emptyStart.style.display = noneAtAll ? '' : 'none';
this.emptyStart.href = histOff
? L.url('admin', 'network', 'librespeed', 'settings')
: L.url('admin', 'network', 'librespeed', 'test');
this.emptyStart.textContent = histOff ? _('Open settings') : _('Start test');
this.emptyNode.style.display = '';
this.dataNode.style.display = 'none';
return;
}
this.emptyNode.style.display = 'none';
this.dataNode.style.display = '';
this.renderSummary();
/* The legend lives in the summary header above; the chart itself has
* nothing below it that could be mistaken for table furniture. */
const drew = lscommon.renderChart(this.chartNode, entries, {
series: this.active[this.group],
resolution: this.resolution,
hover: true
});
/* Entries exist -- the empty state above handles the case where they
* do not -- so reaching here means this metric has no numbers. */
if (!drew)
this.chartNode.appendChild(E('p', { 'class': 'librespeed-muted' },
[ _('No data for the selected metric.') ]));
else if (this.resolution == '1d')
this.chartNode.appendChild(E('p', { 'class': 'librespeed-muted' },
[ _('Daily minimum, average and maximum.') ]));
/* The sort key carries the same decimal count as the display half:
* ui.Table stringifies the whole cell and compares digit runs one by
* one, so ragged fractions would put 94.4 above 94.35. */
const num = (v, d) => (typeof v == 'number') ? v.toFixed(d) : '';
const fmtNum = (v, d) => (typeof v == 'number') ? v.toFixed(d) : '–';
const when = e => lscommon.chartStampFull(e, this.resolution);
/* One column carries both what the packets were (IPv4/IPv6) and how
* they travelled (HTTPS/HTTP); either half may be unknown. */
const protoCell = e => {
const fam = e.family == 'ipv6' ? 'IPv6' : (e.family == 'ipv4' ? 'IPv4' : null),
pr = e.proto ? e.proto.toUpperCase() : null;
return [ fam, pr ].filter(x => x).join(' · ') || '–';
};
/* Build the cells first, then sort them: the sort key comes out of a
* cell, and ui.Table only ever orders the rows it was handed, so
* paging first would turn the headers into a per-page sort while the
* count below still speaks for every measurement. */
/* Every display half is a node: ui.Table writes bare strings into
* innerHTML, and the server name comes from a downloaded list --
* text must stay text. The sort key stays the first element. */
const cell = t => E('span', {}, [ t ]);
let rows = entries.slice().reverse().map(e => [
[ e.epoch ?? 0, cell(when(e)) ],
cell((e.server && e.server.name) || '–'),
cell(e.interface || '–'),
cell(protoCell(e)),
[ num(e.download_mbps, 2), cell(fmtNum(e.download_mbps, 2)) ],
[ num(e.upload_mbps, 2), cell(fmtNum(e.upload_mbps, 2)) ],
[ num(e.ping_ms, 1), cell(fmtNum(e.ping_ms, 1)) ],
[ num(e.jitter_ms, 1), cell(fmtNum(e.jitter_ms, 1)) ]
]);
const sorting = this.table.getActiveSortState();
if (sorting)
rows = rows
.map(L.bind(function(r) {
return [ this.table.deriveSortKey(r[sorting[0]], sorting[0]), r ];
}, this))
.sort((a, b) => sorting[1]
? -L.naturalCompare(a[0], b[0])
: L.naturalCompare(a[0], b[0]))
.map(x => x[1]);
const pages = Math.max(1, Math.ceil(rows.length / pageSize));
if (this.page >= pages)
this.page = pages - 1;
this.renderPager(rows.length, pages);
this.table.update(rows.slice(this.page * pageSize, (this.page + 1) * pageSize));
},
/* Shown only when there is more than one page; the count is always
* worth having, so it stays either way. */
renderPager(total, pages) {
const step = L.bind(function(delta) {
this.page = Math.min(pages - 1, Math.max(0, this.page + delta));
this.redraw();
}, this);
const nav = [ E('span', { 'class': 'librespeed-muted' },
[ N_(total, '%d measurement', '%d measurements').format(total) ]) ];
if (pages > 1) {
nav.push(E('button', {
'class': 'cbi-button',
'disabled': this.page > 0 ? null : '',
'click': ui.createHandlerFn(this, function() { step(-1); })
}, [ '\u2039 ' + _('Previous') ]));
nav.push(E('span', { 'class': 'librespeed-muted' },
[ _('Page %d of %d').format(this.page + 1, pages) ]));
nav.push(E('button', {
'class': 'cbi-button',
'disabled': this.page < pages - 1 ? null : '',
'click': ui.createHandlerFn(this, function() { step(1); })
}, [ _('Next') + ' \u203a' ]));
}
this.pagerNode.replaceChildren(...nav);
},
handleExportCSV() {
lscommon.exportCSV(this.visible(), this.resolution);
},
handleExportJSON() {
lscommon.exportJSON(this.visible(), this.resolution);
},
render() {
this.summaryNode = E('div', {});
this.chartNode = E('div', {});
this.controls = E('div', {});
this.pagerNode = E('div', { 'class': 'librespeed-toolbar', 'style': 'margin:.5em 0' });
/* ui.Table gives sortable headers for free -- every cell below is a
* [sort key, display] pair, so Time orders by epoch even though it
* shows a locale string, and numbers order numerically. The table is
* where sorting belongs; the chart above never reorders time. */
this.table = new ui.Table([
_('Time'), _('Server'), _('Interface'), _('Protocol'),
_('Download [Mbps]'), _('Upload [Mbps]'), _('Ping [ms]'), _('Jitter [ms]')
], { id: 'librespeed-history' });
this.tableNode = this.table.render();
/* The table's own handler re-sorts the page it holds; this runs
* after it and re-sorts the range, from the first page. */
this.tableNode.addEventListener('click', L.bind(function(ev) {
if (ev.target.closest('th[data-sortable-row], .th[data-sortable-row]')) {
this.page = 0;
this.redraw();
}
}, this));
const renderControls = L.bind(function() {
const groups = lscommon.switcher(this,
[ [ 'speed', _('Speed') ], [ 'latency', _('Latency') ] ], this.group,
function(v) { this.group = v; renderControls(); this.redraw(); }, _('Series'));
const ranges = lscommon.switcher(this,
lscommon.RANGES.map(r => [ String(r[1]), r[0] ]), String(this.range),
function(v) {
this.range = +v;
this.page = 0;
renderControls();
return this.fetch().then(L.bind(function() {
renderControls();
this.redraw();
}, this));
}, _('Range'));
ranges.classList.add('librespeed-push');
/* Filter choices are whatever the fetched range actually contains. */
const servers = [], ifaces = [];
this.entries.forEach(e => {
const s = e.server && e.server.name;
if (s && servers.indexOf(s) < 0)
servers.push(s);
if (e.interface && ifaces.indexOf(e.interface) < 0)
ifaces.push(e.interface);
});
/* The freshly fetched window may no longer contain the chosen
* server or interface -- daily aggregates carry neither field at
* all. The widget would then quietly repaint as "All" while the
* model kept filtering everything out, with no control on screen
* able to clear it; reconcile the model here, where the choice
* lists are built. */
if (this.server && servers.indexOf(this.server) < 0)
this.server = '';
if (this.iface && ifaces.indexOf(this.iface) < 0)
this.iface = '';
const filters = E('div', { 'class': 'librespeed-toolbar' }, [
this.filterSelect(_('Server'), servers.sort(), this.server,
function(v) { this.server = v; this.page = 0; this.redraw(); }),
this.filterSelect(_('Interface'), ifaces.sort(), this.iface,
function(v) { this.iface = v; this.page = 0; this.redraw(); })
]);
this.controls.innerHTML = '';
this.controls.appendChild(E('div', { 'class': 'librespeed-toolbar' },
[ groups, ranges ]));
this.controls.appendChild(filters);
}, this);
this.emptyText = E('p', { 'class': 'librespeed-muted', 'style': 'margin:.5em 0 1em' });
this.emptyStart = E('a', {
'class': 'cbi-button cbi-button-action',
'href': L.url('admin', 'network', 'librespeed', 'test')
}, [ _('Start test') ]);
this.emptyNode = E('div', {
'class': 'cbi-section librespeed-center',
'style': 'display:none; margin-top:1em'
}, [
E('div', { 'style': 'font-size:1.4em; font-weight:600' },
[ _('No measurements yet') ]),
this.emptyText,
this.emptyStart
]);
this.dataNode = E('div', {}, [
this.chartNode,
this.summaryNode,
E('h3', { 'style': 'margin-top:.75em' }, [ _('Measurements') ]),
this.tableNode,
this.pagerNode,
E('div', { 'class': 'cbi-page-actions' }, [
E('button', { 'class': 'cbi-button', 'click': ui.createHandlerFn(this, 'handleExportCSV') },
[ _('Export CSV') ]),
' ',
E('button', { 'class': 'cbi-button', 'click': ui.createHandlerFn(this, 'handleExportJSON') },
[ _('Export JSON') ])
])
]);
renderControls();
this.redraw();
return E([], [
lscommon.cssLink(),
E('h2', [ _('LibreSpeed – History') ]),
this.controls,
this.emptyNode,
this.dataNode
]);
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});
@@ -1,172 +0,0 @@
'use strict';
'require view';
'require form';
'require rpc';
'require tools.widgets as widgets';
'require librespeed.common as lscommon';
const callConfig = rpc.declare({
object: 'librespeed',
method: 'config',
expect: { '': {} }
});
return view.extend({
load() {
return callConfig().catch(() => ({}));
},
render(config) {
let m, s, o;
m = new form.Map('librespeed', _('LibreSpeed – Settings'));
s = m.section(form.NamedSection, 'main', 'librespeed', _('Measurement'));
o = s.option(widgets.NetworkSelect, 'interface', _('Interface'),
_('Logical interface the measurement is bound to.'));
o.default = 'wan';
o.nocreate = true;
o = s.option(form.Value, 'server', _('Server'),
_("Numeric server id from the LibreSpeed server list, or 'auto' to pick the closest."));
o.default = 'auto';
o.validate = function(section_id, value) {
if (value == '' || value == 'auto' || /^[0-9]+$/.test(value))
return true;
return _("Must be a server id or 'auto'");
};
o = s.option(form.Value, 'server_list', _('Server list URL'),
_('URL of a LibreSpeed server list in JSON, from a self-hosted deployment: either a static file such as https://example.org/servers.json or a generator such as https://example.org/backend/servers.php. Leave empty for the official list.'));
o.optional = true;
o.placeholder = 'https://librespeed.org/backend-servers/servers.php';
o.validate = function(section_id, value) {
if (value == '' || /^https?:\/\/.+/.test(value))
return true;
return _('Must be an http(s) URL');
};
o = s.option(form.ListValue, 'scheme', _('Protocol'),
_('On routers without AES acceleration, TLS itself can bound the result — forcing plain HTTP then measures the line rather than the cipher.'));
o.value('auto', _('server default'));
o.value('https', _('force HTTPS'));
o.value('http', _('force HTTP'));
o.default = 'auto';
s = m.section(form.NamedSection, 'schedule', 'librespeed', _('Scheduled measurements'));
o = s.option(form.Flag, 'enabled', _('Enable'),
_('Runs a measurement from cron at the chosen interval. Note that a measurement saturates the connection while it runs.'));
o = s.option(form.ListValue, 'interval', _('Interval'));
/* From the shared table, so the Test page's status label and these
* choices cannot drift apart. */
Object.keys(lscommon.INTERVALS).forEach(k =>
o.value(k, lscommon.INTERVALS[k]));
o.default = '1d';
o.depends('enabled', '1');
o = s.option(form.MultiValue, 'days', _('Days'),
_('Leave empty to run every day.'));
o.value('1', _('Monday'));
o.value('2', _('Tuesday'));
o.value('3', _('Wednesday'));
o.value('4', _('Thursday'));
o.value('5', _('Friday'));
o.value('6', _('Saturday'));
o.value('0', _('Sunday'));
o.optional = true;
o.depends('enabled', '1');
/* The init script hands this straight to cron, which wants commas. */
o.cfgvalue = function(section_id) {
const v = form.MultiValue.prototype.cfgvalue.apply(this, arguments);
return (v == null || v == '*') ? [] : String(v).split(',');
};
o.write = function(section_id, value) {
const list = Array.isArray(value) ? value.filter(v => v !== '') : [];
if (!list.length)
return this.remove(section_id);
return this.super('write', [ section_id, list.join(',') ]);
};
o = s.option(form.Value, 'hours', _('Hours'),
_('An hour window on the 24-hour clock, such as 2-5 for 02:00–05:59. A daily measurement runs at a random time inside it, drawn when the schedule is applied; shorter intervals only run within it. Leave empty for any time. A window across midnight is not supported.'));
o.placeholder = '2-5';
o.optional = true;
o.depends('enabled', '1');
o.validate = function(section_id, value) {
if (value == '')
return true;
const m = value.match(/^([01]?[0-9]|2[0-3])(-([01]?[0-9]|2[0-3]))?$/);
if (!m)
return _('Use an hour (0-23) or a range like 2-5');
if (m[3] != null && +m[3] < +m[1])
return _('The window must not cross midnight');
return true;
};
s = m.section(form.NamedSection, 'history', 'librespeed', _('History'));
o = s.option(form.Flag, 'enabled', _('Keep history'));
o.default = '1';
o = s.option(form.Value, 'path', _('Path'),
_('History stored in /tmp is lost when the router reboots.'));
o.default = '/tmp/librespeed/history.jsonl';
o.depends('enabled', '1');
o = s.option(form.ListValue, 'retention', _('Retention'));
o.value('7d', _('7 days'));
o.value('30d', _('30 days'));
o.value('90d', _('90 days'));
o.value('365d', _('1 year'));
o.default = '30d';
o.depends('enabled', '1');
o = s.option(form.Value, 'archive_path', _('Archive path'),
_('Completed days are reduced to daily minimum, average and maximum and appended here once a day, so measurements since the last flush may be lost after an unexpected power loss. Leave empty to keep no persistent history.'));
o.placeholder = '/etc/librespeed/history-daily.jsonl';
o.optional = true;
o.depends('enabled', '1');
o = s.option(form.ListValue, 'archive_retention', _('Archive retention'));
o.value('90d', _('90 days'));
o.value('365d', _('1 year'));
o.value('730d', _('2 years'));
o.default = '365d';
o.depends('enabled', '1');
return m.render().then(node => {
const cron = config && config.schedule && config.schedule.cron;
const box = E('div', { 'style': 'margin-top:1em' });
if (cron) {
/* Epochs computed by the backend in the router's timezone. */
const runs = (config && config.schedule && config.schedule.next_runs || [])
.map(e => new Date(e * 1000));
box.appendChild(E('h4', {}, [ _('Upcoming measurements') ]));
box.appendChild(E('table', { 'class': 'table', 'style': 'max-width:24em' },
runs.map(d => E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td' }, [ d.toLocaleDateString() ]),
E('td', { 'class': 'td' }, [ d.toLocaleTimeString() ])
]))));
box.appendChild(E('p', { 'style': 'color:#888' }, [
_('A daily measurement runs at a time drawn when the schedule is applied; saving draws it anew.')
]));
}
else {
box.appendChild(E('p', { 'style': 'color:#888' }, [
_('No schedule is active. Enable it above and apply to draw the measurement time.')
]));
}
/* A sibling of the form, not a child: Map.render() replaces its
* own root on every Save and Reset, and would take the schedule
* preview with it. The preview reads the applied crontab, so it
* is correct to keep it as it is until Apply. */
return E([], [ node, box ]);
});
}
});
@@ -1,752 +0,0 @@
'use strict';
'require view';
'require ui';
'require poll';
'require rpc';
'require librespeed.common as lscommon';
/* The alias comes from the require above; the repo eslint config only
* knows the stock module names. */
/* global lscommon */
const callStart = rpc.declare({
object: 'librespeed',
method: 'start',
expect: { '': {} }
});
const callStop = rpc.declare({
object: 'librespeed',
method: 'stop',
expect: { '': {} }
});
const callStatus = rpc.declare({
object: 'librespeed',
method: 'status',
expect: { '': {} }
});
const callResult = rpc.declare({
object: 'librespeed',
method: 'result',
expect: { '': {} }
});
const callConfig = rpc.declare({
object: 'librespeed',
method: 'config',
expect: { '': {} }
});
const callHistory = rpc.declare({
object: 'librespeed',
method: 'history',
params: [ 'from', 'to', 'limit' ],
expect: { '': {} }
});
/* The Test page is an overview, not analysis: a fixed last-24h download
* chart. No entry limit -- a 15-minute schedule makes 96 points a day, which
* is nothing for the SVG, and a capped fetch would quietly turn "last 24
* hours" into "last few hours". The History page fetches whatever range the
* user asks for; this page never should. */
const recentSeconds = 86400;
const recentRows = 5;
/* A measurement takes tens of seconds, so nothing waits on it: start returns as
* soon as the job is detached and this is how often we ask how it is going. */
const pollInterval = 2;
function fmt(value, digits) {
return (typeof value == 'number' && isFinite(value))
? value.toFixed(digits != null ? digits : 2)
: '–';
}
function fmtTime(epochOrIso) {
if (!epochOrIso)
return '';
const d = (typeof epochOrIso == 'number')
? new Date(epochOrIso * 1000)
: new Date(epochOrIso);
return isNaN(d.getTime()) ? String(epochOrIso) : d.toLocaleString();
}
function fmtElapsed(seconds) {
if (!(seconds >= 0))
return '';
const m = Math.floor(seconds / 60),
s = Math.floor(seconds % 60);
return '%d:%02d'.format(m, s);
}
/* The three stages a measurement walks through, with the share of the overall
* bar each one gets. Ping streams no percent, so it holds a token slice. */
const PHASES = [
[ 'ping', 0, 10, _('Ping') ],
[ 'download', 10, 55, _('Download') ],
[ 'upload', 55, 100, _('Upload') ]
];
/* Maps the streamed per-phase percent onto one overall run percent, so the
* bar moves forward through the whole run instead of refilling per phase.
* Null means there is nothing honest to show and the bar stays indeterminate. */
function overallProgress(status) {
const span = PHASES.find(p => p[0] == status.phase);
if (!span)
return null;
if (typeof status.progress != 'number')
return (status.phase == 'ping') ? 5 : null;
return Math.round(span[1] + (span[2] - span[1]) * Math.min(status.progress, 100) / 100);
}
function polar(cx, cy, r, deg) {
const a = deg * Math.PI / 180;
return [ cx + r * Math.cos(a), cy - r * Math.sin(a) ];
}
/* The dial's top end: the smallest "nice" ceiling above the fastest rate seen,
* so a 550 Mbps line gets a 600 dial rather than pinning the needle at an
* arbitrary full stop. Grows during the run when the rate outruns it. */
function niceTop(mbps) {
/* Each of these divides cleanly by 5 and 10, so the dial's labels and
* ticks always land on round numbers: a ~560 Mbps line gets a 0-1000
* dial labelled every 200 with ticks every 100. */
const steps = [ 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000 ];
for (let i = 0; i < steps.length; i++)
if (mbps <= steps[i])
return steps[i];
return Math.ceil(mbps / 10000) * 10000;
}
function gaugeLabel(v) {
return v >= 1000 ? (v / 1000) + ' G' : String(v);
}
/* The running test's centrepiece: a circular speedometer with a needle. The
* dial is the current rate against the niceTop() range -- never the test's
* completion, which is a different number and reads as a small percentage
* below the circle. */
function gauge(mbps, top, ci) {
ci = ci || 0;
const cx = 100, cy = 95, r = 80, span = 216, start = 198;
const p1 = polar(cx, cy, r, start),
p2 = polar(cx, cy, r, start - span);
const len = span / 360 * 2 * Math.PI * r;
const path = 'M %f %f A %f %f 0 1 1 %f %f'.format(p1[0], p1[1], r, r, p2[0], p2[1]);
const amt = (typeof mbps == 'number')
? Math.max(0, Math.min(1, mbps / top)) : 0;
/* Ticks every tenth of the range, longer ones with a label every fifth:
* a 0-1000 dial reads 0/200/400/600/800/1000 with marks each 100. */
let marks = '';
for (let i = 0; i <= 10; i++) {
const a = start - i / 10 * span,
major = (i % 2 == 0),
t1 = polar(cx, cy, r - 10, a),
t2 = polar(cx, cy, r - (major ? 18 : 14), a);
marks += '<line class="librespeed-gauge-tick" x1="%f" y1="%f" x2="%f" y2="%f"/>'
.format(t1[0], t1[1], t2[0], t2[1]);
if (major) {
const p = polar(cx, cy, r - 28, a);
marks += '<text x="%f" y="%f" text-anchor="middle" font-size="8.5" fill-opacity=".55">%s</text>'
.format(p[0], p[1] + 3, gaugeLabel(top * i / 10));
}
}
const tip = polar(cx, cy, r - 20, start - amt * span);
return '<svg class="librespeed-gauge" viewBox="0 0 200 150" xmlns="http://www.w3.org/2000/svg">' +
'<path class="librespeed-gauge-bg" d="%s"/>'.format(path) +
'<path class="librespeed-gauge-fill librespeed-stroke-%d" d="%s" stroke-dasharray="%f %f"/>'
.format(ci, path, amt * len, len) +
marks +
'<line class="librespeed-gauge-needle" x1="%f" y1="%f" x2="%f" y2="%f"/>'
.format(cx, cy, tip[0], tip[1]) +
'<circle class="librespeed-gauge-hub" cx="%f" cy="%f" r="4"/>'.format(cx, cy) +
'<text x="100" y="133" text-anchor="middle" font-size="26" font-weight="500">%s</text>'
.format((typeof mbps == 'number') ? '%.2f'.format(mbps) : '–') +
'<text x="100" y="147" text-anchor="middle" font-size="11" fill-opacity=".6">Mbps</text>' +
'</svg>';
}
/* One value with its small label underneath; the figures are the point of the
* page, so they get the size. */
function figure(value, unit, label, big) {
return E('div', {}, [
E('div', { 'class': big ? 'librespeed-value' : 'librespeed-value-sm' }, [
value,
E('span', { 'class': 'librespeed-muted', 'style': 'font-size:.45em' }, [ ' ' + unit ])
]),
E('div', { 'class': 'librespeed-muted librespeed-caps' }, [ label ])
]);
}
return view.extend({
alertNode: null,
statusNode: null,
resultHeading: null,
resultNode: null,
button: null,
lastSeen: null,
config: {},
load() {
return Promise.all([
callStatus().catch(() => ({})),
callResult().catch(() => ({})),
callConfig().catch(() => ({})),
callHistory(Math.floor(Date.now() / 1000 - recentSeconds),
undefined, 0).catch(() => ({}))
]);
},
renderRecent(data) {
const entries = Array.isArray(data && data.entries) ? data.entries : [];
/* Kept so switching the metric can redraw without refetching. */
this.recentData = data;
this.recentNode.innerHTML = '';
this.recentCount.textContent = entries.length
? N_(entries.length, '%d measurement', '%d measurements')
.format(entries.length) : '';
/* The active button names the metric, so the subtitle only carries
* the window. */
this.recentSwitch.replaceChildren(...lscommon.switcher(this,
lscommon.GROUPS.speed.map(k => {
const m = lscommon.METRICS.find(x => x[0] == k);
return [ k, m[1] ];
}), this.recentMetric,
function(v) {
this.recentMetric = v;
this.renderRecent(this.recentData);
}, _('Metric')).childNodes);
/* The stability story of the last day, told in one sentence and four
* figures: what is normal, how much it wobbles, where it is now. */
const metric = this.recentMetric || 'download_mbps';
const st = lscommon.seriesStats(entries, metric, data && data.resolution);
if (st && st.count > 1) {
const varPct = (st.max - st.min) / st.avg * 100,
offPct = (st.current - st.avg) / st.avg * 100;
let story;
/* The heading above names the metric, so the sentence does not
* repeat it -- and a word like "download" cannot be spliced into
* a translated sentence anyway. */
if (offPct < -15)
story = _('Declining — the last measurement is %d%% below the 24-hour average.')
.format(Math.round(-offPct));
else if (varPct > 30)
story = _('Variable — ranged from %d to %d Mbps.')
.format(Math.round(st.min), Math.round(st.max));
else
story = _('Stable — averaged %d Mbps, ranging from %d to %d Mbps.')
.format(Math.round(st.avg), Math.round(st.min), Math.round(st.max));
this.recentNode.appendChild(E('p', { 'class': 'librespeed-muted', 'style': 'margin:.25em 0 .5em' },
[ story ]));
}
const chart = E('div', {});
this.recentNode.appendChild(chart);
const drew = lscommon.renderChart(chart, entries, {
series: [ metric ],
resolution: data && data.resolution,
hover: true
});
/* Entries may well exist -- the count above says so -- while none of
* them carries a download figure, so name the window rather than
* claiming there is nothing at all. */
if (!drew) {
this.recentNode.appendChild(E('p', { 'class': 'librespeed-muted' },
[ _('No data in the last 24 hours.') ]));
return;
}
if (st && st.count > 1) {
const stat = (label, v) => E('span', {}, [
E('strong', {}, [ '%d'.format(Math.round(v)) ]),
E('span', { 'class': 'librespeed-muted' }, [ ' ' + label ])
]);
this.recentNode.appendChild(E('div', {
'class': 'librespeed-muted librespeed-stats',
'style': 'margin:1em 0 1.5em'
}, [
stat(_('current'), st.current),
stat(_('average'), st.avg),
stat(_('best'), st.max),
stat(_('worst'), st.min)
]));
}
const fmtNum = (v, d) => (typeof v == 'number') ? v.toFixed(d) : '–';
/* The shared formatter carries the date-only guard: a daily
* aggregate's bare date fed to new Date() is read as UTC midnight
* and shifts a calendar day in negative offsets. */
const when = e => lscommon.chartStampFull(e, data && data.resolution);
/* The sort key carries the same decimal count as the display half:
* ui.Table stringifies the whole cell and compares digit runs one by
* one, so ragged fractions would put 94.4 above 94.35. */
const num = (v, d) => (typeof v == 'number') ? v.toFixed(d) : '';
/* Nodes, not strings: ui.Table writes bare strings into innerHTML
* and the server name comes from a downloaded list. */
const cell = t => E('span', {}, [ t ]);
this.recentTable.update(entries.slice(-recentRows).reverse().map(e => [
[ e.epoch ?? 0, cell(when(e)) ],
cell((e.server && e.server.name) || '–'),
[ num(e.download_mbps, 2), cell(fmtNum(e.download_mbps, 2)) ],
[ num(e.upload_mbps, 2), cell(fmtNum(e.upload_mbps, 2)) ],
[ num(e.ping_ms, 1), cell(fmtNum(e.ping_ms, 1)) ],
[ num(e.jitter_ms, 1), cell(fmtNum(e.jitter_ms, 1)) ]
]));
this.recentNode.appendChild(this.recentTableNode);
this.recentNode.appendChild(E('div', { 'style': 'text-align:right; margin-top:.75em' }, [
E('a', { 'href': L.url('admin', 'network', 'librespeed', 'history') },
[ _('View full history »') ])
]));
},
handleStart(ev) {
return callStart().then(L.bind(function(res) {
if (res && res.error) {
ui.addNotification(null, E('p', [
_('Could not start the measurement: %s').format(res.error)
]));
return;
}
/* Show the running state immediately rather than waiting up to
* pollInterval for the next status. */
this.renderStatus({ running: true, started: Date.now() / 1000 });
this.setBusy(true);
}, this));
},
handleStop(ev) {
return callStop().then(L.bind(function() {
this.setBusy(false);
}, this));
},
setBusy(running) {
if (!this.button)
return;
this.button.textContent = running ? _('Stop test') : _('Start test');
this.button.className = running ? 'cbi-button cbi-button-reset'
: 'cbi-button cbi-button-action';
this.button.onclick = ui.createHandlerFn(this,
running ? 'handleStop' : 'handleStart');
},
runContext() {
const parts = [];
if (this.config.server && this.config.server != 'auto')
parts.push(_('server %s').format(this.config.server));
if (this.config.interface)
parts.push(this.config.interface);
return parts.join(' · ');
},
renderStatus(status) {
this.alertNode.innerHTML = '';
this.statusNode.innerHTML = '';
/* The result block steps aside while a measurement runs -- the page
* narrates one thing at a time. */
this.resultNode.style.display = status.running ? 'none' : '';
this.resultHeading.textContent = (!status.running && status.last_error)
? _('Last successful measurement') : '';
/* One quiet live region, outside the tree that is rebuilt every
* poll: announcing the rebuilt gauge SVG would read the whole dial
* out several times a run. Set only on change, so it speaks once
* per phase step. */
const announce = status.running
? (PHASES.find(p => p[0] == status.phase) || [])[3] || _('Connecting to the test server…')
: '';
if (this.liveNode.textContent != announce)
this.liveNode.textContent = announce;
if (status.running) {
const rows = [];
/* The walk through the stages, with the current one highlighted --
* this is what says why nothing moves yet while connecting. */
const phaseIdx = PHASES.findIndex(p => p[0] == status.phase);
rows.push(E('div', { 'class': 'librespeed-steps librespeed-caps' },
PHASES.map((p, i) => E('span', {
'class': 'librespeed-step' +
(i == phaseIdx ? ' librespeed-step-active' :
(phaseIdx >= 0 && i < phaseIdx ? ' librespeed-step-done' : '')),
/* Spoken as well as dimmed: opacity is invisible to a
* screen reader. */
'aria-current': i == phaseIdx ? 'step' : null
}, [ p[3] ]))));
/* The dial range is frozen at the start of the run -- a familiar
* line gets a familiar dial -- and only ever expands, once the
* rate actually exceeds it. Recomputing it downwards mid-run
* would pull the needle back while the speed grows. */
if (!this.wasRunning)
this.runTop = niceTop(Math.max(this.lastPeak || 0, 50) * 1.02);
if (typeof status.mbps == 'number' && status.mbps > this.runTop)
this.runTop = niceTop(status.mbps * 1.02);
this.wasRunning = true;
/* The spinner is LuCI's own; while the client still negotiates
* with the server there is nothing to point the gauge at. */
if (phaseIdx < 0)
rows.push(E('p', { 'class': 'spinning', 'style': 'margin-top:.75em' },
[ _('Connecting to the test server…') ]));
else {
/* The dial is hidden from assistive tech -- its ten tick
* labels are noise there -- so the one figure it carries,
* the running rate, is repeated as plain hidden text that
* browse mode can still reach. */
const holder = E('div', { 'aria-hidden': 'true' });
holder.innerHTML = gauge(
(typeof status.mbps == 'number' && status.mbps > 0)
? status.mbps : null,
this.runTop,
status.phase == 'upload' ? 1 : 0);
rows.push(holder);
if (typeof status.mbps == 'number' && status.mbps > 0)
rows.push(E('span', { 'class': 'librespeed-visually-hidden' },
[ '%.2f Mbps'.format(status.mbps) ]));
}
/* Completion is a different number than the rate on the arc, so
* it stays a small figure below the circle rather than feeding
* the gauge and pretending to be a speed. */
const overall = overallProgress(status);
if (overall != null)
rows.push(E('div', { 'class': 'librespeed-muted' },
[ overall + ' %' ]));
/* The backend counts on the clock that stamped the start; the
* difference of two unrelated clocks can be negative. The old
* subtraction stays as a fallback for an updating router. */
const elapsed = typeof status.elapsed == 'number'
? fmtElapsed(status.elapsed)
: (status.started
? fmtElapsed(Date.now() / 1000 - status.started) : '');
if (elapsed)
rows.push(E('div', { 'class': 'librespeed-muted' },
[ _('%s elapsed').format(elapsed) ]));
const ctx = this.runContext();
if (ctx)
rows.push(E('div', { 'class': 'librespeed-muted', 'style': 'margin-top:.5em' }, [ ctx ]));
rows.forEach(r => this.statusNode.appendChild(r));
}
else
this.wasRunning = false;
if (!status.running && status.last_error == 'stopped') {
/* Stopping is something the user did, not something that went
* wrong; a red banner would turn their own click into an error. */
this.alertNode.appendChild(E('p', { 'class': 'librespeed-muted' }, [
_('The last measurement was stopped.'),
status.last_finished ? ' · ' + fmtTime(status.last_finished) : ''
]));
}
else if (!status.running && status.last_error) {
/* Compact, and above the figures: the failure is news, the last
* good result is still the substance of the page. */
this.alertNode.appendChild(E('div', {
'class': 'alert-message warning',
'style': 'display:inline-block; text-align:left'
}, [
E('strong', {}, [ _('Last measurement failed') ]),
E('br'),
status.last_error,
status.last_finished ? ' · ' + fmtTime(status.last_finished) : ''
]));
}
},
renderResult(result) {
this.resultNode.innerHTML = '';
/* Remembered as the dial-range seed for the next run. */
if (result && (typeof result.download_mbps == 'number' || typeof result.upload_mbps == 'number'))
this.lastPeak = Math.max(result.download_mbps || 0, result.upload_mbps || 0);
if (!result || result.download_mbps == null) {
this.resultNode.appendChild(E('p', { 'class': 'librespeed-muted', 'style': 'margin-top:.5em' }, [
_('No measurement yet.')
]));
return;
}
/* The result in the same visual language the run used: one dial per
* direction, both on one shared scale so they compare at a glance,
* with the latency figures small beside them. */
const top = niceTop(Math.max(result.download_mbps || 0,
result.upload_mbps || 0, 50) * 1.02);
/* Same color language as the History chart -- download is always the
* theme color, upload the companion shade -- plus the direction arrow,
* so the two dials cannot be mistaken for one another. */
const dial = (v, label, ci, arrow) => {
const holder = E('div', { 'class': 'librespeed-result-gauge' });
holder.innerHTML = gauge((typeof v == 'number') ? v : null, top, ci);
holder.appendChild(E('div', { 'class': 'librespeed-caps' }, [
E('span', { 'class': 'librespeed-fg-%d'.format(ci), 'style': 'font-weight:700' },
[ arrow + ' ' ]),
E('span', { 'class': 'librespeed-muted' }, [ label ])
]));
return holder;
};
this.resultNode.appendChild(E('div', { 'class': 'librespeed-result-gauges' }, [
dial(result.download_mbps, _('Download'), 0, '\u2193'),
dial(result.upload_mbps, _('Upload'), 1, '\u2191')
]));
this.resultNode.appendChild(E('div', {
'style': 'display:flex; justify-content:center; gap:3.5em; margin-top:.5em'
}, [
figure(fmt(result.ping_ms, 1), 'ms', _('Ping'), false),
figure(fmt(result.jitter_ms, 1), 'ms', _('Jitter'), false)
]));
/* One fact per row rather than one long sentence: each has its own
* meaning and the eye can scan for the one it wants. */
const meta = [];
if (result.server && result.server.name)
meta.push([ _('Server'), result.server.name ]);
if (result.interface)
meta.push([ _('Interface'), result.interface ]);
const proto = [];
if (result.family)
proto.push(result.family == 'ipv6' ? 'IPv6' : 'IPv4');
if (result.proto)
proto.push(result.proto == 'https' ? _('encrypted (HTTPS)') : _('plain HTTP'));
if (proto.length)
meta.push([ _('Protocol'), proto.join(' \u00b7 ') ]);
if (result.timestamp)
meta.push([ _('Completed'), fmtTime(result.timestamp) ]);
const grid = E('div', { 'class': 'librespeed-meta' });
meta.forEach(m => {
grid.appendChild(E('span', { 'class': 'librespeed-muted' }, [ m[0] ]));
grid.appendChild(E('span', {}, [ m[1] ]));
});
this.resultNode.appendChild(grid);
/* The link comes from the telemetry server's reply -- from outside
* the router -- so only plain web schemes may pass into the href. */
if (result.share && /^https?:\/\//i.test(result.share))
this.resultNode.appendChild(E('p', { 'style': 'margin:.5em 0 0' }, [
E('a', {
'href': result.share,
'target': '_blank',
'rel': 'noreferrer'
}, [ _('View result') ])
]));
},
render(data) {
const status = data[0] || {},
result = data[1] || {},
config = data[2] || {},
recent = data[3] || {};
this.config = config;
this.alertNode = E('div', {});
this.statusNode = E('div', {});
this.resultHeading = E('div', { 'class': 'librespeed-muted', 'style': 'margin-top:.35em' });
this.resultNode = E('div', {});
this.recentNode = E('div', {});
this.liveNode = E('div', {
'role': 'status',
'aria-live': 'polite',
'class': 'librespeed-visually-hidden'
});
this.recentCount = E('span', { 'class': 'librespeed-muted' });
this.recentSubtitle = E('div', { 'class': 'librespeed-muted' },
[ _('last 24 hours') ]);
/* Built once and refilled: ui.Table gives sortable headers, the same
* as the History page's table. */
this.recentTable = new ui.Table([
_('Time'), _('Server'),
_('Download [Mbps]'), _('Upload [Mbps]'), _('Ping [ms]'), _('Jitter [ms]')
], { id: 'librespeed-recent' });
this.recentTableNode = this.recentTable.render();
this.recentMetric = 'download_mbps';
/* Two directions, one chart at a time: the page answers "how am I
* doing" at a glance, and mixing both series would make that a
* comparison instead. History is where series get combined. The
* buttons themselves are filled by renderRecent, first call included. */
this.recentSwitch = E('div', {});
this.button = E('button', { 'class': 'cbi-button cbi-button-action' },
[ _('Start test') ]);
this.lastSeen = status.last_finished;
this.renderStatus(status);
this.renderResult(result);
this.renderRecent(recent);
this.setBusy(!!status.running);
poll.add(L.bind(function() {
return callStatus().then(L.bind(function(st) {
st = st || {};
this.renderStatus(st);
this.setBusy(!!st.running);
/* Only refetch the result and the recent chart when a run has
* actually finished, rather than on every tick. */
if (!st.running && st.last_finished !== this.lastSeen) {
this.lastSeen = st.last_finished;
return Promise.all([
callResult().then(L.bind(this.renderResult, this)),
callHistory(Math.floor(Date.now() / 1000 - recentSeconds),
undefined, 0)
.then(L.bind(this.renderRecent, this))
.catch(() => {})
]);
}
}, this));
}, this), pollInterval);
/* A third element, where a row has one, explains the value on hover
* or focus. data-tooltip is LuCI's own mechanism -- a plain title
* attribute leaves it to the browser, which shows nothing on some --
* and the dotted underline is the affordance, since the theme styles
* tooltips only inside form fields. The panel stays a compact status
* list, and the reasoning still lives where the choice is made. */
const kv = rows => E('table', { 'class': 'table' },
rows.map(r => E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td librespeed-muted', 'style': 'width:45%' }, [ r[0] ]),
E('td', { 'class': 'td' }, [
r[2] ? E('span', {
/* tabindex, or the focus half of "hover or focus"
* never fires: a bare span is not in the tab order. */
'class': 'librespeed-hint',
'tabindex': '0',
'data-tooltip': r[2]
}, [ r[1] ]) : r[1]
])
])));
/* Computed by the backend in the router's timezone; the browser may
* well sit in another one, so it only formats the epoch. */
const sched = config.schedule || {};
const next = sched.next_runs || [];
/* Strict, matching history.js: the backend derives this from the
* crontab and emits a real boolean, never UCI's '0'/'1'. */
const schedRows = [ [ _('Enabled'), sched.enabled === true ? _('Yes') : _('No') ] ];
if (sched.enabled === true) {
/* The shared table translates the six tokens the UI offers; the
* init script accepts more, so the raw token is the fallback. */
schedRows.push([ _('Interval'),
lscommon.INTERVALS[sched.interval || '1d'] || sched.interval ]);
if (next.length)
schedRows.push([ _('Next run'), new Date(next[0] * 1000).toLocaleString() ]);
}
/* The main card carries the result; the aside carries what the next
* run will do. Plain sections and tables, so every theme renders it. */
const aside = E('div', {}, [
E('div', { 'class': 'cbi-section', 'style': 'margin:0 0 1em 0' }, [
E('h3', [ _('Test configuration') ]),
kv([
[ _('Interface'), config.interface || 'wan' ],
[ _('Server'), (config.server == 'auto' || !config.server)
? _('automatic (nearest)') : config.server ],
[ _('Protocol'),
config.scheme == 'https' ? _('HTTPS (forced)')
: (config.scheme == 'http' ? _('HTTP (forced)')
: _('server default')),
config.scheme == 'http'
? _('Unencrypted transfers, so the line is measured rather than the cipher.')
: (config.scheme == 'https'
? _('Encrypted transfers, which on hardware without AES acceleration may bound the result.')
: _('Whichever scheme the server list provides.')) ]
])
]),
E('div', { 'class': 'cbi-section', 'style': 'margin:0' }, [
E('h3', [ _('Schedule status') ]),
kv(schedRows)
])
]);
const main = E('div', { 'class': 'cbi-section librespeed-center', 'style': 'margin:0' }, [
this.alertNode,
this.liveNode,
this.statusNode,
this.resultHeading,
this.resultNode,
E('div', { 'style': 'margin-top:1em' }, [ this.button ])
]);
const recentSection = E('div', { 'class': 'cbi-section', 'style': 'margin:1.5em 0 0 0' }, [
/* Heading and count on one line, the controls on their own below:
* the metric buttons and the window they cover belong together,
* the way the History toolbar pairs series with range. */
E('div', { 'class': 'librespeed-footer', 'style': 'margin:0; align-items:baseline' }, [
E('h3', { 'style': 'margin:0' }, [ _('Recent history') ]),
this.recentCount
]),
E('div', { 'class': 'librespeed-toolbar', 'style': 'margin:.5em 0 .75em' }, [
this.recentSwitch,
this.recentSubtitle
]),
this.recentNode
]);
return E([], [
lscommon.cssLink(),
E('h2', [ _('LibreSpeed') ]),
E('h3', [ _('Router speed test') ]),
E('p', { 'class': 'librespeed-muted' },
[ _('Measures the internet connection from this router.') ]),
E('div', { 'class': 'librespeed-columns' }, [
E('div', {}, [ main, recentSection ]),
aside
])
]);
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});