🎨 Sync 2026-08-19 20:43:52

This commit is contained in:
github-actions[bot]
2026-08-19 20:43:52 +08:00
parent 5f4f97a473
commit 7c3ffdd761
17 changed files with 4255 additions and 0 deletions
+355
View File
@@ -0,0 +1,355 @@
Valid-License-Identifier: GPL-2.0-only
Valid-License-Identifier: GPL-2.0
Valid-License-Identifier: GPL-2.0-or-later
Valid-License-Identifier: GPL-2.0+
SPDX-URL: https://spdx.org/licenses/GPL-2.0.html
Usage-Guide:
To use this license in source code, put one of the following SPDX
tag/value pairs into a comment according to the placement
guidelines in the licensing rules documentation.
For 'GNU General Public License (GPL) version 2 only' use:
SPDX-License-Identifier: GPL-2.0-only
For 'GNU General Public License (GPL) version 2 or any later version' use:
SPDX-License-Identifier: GPL-2.0-or-later
License-Text:
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.
51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Library General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Ty Coon>, 1 April 1989
Ty Coon, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Library General
Public License instead of this License.
+77
View File
@@ -0,0 +1,77 @@
#
# Copyright (C) 2026 Josef Schlehofer
#
# This is free software, licensed under the GNU General Public License v2.
# See /LICENSE for more information.
#
include $(TOPDIR)/rules.mk
PKG_NAME:=librespeed-common
PKG_VERSION:=1.0.0
PKG_RELEASE:=1
PKG_MAINTAINER:=Josef Schlehofer <pepe.schlehofer@gmail.com>
PKG_LICENSE:=GPL-2.0-only
PKG_LICENSE_FILES:=LICENSE
include $(INCLUDE_DIR)/package.mk
define Package/librespeed-common
SECTION:=utils
CATEGORY:=Utilities
TITLE:=LibreSpeed measurement orchestration
DEPENDS:=+librespeed-cli +rpcd-mod-ucode +jsonfilter
PKGARCH:=all
endef
define Package/librespeed-common/description
Runs LibreSpeed measurements from UCI configuration: a single entry point
used both interactively and from cron, a schedule kept in step with UCI by
the init script, and results recorded as JSON for anything that wants to
read them, such as luci-app-librespeed, and a ubus interface for anything else.
endef
define Package/librespeed-common/conffiles
/etc/config/librespeed
endef
# rpcd only enumerates its plugin directory at startup, so the librespeed ubus
# object appears (and disappears) with a reload, not with the file.
define Package/librespeed-common/postinst
#!/bin/sh
[ -n "$${IPKG_INSTROOT}" ] || /etc/init.d/rpcd reload
exit 0
endef
define Package/librespeed-common/postrm
#!/bin/sh
[ -n "$${IPKG_INSTROOT}" ] || /etc/init.d/rpcd reload
exit 0
endef
# No source archive to unpack: the license text ships beside the Makefile and
# is staged so PKG_LICENSE_FILES points at a real file.
define Build/Prepare
$(INSTALL_DATA) ./LICENSE $(PKG_BUILD_DIR)/
endef
define Build/Compile
endef
define Package/librespeed-common/install
$(INSTALL_DIR) $(1)/usr/libexec
$(INSTALL_BIN) ./files/librespeed-run $(1)/usr/libexec/librespeed-run
$(INSTALL_BIN) ./files/librespeed-aggregate $(1)/usr/libexec/librespeed-aggregate
$(SED) 's/%%VERSION%%/$(PKG_VERSION)-$(PKG_RELEASE)/g' \
$(1)/usr/libexec/librespeed-run \
$(1)/usr/libexec/librespeed-aggregate
$(INSTALL_DIR) $(1)/usr/share/rpcd/ucode
$(INSTALL_DATA) ./files/librespeed.uc $(1)/usr/share/rpcd/ucode/librespeed.uc
$(INSTALL_DIR) $(1)/etc/config
$(INSTALL_CONF) ./files/librespeed.config $(1)/etc/config/librespeed
$(INSTALL_DIR) $(1)/etc/init.d
$(INSTALL_BIN) ./files/librespeed.init $(1)/etc/init.d/librespeed
endef
$(eval $(call BuildPackage,librespeed-common))
+184
View File
@@ -0,0 +1,184 @@
#!/usr/bin/env ucode
// Reduces completed days of raw measurement history to one line per day --
// min/avg/max over that day's samples, not a measurement itself -- and
// appends them to the archive on persistent storage. Runs from cron shortly
// after midnight; librespeed.init keeps that entry in step with UCI.
//
// Only days strictly before today are archived: a day's aggregate is written
// once and never revisited, which is what makes reruns idempotent without any
// marker file -- a day already present in the archive is simply skipped.
// Today's raw measurements stay in RAM only; if power is lost they are gone,
// which the Settings page says out loud.
'use strict';
import { open, readfile, writefile, rename, mkdir } from 'fs';
// Packaging checks probe every executable for these.
if (length(ARGV) > 0) {
if (ARGV[0] == '--version') {
print("librespeed-common %%VERSION%%\n");
exit(0);
}
print("Usage: librespeed-aggregate\n" +
"Reduces completed days of measurement history to daily min/avg/max\n" +
"aggregates. Runs from cron; takes no arguments.\n");
exit(0);
}
import { cursor } from 'uci';
const METRICS = [ 'download_mbps', 'upload_mbps', 'ping_ms', 'jitter_ms' ];
const uci = cursor();
function conf(section, option, fallback) {
const v = uci.get('librespeed', section, option);
return (v == null || v == '') ? fallback : v;
}
if (conf('history', 'enabled', '1') == '0')
exit(0);
const raw_path = conf('history', 'path', '/tmp/librespeed/history.jsonl');
const archive_path = conf('history', 'archive_path', '');
const archive_days = int(conf('history', 'archive_retention', '365d')) || 365;
if (archive_path == '')
exit(0);
function read_lines(path) {
const out = [];
const f = open(path, 'r');
if (!f)
return out;
for (let line = f.read('line'); length(line); line = f.read('line')) {
try {
push(out, json(line));
}
catch (e) {
continue;
}
}
f.close();
return out;
}
function day_key(epoch) {
const lt = localtime(epoch);
return sprintf('%04d-%02d-%02d', lt.year, lt.mon, lt.mday);
}
function day_start(key) {
const p = split(key, '-');
return timelocal({
year: int(p[0]), mon: int(p[1]), mday: int(p[2]),
hour: 0, min: 0, sec: 0
});
}
function round2(v) {
return int(v * 100 + 0.5) / 100.0;
}
const today = day_key(time());
// Which days the archive already holds. Entries carry the day in `timestamp`.
const archive = read_lines(archive_path);
const have = {};
for (let e in archive)
have[e.timestamp] = true;
// Group raw lines by local calendar day, completed days only.
const days = {};
for (let e in read_lines(raw_path)) {
const epoch = int(e?.epoch ?? 0);
if (!epoch)
continue;
const key = day_key(epoch);
if (key >= today || have[key])
continue;
days[key] = days[key] ?? [];
push(days[key], e);
}
let changed = false;
for (let key in sort(keys(days))) {
const entry = {
timestamp: key,
epoch: day_start(key),
samples: length(days[key])
};
for (let m in METRICS) {
let lo = null, hi = null, sum = 0.0, n = 0;
for (let e in days[key]) {
const v = e[m];
if (type(v) != 'double' && type(v) != 'int')
continue;
lo = (lo == null || v < lo) ? v : lo;
hi = (hi == null || v > hi) ? v : hi;
sum += v;
n++;
}
if (n > 0) {
// The mean lives in the plain field so a consumer that only knows
// raw entries keeps working; min and max sit beside it.
entry[m] = round2(sum / n);
entry[`${m}_min`] = lo;
entry[`${m}_max`] = hi;
}
}
push(archive, entry);
changed = true;
}
// Archive retention: integer comparison on the day-start epoch.
const cutoff = time() - archive_days * 86400;
const kept = filter(archive, e => int(e?.epoch ?? 0) >= cutoff);
if (length(kept) != length(archive))
changed = true;
if (!changed)
exit(0);
let tmp = `${archive_path}.tmp`;
let out = '';
for (let e in sort(kept, (a, b) => int(a.epoch) - int(b.epoch)))
out += sprintf('%J\n', e);
// The last component only, never the whole tree: archive_path commonly
// points at external storage, and with the mount down a recursive mkdir
// would build the path on the overlay and write every night's aggregate to
// internal flash, to be shadowed once the disk is back. Failing here leaves
// the location as the user prepared it.
const dir = replace(archive_path, /\/[^\/]+$/, '');
if (dir != '' && dir != archive_path)
mkdir(dir, 0o755);
// Atomic: a reader never sees a half-written archive. A failed write goes to
// syslog: this runs from cron, where stderr has nowhere to go.
if (writefile(tmp, out) != null)
rename(tmp, archive_path);
else
system(['logger', '-t', 'librespeed', `aggregate: cannot write ${tmp}`]);
+396
View File
@@ -0,0 +1,396 @@
#!/bin/sh
#
# Runs one LibreSpeed measurement and records the result.
#
# The only thing that starts a measurement: LuCI reaches it through rpcd, the
# scheduler calls it directly. Holding the lock for the whole run means a second
# invocation from either side fails instead of measuring against the first.
# Packaging checks probe every executable for these; a runner that ignored
# them would start a measurement instead of answering.
case "$1" in
--version)
echo "librespeed-common %%VERSION%%"
exit 0
;;
--help|-h)
cat <<'EOF'
Usage: librespeed-run
Runs one LibreSpeed measurement according to /etc/config/librespeed and
records the result for the ubus interface. Started by LuCI or cron; takes
no arguments.
EOF
exit 0
;;
esac
. /lib/functions.sh
. /lib/functions/network.sh
. /usr/share/libubox/jshn.sh
CLI=/usr/bin/librespeed-cli
STATE_DIR=/tmp/librespeed
STATE="$STATE_DIR/state.json"
RESULT="$STATE_DIR/result.json"
LOCK=/var/lock/librespeed.lock
mkdir -p "$STATE_DIR"
# Writes $2 to $1 without ever leaving a half-written file for a reader.
atomic_write() {
local target="$1" tmp="$1.$$"
printf '%s\n' "$2" > "$tmp" && mv "$tmp" "$target"
}
write_state() {
json_init
json_add_boolean running "$1"
[ -n "$2" ] && json_add_int pid "$2"
[ -n "$3" ] && json_add_int started "$3"
[ -n "$4" ] && json_add_int last_finished "$4"
json_add_string last_error "${5:-}"
json_add_string phase "${6:-}"
[ -n "$7" ] && json_add_double mbps "$7"
[ -n "$8" ] && json_add_int progress "$8"
atomic_write "$STATE" "$(json_dump)"
}
# Approximates progress from the interface byte counters, one sample a second.
#
# The fallback narrator for clients that cannot stream: it reads the whole
# interface, not the test, so it is honest only while the test dominates the
# link -- which is exactly the situation a progress line describes. Whichever
# direction carries the traffic names the phase.
sampler() {
local dev="$1" rx tx prx ptx phase mbps
[ -r "/sys/class/net/$dev/statistics/rx_bytes" ] || return 0
prx=$(cat "/sys/class/net/$dev/statistics/rx_bytes")
ptx=$(cat "/sys/class/net/$dev/statistics/tx_bytes")
while :; do
sleep 1
rx=$(cat "/sys/class/net/$dev/statistics/rx_bytes" 2>/dev/null) || return 0
tx=$(cat "/sys/class/net/$dev/statistics/tx_bytes" 2>/dev/null) || return 0
set -- $(awk -v rx="$rx" -v prx="$prx" -v tx="$tx" -v ptx="$ptx" 'BEGIN {
drx = (rx - prx) * 8 / 1000000
dtx = (tx - ptx) * 8 / 1000000
if (drx >= dtx && drx > 1) printf "download %.1f", drx
else if (dtx > 1) printf "upload %.1f", dtx
else printf "- -"
}')
prx=$rx; ptx=$tx
if [ "$1" = "-" ]; then
write_state 1 $$ "$started" "" "" "" ""
else
write_state 1 $$ "$started" "" "" "$1" "$2"
fi
done
}
fail() {
# A run that failed with the cached server drops the cache: the next run
# rediscovers instead of failing against the same dead choice forever.
[ "${used_cache:-0}" = 1 ] && rm -f "$LIST_CACHE" "$LIST_CACHE.src" "$CHOICE_CACHE"
write_state 0 "" "" "$(date +%s)" "$1"
logger -t librespeed "measurement failed: $1"
exit 1
}
# One measurement at a time. The descriptor stays open for the whole run, so the
# kernel releases the lock even if this script is killed -- there is no stale
# state to time out.
exec 9>"$LOCK"
flock -n 9 || {
echo "already running" >&2
exit 3
}
config_load librespeed
config_get iface main interface wan
config_get server main server auto
config_get scheme main scheme auto
config_get server_list main server_list ''
config_get_bool hist_enabled history enabled 1
config_get hist_path history path "$STATE_DIR/history.jsonl"
config_get hist_retention history retention 30d
# --interface takes a device, and UCI carries a logical interface name.
dev=""
network_get_device dev "$iface" 2>/dev/null
[ -x "$CLI" ] || fail "librespeed-cli is not installed"
# The Rust client reports progress as NDJSON under --json-stream; the Go one
# does not have the flag yet. Asking --help is one extra exec per measurement
# and keeps one script driving either client.
stream=0
if "$CLI" --help 2>&1 | grep -q -- '--json-stream'; then
stream=1
set -- "$CLI" --json-stream
else
set -- "$CLI" --json
fi
# The router-side counterpart of the web UI remembering its chosen server:
# after an automatic run the picked server's id and the downloaded list are
# cached, and later runs go straight to the same server with --server and
# --local-json instead of fetching the list and pinging everything on it.
LIST_CACHE="$STATE_DIR/servers.json"
CHOICE_CACHE="$STATE_DIR/server-choice"
CACHE_TTL=86400
# Where the list comes from: a self-hosted deployment -- Turris runs
# https://librespeed.turris.cz/servers.json -- replaces the official one.
LIST_URL="${server_list:-https://librespeed.org/backend-servers/servers.php}"
cache_fresh=0
c_id=""
if [ -s "$LIST_CACHE" ] && [ -s "$CHOICE_CACHE" ]; then
read -r c_epoch c_id < "$CHOICE_CACHE"
case "$c_id" in *[!0-9]*) c_id="" ;; esac
[ -n "$c_epoch" ] && [ $(( $(date +%s) - c_epoch )) -lt "$CACHE_TTL" ] || c_id=""
# A cache fetched from another list is no cache at all.
[ "$(cat "$LIST_CACHE.src" 2>/dev/null)" = "$LIST_URL" ] || c_id=""
fi
[ -n "$c_id" ] && cache_fresh=1
used_cache=0
if [ "$server" != "auto" ]; then
set -- "$@" --server "$server"
# An explicit id still needs the list to resolve it; the cached copy
# saves that download too.
if [ "$cache_fresh" = 1 ]; then
set -- "$@" --local-json "$LIST_CACHE"
used_cache=1
elif [ -n "$server_list" ]; then
set -- "$@" --server-json "$LIST_URL"
fi
elif [ "$cache_fresh" = 1 ]; then
set -- "$@" --server "$c_id" --local-json "$LIST_CACHE"
used_cache=1
elif [ -n "$server_list" ]; then
set -- "$@" --server-json "$LIST_URL"
fi
[ -n "$dev" ] && set -- "$@" --interface "$dev"
# TLS itself can bound the result on routers without AES acceleration, so the
# scheme is a measurement setting, not just a transport detail.
case "$scheme" in
https) set -- "$@" --secure ;;
http) set -- "$@" --insecure ;;
esac
# Never --bytes: it switches the report to MB/s and the history would end up
# holding two units that cannot be told apart afterwards.
started=$(date +%s)
write_state 1 $$ "$started"
# stop kills our whole process group, so librespeed-cli dies with us; ash runs
# this trap once the foreground child has exited, and it records that the run
# was stopped rather than pretending the measurement failed.
trap 'write_state 0 "" "" "$(date +%s)" "stopped"; exit 1' TERM
if [ "$stream" = 1 ]; then
# The while runs in a subshell, so the reports cannot come back in a
# variable; they land in a file instead. State updates are throttled to
# nothing -- one arrives a second and state.json lives in tmpfs.
rm -f "$STATE_DIR/reports.json"
"$@" 2>"$STATE_DIR/stderr.log" < /dev/null | while IFS= read -r line; do
case "$line" in
*'"event":"result"'*)
printf '%s' "$line" | jsonfilter -e '@.reports' \
> "$STATE_DIR/reports.json" 2>/dev/null
;;
*'"event":"progress"'*)
write_state 1 $$ "$started" "" "" \
"$(printf '%s' "$line" | jsonfilter -e '@.phase' 2>/dev/null)" \
"$(printf '%s' "$line" | jsonfilter -e '@.mbps' 2>/dev/null)" \
"$(printf '%s' "$line" | jsonfilter -e '@.progress' 2>/dev/null)"
;;
*'"event":"phase"'*)
write_state 1 $$ "$started" "" "" \
"$(printf '%s' "$line" | jsonfilter -e '@.phase' 2>/dev/null)" ""
;;
esac
done
# The pipeline's status is the reader's, so success is judged by what the
# stream delivered: a client that failed never emitted a result event.
out=$(cat "$STATE_DIR/reports.json" 2>/dev/null)
rm -f "$STATE_DIR/reports.json"
[ -n "$out" ] || \
fail "$(tail -n 1 "$STATE_DIR/stderr.log" 2>/dev/null || echo 'measurement failed')"
else
sampler_pid=""
if [ -n "$dev" ]; then
sampler "$dev" &
sampler_pid=$!
fi
out=$("$@" 2>"$STATE_DIR/stderr.log" < /dev/null); rc=$?
[ -n "$sampler_pid" ] && kill "$sampler_pid" 2>/dev/null
[ "$rc" = 0 ] || \
fail "$(tail -n 1 "$STATE_DIR/stderr.log" 2>/dev/null || echo 'measurement failed')"
fi
finished=$(date +%s)
[ -n "$out" ] || fail "no output from librespeed-cli"
# Both clients print an array of reports, one per server tested: the Go
# client marshals []report.JSONReport, and the Rust port mirrors that shape.
# jshn cannot load a bare array -- blobmsg wants an object at the top -- so the
# report array is wrapped before parsing. Found the hard way on a router: this
# is exactly the step no macOS test could reach.
json_load "{ \"reports\": $out }" 2>/dev/null || fail "unparseable output from librespeed-cli"
json_select reports 2>/dev/null || fail "unparseable output from librespeed-cli"
# The report is an array with one entry per server tested.
json_select 1 2>/dev/null || fail "empty report"
json_get_var ts timestamp
json_get_var ping ping
json_get_var jitter jitter
json_get_var download download
json_get_var upload upload
json_get_var bsent bytes_sent
json_get_var brecv bytes_received
json_get_var share share
srv_id=""; srv_name=""; srv_url=""
if json_select server 2>/dev/null; then
json_get_var srv_id id
json_get_var srv_name name
json_get_var srv_url url
json_select ..
fi
cli_ip=""; cli_org=""
if json_select client 2>/dev/null; then
json_get_var cli_ip ip
json_get_var cli_org org
json_select ..
fi
# The family of the address the backend saw is the family the test ran over.
# Derived here rather than asked of the CLI, so it works with any client; a
# redacted or missing address simply leaves the field out.
family=""
case "$cli_ip" in
*:*) family="ipv6" ;;
*.*) family="ipv4" ;;
esac
# Whether the run was encrypted is visible from the URL the CLI settled on;
# stored per measurement because the scheme option can change between runs.
proto=""
case "$srv_url" in
https:*) proto="https" ;;
http:*) proto="http" ;;
esac
# result.json -- the last completed measurement, not a database.
json_init
json_add_string timestamp "$ts"
json_add_int started "$started"
json_add_int finished "$finished"
json_add_string interface "$iface"
json_add_object server
# Absent until the CLI reports it; consumers treat null as unknown.
[ -n "$srv_id" ] && json_add_int id "$srv_id"
json_add_string name "$srv_name"
json_add_string url "$srv_url"
json_close_object
json_add_object client
json_add_string ip "$cli_ip"
json_add_string org "$cli_org"
json_close_object
[ -n "$family" ] && json_add_string family "$family"
[ -n "$proto" ] && json_add_string proto "$proto"
json_add_double download_mbps "$download"
json_add_double upload_mbps "$upload"
json_add_double ping_ms "$ping"
json_add_double jitter_ms "$jitter"
json_add_int bytes_sent "$bsent"
json_add_int bytes_received "$brecv"
json_add_string share "$share"
result="$(json_dump)"
atomic_write "$RESULT" "$result"
if [ "$hist_enabled" = "1" ]; then
mkdir -p "$(dirname "$hist_path")"
json_init
json_add_string timestamp "$ts"
json_add_int epoch "$finished"
json_add_string interface "$iface"
json_add_object server
[ -n "$srv_id" ] && json_add_int id "$srv_id"
json_add_string name "$srv_name"
json_add_string url "$srv_url"
json_close_object
[ -n "$family" ] && json_add_string family "$family"
[ -n "$proto" ] && json_add_string proto "$proto"
json_add_double download_mbps "$download"
json_add_double upload_mbps "$upload"
json_add_double ping_ms "$ping"
json_add_double jitter_ms "$jitter"
printf '%s\n' "$(json_dump)" >> "$hist_path"
# Retention. The epoch above makes this an integer comparison, so no date(1)
# runs here -- a year of history is thousands of lines and forking once per
# line is not something a router should be asked to do.
days=${hist_retention%d}
case "$days" in
''|*[!0-9]*) days=0 ;;
esac
if [ "$days" -gt 0 ]; then
cutoff=$(( finished - days * 86400 ))
# Compacting rewrites the whole file, so it happens in batches rather
# than whenever a single line falls out. Past the retention window every
# run expires something, and rewriting on each of them would push about
# a gigabyte a year through the flash instead of a few megabytes. The
# file therefore holds somewhat more than the window, and is trimmed
# once enough has accumulated to be worth the write.
expired=$(awk -v c="$cutoff" '
match($0, /"epoch":[0-9]+/) {
if (substr($0, RSTART + 8, RLENGTH - 8) + 0 < c) n++
}
END { print n + 0 }
' "$hist_path")
total=$(wc -l < "$hist_path")
if [ "$expired" -ge 50 ] || [ "$expired" -ge $(( total / 2 )) ] && [ "$expired" -gt 0 ]; then
tmp="$hist_path.$$"
awk -v c="$cutoff" '
match($0, /"epoch":[0-9]+/) {
if (substr($0, RSTART + 8, RLENGTH - 8) + 0 < c) next
}
{ print }
' "$hist_path" > "$tmp" && mv "$tmp" "$hist_path"
fi
fi
fi
write_state 0 "" "" "$finished" ""
# Refresh the server cache after the run, so the next one starts instantly:
# at most one list download a day, and the choice is the server this run
# actually used, looked up by the name the report carries.
if [ "$cache_fresh" = 0 ]; then
if uclient-fetch -q -T 15 -O "$LIST_CACHE.tmp" "$LIST_URL" 2>/dev/null \
&& [ -s "$LIST_CACHE.tmp" ]; then
mv "$LIST_CACHE.tmp" "$LIST_CACHE"
printf '%s\n' "$LIST_URL" > "$LIST_CACHE.src"
else
rm -f "$LIST_CACHE.tmp"
fi
if [ -s "$LIST_CACHE" ] && [ -n "$srv_name" ]; then
new_id=$(LIST="$LIST_CACHE" NAME="$srv_name" ucode -e '
let fs = require("fs");
let list = json(fs.readfile(getenv("LIST")) || "[]");
for (s in list)
if (s.name == getenv("NAME")) { print(s.id); break; }
' 2>/dev/null)
case "$new_id" in
''|*[!0-9]*) ;;
*) printf '%s %s\n' "$(date +%s)" "$new_id" > "$CHOICE_CACHE" ;;
esac
fi
fi
logger -t librespeed "measurement done: ${download} Mbps down, ${upload} Mbps up"
exit 0
+18
View File
@@ -0,0 +1,18 @@
config librespeed 'main'
option interface 'wan'
option server 'auto'
option scheme 'auto'
option server_list ''
config librespeed 'schedule'
option enabled '0'
option interval '1d'
option days '*'
option hours '2-5'
config librespeed 'history'
option enabled '1'
option path '/tmp/librespeed/history.jsonl'
option retention '30d'
option archive_path ''
option archive_retention '365d'
+120
View File
@@ -0,0 +1,120 @@
#!/bin/sh /etc/rc.common
# Keeps the scheduled-measurement cron entry in step with UCI. There is no
# daemon here: measurements are one-shot runs of librespeed-run, and the rpcd
# interface needs no service of its own.
START=95
USE_PROCD=1
CRONTAB=/etc/crontabs/root
RUN=/usr/libexec/librespeed-run
AGG=/usr/libexec/librespeed-aggregate
# Builds the cron time fields from UCI. For sub-daily intervals `hours`
# restricts when measurements may run; for a daily one it is the window a
# random time is drawn from, drawn here at sync time. That keeps the herd
# apart -- every router lands on its own minute -- without any process
# sleeping through the night, at the cost of re-drawing on every reload.
cron_expr() {
local interval="$1" days="$2" hours="$3"
local start end m h v
case "$interval" in
*m)
v=${interval%m}
case "$v" in ''|0|*[!0-9]*) return 1 ;; esac
[ "$v" -le 59 ] || return 1
echo "*/$v ${hours:-*} * * $days"
;;
*h)
v=${interval%h}
case "$v" in ''|0|*[!0-9]*) return 1 ;; esac
[ "$v" -le 23 ] || return 1
if [ -n "$hours" ]; then
echo "0 $hours/$v * * $days"
else
echo "0 */$v * * $days"
fi
;;
1d|24h)
start=${hours%-*}; end=${hours#*-}
[ -n "$hours" ] || { start=0; end=23; }
# A window wrapping midnight (22-4) is not supported; treat it as the
# single starting hour rather than guessing.
[ "$end" -ge "$start" ] 2>/dev/null || end=$start
# Time xor pid as the seed: seconds-granular srand() alone would give
# two syncs in the same second the same draw.
set -- $(awk -v a="$start" -v b="$end" -v s="$(( $(date +%s) ^ $$ ))" 'BEGIN {
srand(s)
printf "%d %d", int(rand() * 60), a + int(rand() * (b - a + 1))
}')
echo "$1 $2 * * $days"
;;
*)
return 1
;;
esac
}
restart_cron() {
# Unconditional: on a stock system the crontab dir starts empty, so crond
# is not running at all -- restart starts a stopped procd service, and a
# guard on "running" would mean the schedule never fires until reboot.
/etc/init.d/cron restart >/dev/null 2>&1
return 0
}
sync_cron() {
local enabled interval days hours expr hist_enabled archive_path
config_load librespeed
config_get_bool enabled schedule enabled 0
config_get interval schedule interval 1d
config_get days schedule days '*'
config_get hours schedule hours ''
config_get_bool hist_enabled history enabled 1
config_get archive_path history archive_path ''
# These end up verbatim in root's crontab, so anything unexpected becomes
# the harmless default rather than a cron field it was not meant to be.
case "$days" in *[!0-9,-]*) days='*' ;; '') days='*' ;; esac
case "$hours" in *[!0-9-]*) hours='' ;; esac
mkdir -p "${CRONTAB%/*}"
touch "$CRONTAB"
sed -i "\\#$RUN#d;\\#$AGG#d" "$CRONTAB"
# The day completes at midnight; five past, it is reduced to one archive
# line. A missed run costs nothing to catch up: raw lives in RAM, so after
# a power cut there is nothing left to aggregate anyway.
if [ "$hist_enabled" = 1 ] && [ -n "$archive_path" ]; then
echo "5 0 * * * $AGG" >> "$CRONTAB"
fi
if [ "$enabled" = 1 ]; then
if expr=$(cron_expr "$interval" "$days" "$hours"); then
echo "$expr $RUN" >> "$CRONTAB"
else
logger -t librespeed "unsupported schedule interval '$interval'"
fi
fi
restart_cron
}
start_service() {
sync_cron
}
stop_service() {
sed -i "\\#$RUN#d;\\#$AGG#d" "$CRONTAB"
restart_cron
}
service_triggers() {
procd_add_reload_trigger "librespeed"
}
reload_service() {
sync_cron
}
+322
View File
@@ -0,0 +1,322 @@
#!/usr/bin/env ucode
// ubus interface for LibreSpeed measurements.
//
// Runs inside rpcd, which is exactly why it never measures anything itself: a
// measurement takes tens of seconds and would stall rpcd's event loop -- and
// with it every rpcd consumer on the system. Anything long-lived is handed to
// librespeed-run as a detached process.
'use strict';
import { open, readfile, stat } from 'fs';
import { cursor } from 'uci';
const STATE_DIR = '/tmp/librespeed';
const STATE = `${STATE_DIR}/state.json`;
const RESULT = `${STATE_DIR}/result.json`;
const LOCK = '/var/lock/librespeed.lock';
const RUN = '/usr/libexec/librespeed-run';
function read_json(path) {
const text = readfile(path);
if (text == null)
return null;
let value = null;
try {
value = json(text);
}
catch (e) {
value = null;
}
return value;
}
// The process group of a pid, read from /proc: the runner is spawned detached
// (start-stop-daemon -b or setsid), so its group holds the whole measurement
// tree and nothing else. Parsed after the comm field's closing parenthesis,
// the one place a process can put spaces.
function pgid_of(pid) {
const st = readfile(`/proc/${pid}/stat`);
if (!st)
return 0;
const f = split(trim(substr(st, rindex(st, ')') + 1)), ' ');
return int(f[2] ?? 0);
}
// The lock says whether a measurement runs, not a field in a file: a process
// that dies takes its lock with it, so there is no stale state to age out.
function is_running() {
const f = open(LOCK, 'r');
if (!f)
return false;
const acquired = f.lock('xn');
if (acquired)
f.lock('u');
f.close();
return !acquired;
}
function config_get(uci, section, option, fallback) {
const v = uci.get('librespeed', section, option);
return (v == null || v == '') ? fallback : v;
}
// Next occurrences of the drawn cron line, computed here rather than in the
// browser: the schedule fires in the router's timezone, and the browser may
// well sit in another one. Understands only the shapes librespeed.init
// emits: numbers, ranges, ranges with a step, star, and comma lists.
function cron_next(line, count) {
const f = split(trim(line ?? ''), /\s+/);
if (length(f) < 5)
return [];
const match_field = function(pat, val) {
for (let part in split(pat, ',')) {
let step = 1;
let m = match(part, /^(.+)\/([0-9]+)$/);
if (m) {
part = m[1];
step = int(m[2]);
}
let a, b;
if (part == '*') {
a = 0;
b = 59;
}
else {
m = match(part, /^([0-9]+)-([0-9]+)$/);
if (m) {
a = int(m[1]);
b = int(m[2]);
}
else {
a = int(part);
b = a;
}
}
if (val >= a && val <= b && (val - a) % step == 0)
return true;
}
return false;
};
const out = [];
let t = time();
t -= t % 60;
for (let i = 0; i < 8 * 24 * 60 && length(out) < count; i++) {
t += 60;
const lt = localtime(t);
// % 7 folds both weekday conventions onto cron's 0-6 with Sunday 0.
if (match_field(f[0], lt.min) && match_field(f[1], lt.hour) &&
match_field(f[4], lt.wday % 7))
push(out, t);
}
return out;
}
const methods = {
start: {
call: function() {
if (is_running())
return { error: 'already running' };
if (!stat(RUN))
return { error: 'not installed' };
// Detached: the frontend polls status instead of waiting here.
system(`start-stop-daemon -S -b -x ${RUN} >/dev/null 2>&1 || ( setsid ${RUN} >/dev/null 2>&1 & )`);
return { started: true };
}
},
stop: {
call: function() {
const st = read_json(STATE);
// Kill the whole process group, not just the wrapper shell:
// librespeed-cli and the sampler must die with it, or a "stopped"
// answer would leave the measurement running on inherited fds.
// busybox kill takes the negative pgid without `--`.
if (is_running() && st?.pid) {
const pg = pgid_of(int(st.pid));
if (pg > 0)
system(`kill -TERM -${pg} 2>/dev/null`);
else
system(`kill -TERM ${int(st.pid)} 2>/dev/null`);
}
return { stopped: true };
}
},
status: {
call: function() {
const st = read_json(STATE) ?? {};
if (is_running()) {
const out = { running: true, phase: st.phase ?? '' };
if (st.pid)
out.pid = int(st.pid);
if (st.started)
out.started = int(st.started);
if (st.mbps != null)
out.mbps = st.mbps + 0.0;
if (st.progress != null)
out.progress = int(st.progress);
return out;
}
const out = { running: false, last_error: st.last_error ?? '' };
if (st.last_finished)
out.last_finished = int(st.last_finished);
return out;
}
},
result: {
call: function() {
return read_json(RESULT) ?? {};
}
},
// Contract: one response comes from exactly one source. A range that fits
// the raw retention window returns raw measurements; an older range is
// served from the daily archive at 1d resolution (completed days only, so
// today is absent there). The two never mix in one response --
// `resolution` names the source, and a consumer comparing two windows
// must compare like with like.
history: {
args: { from: 0, to: 0, limit: 0 },
call: function(request) {
const from = int(request.args?.from ?? 0);
const to = int(request.args?.to ?? 0);
const limit = int(request.args?.limit ?? 0);
const uci = cursor();
const raw_path = config_get(uci, 'history', 'path',
`${STATE_DIR}/history.jsonl`);
const archive_path = config_get(uci, 'history', 'archive_path', '');
const raw_days = int(config_get(uci, 'history', 'retention', '30d')) || 30;
uci.unload('librespeed');
// Ranges the raw window can answer come from raw; anything reaching
// further back is served from the daily archive when one is kept.
// The hour of slack keeps the boundary request -- "the last 30
// days" against a 30-day window -- from flapping between sources
// over clock skew.
let resolution = 'raw';
let path = raw_path;
if (archive_path != '' && stat(archive_path) &&
(from == 0 || from < time() - raw_days * 86400 - 3600)) {
resolution = '1d';
path = archive_path;
}
const entries = [];
const f = open(path, 'r');
if (f) {
for (let line = f.read('line'); length(line); line = f.read('line')) {
let e = null;
try {
e = json(line);
}
catch (err) {
continue;
}
if (from > 0 && int(e?.epoch ?? 0) < from)
continue;
if (to > 0 && int(e?.epoch ?? 0) > to)
continue;
push(entries, e);
}
f.close();
}
// Newest N, still oldest first.
const kept = (limit > 0 && length(entries) > limit)
? slice(entries, -limit) : entries;
return { resolution: resolution, entries: kept };
}
},
config: {
call: function() {
const uci = cursor();
// The drawn schedule lives in the crontab, not in UCI: for a daily
// interval the time is picked at sync. Handing the line out lets
// the frontend show when measurements will actually run.
let cron = '';
const cf = open('/etc/crontabs/root', 'r');
if (cf) {
for (let line = cf.read('line'); length(line); line = cf.read('line'))
if (index(line, '/usr/libexec/librespeed-run') >= 0)
cron = trim(line);
cf.close();
}
const out = {
interface: config_get(uci, 'main', 'interface', 'wan'),
server: config_get(uci, 'main', 'server', 'auto'),
scheme: config_get(uci, 'main', 'scheme', 'auto'),
server_list: config_get(uci, 'main', 'server_list', ''),
schedule: {
enabled: config_get(uci, 'schedule', 'enabled', '0') == '1',
interval: config_get(uci, 'schedule', 'interval', '1d'),
days: config_get(uci, 'schedule', 'days', '*'),
hours: config_get(uci, 'schedule', 'hours', ''),
cron: cron,
next_runs: cron != '' ? cron_next(cron, 3) : []
},
history: {
enabled: config_get(uci, 'history', 'enabled', '1') != '0',
path: config_get(uci, 'history', 'path',
`${STATE_DIR}/history.jsonl`),
retention: config_get(uci, 'history', 'retention', '30d')
}
};
uci.unload('librespeed');
return out;
}
}
};
return { librespeed: methods };
+26
View File
@@ -0,0 +1,26 @@
#!/bin/sh
# The package ships scripts and an rpcd plugin, no binary of its own, so the
# generic --version probe cannot apply; check the installed pieces instead.
fail() { echo "FAIL: $1"; exit 1; }
[ -x /usr/libexec/librespeed-run ] || fail "librespeed-run not installed"
[ -x /usr/libexec/librespeed-aggregate ] || fail "librespeed-aggregate not installed"
[ -f /usr/share/rpcd/ucode/librespeed.uc ] || fail "rpcd plugin not installed"
[ -f /etc/config/librespeed ] || fail "UCI config not installed"
[ -x /etc/init.d/librespeed ] || fail "init script not installed"
sh -n /usr/libexec/librespeed-run || fail "librespeed-run does not parse"
/usr/libexec/librespeed-run --version | grep librespeed-common \
|| fail "librespeed-run --version"
/usr/libexec/librespeed-aggregate --version | grep librespeed-common \
|| fail "librespeed-aggregate --version"
sh -n /etc/init.d/librespeed || fail "init script does not parse"
# The plugin file has no side effects at load time: it defines its methods
# and returns them. Run as a file, the way rpcd loads it -- include() cannot
# be used here, it rejects the module import statements the plugin needs.
ucode /usr/share/rpcd/ucode/librespeed.uc >/dev/null \
|| fail "rpcd plugin does not load"
echo "librespeed-common: installed files OK"
+11
View File
@@ -0,0 +1,11 @@
include $(TOPDIR)/rules.mk
LUCI_TITLE:=LibreSpeed internet speed measurements
LUCI_DEPENDS:=+luci-base +librespeed-common
PKG_LICENSE:=Apache-2.0
PKG_MAINTAINER:=Josef Schlehofer <pepe.schlehofer@gmail.com>
include $(TOPDIR)/feeds/luci/luci.mk
# call BuildPackage - OpenWrt buildroot signature
@@ -0,0 +1,506 @@
'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);
}
/* Bumped whenever librespeed.css changes, so a routine page load picks the
* new stylesheet up instead of pairing new views with a cached old one. */
const CSS_VERSION = '18';
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,
/* The app stylesheet, cache-busted by its own version: LuCI's resource
* version only changes when luci-base does, which is not when we do. */
cssLink() {
return E('link', {
'rel': 'stylesheet',
'href': L.resource('librespeed/librespeed.css') + '?v=' + CSS_VERSION
});
},
/* Plain figures of one series: what is normal, how much it wobbles, and
* where it stands now. The story sentences are built from these. */
seriesStats(entries, metric) {
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;
return {
count: vals.length,
avg: avg,
min: Math.min.apply(null, vals),
max: Math.max.apply(null, vals),
current: vals[vals.length - 1]
};
},
/* A row of toggle buttons; the active one is highlighted. */
switcher(view, items, current, onpick) {
return E('div', {}, items.map(it =>
E('button', {
'class': it[0] == current ? 'cbi-button cbi-button-action' : 'cbi-button',
'style': 'margin-right:.3em',
'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">avg %s</text>'
.format(W - R, sc.ys(avg) - 4, 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);
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')
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');
}
});
@@ -0,0 +1,315 @@
/* 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(--primary-color-medium, #5bc0de);
}
/* 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(--primary-color-medium, #5bc0de);
fill: none;
stroke-width: 1.5;
}
.librespeed-chart-fill {
fill: var(--primary-color-medium, #5bc0de);
fill-opacity: .1;
stroke: none;
}
.librespeed-chart-band {
fill: var(--primary-color-medium, #5bc0de);
fill-opacity: .18;
stroke: none;
}
.librespeed-chart-dot {
fill: var(--primary-color-medium, #5bc0de);
}
.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(--primary-color-medium, #5bc0de);
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(--primary-color-medium, #5bc0de); }
.librespeed-stroke-1 { stroke: var(--primary-color-medium, #5bc0de); }
polyline.librespeed-stroke-1 { stroke-dasharray: 6 3; }
.librespeed-avgline.librespeed-stroke-1 { stroke-dasharray: 2 4; }
.librespeed-fill-0 { fill: var(--primary-color-medium, #5bc0de); }
.librespeed-fill-1 { fill: var(--primary-color-medium, #5bc0de); }
circle.librespeed-fill-1 { fill-opacity: .55; }
.librespeed-bg-0 { background: var(--primary-color-medium, #5bc0de); }
.librespeed-bg-1 {
background: repeating-linear-gradient(90deg,
var(--primary-color-medium, #5bc0de) 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(--primary-color-medium, #5bc0de); }
.librespeed-fg-1 { color: var(--primary-color-medium, #5bc0de); }
.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;
opacity: .5;
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(--primary-color-medium, #5bc0de); }
.librespeed-accent-1 { border-top-color: var(--primary-color-medium, #5bc0de); 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;
}
@@ -0,0 +1,403 @@
'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 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()
};
return this.fetch();
},
/* 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),
ps = lscommon.seriesStats(prev, k),
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')
}, [
E('div', { 'class': 'librespeed-muted 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) {
this.emptyText.textContent = noneAtAll
? _('Run a speed test to start building your connection history.')
: _('No measurements match the current filters.');
this.emptyStart.style.display = noneAtAll ? '' : 'none';
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.') ]));
const num = v => (typeof v == 'number') ? v : -1;
const fmtNum = (v, d) => (typeof v == 'number') ? v.toFixed(d) : '';
const when = e => {
if (!e.timestamp)
return '?';
if (this.resolution == '1d' || e.timestamp.indexOf('T') < 0)
return e.timestamp;
const d = new Date(e.timestamp);
/* A garbled timestamp shows as itself, never as "Invalid Date". */
return isNaN(d.getTime()) ? e.timestamp : d.toLocaleString();
};
/* 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(' · ') || '';
};
const rows = entries.slice().reverse();
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).map(e => [
[ e.epoch ?? 0, when(e) ],
(e.server && e.server.name) || '',
e.interface || '',
protoCell(e),
[ num(e.download_mbps), fmtNum(e.download_mbps, 2) ],
[ num(e.upload_mbps), fmtNum(e.upload_mbps, 2) ],
[ num(e.ping_ms), fmtNum(e.ping_ms, 1) ],
[ num(e.jitter_ms), fmtNum(e.jitter_ms, 1) ]
]));
},
/* 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' },
[ _('%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();
const renderControls = L.bind(function() {
const groups = lscommon.switcher(this,
[ [ 'speed', _('Speed') ], [ 'latency', _('Latency') ] ], this.group,
function(v) { this.group = v; renderControls(); this.redraw(); });
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));
});
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);
});
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
});
@@ -0,0 +1,170 @@
'use strict';
'require view';
'require form';
'require rpc';
'require tools.widgets as widgets';
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'));
o.value('15m', _('Every 15 minutes'));
o.value('30m', _('Every 30 minutes'));
o.value('1h', _('Hourly'));
o.value('6h', _('Every 6 hours'));
o.value('12h', _('Every 12 hours'));
o.value('1d', _('Daily'));
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:0005: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.')
]));
}
node.appendChild(box);
return node;
});
}
});
@@ -0,0 +1,701 @@
'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
? _('%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);
}).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);
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) : '';
const when = e => {
const d = new Date(e.timestamp);
return isNaN(d.getTime()) ? String(e.timestamp || '?') : d.toLocaleString();
};
const num = v => (typeof v == 'number') ? v : -1;
this.recentTable.update(entries.slice(-recentRows).reverse().map(e => [
[ e.epoch ?? 0, when(e) ],
(e.server && e.server.name) || '',
[ num(e.download_mbps), fmtNum(e.download_mbps, 2) ],
[ num(e.upload_mbps), fmtNum(e.upload_mbps, 2) ],
[ num(e.ping_ms), fmtNum(e.ping_ms, 1) ],
[ num(e.jitter_ms), 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') : '';
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' : ''))
}, [ 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 {
const holder = E('div', {});
holder.innerHTML = gauge(
(typeof status.mbps == 'number' && status.mbps > 0)
? status.mbps : null,
this.runTop,
status.phase == 'upload' ? 1 : 0);
rows.push(holder);
}
/* 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 + ' %' ]));
const 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) {
/* 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.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. */
this.recentSwitch = 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);
});
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', { 'class': 'librespeed-hint', '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 || [];
const schedRows = [ [ _('Enabled'), sched.enabled ? _('Yes') : _('No') ] ];
if (sched.enabled) {
schedRows.push([ _('Interval'), sched.interval || '1d' ]);
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.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
});
@@ -0,0 +1,593 @@
msgid ""
msgstr "Content-Type: text/plain; charset=UTF-8"
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:272
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:223
msgid "%d measurements"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:430
msgid "%s elapsed"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:124
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:136
msgid "1 year"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/librespeed/common.js:30
msgid "1y"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:137
msgid "2 years"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/librespeed/common.js:27
msgid "24h"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:122
msgid "30 days"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/librespeed/common.js:29
msgid "30d"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:121
msgid "7 days"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/librespeed/common.js:28
msgid "7d"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:123
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:135
msgid "90 days"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:157
msgid ""
"A daily measurement runs at a time drawn when the schedule is applied; "
"saving draws it anew."
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/librespeed/common.js:31
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:82
msgid "All"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:95
msgid ""
"An hour window on the 24-hour clock, such as 2-5 for 02:0005: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."
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:128
msgid "Archive path"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:134
msgid "Archive retention"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:521
msgid "Completed"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:129
msgid ""
"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."
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:405
msgid "Connecting to the test server…"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:327
msgid "Could not start the measurement: %s"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:67
msgid "Daily"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:221
msgid "Daily minimum, average and maximum."
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:71
msgid "Days"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:251
msgid "Declining — the last measurement is %d%% below the 24-hour average."
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/librespeed/common.js:11
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:92
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:491
msgid "Download"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:312
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:563
msgid "Download [Mbps]"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:58
msgid "Enable"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:628
msgid "Enabled"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:652
msgid ""
"Encrypted transfers, which on hardware without AES acceleration may bound "
"the result."
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:66
msgid "Every 12 hours"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:62
msgid "Every 15 minutes"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:63
msgid "Every 30 minutes"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:65
msgid "Every 6 hours"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:381
msgid "Export CSV"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:384
msgid "Export JSON"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:77
msgid "Friday"
msgstr ""
#: applications/luci-app-librespeed/root/usr/share/rpcd/acl.d/luci-app-librespeed.json:3
msgid "Grant access to LibreSpeed measurements"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:647
msgid "HTTP (forced)"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:646
msgid "HTTPS (forced)"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:147
msgid "Hide this series"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:110
#: applications/luci-app-librespeed/root/usr/share/luci/menu.d/luci-app-librespeed.json:25
msgid "History"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:116
msgid "History stored in /tmp is lost when the router reboots."
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:64
msgid "Hourly"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:94
msgid "Hours"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:311
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:348
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:25
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:509
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:642
msgid "Interface"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:61
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:631
msgid "Interval"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/librespeed/common.js:14
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:499
msgid "Jitter"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:312
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:563
msgid "Jitter [ms]"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:112
msgid "Keep history"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:449
msgid "Last measurement failed"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:375
msgid "Last successful measurement"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:318
msgid "Latency"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:72
msgid "Leave empty to run every day."
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:684
#: applications/luci-app-librespeed/root/usr/share/luci/menu.d/luci-app-librespeed.json:3
msgid "LibreSpeed"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:393
msgid "LibreSpeed History"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:21
msgid "LibreSpeed Settings"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:26
msgid "Logical interface the measurement is bound to."
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:23
msgid "Measurement"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:376
msgid "Measurements"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:687
msgid "Measures the internet connection from this router."
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:73
msgid "Monday"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:36
msgid "Must be a server id or 'auto'"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:46
msgid "Must be an http(s) URL"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:286
msgid "Next"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:633
msgid "Next run"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:628
msgid "No"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:218
msgid "No data for the selected metric."
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:278
msgid "No data in the last 24 hours."
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:466
msgid "No measurement yet."
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:194
msgid "No measurements match the current filters."
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:368
msgid "No measurements yet"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:162
msgid ""
"No schedule is active. Enable it above and apply to draw the measurement "
"time."
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:31
msgid ""
"Numeric server id from the LibreSpeed server list, or 'auto' to pick the "
"closest."
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:50
msgid ""
"On routers without AES acceleration, TLS itself can bound the result — "
"forcing plain HTTP then measures the line rather than the cipher."
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:281
msgid "Page %d of %d"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:115
msgid "Path"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/librespeed/common.js:13
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:91
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:498
msgid "Ping"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:312
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:563
msgid "Ping [ms]"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:279
msgid "Previous"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:311
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:49
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:518
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:645
msgid "Protocol"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:673
msgid "Recent history"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:120
msgid "Retention"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:685
msgid "Router speed test"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:193
msgid "Run a speed test to start building your connection history."
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:59
msgid ""
"Runs a measurement from cron at the chosen interval. Note that a measurement "
"saturates the connection while it runs."
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:78
msgid "Saturday"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:657
msgid "Schedule status"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:56
msgid "Scheduled measurements"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:311
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:346
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:30
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:507
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:562
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:643
msgid "Server"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:39
msgid "Server list URL"
msgstr ""
#: applications/luci-app-librespeed/root/usr/share/luci/menu.d/luci-app-librespeed.json:34
msgid "Settings"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:147
msgid "Show this series"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:318
msgid "Speed"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:175
msgid "Stable over the selected period."
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:257
msgid "Stable — averaged %d Mbps, ranging from %d to %d Mbps."
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:362
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:349
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:580
msgid "Start test"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:349
msgid "Stop test"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:79
msgid "Sunday"
msgstr ""
#: applications/luci-app-librespeed/root/usr/share/luci/menu.d/luci-app-librespeed.json:16
msgid "Test"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:640
msgid "Test configuration"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:106
msgid "The window must not cross midnight"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:76
msgid "Thursday"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:311
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:562
msgid "Time"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:74
msgid "Tuesday"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:40
msgid ""
"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."
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:650
msgid "Unencrypted transfers, so the line is measured rather than the cipher."
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:150
msgid "Upcoming measurements"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/librespeed/common.js:12
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:93
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:492
msgid "Upload"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:312
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:563
msgid "Upload [Mbps]"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:104
msgid "Use an hour (0-23) or a range like 2-5"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:254
msgid "Variable — ranged from %d to %d Mbps."
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:319
msgid "View full history »"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:540
msgid "View result"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:75
msgid "Wednesday"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:653
msgid "Whichever scheme the server list provides."
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:628
msgid "Yes"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:644
msgid "automatic (nearest)"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:293
msgid "average"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/librespeed/common.js:328
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:154
msgid "avg"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:294
msgid "best"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:292
msgid "current"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:165
msgid "declining"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:516
msgid "encrypted (HTTPS)"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:53
msgid "force HTTP"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:52
msgid "force HTTPS"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:165
msgid "highly variable"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:558
msgid "last 24 hours"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:516
msgid "plain HTTP"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:360
msgid "server %s"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:51
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:648
msgid "server default"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:165
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:172
msgid "stable"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:122
msgid "vs previous period"
msgstr ""
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:295
msgid "worst"
msgstr ""
@@ -0,0 +1,41 @@
{
"admin/network/librespeed": {
"title": "LibreSpeed",
"order": 70,
"action": {
"type": "alias",
"path": "admin/network/librespeed/test"
},
"depends": {
"acl": [ "luci-app-librespeed" ],
"uci": { "librespeed": true }
}
},
"admin/network/librespeed/test": {
"title": "Test",
"order": 10,
"action": {
"type": "view",
"path": "librespeed/test"
}
},
"admin/network/librespeed/history": {
"title": "History",
"order": 20,
"action": {
"type": "view",
"path": "librespeed/history"
}
},
"admin/network/librespeed/settings": {
"title": "Settings",
"order": 30,
"action": {
"type": "view",
"path": "librespeed/settings"
}
}
}
@@ -0,0 +1,17 @@
{
"luci-app-librespeed": {
"description": "Grant access to LibreSpeed measurements",
"read": {
"ubus": {
"librespeed": [ "status", "result", "history", "config" ]
},
"uci": [ "librespeed" ]
},
"write": {
"ubus": {
"librespeed": [ "start", "stop" ]
},
"uci": [ "librespeed" ]
}
}
}