1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
|
#!/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, rename, mkdir, unlink, 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. Written by hand
// rather than writefile(), which drops fclose()'s status and would let a
// full disk truncate the archive in silence -- flush() is where a short
// write surfaces. 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.
const af = open(tmp, 'w');
let wrote = af != null && af.write(out) == length(out);
if (af) {
wrote = af.flush() != null && wrote;
af.close();
}
if (wrote)
rename(tmp, archive_path);
else {
system(['logger', '-t', 'librespeed',
`aggregate: cannot write ${tmp}: ${error()}`]);
unlink(tmp);
}
|