mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-09-11 02:44:57 +08:00
188 lines
4.7 KiB
Plaintext
Executable File
188 lines
4.7 KiB
Plaintext
Executable File
#!/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, error } 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. The reason
|
|
// comes along, since a missing mount, a read-only filesystem and a full disk
|
|
// each want something different from whoever reads that log.
|
|
if (writefile(tmp, out) != null)
|
|
rename(tmp, archive_path);
|
|
else
|
|
system(['logger', '-t', 'librespeed',
|
|
`aggregate: cannot write ${tmp}: ${error()}`]);
|