blockyard 0.0.1 → 0.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +679 -0
- package/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +172 -4
- package/SECURITY.md +38 -0
- package/bin/blockyard.js +40 -0
- package/config/pool-map.json +2620 -0
- package/docs/API.md +1575 -0
- package/docs/ARCHITECTURE.md +1307 -0
- package/docs/AUTO-UPDATE.md +269 -0
- package/docs/CONFIGURATION.md +840 -0
- package/docs/DEFECTS.md +813 -0
- package/docs/EFFECTS-AGENTS.md +448 -0
- package/docs/GETTING-STARTED.md +202 -0
- package/docs/INSTALL.md +490 -0
- package/docs/MEASUREMENTS.md +1254 -0
- package/docs/PRIVATE-LEADERBOARD.md +230 -0
- package/docs/RULES.md +681 -0
- package/docs/SECURITY-AUDIT-2026-09-14.md +177 -0
- package/docs/SECURITY-AUDIT.md +258 -0
- package/docs/SECURITY.md +195 -0
- package/docs/STATE-2026-09-09.md +200 -0
- package/docs/TROUBLESHOOTING.md +298 -0
- package/docs/USER-GUIDE.md +1022 -0
- package/package.json +53 -5
- package/public/404.html +9 -0
- package/public/css/app.css +1785 -0
- package/public/index.html +893 -0
- package/public/js/about.js +112 -0
- package/public/js/agents.js +964 -0
- package/public/js/app.js +1312 -0
- package/public/js/arkanoid.js +806 -0
- package/public/js/blockanoid.js +347 -0
- package/public/js/blockout.js +347 -0
- package/public/js/blockpack.js +428 -0
- package/public/js/blockscene3d.js +2678 -0
- package/public/js/breakout.js +224 -0
- package/public/js/charts.js +635 -0
- package/public/js/depthchart.js +311 -0
- package/public/js/details3d.js +2957 -0
- package/public/js/explorer.js +405 -0
- package/public/js/feepalette.js +149 -0
- package/public/js/fmt.js +162 -0
- package/public/js/goggles.js +886 -0
- package/public/js/kiosk.js +41 -0
- package/public/js/login.js +83 -0
- package/public/js/markets.js +357 -0
- package/public/js/mining.js +1138 -0
- package/public/js/panels.js +966 -0
- package/public/js/pricechart.js +188 -0
- package/public/js/settings.js +1014 -0
- package/public/js/tetris.js +226 -0
- package/public/js/tetrust.js +356 -0
- package/public/js/tetsound.js +175 -0
- package/public/login.html +33 -0
- package/scripts/blockfile-measure.js +156 -0
- package/scripts/browser-check.mjs +286 -0
- package/scripts/check.js +173 -0
- package/scripts/decode-check.js +81 -0
- package/scripts/doc-counts.js +109 -0
- package/scripts/donate-qr.py +20 -0
- package/scripts/fake-node.js +534 -0
- package/scripts/index-bench.js +216 -0
- package/scripts/index-benchmark.js +117 -0
- package/scripts/index-build.js +40 -0
- package/scripts/live-render-check.mjs +89 -0
- package/scripts/manage-users.js +132 -0
- package/scripts/motion-check.mjs +138 -0
- package/scripts/pool-map.js +157 -0
- package/scripts/setup.js +410 -0
- package/scripts/shots.mjs +272 -0
- package/scripts/smoke.sh +327 -0
- package/scripts/ui.js +174 -0
- package/server/auth/sessions.js +221 -0
- package/server/auth/users.js +243 -0
- package/server/chain/blockfile.js +234 -0
- package/server/chain/index/build.js +193 -0
- package/server/chain/index/heights.js +36 -0
- package/server/chain/index/live.js +276 -0
- package/server/chain/index/rows.js +145 -0
- package/server/chain/index/store.js +154 -0
- package/server/chain/index/worker.js +109 -0
- package/server/chain/tx.js +310 -0
- package/server/collect/gbt.js +229 -0
- package/server/collect/logparse.js +765 -0
- package/server/collect/logtail.js +189 -0
- package/server/collect/markets.js +333 -0
- package/server/collect/mining.js +333 -0
- package/server/collect/monitor.js +2516 -0
- package/server/collect/nextblock.js +275 -0
- package/server/collect/sync.js +386 -0
- package/server/config.js +620 -0
- package/server/http/api.js +1275 -0
- package/server/http/explorer.js +418 -0
- package/server/http/server.js +412 -0
- package/server/http/sse.js +176 -0
- package/server/http/static.js +212 -0
- package/server/main.js +628 -0
- package/server/netinfo.js +253 -0
- package/server/rpc/allowlist.js +130 -0
- package/server/rpc/client.js +414 -0
- package/server/store/audit.js +148 -0
- package/server/store/history.js +220 -0
- package/server/store/ledger.js +290 -0
- package/server/store/ring.js +173 -0
- package/server/util/fmt.js +29 -0
- package/systemd/blockyard.service +100 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// Named time series + crash-safe persistence.
|
|
2
|
+
//
|
|
3
|
+
// Snapshots are written tmp+fsync+rename, i.e. a reader sees the old file or the
|
|
4
|
+
// new one and never a half-written one. That is the same discipline the node's
|
|
5
|
+
// own writers use (its docs repeat the rule: "header written last, so a crash
|
|
6
|
+
// leaves a file that reads as absent rather than a partial one that looks
|
|
7
|
+
// whole"), and it is the only sane answer when the process can be SIGKILLed
|
|
8
|
+
// mid-flush by a system OOM killer this box has actually triggered before.
|
|
9
|
+
import fsp from 'node:fs/promises';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { Ring } from './ring.js';
|
|
12
|
+
|
|
13
|
+
// The single schema both the collectors and the HTTP layer use. Field names here
|
|
14
|
+
// are what the browser receives; adding a field means adding it here first.
|
|
15
|
+
export const SERIES = {
|
|
16
|
+
node: ['t', 'blocks', 'headers', 'progress', 'difficulty', 'sizeOnDisk', 'ibd', 'connections',
|
|
17
|
+
'peersIn', 'peersOut', 'uptimeMs', 'txRate', 'chainTxCount', 'txouts', 'totalAmount', 'muhash'],
|
|
18
|
+
mempool: ['t', 'count', 'bytes', 'usage', 'maxUsage', 'totalFee', 'minFee', 'minRelayFee', 'unbroadcast',
|
|
19
|
+
'ingestRate', 'acceptedDelta', 'rejectMissing', 'rejectPolicy', 'rejectInvalid', 'confirmedDrain',
|
|
20
|
+
'pendingAncestors', 'replaceable', 'avgFee', 'avgVsize'],
|
|
21
|
+
net: ['t', 'inBps', 'outBps', 'diskWriteBps', 'inTotal', 'outTotal', 'diskTotal', 'avgRecvBps', 'avgWriteBps',
|
|
22
|
+
'floorBps', 'poolMedianBps'],
|
|
23
|
+
fees: ['t', 'f1', 'f2', 'f6', 'f24', 'f144', 'mempoolmin', 'priority', 'estimatorOk'],
|
|
24
|
+
peers: ['t', 'connections', 'in', 'out', 'relayPeers', 'servedBlocks', 'txRelayPeers', 'wanted', 'banned',
|
|
25
|
+
'rankingLive', 'rankingAnswered', 'rankingMedianKbps'],
|
|
26
|
+
blocks: ['t', 'height', 'time', 'mediantime', 'totalfee', 'txs', 'size', 'weight', 'medianTxSize', 'avgTxSize', 'swtotalSize', 'swtxs', 'avgFeerate', 'subsidy',
|
|
27
|
+
'utxoIncrease', 'ins', 'outs', 'avgfee', 'medianfee', 'maxfee', 'p0', 'p1', 'p2', 'p3', 'p4', 'gapSec', 'viaPeer', 'source'],
|
|
28
|
+
txflow: ['t', 'accepted', 'relayAccepted', 'rejectMissing', 'rejectPolicy', 'rejectInvalid', 'alreadyConfirmed',
|
|
29
|
+
'orphansHeld', 'orphansParked', 'orphansResolved', 'orphansDropped', 'inFlight', 'oneP1C', 'oneP1CFailed', 'windowSec'],
|
|
30
|
+
rpc: ['t', 'latencyMs', 'avgLatencyMs', 'ratePerSec', 'queued', 'errors', 'breakerTrips', 'busyMsPerSec'],
|
|
31
|
+
self: ['t', 'rssMb', 'heapMb', 'sseClients', 'usersActive', 'cpuPct', 'eventRate'],
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export class History {
|
|
35
|
+
constructor(dir, cfg, { log = () => {} } = {}) {
|
|
36
|
+
this.dir = dir;
|
|
37
|
+
this.cfg = cfg;
|
|
38
|
+
this.log = log;
|
|
39
|
+
this.rings = new Map();
|
|
40
|
+
for (const [name] of Object.entries(SERIES)) this.rings.set(name, new Ring(cfg.ringCapacity));
|
|
41
|
+
this.events = []; // newest first, capped
|
|
42
|
+
this.eventsSeq = 0;
|
|
43
|
+
this.file = path.join(dir, 'history.json');
|
|
44
|
+
this.eventsFile = path.join(dir, 'events.jsonl');
|
|
45
|
+
this.saving = false;
|
|
46
|
+
this.lastSavedAt = null;
|
|
47
|
+
this.lastSaveError = null;
|
|
48
|
+
this.dirtySince = 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
ring(name) {
|
|
52
|
+
if (!this.rings.has(name)) throw new Error(`unknown series ${name}`);
|
|
53
|
+
return this.rings.get(name);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// A node-scoped view of the same store. Series rings are shared by every
|
|
57
|
+
// configured node, so writes are stamped and reads are filtered; without this,
|
|
58
|
+
// a two-node deployment draws one line that averages two daemons (measured:
|
|
59
|
+
// 2,308 production rows against 1,816 bench rows in the `peers` ring).
|
|
60
|
+
//
|
|
61
|
+
// Deliberately still ONE ring per series rather than one per node: capacity and
|
|
62
|
+
// retention are configured per series and shared, and per-node rings would double
|
|
63
|
+
// the memory of every added node while making a removed node's history unreachable.
|
|
64
|
+
// If capacity ever becomes the constraint, say so here rather than discovering it
|
|
65
|
+
// in a chart that quietly stopped reaching back 24 h.
|
|
66
|
+
forNode(nodeId) {
|
|
67
|
+
if (!nodeId) throw new Error('forNode needs a node id');
|
|
68
|
+
const history = this;
|
|
69
|
+
return {
|
|
70
|
+
__perNode: true,
|
|
71
|
+
node: nodeId,
|
|
72
|
+
ring(name) {
|
|
73
|
+
const ring = history.ring(name);
|
|
74
|
+
return {
|
|
75
|
+
node: nodeId,
|
|
76
|
+
get length() { return ring.length; },
|
|
77
|
+
first: () => ring.first(),
|
|
78
|
+
last: (opts = {}) => ring.tail(50).filter((r) => r.node === nodeId).pop() ?? null,
|
|
79
|
+
since: (t, o = {}) => ring.since(t).filter((r) => (o.node === undefined ? r.node === nodeId : o.node === r.node)),
|
|
80
|
+
tail: (n, o = {}) => ring.tail(n * 4).filter((r) => (o.node === undefined ? r.node === nodeId : o.node === r.node)).slice(-n),
|
|
81
|
+
series: (field, opts = {}) => ring.series(field, { ...opts, node: opts.node ?? nodeId }),
|
|
82
|
+
stats: (field, opts = {}) => ring.stats(field, { ...opts, node: opts.node ?? nodeId }),
|
|
83
|
+
raw: ring,
|
|
84
|
+
};
|
|
85
|
+
},
|
|
86
|
+
record(name, row) { return history.record(name, { ...row, node: nodeId }); },
|
|
87
|
+
addEvent(ev) { return history.addEvent({ ...ev, node: nodeId }); },
|
|
88
|
+
addEvents(evs) { return evs.map((e) => this.addEvent(e)); },
|
|
89
|
+
eventsSinceSeq: (seq, limit) => history.eventsSinceSeq(seq, limit),
|
|
90
|
+
forNode: () => history.forNode(nodeId),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
record(name, row) {
|
|
95
|
+
const ring = this.ring(name);
|
|
96
|
+
ring.push({ t: row.t ?? Date.now(), ...row });
|
|
97
|
+
this.dirtySince ||= Date.now();
|
|
98
|
+
return ring.last();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
addEvent(ev) {
|
|
102
|
+
this.eventsSeq += 1;
|
|
103
|
+
const row = { seq: this.eventsSeq, ...ev };
|
|
104
|
+
this.events.unshift(row);
|
|
105
|
+
if (this.events.length > this.cfg.maxEventLog) this.events.length = this.cfg.maxEventLog;
|
|
106
|
+
this.dirtySince ||= Date.now();
|
|
107
|
+
return row;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
addEvents(evs) { return evs.map((e) => this.addEvent(e)); }
|
|
111
|
+
|
|
112
|
+
// Newest-first events with a sequence above `seq` (this.events is maintained
|
|
113
|
+
// newest-first, so a plain filter already yields the right order).
|
|
114
|
+
eventsSinceSeq(seq, limit = 500) {
|
|
115
|
+
return this.events.filter((e) => e.seq > seq).slice(0, limit);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
prune() {
|
|
119
|
+
const cutoff = Date.now() - this.cfg.retentionHours * 3600 * 1000;
|
|
120
|
+
for (const ring of this.rings.values()) ring.pruneBefore(cutoff);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
summary() {
|
|
124
|
+
const out = {};
|
|
125
|
+
for (const [name, ring] of this.rings) {
|
|
126
|
+
out[name] = {
|
|
127
|
+
points: ring.length,
|
|
128
|
+
firstAt: ring.first()?.t ?? null,
|
|
129
|
+
lastAt: ring.last()?.t ?? null,
|
|
130
|
+
// Rows written before per-node tagging, and which nodes are represented.
|
|
131
|
+
// Stated because a node-filtered chart legitimately cannot draw them, and
|
|
132
|
+
// silence about that would look like missing history.
|
|
133
|
+
unattributed: ring.unattributed(),
|
|
134
|
+
nodes: ring.nodes(),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async save() {
|
|
141
|
+
if (this.saving) return { skipped: true };
|
|
142
|
+
this.saving = true;
|
|
143
|
+
try {
|
|
144
|
+
await fsp.mkdir(this.dir, { recursive: true });
|
|
145
|
+
const payload = {
|
|
146
|
+
version: 1,
|
|
147
|
+
savedAt: Date.now(),
|
|
148
|
+
retentionHours: this.cfg.retentionHours,
|
|
149
|
+
rings: Object.fromEntries([...this.rings].map(([k, r]) => [k, r.toJSON()])),
|
|
150
|
+
events: this.events.slice(0, this.cfg.maxEventLog),
|
|
151
|
+
eventsSeq: this.eventsSeq,
|
|
152
|
+
};
|
|
153
|
+
const tmp = `${this.file}.tmp`;
|
|
154
|
+
const fh = await fsp.open(tmp, 'w', 0o600); // node-derived detail: owner-only, like users and sessions
|
|
155
|
+
await fh.writeFile(JSON.stringify(payload));
|
|
156
|
+
await fh.sync();
|
|
157
|
+
await fh.close();
|
|
158
|
+
await fsp.rename(tmp, this.file);
|
|
159
|
+
this.lastSavedAt = Date.now();
|
|
160
|
+
this.lastSaveError = null;
|
|
161
|
+
this.dirtySince = 0;
|
|
162
|
+
return { saved: true, bytes: JSON.stringify(payload).length };
|
|
163
|
+
} catch (err) {
|
|
164
|
+
this.lastSaveError = { at: Date.now(), message: err.message };
|
|
165
|
+
this.log({ level: 'error', msg: `history snapshot failed: ${err.message}` });
|
|
166
|
+
return { saved: false, error: err.message };
|
|
167
|
+
} finally {
|
|
168
|
+
this.saving = false;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async load() {
|
|
173
|
+
let raw;
|
|
174
|
+
try {
|
|
175
|
+
raw = await fsp.readFile(this.file, 'utf8');
|
|
176
|
+
} catch {
|
|
177
|
+
return { loaded: false, reason: 'no snapshot yet' };
|
|
178
|
+
}
|
|
179
|
+
let data;
|
|
180
|
+
try {
|
|
181
|
+
data = JSON.parse(raw);
|
|
182
|
+
} catch (err) {
|
|
183
|
+
// A corrupt snapshot is not a reason to refuse to start; it is a reason to
|
|
184
|
+
// say so and fall back to an empty history.
|
|
185
|
+
this.log({ level: 'warn', msg: `history snapshot unparseable (${err.message}); starting empty` });
|
|
186
|
+
return { loaded: false, reason: `unparseable: ${err.message}` };
|
|
187
|
+
}
|
|
188
|
+
const cutoff = Date.now() - this.cfg.retentionHours * 3600 * 1000;
|
|
189
|
+
for (const name of Object.keys(SERIES)) {
|
|
190
|
+
const r = Ring.fromJSON(data.rings?.[name], this.cfg.ringCapacity);
|
|
191
|
+
r.pruneBefore(cutoff);
|
|
192
|
+
this.rings.set(name, r);
|
|
193
|
+
}
|
|
194
|
+
this.events = (data.events ?? []).filter((e) => e.ts >= cutoff).slice(0, this.cfg.maxEventLog);
|
|
195
|
+
this.eventsSeq = Math.max(data.eventsSeq ?? 0, this.events[0]?.seq ?? 0);
|
|
196
|
+
return { loaded: true, points: [...this.rings.values()].reduce((a, r) => a + r.length, 0), savedAt: data.savedAt ?? null };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
startAutosave() {
|
|
200
|
+
this.timer = setInterval(() => {
|
|
201
|
+
if (!this.dirtySince) return;
|
|
202
|
+
if (Date.now() - this.dirtySince < this.cfg.snapshotEveryMs) return;
|
|
203
|
+
this.prune();
|
|
204
|
+
this.save().catch(() => {});
|
|
205
|
+
}, Math.min(60_000, this.cfg.snapshotEveryMs));
|
|
206
|
+
this.timer.unref?.();
|
|
207
|
+
return this;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async stop() {
|
|
211
|
+
if (this.timer) clearInterval(this.timer);
|
|
212
|
+
if (this.dirtySince) await this.save();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Atomic append-only text sink, used by the audit log.
|
|
217
|
+
export async function appendJsonl(file, row) {
|
|
218
|
+
await fsp.mkdir(path.dirname(file), { recursive: true });
|
|
219
|
+
await fsp.appendFile(file, JSON.stringify(row) + '\n', { encoding: 'utf8', mode: 0o600 }); // who did what: owner-only (audit 2026-09-14, L4)
|
|
220
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// The durable store for facts the monitor derives and must not re-derive.
|
|
2
|
+
//
|
|
3
|
+
// Why this exists: coinbase attribution is two RPC reads per block, and until now the
|
|
4
|
+
// result lived in a 400-row in-memory map that died on every restart -- so a monitor that
|
|
5
|
+
// had been running for a month could still tell you about exactly the last four minutes of
|
|
6
|
+
// mining, and a restart threw away everything it had learned about who mines this chain.
|
|
7
|
+
// "Durable, and the next run starts from what this one saw" is the requirement; this is
|
|
8
|
+
// the smallest thing that satisfies it.
|
|
9
|
+
//
|
|
10
|
+
// Two engines behind one interface, chosen at boot, no dependency either way:
|
|
11
|
+
//
|
|
12
|
+
// sqlite -- node:sqlite, in the runtime we already run. Measured on this box
|
|
13
|
+
// (2026-09-09): 52,578 rows (a year of blocks) written in 62 ms, aggregate
|
|
14
|
+
// over the lot in 6 ms, 7.93 MB on disk. Crash safety was tested, not
|
|
15
|
+
// assumed: 1,000 committed rows, a child process inserting 1,000 more inside
|
|
16
|
+
// an open transaction, SIGKILL mid-transaction -- reopen gave rows=1000,
|
|
17
|
+
// uncommitted kept=0, PRAGMA integrity_check = ok.
|
|
18
|
+
// Caveat carried honestly: node:sqlite prints
|
|
19
|
+
// "ExperimentalWarning: SQLite is an experimental feature and might change",
|
|
20
|
+
// so it is feature-detected and never assumed.
|
|
21
|
+
//
|
|
22
|
+
// jsonl -- append-only newline-delimited JSON, fsync'd. Measured: the same year of
|
|
23
|
+
// rows written + fsync'd in 13 ms, 14.84 MB, replayed in 5 ms. No queries,
|
|
24
|
+
// no transactions across rows, but it has no API risk at all, is readable by
|
|
25
|
+
// grep while the service runs, and doubles as the export of the sqlite file.
|
|
26
|
+
//
|
|
27
|
+
// The fallback is not a downgrade path that changes behaviour: aggregation lives here, in
|
|
28
|
+
// JavaScript, over the same row shape, so both engines answer identically. That is a
|
|
29
|
+
// deliberate trade -- sqlite could answer the aggregate in SQL in 6 ms -- in exchange for
|
|
30
|
+
// one code path, one set of tests, and a fallback that is provably the same product.
|
|
31
|
+
|
|
32
|
+
import fs from 'node:fs';
|
|
33
|
+
import path from 'node:path';
|
|
34
|
+
|
|
35
|
+
const ROW_COLUMNS = ['height', 'hash', 'poolKey', 'poolLabelKey', 'poolLabel', 'matchedTag',
|
|
36
|
+
'tagText', 'tagSource', 'weight', 'size', 'strippedSize', 'txs', 'totalfee', 'avgFeerate',
|
|
37
|
+
'p50', 'p75', 'p99', 'extraNonce', 'commitment', 'rawCoinbase', 'mapSha', 'seenAt'];
|
|
38
|
+
|
|
39
|
+
/** Does this runtime give us a working node:sqlite? Tested by using it, not by version. */
|
|
40
|
+
async function sqliteEngine() {
|
|
41
|
+
try {
|
|
42
|
+
const mod = await import('node:sqlite');
|
|
43
|
+
if (typeof mod.DatabaseSync !== 'function') return null;
|
|
44
|
+
// Open an in-memory database and write to it. If that works, the engine works.
|
|
45
|
+
const probe = new mod.DatabaseSync(':memory:');
|
|
46
|
+
probe.exec('CREATE TABLE probe(k INTEGER PRIMARY KEY, v TEXT)');
|
|
47
|
+
probe.prepare('INSERT INTO probe VALUES (1,?)').run('ok');
|
|
48
|
+
probe.close();
|
|
49
|
+
return mod.DatabaseSync;
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function openLedger({ file, engine = 'auto', keepHeights = 52_594, log = () => {} } = {}) {
|
|
56
|
+
if (!file) throw new Error('openLedger needs a file path');
|
|
57
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
58
|
+
let chosen = engine;
|
|
59
|
+
if (engine === 'auto') {
|
|
60
|
+
chosen = (process.env.BLOCKYARD_LEDGER_ENGINE ?? 'sqlite').trim().toLowerCase();
|
|
61
|
+
if (chosen === 'sqlite') {
|
|
62
|
+
const DatabaseSync = await sqliteEngine();
|
|
63
|
+
if (!DatabaseSync) {
|
|
64
|
+
chosen = 'jsonl';
|
|
65
|
+
log({ level: 'warn', msg: `ledger: node:sqlite unavailable, using the append-only file (same rows, same answers, no queries)` });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (chosen === 'sqlite') {
|
|
70
|
+
const DatabaseSync = await sqliteEngine();
|
|
71
|
+
if (DatabaseSync) return new SqliteLedger({ file, DatabaseSync, keepHeights, log });
|
|
72
|
+
chosen = 'jsonl';
|
|
73
|
+
}
|
|
74
|
+
return new JsonlLedger({ file, keepHeights, log });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ------------------------------------------------------------------ sqlite
|
|
78
|
+
|
|
79
|
+
class SqliteLedger {
|
|
80
|
+
constructor({ file, DatabaseSync, keepHeights, log }) {
|
|
81
|
+
this.kind = 'sqlite';
|
|
82
|
+
this.file = file;
|
|
83
|
+
this.keepHeights = keepHeights;
|
|
84
|
+
this.log = log;
|
|
85
|
+
this.db = new DatabaseSync(file);
|
|
86
|
+
// WAL + synchronous=FULL: measured to roll back an open transaction on SIGKILL.
|
|
87
|
+
// WAL leaves a -wal beside the file; that is expected and is part of the store.
|
|
88
|
+
this.db.exec('PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;');
|
|
89
|
+
this.db.exec(`CREATE TABLE IF NOT EXISTS attribution (
|
|
90
|
+
height INTEGER PRIMARY KEY, pool_key TEXT, pool_label TEXT, seen_at INTEGER, row TEXT
|
|
91
|
+
)`);
|
|
92
|
+
this.db.exec('CREATE INDEX IF NOT EXISTS attribution_seen ON attribution(seen_at DESC)');
|
|
93
|
+
this.insert = this.db.prepare('INSERT OR REPLACE INTO attribution VALUES (?,?,?,?,?)');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
put(row) { return this.putMany([row]); }
|
|
97
|
+
|
|
98
|
+
putMany(rows) {
|
|
99
|
+
const good = (rows ?? []).filter((r) => Number.isInteger(r?.height));
|
|
100
|
+
if (!good.length) return 0;
|
|
101
|
+
this.db.exec('BEGIN');
|
|
102
|
+
try {
|
|
103
|
+
for (const r of good) {
|
|
104
|
+
this.insert.run(r.height, r.poolKey ?? null, r.poolLabel ?? null, r.seenAt ?? Date.now(), JSON.stringify(pick(r)));
|
|
105
|
+
}
|
|
106
|
+
this.db.exec('COMMIT');
|
|
107
|
+
} catch (err) {
|
|
108
|
+
this.db.exec('ROLLBACK');
|
|
109
|
+
throw err;
|
|
110
|
+
}
|
|
111
|
+
return good.length;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** A reorg happened: nothing above the new fork height may survive. */
|
|
115
|
+
dropAbove(height) {
|
|
116
|
+
const gone = this.db.prepare('SELECT COUNT(*) c FROM attribution WHERE height > ?').get(height).c;
|
|
117
|
+
this.db.prepare('DELETE FROM attribution WHERE height > ?').run(height);
|
|
118
|
+
return gone;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
latest(n = 50) {
|
|
122
|
+
return this.db.prepare('SELECT row FROM attribution ORDER BY height DESC LIMIT ?').all(n)
|
|
123
|
+
.map((r) => JSON.parse(r.row));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
since(height) {
|
|
127
|
+
return this.db.prepare('SELECT row FROM attribution WHERE height >= ? ORDER BY height DESC').all(height)
|
|
128
|
+
.map((r) => JSON.parse(r.row));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
deleteOlderThan(height) {
|
|
132
|
+
return this.db.prepare('DELETE FROM attribution WHERE height < ?').run(height).changes ?? 0;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
depth() {
|
|
136
|
+
const row = this.db.prepare('SELECT COUNT(*) n, MIN(height) lo, MAX(height) hi FROM attribution').get();
|
|
137
|
+
return {
|
|
138
|
+
engine: this.kind, file: path.basename(this.file), rows: row.n ?? 0,
|
|
139
|
+
from: row.lo ?? null, to: row.hi ?? null, bytes: fileSize(this.file),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
close() { try { this.db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch { /* best effort */ } this.db.close(); }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ------------------------------------------------------------------- jsonl
|
|
147
|
+
|
|
148
|
+
class JsonlLedger {
|
|
149
|
+
constructor({ file, keepHeights, log }) {
|
|
150
|
+
this.kind = 'jsonl';
|
|
151
|
+
this.file = file;
|
|
152
|
+
this.keepHeights = keepHeights;
|
|
153
|
+
this.log = log;
|
|
154
|
+
this.byHeight = new Map();
|
|
155
|
+
this.dirty = 0;
|
|
156
|
+
// A torn last line is normal after a crash, not corruption: read what parses and
|
|
157
|
+
// keep appending. Same discipline as the log follower uses on the node's log.
|
|
158
|
+
let tail = '';
|
|
159
|
+
try { tail = fs.readFileSync(file, 'utf8'); } catch { tail = ''; }
|
|
160
|
+
for (const line of tail.split('\n')) {
|
|
161
|
+
if (!line.trim()) continue;
|
|
162
|
+
try {
|
|
163
|
+
const row = JSON.parse(line);
|
|
164
|
+
if (Number.isInteger(row?.height)) this.byHeight.set(row.height, row);
|
|
165
|
+
} catch { /* the incomplete final write; ignored, then overwritten */ }
|
|
166
|
+
}
|
|
167
|
+
this.fd = fs.openSync(file, 'a');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
put(row) { return this.putMany([row]); }
|
|
171
|
+
|
|
172
|
+
putMany(rows) {
|
|
173
|
+
const good = (rows ?? []).filter((r) => Number.isInteger(r?.height));
|
|
174
|
+
let wrote = 0;
|
|
175
|
+
for (const r of good) {
|
|
176
|
+
this.byHeight.set(r.height, pick(r));
|
|
177
|
+
fs.writeSync(this.fd, JSON.stringify(pick(r)) + '\n');
|
|
178
|
+
wrote++;
|
|
179
|
+
}
|
|
180
|
+
if (wrote) {
|
|
181
|
+
fs.fsyncSync(this.fd); // durability is the whole point of this file
|
|
182
|
+
this.dirty += wrote;
|
|
183
|
+
if (this.dirty > 20_000) this.#compact(); // keep the file from growing without bound
|
|
184
|
+
}
|
|
185
|
+
return wrote;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
#compact() {
|
|
189
|
+
const rows = [...this.byHeight.values()].sort((a, b) => a.height - b.height);
|
|
190
|
+
const tmp = `${this.file}.tmp`;
|
|
191
|
+
const fd = fs.openSync(tmp, 'w');
|
|
192
|
+
for (const r of rows) fs.writeSync(fd, JSON.stringify(r) + '\n');
|
|
193
|
+
fs.fsyncSync(fd);
|
|
194
|
+
fs.closeSync(fd);
|
|
195
|
+
fs.closeSync(this.fd);
|
|
196
|
+
fs.renameSync(tmp, this.file);
|
|
197
|
+
this.fd = fs.openSync(this.file, 'a');
|
|
198
|
+
this.dirty = 0;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
dropAbove(height) {
|
|
202
|
+
const rows = [...this.byHeight.keys()].filter((h) => h > height);
|
|
203
|
+
for (const h of rows) this.byHeight.delete(h);
|
|
204
|
+
if (rows.length) this.#compact();
|
|
205
|
+
return rows.length;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
latest(n = 50) {
|
|
209
|
+
return [...this.byHeight.keys()].sort((a, b) => b - a).slice(0, n).map((h) => this.byHeight.get(h));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
since(height) {
|
|
213
|
+
return [...this.byHeight.entries()].filter(([h]) => h >= height)
|
|
214
|
+
.sort((a, b) => b[0] - a[0]).map(([, r]) => r);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
deleteOlderThan(height) {
|
|
218
|
+
let n = 0;
|
|
219
|
+
for (const h of [...this.byHeight.keys()]) if (h < height) { this.byHeight.delete(h); n++; }
|
|
220
|
+
if (n) this.#compact();
|
|
221
|
+
return n;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
depth() {
|
|
225
|
+
const ks = [...this.byHeight.keys()];
|
|
226
|
+
return {
|
|
227
|
+
engine: this.kind, file: path.basename(this.file), rows: ks.length,
|
|
228
|
+
from: ks.length ? Math.min(...ks) : null, to: ks.length ? Math.max(...ks) : null,
|
|
229
|
+
bytes: fileSize(this.file),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
close() { try { this.#compact(); } catch { /* closing must not throw */ } try { fs.closeSync(this.fd); } catch { /* already closed */ } }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ----------------------------------------------------------------- shared
|
|
237
|
+
|
|
238
|
+
function pick(r) {
|
|
239
|
+
const out = {};
|
|
240
|
+
for (const k of ROW_COLUMNS) if (r[k] !== undefined) out[k] = r[k];
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const fileSize = (f) => { try { return fs.statSync(f).size; } catch { return 0; } };
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Fold rows into the pool view. One implementation for both engines, so an engine
|
|
248
|
+
* switch cannot change what the page says about who mines this chain.
|
|
249
|
+
*/
|
|
250
|
+
export function aggregate(rows = []) {
|
|
251
|
+
const pools = new Map();
|
|
252
|
+
for (const r of rows) {
|
|
253
|
+
const key = r.poolLabelKey ?? r.poolKey ?? 'unknown';
|
|
254
|
+
const p = pools.get(key) ?? {
|
|
255
|
+
poolKey: r.poolKey ?? key, label: r.poolLabel ?? null, labelled: !!r.poolLabel,
|
|
256
|
+
blocks: 0, txs: 0, weightSum: 0, feeSum: 0, feerates: [], sizes: [], tags: new Set(),
|
|
257
|
+
firstHeight: r.height, lastHeight: r.height,
|
|
258
|
+
};
|
|
259
|
+
p.blocks++;
|
|
260
|
+
p.txs += r.txs ?? 0;
|
|
261
|
+
if (r.weight != null) p.weightSum += r.weight;
|
|
262
|
+
if (r.totalfee != null) p.feeSum += r.totalfee;
|
|
263
|
+
if (r.avgFeerate != null) p.feerates.push(r.avgFeerate);
|
|
264
|
+
if (r.size != null) p.sizes.push(r.size);
|
|
265
|
+
if (r.tagText) p.tags.add(String(r.tagText).slice(0, 60));
|
|
266
|
+
p.firstHeight = Math.min(p.firstHeight, r.height);
|
|
267
|
+
p.lastHeight = Math.max(p.lastHeight, r.height);
|
|
268
|
+
if (r.poolLabel && !p.label) { p.label = r.poolLabel; p.labelled = true; }
|
|
269
|
+
pools.set(key, p);
|
|
270
|
+
}
|
|
271
|
+
const total = rows.length;
|
|
272
|
+
const median = (a) => {
|
|
273
|
+
if (!a.length) return null;
|
|
274
|
+
const s = [...a].sort((x, y) => x - y);
|
|
275
|
+
return s.length % 2 ? s[(s.length - 1) / 2] : +((s[s.length / 2 - 1] + s[s.length / 2]) / 2).toFixed(2);
|
|
276
|
+
};
|
|
277
|
+
return [...pools.values()]
|
|
278
|
+
.map((p) => ({
|
|
279
|
+
poolKey: p.poolKey, label: p.label, labelled: p.labelled, blocks: p.blocks,
|
|
280
|
+
sharePct: total ? +(100 * p.blocks / total).toFixed(1) : null,
|
|
281
|
+
txs: p.txs || null,
|
|
282
|
+
avgWeight: p.blocks && p.weightSum ? Math.round(p.weightSum / p.blocks) : null,
|
|
283
|
+
medianSize: median(p.sizes),
|
|
284
|
+
medianFeeRate: median(p.feerates),
|
|
285
|
+
totalFeesSat: p.feeSum || null,
|
|
286
|
+
tags: [...p.tags].slice(0, 4),
|
|
287
|
+
firstHeight: p.firstHeight, lastHeight: p.lastHeight,
|
|
288
|
+
}))
|
|
289
|
+
.sort((a, b) => b.blocks - a.blocks || b.lastHeight - a.lastHeight);
|
|
290
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// Fixed-capacity time-ordered store with on-read downsampling.
|
|
2
|
+
//
|
|
3
|
+
// Charts ask "give me 6 hours at ~200 pixels", which is a bucketed aggregate,
|
|
4
|
+
// not a raw walk. Doing that here means the browser receives a few hundred
|
|
5
|
+
// numbers per series instead of 20k, on every SSE refresh.
|
|
6
|
+
export class Ring {
|
|
7
|
+
constructor(capacity = 20000) {
|
|
8
|
+
this.capacity = capacity;
|
|
9
|
+
this.rows = [];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
push(row) {
|
|
13
|
+
if (row == null || !Number.isFinite(row.t)) return;
|
|
14
|
+
// Append-only assumption: collectors are the only writers and always move
|
|
15
|
+
// forward in time, so a binary search is safe and keeps `since` cheap.
|
|
16
|
+
this.rows.push(row);
|
|
17
|
+
if (this.rows.length > this.capacity) this.rows.splice(0, this.rows.length - this.capacity);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
get length() { return this.rows.length; }
|
|
21
|
+
first() { return this.rows[0] ?? null; }
|
|
22
|
+
last() { return this.rows[this.rows.length - 1] ?? null; }
|
|
23
|
+
|
|
24
|
+
pruneBefore(cutoffMs) {
|
|
25
|
+
const i = lowerBound(this.rows, cutoffMs);
|
|
26
|
+
if (i > 0) this.rows.splice(0, i);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
since(t) {
|
|
30
|
+
const i = lowerBound(this.rows, t);
|
|
31
|
+
return this.rows.slice(i);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
tail(n) { return this.rows.slice(Math.max(0, this.rows.length - n)); }
|
|
35
|
+
|
|
36
|
+
// Rows written before per-node tagging existed. Reported rather than silently
|
|
37
|
+
// dropped or silently attributed.
|
|
38
|
+
unattributed() { return this.rows.reduce((n, r) => n + (r.node == null ? 1 : 0), 0); }
|
|
39
|
+
|
|
40
|
+
nodes() {
|
|
41
|
+
const seen = [];
|
|
42
|
+
for (const r of this.rows) if (r.node != null && !seen.includes(r.node)) seen.push(r.node);
|
|
43
|
+
return seen;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Bucketed single-field aggregate for a chart series.
|
|
47
|
+
//
|
|
48
|
+
// `node` matters because one ring per series is shared by every configured node.
|
|
49
|
+
// Measured with two live nodes on 2026-09-08: the `peers` ring held 2,308 rows
|
|
50
|
+
// from production interleaved with 1,816 from the bench node, and no row said
|
|
51
|
+
// which. Drawn as one line, that is a chart of an average of two daemons, which
|
|
52
|
+
// is not a fact about either. Rows written before the tagging exist and have no
|
|
53
|
+
// node: they are excluded from a node-filtered read, because guessing their owner
|
|
54
|
+
// would put bench traffic on a production chart. They still count in `stats()`
|
|
55
|
+
// with no filter, and in `unattributed()` so the number is visible.
|
|
56
|
+
series(field, { since: from, bucketMs = 0, agg = 'last', node = null } = {}) {
|
|
57
|
+
const rows = pick(this.rows, from, node);
|
|
58
|
+
if (!bucketMs) return rows.map((r) => ({ t: r.t, v: r[field] })).filter((p) => p.v != null);
|
|
59
|
+
const out = [];
|
|
60
|
+
let bucket = null;
|
|
61
|
+
const acc = { n: 0, sum: 0, min: Infinity, max: -Infinity, last: null, first: null, prev: null };
|
|
62
|
+
const flush = () => {
|
|
63
|
+
if (bucket == null) return;
|
|
64
|
+
out.push({ t: bucket + bucketMs, v: aggregate(agg, acc) });
|
|
65
|
+
acc.n = 0; acc.sum = 0; acc.min = Infinity; acc.max = -Infinity; acc.last = null; acc.first = null; acc.prev = null;
|
|
66
|
+
};
|
|
67
|
+
for (const r of rows) {
|
|
68
|
+
const v = r[field];
|
|
69
|
+
const b = Math.floor(r.t / bucketMs) * bucketMs;
|
|
70
|
+
if (bucket === null) bucket = b;
|
|
71
|
+
if (b !== bucket) { flush(); bucket = b; }
|
|
72
|
+
if (v == null || Number.isNaN(v)) continue;
|
|
73
|
+
if (acc.n === 0) acc.first = v;
|
|
74
|
+
if (agg === 'delta') {
|
|
75
|
+
if (acc.n > 0) { acc.sum += Math.max(0, v - acc.last); }
|
|
76
|
+
acc.prev = acc.last;
|
|
77
|
+
}
|
|
78
|
+
acc.n += 1; acc.sum += v; acc.min = Math.min(acc.min, v); acc.max = Math.max(acc.max, v); acc.last = v;
|
|
79
|
+
}
|
|
80
|
+
flush();
|
|
81
|
+
return out.filter((p) => p.v != null);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
stats(field, { since: from, node = null } = {}) {
|
|
85
|
+
const rows = pick(this.rows, from, node);
|
|
86
|
+
const acc = { n: 0, sum: 0, min: Infinity, max: -Infinity, last: null, first: null, prev: null };
|
|
87
|
+
for (const r of rows) {
|
|
88
|
+
const v = r[field];
|
|
89
|
+
if (v == null || Number.isNaN(v)) continue;
|
|
90
|
+
if (acc.n === 0) acc.first = v;
|
|
91
|
+
if (acc.n > 0) acc.sum += Math.max(0, v - acc.last);
|
|
92
|
+
acc.n += 1;
|
|
93
|
+
acc.min = Math.min(acc.min, v); acc.max = Math.max(acc.max, v); acc.last = v;
|
|
94
|
+
}
|
|
95
|
+
if (!acc.n) return null;
|
|
96
|
+
return {
|
|
97
|
+
n: acc.n, first: acc.first, last: acc.last, min: acc.min, max: acc.max,
|
|
98
|
+
avg: acc.sum / acc.n, spread: acc.max - acc.min,
|
|
99
|
+
// A counter's growth per bucket, not its level.
|
|
100
|
+
growth: acc.n > 1 ? acc.sum : 0,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
toJSON() { return { capacity: this.capacity, rows: this.rows }; }
|
|
105
|
+
static fromJSON(o, capacity) { const r = new Ring(capacity ?? o?.capacity ?? 20000); if (o?.rows) r.rows = o.rows.slice(-1 * (capacity ?? o.capacity ?? 20000)); return r; }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function aggregate(kind, acc) {
|
|
109
|
+
if (!acc.n) return null;
|
|
110
|
+
switch (kind) {
|
|
111
|
+
case 'avg': return acc.sum / acc.n;
|
|
112
|
+
case 'min': return acc.min;
|
|
113
|
+
case 'max': return acc.max;
|
|
114
|
+
case 'sum': return acc.sum;
|
|
115
|
+
case 'first': return acc.first;
|
|
116
|
+
case 'delta': return acc.sum; // sum of positive inter-sample steps in the bucket
|
|
117
|
+
case 'last':
|
|
118
|
+
default: return acc.last;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
export function lowerBound(rows, t) {
|
|
124
|
+
let lo = 0;
|
|
125
|
+
let hi = rows.length;
|
|
126
|
+
while (lo < hi) {
|
|
127
|
+
const mid = (lo + hi) >> 1;
|
|
128
|
+
if (rows[mid].t < t) lo = mid + 1; else hi = mid;
|
|
129
|
+
}
|
|
130
|
+
return lo;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Time slice, then optional owner filter. The filter runs after the binary search
|
|
134
|
+
// so the cheap part stays cheap; a node-filtered walk is O(rows-since) which is
|
|
135
|
+
// bounded by the ring capacity either way.
|
|
136
|
+
function pick(rows, from, node) {
|
|
137
|
+
let out = from ? rows.slice(lowerBound(rows, from)) : rows;
|
|
138
|
+
if (node != null) out = out.filter((r) => r.node === node);
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// A sliding window that also answers "per second over the window", which is how
|
|
143
|
+
// every counter in this system (bytes, tx counts, txouts) becomes a rate.
|
|
144
|
+
export class CounterRate {
|
|
145
|
+
constructor(windowMs = 120000) {
|
|
146
|
+
this.windowMs = windowMs;
|
|
147
|
+
this.samples = [];
|
|
148
|
+
}
|
|
149
|
+
add(value, t = Date.now()) {
|
|
150
|
+
if (value == null || !Number.isFinite(value)) return null;
|
|
151
|
+
this.samples.push({ t, value });
|
|
152
|
+
const cutoff = t - this.windowMs;
|
|
153
|
+
while (this.samples.length > 1 && this.samples[0].t < cutoff) this.samples.shift();
|
|
154
|
+
if (this.samples.length > 2000) this.samples.splice(0, this.samples.length - 2000);
|
|
155
|
+
// A counter reset (node restart zeroes it) must not read as a negative rate
|
|
156
|
+
// nor as a colossal one; treat a decrease as a fresh baseline.
|
|
157
|
+
if (value < this.samples[0].value) { this.samples = [{ t, value }]; return 0; }
|
|
158
|
+
return this.rate();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Current rate without pushing a sample, so a read model can ask for the rate
|
|
162
|
+
// at snapshot time.
|
|
163
|
+
rate() {
|
|
164
|
+
if (this.samples.length < 2) return null;
|
|
165
|
+
const a = this.samples[0];
|
|
166
|
+
const b = this.samples[this.samples.length - 1];
|
|
167
|
+
const dt = (b.t - a.t) / 1000;
|
|
168
|
+
if (dt <= 0) return null;
|
|
169
|
+
return (b.value - a.value) / dt;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
get span() { return this.samples.length >= 2 ? this.samples[this.samples.length - 1].t - this.samples[0].t : 0; }
|
|
173
|
+
}
|