@switchbot/openapi-cli 3.1.1 → 3.2.0
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/README.md +3 -3
- package/dist/index.js +56945 -169
- package/dist/policy/schema/v0.2.json +1 -1
- package/package.json +3 -2
- package/dist/api/client.js +0 -235
- package/dist/auth.js +0 -20
- package/dist/commands/agent-bootstrap.js +0 -182
- package/dist/commands/auth.js +0 -354
- package/dist/commands/batch.js +0 -413
- package/dist/commands/cache.js +0 -126
- package/dist/commands/capabilities.js +0 -385
- package/dist/commands/catalog.js +0 -359
- package/dist/commands/completion.js +0 -385
- package/dist/commands/config.js +0 -376
- package/dist/commands/daemon.js +0 -410
- package/dist/commands/device-meta.js +0 -159
- package/dist/commands/devices.js +0 -948
- package/dist/commands/doctor.js +0 -1015
- package/dist/commands/events.js +0 -563
- package/dist/commands/expand.js +0 -130
- package/dist/commands/explain.js +0 -139
- package/dist/commands/health.js +0 -113
- package/dist/commands/history.js +0 -320
- package/dist/commands/identity.js +0 -59
- package/dist/commands/install.js +0 -246
- package/dist/commands/mcp.js +0 -2017
- package/dist/commands/plan.js +0 -653
- package/dist/commands/policy.js +0 -586
- package/dist/commands/quota.js +0 -78
- package/dist/commands/rules.js +0 -875
- package/dist/commands/scenes.js +0 -264
- package/dist/commands/schema.js +0 -177
- package/dist/commands/status-sync.js +0 -131
- package/dist/commands/uninstall.js +0 -237
- package/dist/commands/upgrade-check.js +0 -107
- package/dist/commands/watch.js +0 -194
- package/dist/commands/webhook.js +0 -182
- package/dist/config.js +0 -258
- package/dist/credentials/backends/file.js +0 -101
- package/dist/credentials/backends/linux.js +0 -129
- package/dist/credentials/backends/macos.js +0 -129
- package/dist/credentials/backends/windows.js +0 -215
- package/dist/credentials/keychain.js +0 -88
- package/dist/credentials/prime.js +0 -52
- package/dist/devices/cache.js +0 -293
- package/dist/devices/catalog.js +0 -767
- package/dist/devices/device-meta.js +0 -56
- package/dist/devices/history-agg.js +0 -138
- package/dist/devices/history-query.js +0 -181
- package/dist/devices/param-validator.js +0 -433
- package/dist/devices/resources.js +0 -270
- package/dist/install/default-steps.js +0 -257
- package/dist/install/preflight.js +0 -212
- package/dist/install/steps.js +0 -67
- package/dist/lib/command-keywords.js +0 -17
- package/dist/lib/daemon-state.js +0 -46
- package/dist/lib/destructive-mode.js +0 -12
- package/dist/lib/devices.js +0 -382
- package/dist/lib/idempotency.js +0 -106
- package/dist/lib/plan-store.js +0 -68
- package/dist/lib/request-context.js +0 -12
- package/dist/lib/scenes.js +0 -10
- package/dist/logger.js +0 -16
- package/dist/mcp/device-history.js +0 -145
- package/dist/mcp/events-subscription.js +0 -213
- package/dist/mqtt/client.js +0 -180
- package/dist/mqtt/credential.js +0 -30
- package/dist/policy/add-rule.js +0 -124
- package/dist/policy/diff.js +0 -91
- package/dist/policy/format.js +0 -57
- package/dist/policy/load.js +0 -61
- package/dist/policy/migrate.js +0 -67
- package/dist/policy/schema.js +0 -18
- package/dist/policy/validate.js +0 -262
- package/dist/rules/action.js +0 -216
- package/dist/rules/audit-query.js +0 -89
- package/dist/rules/conflict-analyzer.js +0 -214
- package/dist/rules/cron-scheduler.js +0 -186
- package/dist/rules/destructive.js +0 -52
- package/dist/rules/engine.js +0 -757
- package/dist/rules/matcher.js +0 -230
- package/dist/rules/pid-file.js +0 -95
- package/dist/rules/quiet-hours.js +0 -45
- package/dist/rules/suggest.js +0 -95
- package/dist/rules/throttle.js +0 -116
- package/dist/rules/types.js +0 -34
- package/dist/rules/webhook-listener.js +0 -223
- package/dist/rules/webhook-token.js +0 -90
- package/dist/schema/field-aliases.js +0 -131
- package/dist/sinks/dispatcher.js +0 -12
- package/dist/sinks/file.js +0 -19
- package/dist/sinks/format.js +0 -56
- package/dist/sinks/homeassistant.js +0 -44
- package/dist/sinks/openclaw.js +0 -33
- package/dist/sinks/stdout.js +0 -5
- package/dist/sinks/telegram.js +0 -28
- package/dist/sinks/types.js +0 -1
- package/dist/sinks/webhook.js +0 -22
- package/dist/status-sync/manager.js +0 -268
- package/dist/utils/arg-parsers.js +0 -66
- package/dist/utils/audit.js +0 -121
- package/dist/utils/filter.js +0 -189
- package/dist/utils/flags.js +0 -186
- package/dist/utils/format.js +0 -117
- package/dist/utils/health.js +0 -101
- package/dist/utils/help-json.js +0 -54
- package/dist/utils/name-resolver.js +0 -137
- package/dist/utils/output.js +0 -404
- package/dist/utils/quota.js +0 -227
- package/dist/utils/redact.js +0 -68
- package/dist/utils/retry.js +0 -140
- package/dist/utils/string.js +0 -22
- package/dist/version.js +0 -4
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import os from 'node:os';
|
|
4
|
-
import { getConfigPath } from '../utils/flags.js';
|
|
5
|
-
function metaFilePath() {
|
|
6
|
-
const override = getConfigPath();
|
|
7
|
-
const dir = override
|
|
8
|
-
? path.dirname(path.resolve(override))
|
|
9
|
-
: path.join(os.homedir(), '.switchbot');
|
|
10
|
-
return path.join(dir, 'device-meta.json');
|
|
11
|
-
}
|
|
12
|
-
export function getMetaFilePath() {
|
|
13
|
-
return metaFilePath();
|
|
14
|
-
}
|
|
15
|
-
export function loadDeviceMeta() {
|
|
16
|
-
const file = metaFilePath();
|
|
17
|
-
if (!fs.existsSync(file))
|
|
18
|
-
return { version: '1', devices: {} };
|
|
19
|
-
try {
|
|
20
|
-
const raw = fs.readFileSync(file, 'utf-8');
|
|
21
|
-
const parsed = JSON.parse(raw);
|
|
22
|
-
if (!parsed ||
|
|
23
|
-
parsed.version !== '1' ||
|
|
24
|
-
typeof parsed.devices !== 'object' ||
|
|
25
|
-
parsed.devices === null) {
|
|
26
|
-
return { version: '1', devices: {} };
|
|
27
|
-
}
|
|
28
|
-
return parsed;
|
|
29
|
-
}
|
|
30
|
-
catch {
|
|
31
|
-
return { version: '1', devices: {} };
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
export function saveDeviceMeta(meta) {
|
|
35
|
-
const file = metaFilePath();
|
|
36
|
-
const dir = path.dirname(file);
|
|
37
|
-
if (!fs.existsSync(dir))
|
|
38
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
39
|
-
const tmp = `${file}.tmp-${process.pid}`;
|
|
40
|
-
fs.writeFileSync(tmp, JSON.stringify(meta, null, 2), { mode: 0o600 });
|
|
41
|
-
fs.renameSync(tmp, file);
|
|
42
|
-
}
|
|
43
|
-
export function getDeviceMeta(deviceId) {
|
|
44
|
-
const meta = loadDeviceMeta();
|
|
45
|
-
return meta.devices[deviceId] ?? null;
|
|
46
|
-
}
|
|
47
|
-
export function setDeviceMeta(deviceId, patch) {
|
|
48
|
-
const meta = loadDeviceMeta();
|
|
49
|
-
meta.devices[deviceId] = { ...meta.devices[deviceId], ...patch };
|
|
50
|
-
saveDeviceMeta(meta);
|
|
51
|
-
}
|
|
52
|
-
export function clearDeviceMeta(deviceId) {
|
|
53
|
-
const meta = loadDeviceMeta();
|
|
54
|
-
delete meta.devices[deviceId];
|
|
55
|
-
saveDeviceMeta(meta);
|
|
56
|
-
}
|
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import readline from 'node:readline';
|
|
3
|
-
import { jsonlFilesForDevice, parseDurationToMs, resolveRange } from './history-query.js';
|
|
4
|
-
export const ALL_AGG_FNS = ['count', 'min', 'max', 'avg', 'sum', 'p50', 'p95'];
|
|
5
|
-
export const DEFAULT_AGGS = ['count', 'avg'];
|
|
6
|
-
export const DEFAULT_SAMPLE_CAP = 10_000;
|
|
7
|
-
export const MAX_SAMPLE_CAP = 100_000;
|
|
8
|
-
export async function aggregateDeviceHistory(deviceId, opts) {
|
|
9
|
-
const { fromMs, toMs } = resolveRange(opts);
|
|
10
|
-
const aggs = (opts.aggs && opts.aggs.length > 0) ? opts.aggs : [...DEFAULT_AGGS];
|
|
11
|
-
const needQuantile = aggs.includes('p50') || aggs.includes('p95');
|
|
12
|
-
let bucketMs = null;
|
|
13
|
-
if (opts.bucket !== undefined) {
|
|
14
|
-
bucketMs = parseDurationToMs(opts.bucket);
|
|
15
|
-
if (bucketMs === null) {
|
|
16
|
-
throw new Error(`Invalid --bucket "${opts.bucket}". Expected e.g. "15m", "1h", "1d".`);
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
const sampleCap = Math.max(1, Math.min(opts.maxBucketSamples ?? DEFAULT_SAMPLE_CAP, MAX_SAMPLE_CAP));
|
|
20
|
-
let partial = false;
|
|
21
|
-
const notes = [];
|
|
22
|
-
// bucketKey (epoch ms; 0 when no --bucket) → metric name → Acc
|
|
23
|
-
const buckets = new Map();
|
|
24
|
-
for (const file of jsonlFilesForDevice(deviceId)) {
|
|
25
|
-
try {
|
|
26
|
-
const st = fs.statSync(file);
|
|
27
|
-
if (st.mtimeMs < fromMs)
|
|
28
|
-
continue;
|
|
29
|
-
}
|
|
30
|
-
catch {
|
|
31
|
-
continue;
|
|
32
|
-
}
|
|
33
|
-
const stream = fs.createReadStream(file, { encoding: 'utf-8' });
|
|
34
|
-
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
35
|
-
for await (const line of rl) {
|
|
36
|
-
if (!line)
|
|
37
|
-
continue;
|
|
38
|
-
let rec;
|
|
39
|
-
try {
|
|
40
|
-
rec = JSON.parse(line);
|
|
41
|
-
}
|
|
42
|
-
catch {
|
|
43
|
-
continue;
|
|
44
|
-
}
|
|
45
|
-
const tMs = Date.parse(rec.t);
|
|
46
|
-
if (!Number.isFinite(tMs) || tMs < fromMs || tMs > toMs)
|
|
47
|
-
continue;
|
|
48
|
-
const key = bucketMs !== null ? Math.floor(tMs / bucketMs) * bucketMs : 0;
|
|
49
|
-
let bkt = buckets.get(key);
|
|
50
|
-
if (!bkt) {
|
|
51
|
-
bkt = new Map();
|
|
52
|
-
buckets.set(key, bkt);
|
|
53
|
-
}
|
|
54
|
-
for (const metric of opts.metrics) {
|
|
55
|
-
const v = rec.payload?.[metric];
|
|
56
|
-
if (typeof v !== 'number' || !Number.isFinite(v))
|
|
57
|
-
continue;
|
|
58
|
-
let acc = bkt.get(metric);
|
|
59
|
-
if (!acc) {
|
|
60
|
-
acc = {
|
|
61
|
-
min: v,
|
|
62
|
-
max: v,
|
|
63
|
-
sum: 0,
|
|
64
|
-
count: 0,
|
|
65
|
-
samples: needQuantile ? [] : null,
|
|
66
|
-
sampleCapHit: false,
|
|
67
|
-
};
|
|
68
|
-
bkt.set(metric, acc);
|
|
69
|
-
}
|
|
70
|
-
acc.min = Math.min(acc.min, v);
|
|
71
|
-
acc.max = Math.max(acc.max, v);
|
|
72
|
-
acc.sum += v;
|
|
73
|
-
acc.count += 1;
|
|
74
|
-
if (acc.samples) {
|
|
75
|
-
if (acc.samples.length < sampleCap) {
|
|
76
|
-
acc.samples.push(v);
|
|
77
|
-
}
|
|
78
|
-
else if (!acc.sampleCapHit) {
|
|
79
|
-
acc.sampleCapHit = true;
|
|
80
|
-
partial = true;
|
|
81
|
-
notes.push(`bucket ${new Date(key).toISOString()} metric ${metric}: sample cap ${sampleCap} reached, quantiles approximate`);
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
return finalize(deviceId, opts, aggs, buckets, partial, notes, fromMs, toMs);
|
|
88
|
-
}
|
|
89
|
-
function finalize(deviceId, opts, aggs, buckets, partial, notes, fromMs, toMs) {
|
|
90
|
-
const fromIso = Number.isFinite(fromMs) ? new Date(fromMs).toISOString() : new Date(0).toISOString();
|
|
91
|
-
const toIso = Number.isFinite(toMs) ? new Date(toMs).toISOString() : new Date(Date.now()).toISOString();
|
|
92
|
-
const keys = [...buckets.keys()].sort((a, b) => a - b);
|
|
93
|
-
const outBuckets = [];
|
|
94
|
-
for (const key of keys) {
|
|
95
|
-
const perMetric = buckets.get(key);
|
|
96
|
-
const metricsOut = {};
|
|
97
|
-
for (const [metric, acc] of perMetric.entries()) {
|
|
98
|
-
if (acc.count === 0)
|
|
99
|
-
continue;
|
|
100
|
-
const r = {};
|
|
101
|
-
if (aggs.includes('count'))
|
|
102
|
-
r.count = acc.count;
|
|
103
|
-
if (aggs.includes('min'))
|
|
104
|
-
r.min = acc.min;
|
|
105
|
-
if (aggs.includes('max'))
|
|
106
|
-
r.max = acc.max;
|
|
107
|
-
if (aggs.includes('avg'))
|
|
108
|
-
r.avg = acc.sum / acc.count;
|
|
109
|
-
if (aggs.includes('sum'))
|
|
110
|
-
r.sum = acc.sum;
|
|
111
|
-
if ((aggs.includes('p50') || aggs.includes('p95')) && acc.samples) {
|
|
112
|
-
const sorted = [...acc.samples].sort((a, b) => a - b);
|
|
113
|
-
if (aggs.includes('p50'))
|
|
114
|
-
r.p50 = sorted[Math.floor(0.5 * (sorted.length - 1))];
|
|
115
|
-
if (aggs.includes('p95'))
|
|
116
|
-
r.p95 = sorted[Math.floor(0.95 * (sorted.length - 1))];
|
|
117
|
-
}
|
|
118
|
-
metricsOut[metric] = r;
|
|
119
|
-
}
|
|
120
|
-
if (Object.keys(metricsOut).length === 0)
|
|
121
|
-
continue;
|
|
122
|
-
outBuckets.push({
|
|
123
|
-
t: new Date(key).toISOString(),
|
|
124
|
-
metrics: metricsOut,
|
|
125
|
-
});
|
|
126
|
-
}
|
|
127
|
-
return {
|
|
128
|
-
deviceId,
|
|
129
|
-
bucket: opts.bucket,
|
|
130
|
-
from: fromIso,
|
|
131
|
-
to: toIso,
|
|
132
|
-
metrics: [...opts.metrics],
|
|
133
|
-
aggs: [...aggs],
|
|
134
|
-
buckets: outBuckets,
|
|
135
|
-
partial,
|
|
136
|
-
notes,
|
|
137
|
-
};
|
|
138
|
-
}
|
|
@@ -1,181 +0,0 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import os from 'node:os';
|
|
4
|
-
import readline from 'node:readline';
|
|
5
|
-
const DEFAULT_LIMIT = 1000;
|
|
6
|
-
function historyDir() {
|
|
7
|
-
return path.join(os.homedir(), '.switchbot', 'device-history');
|
|
8
|
-
}
|
|
9
|
-
/**
|
|
10
|
-
* Parse a duration shortcut like "7d", "12h", "30m", "45s" into milliseconds.
|
|
11
|
-
* Returns null on malformed input (caller throws UsageError).
|
|
12
|
-
*/
|
|
13
|
-
export function parseDurationToMs(spec) {
|
|
14
|
-
const m = spec.trim().match(/^(\d+)(ms|s|m|h|d)$/i);
|
|
15
|
-
if (!m)
|
|
16
|
-
return null;
|
|
17
|
-
const n = Number(m[1]);
|
|
18
|
-
const unit = m[2].toLowerCase();
|
|
19
|
-
const factor = unit === 'ms' ? 1 : unit === 's' ? 1_000 : unit === 'm' ? 60_000 : unit === 'h' ? 3_600_000 : 86_400_000;
|
|
20
|
-
return n * factor;
|
|
21
|
-
}
|
|
22
|
-
/** Parse ISO-8601 (with Z or offset) or Date-parseable string → ms, else null. */
|
|
23
|
-
export function parseInstantToMs(spec) {
|
|
24
|
-
const ms = Date.parse(spec);
|
|
25
|
-
return Number.isFinite(ms) ? ms : null;
|
|
26
|
-
}
|
|
27
|
-
export function resolveRange(opts) {
|
|
28
|
-
let fromMs = Number.NEGATIVE_INFINITY;
|
|
29
|
-
let toMs = Number.POSITIVE_INFINITY;
|
|
30
|
-
if (opts.since && (opts.from || opts.to)) {
|
|
31
|
-
throw new Error('--since is mutually exclusive with --from/--to.');
|
|
32
|
-
}
|
|
33
|
-
if (opts.since) {
|
|
34
|
-
const durMs = parseDurationToMs(opts.since);
|
|
35
|
-
if (durMs === null) {
|
|
36
|
-
throw new Error(`Invalid --since value "${opts.since}". Expected e.g. "30s", "15m", "1h", "7d".`);
|
|
37
|
-
}
|
|
38
|
-
fromMs = Date.now() - durMs;
|
|
39
|
-
}
|
|
40
|
-
else {
|
|
41
|
-
if (opts.from) {
|
|
42
|
-
const parsed = parseInstantToMs(opts.from);
|
|
43
|
-
if (parsed === null)
|
|
44
|
-
throw new Error(`Invalid --from value "${opts.from}". Expected ISO-8601 timestamp.`);
|
|
45
|
-
fromMs = parsed;
|
|
46
|
-
}
|
|
47
|
-
if (opts.to) {
|
|
48
|
-
const parsed = parseInstantToMs(opts.to);
|
|
49
|
-
if (parsed === null)
|
|
50
|
-
throw new Error(`Invalid --to value "${opts.to}". Expected ISO-8601 timestamp.`);
|
|
51
|
-
toMs = parsed;
|
|
52
|
-
}
|
|
53
|
-
if (fromMs > toMs)
|
|
54
|
-
throw new Error('--from must be <= --to.');
|
|
55
|
-
}
|
|
56
|
-
return { fromMs, toMs };
|
|
57
|
-
}
|
|
58
|
-
/** Return jsonl candidate files for a device, oldest-first. */
|
|
59
|
-
export function jsonlFilesForDevice(deviceId, baseDir = historyDir()) {
|
|
60
|
-
const out = [];
|
|
61
|
-
if (!fs.existsSync(baseDir))
|
|
62
|
-
return out;
|
|
63
|
-
// Oldest-first so range walks can bail early once a line overshoots `toMs`.
|
|
64
|
-
for (let i = 3; i >= 1; i--) {
|
|
65
|
-
const p = path.join(baseDir, `${deviceId}.jsonl.${i}`);
|
|
66
|
-
if (fs.existsSync(p))
|
|
67
|
-
out.push(p);
|
|
68
|
-
}
|
|
69
|
-
const current = path.join(baseDir, `${deviceId}.jsonl`);
|
|
70
|
-
if (fs.existsSync(current))
|
|
71
|
-
out.push(current);
|
|
72
|
-
return out;
|
|
73
|
-
}
|
|
74
|
-
function projectFields(record, fields) {
|
|
75
|
-
if (fields.length === 0)
|
|
76
|
-
return record;
|
|
77
|
-
const projected = {};
|
|
78
|
-
const payload = (record.payload ?? {});
|
|
79
|
-
for (const f of fields) {
|
|
80
|
-
if (f in payload)
|
|
81
|
-
projected[f] = payload[f];
|
|
82
|
-
}
|
|
83
|
-
return { t: record.t, topic: record.topic, deviceType: record.deviceType, payload: projected };
|
|
84
|
-
}
|
|
85
|
-
/**
|
|
86
|
-
* Stream-read the JSONL rotation files for `deviceId` and return records
|
|
87
|
-
* within [fromMs, toMs]. Parse failures are silently dropped (best-effort).
|
|
88
|
-
*
|
|
89
|
-
* Files whose mtime < fromMs are skipped whole (coarse but sound: the newest
|
|
90
|
-
* record in the file is <= mtime, so nothing in it can match).
|
|
91
|
-
*/
|
|
92
|
-
export async function queryDeviceHistory(deviceId, opts = {}) {
|
|
93
|
-
const { fromMs, toMs } = resolveRange(opts);
|
|
94
|
-
const limit = Math.max(0, opts.limit ?? DEFAULT_LIMIT);
|
|
95
|
-
const fields = opts.fields ?? [];
|
|
96
|
-
const files = jsonlFilesForDevice(deviceId);
|
|
97
|
-
const out = [];
|
|
98
|
-
for (const file of files) {
|
|
99
|
-
try {
|
|
100
|
-
const stat = fs.statSync(file);
|
|
101
|
-
if (stat.mtimeMs < fromMs)
|
|
102
|
-
continue;
|
|
103
|
-
}
|
|
104
|
-
catch {
|
|
105
|
-
continue;
|
|
106
|
-
}
|
|
107
|
-
const stream = fs.createReadStream(file, { encoding: 'utf-8' });
|
|
108
|
-
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
109
|
-
for await (const line of rl) {
|
|
110
|
-
if (!line)
|
|
111
|
-
continue;
|
|
112
|
-
let rec;
|
|
113
|
-
try {
|
|
114
|
-
rec = JSON.parse(line);
|
|
115
|
-
}
|
|
116
|
-
catch {
|
|
117
|
-
continue;
|
|
118
|
-
}
|
|
119
|
-
const tMs = Date.parse(rec.t);
|
|
120
|
-
if (!Number.isFinite(tMs))
|
|
121
|
-
continue;
|
|
122
|
-
if (tMs < fromMs || tMs > toMs)
|
|
123
|
-
continue;
|
|
124
|
-
out.push(projectFields(rec, fields));
|
|
125
|
-
if (out.length >= limit) {
|
|
126
|
-
rl.close();
|
|
127
|
-
stream.destroy();
|
|
128
|
-
return out;
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
return out;
|
|
133
|
-
}
|
|
134
|
-
export function queryDeviceHistoryStats(deviceId) {
|
|
135
|
-
const dir = historyDir();
|
|
136
|
-
const files = jsonlFilesForDevice(deviceId);
|
|
137
|
-
let totalBytes = 0;
|
|
138
|
-
let oldest = null;
|
|
139
|
-
let newest = null;
|
|
140
|
-
let count = 0;
|
|
141
|
-
for (const file of files) {
|
|
142
|
-
try {
|
|
143
|
-
totalBytes += fs.statSync(file).size;
|
|
144
|
-
}
|
|
145
|
-
catch { /* */ }
|
|
146
|
-
}
|
|
147
|
-
// Walk the oldest file's head + current file's tail for oldest/newest + count.
|
|
148
|
-
// Counting is O(records) here, acceptable for "stats" which isn't a hot path.
|
|
149
|
-
for (const file of files) {
|
|
150
|
-
try {
|
|
151
|
-
const lines = fs.readFileSync(file, 'utf-8').split('\n');
|
|
152
|
-
for (const line of lines) {
|
|
153
|
-
if (!line)
|
|
154
|
-
continue;
|
|
155
|
-
count += 1;
|
|
156
|
-
try {
|
|
157
|
-
const rec = JSON.parse(line);
|
|
158
|
-
const tMs = Date.parse(rec.t);
|
|
159
|
-
if (Number.isFinite(tMs)) {
|
|
160
|
-
if (oldest === null || tMs < oldest)
|
|
161
|
-
oldest = tMs;
|
|
162
|
-
if (newest === null || tMs > newest)
|
|
163
|
-
newest = tMs;
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
catch { /* */ }
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
catch { /* */ }
|
|
170
|
-
}
|
|
171
|
-
return {
|
|
172
|
-
deviceId,
|
|
173
|
-
fileCount: files.length,
|
|
174
|
-
totalBytes,
|
|
175
|
-
recordCount: count,
|
|
176
|
-
oldest: oldest !== null ? new Date(oldest).toISOString() : undefined,
|
|
177
|
-
newest: newest !== null ? new Date(newest).toISOString() : undefined,
|
|
178
|
-
jsonlFiles: files.map((f) => path.basename(f)),
|
|
179
|
-
historyDir: dir,
|
|
180
|
-
};
|
|
181
|
-
}
|