smart-home-engine 1.35.0 → 1.36.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.
@@ -1,4 +1,4 @@
1
- import{m as O}from"./monaco-langs-DKJI_o9J.js";import{t as I}from"./index-DfNXDuwc.js";/*!-----------------------------------------------------------------------------
1
+ import{m as O}from"./monaco-langs-DKJI_o9J.js";import{t as I}from"./index-y0wyYG2_.js";/*!-----------------------------------------------------------------------------
2
2
  * Copyright (c) Microsoft Corporation. All rights reserved.
3
3
  * Version: 0.52.2(404545bded1df6ffa41ea0af4e8ddb219018c6c1)
4
4
  * Released under the MIT license
@@ -172,10 +172,10 @@
172
172
  }
173
173
  })();
174
174
  </script>
175
- <script type="module" crossorigin src="/assets/index-DfNXDuwc.js"></script>
175
+ <script type="module" crossorigin src="/assets/index-y0wyYG2_.js"></script>
176
176
  <link rel="modulepreload" crossorigin href="/assets/monaco-langs-DKJI_o9J.js">
177
177
  <link rel="stylesheet" crossorigin href="/assets/monaco-langs-DyX1CsEw.css">
178
- <link rel="stylesheet" crossorigin href="/assets/index-DwaZ1Sfz.css">
178
+ <link rel="stylesheet" crossorigin href="/assets/index-hiXQxo8M.css">
179
179
  </head>
180
180
  <body>
181
181
  <div id="app"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smart-home-engine",
3
- "version": "1.35.0",
3
+ "version": "1.36.0",
4
4
  "description": "Node.js based script runner for use in MQTT based Smart Home environments",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
@@ -16,7 +16,8 @@
16
16
  # a wrapper or symlink /usr/local/bin/<adapter>.
17
17
  #
18
18
  # she-servicectl version
19
- # she-servicectl list (adapters = template units + npm packages with mqttInterfaces)
19
+ # she-servicectl list (adapters = template units + npm packages with mqttInterfaces; instances carry
20
+ # systemd's MemoryCurrent / CPUUsageNSec for the dashboard)
20
21
  # she-servicectl unit <adapter> <instance> start|stop|restart|enable|disable|status
21
22
  # she-servicectl logs <adapter> <instance> [-n N] [--follow]
22
23
  # she-servicectl env <adapter> <instance> read|write (write: file on stdin)
@@ -40,7 +41,7 @@
40
41
 
41
42
  set -eu
42
43
 
43
- VERSION=9
44
+ VERSION=10
44
45
  BROKER_ENV=/etc/mqtt-interfaces/broker.env
45
46
  UNIT_DIR=/etc/systemd/system
46
47
 
@@ -314,15 +315,20 @@ cmd_list() {
314
315
  valid_instance "$i" || continue
315
316
  [ $first -eq 1 ] || printf ','
316
317
  first=0
317
- show=$(systemctl show -p ActiveState -p SubState -p UnitFileState -p ExecMainStartTimestamp -p NRestarts "$a@$i.service" 2>/dev/null || true)
318
+ show=$(systemctl show -p ActiveState -p SubState -p UnitFileState -p ExecMainStartTimestamp -p NRestarts -p MainPID -p MemoryCurrent -p CPUUsageNSec "$a@$i.service" 2>/dev/null || true)
318
319
  active=$(printf '%s\n' "$show" | sed -n 's/^ActiveState=//p')
320
+ pid=$(printf '%s\n' "$show" | sed -n 's/^MainPID=//p')
321
+ mem=$(printf '%s\n' "$show" | sed -n 's/^MemoryCurrent=//p')
322
+ cpuns=$(printf '%s\n' "$show" | sed -n 's/^CPUUsageNSec=//p')
323
+ case "$mem" in *[!0-9]*|'') mem=null ;; esac
324
+ case "$cpuns" in *[!0-9]*|'') cpuns=null ;; esac
319
325
  sub=$(printf '%s\n' "$show" | sed -n 's/^SubState=//p')
320
326
  uf=$(printf '%s\n' "$show" | sed -n 's/^UnitFileState=//p')
321
327
  since=$(printf '%s\n' "$show" | sed -n 's/^ExecMainStartTimestamp=//p')
322
328
  nr=$(printf '%s\n' "$show" | sed -n 's/^NRestarts=//p')
323
- printf '{"adapter":"%s","instance":"%s","active":"%s","sub":"%s","unitFile":"%s","since":"%s","restarts":%s}' \
329
+ printf '{"adapter":"%s","instance":"%s","active":"%s","sub":"%s","unitFile":"%s","since":"%s","restarts":%s,"pid":%s,"memory":%s,"cpuNs":%s}' \
324
330
  "$a" "$i" "$(json_escape "$active")" "$(json_escape "$sub")" "$(json_escape "$uf")" \
325
- "$(json_escape "$since")" "${nr:-0}"
331
+ "$(json_escape "$since")" "${nr:-0}" "${pid:-0}" "$mem" "$cpuns"
326
332
  done
327
333
  done
328
334
  printf ']}\n'
@@ -10,10 +10,14 @@
10
10
  * package when `versions[dist-tags.latest].dependencies` has mqtt-interfaces-core
11
11
  * 3. full packument for the members only → description, homepage, repository, `mqttInterfaces`
12
12
  *
13
- * Cached for 24 h with the last good result kept on failure; `refresh` forces a new sweep.
13
+ * Cached for 24 h in memory and on disk (the data dir), answered from the cache at once and
14
+ * refreshed in the background — see catalog(); the last good result is kept on failure.
14
15
  * Best effort throughout: a publisher that fails is reported in `errors`, the rest still lists.
15
16
  */
16
17
 
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+
17
21
  const CORE = 'mqtt-interfaces-core';
18
22
  const REGISTRY = 'https://registry.npmjs.org';
19
23
  const TTL = 24 * 60 * 60 * 1000;
@@ -23,12 +27,35 @@ function setFetch(fn) {
23
27
  _fetch = fn;
24
28
  }
25
29
 
26
- /** @type {{key: string, ts: number, result: object, promise?: Promise<object>} | null} */
30
+ /** @type {{key: string, ts: number, result: object|null, promise?: Promise<object>} | null} */
27
31
  let _cache = null;
32
+ let _file = null;
28
33
  function clearCache() {
29
34
  _cache = null;
30
35
  }
31
36
 
37
+ /** Persist the catalog under the data dir so a restarted daemon answers at once (best effort). */
38
+ function init({ file } = {}) {
39
+ _file = file || null;
40
+ _cache = null;
41
+ if (!_file) return;
42
+ try {
43
+ const saved = JSON.parse(fs.readFileSync(_file, 'utf8'));
44
+ if (saved && typeof saved.key === 'string' && saved.result && Array.isArray(saved.result.packages)) _cache = { key: saved.key, ts: saved.ts || 0, result: saved.result };
45
+ } catch {
46
+ /* no cache yet */
47
+ }
48
+ }
49
+ function persist() {
50
+ if (!_file || !_cache || !_cache.result) return;
51
+ try {
52
+ fs.mkdirSync(path.dirname(_file), { recursive: true });
53
+ fs.writeFileSync(_file, JSON.stringify({ key: _cache.key, ts: _cache.ts, result: _cache.result }), 'utf8');
54
+ } catch {
55
+ /* read-only data dir — the in-memory copy still works */
56
+ }
57
+ }
58
+
32
59
  function validPublisher(p) {
33
60
  return typeof p === 'string' && /^[a-z0-9][a-z0-9._-]{0,63}$/i.test(p);
34
61
  }
@@ -107,38 +134,57 @@ async function sweep(publishers, now) {
107
134
  return { packages, publishers, errors, fetchedAt: now };
108
135
  }
109
136
 
137
+ /** A sweep in the background; the last good list survives a failed one. */
138
+ function startSweep(list, key, now) {
139
+ const previous = _cache && _cache.key === key ? _cache.result : null;
140
+ const promise = sweep(list, now)
141
+ .then((r) => {
142
+ // keep the last good list when the whole sweep produced nothing but errors
143
+ if (r.packages.length === 0 && r.errors.length > 0 && previous && previous.packages.length > 0) return { ...previous, errors: r.errors, stale: true };
144
+ return r;
145
+ })
146
+ .then((result) => {
147
+ _cache = { key, ts: now, result };
148
+ persist();
149
+ return result;
150
+ })
151
+ .catch((err) => {
152
+ _cache = previous ? { key, ts: now, result: previous } : null;
153
+ if (previous) return { ...previous, errors: [{ error: err.message }], stale: true };
154
+ throw err;
155
+ });
156
+ _cache = { key, ts: now, result: previous, promise };
157
+ promise.finally(() => {
158
+ if (_cache && _cache.promise === promise) delete _cache.promise;
159
+ });
160
+ return promise;
161
+ }
162
+
110
163
  /**
111
- * The catalog for the given trusted publishers (cached).
164
+ * The catalog for the given trusted publishers. Answers from the cache (memory, or the file from a
165
+ * previous run) at once and refreshes in the background when the cache is older than a day or
166
+ * `force` is set — `refreshing: true` tells the caller to ask again. Only the very first sweep (no
167
+ * cache at all) or `wait: true` makes the call wait for the registry.
112
168
  * @param {string[]} publishers
113
- * @param {{force?: boolean, now?: number}} [opts]
169
+ * @param {{force?: boolean, wait?: boolean, now?: number}} [opts]
114
170
  */
115
171
  async function catalog(publishers, opts = {}) {
116
172
  const list = [...new Set((publishers || []).filter(validPublisher).map((p) => p.toLowerCase()))];
117
173
  const now = opts.now ?? Date.now();
118
174
  const key = list.join(',');
119
- if (list.length === 0) return { packages: [], publishers: [], errors: [], fetchedAt: now, cached: false };
120
- if (_cache && _cache.key === key && !opts.force) {
121
- if (_cache.promise) return { ...(await _cache.promise), cached: true };
122
- if (now - _cache.ts < TTL) return { ..._cache.result, cached: true };
123
- }
124
- const previous = _cache && _cache.key === key ? _cache.result : null;
125
- const promise = sweep(list, now).then((r) => {
126
- // keep the last good list when the whole sweep produced nothing but errors
127
- if (r.packages.length === 0 && r.errors.length > 0 && previous && previous.packages.length > 0) {
128
- return { ...previous, errors: r.errors, stale: true };
129
- }
130
- return r;
131
- });
132
- _cache = { key, ts: now, result: previous, promise };
133
- try {
134
- const result = await promise;
135
- _cache = { key, ts: now, result };
136
- return { ...result, cached: false };
137
- } catch (err) {
138
- _cache = previous ? { key, ts: now, result: previous } : null;
139
- if (previous) return { ...previous, errors: [{ error: err.message }], stale: true, cached: true };
140
- throw err;
141
- }
175
+ if (list.length === 0) return { packages: [], publishers: [], errors: [], fetchedAt: now, cached: false, refreshing: false };
176
+ const entry = _cache && _cache.key === key ? _cache : null;
177
+ const fresh = entry && entry.result && now - entry.ts < TTL;
178
+ if ((!fresh || opts.force) && !(entry && entry.promise)) startSweep(list, key, now);
179
+ const cur = _cache;
180
+ if (cur.result && !opts.wait) return { ...cur.result, cached: true, refreshing: Boolean(cur.promise) };
181
+ const result = await (cur.promise || Promise.resolve(cur.result));
182
+ return { ...result, cached: !cur.promise, refreshing: false };
183
+ }
184
+
185
+ /** Whether a background sweep is running. */
186
+ function refreshing() {
187
+ return Boolean(_cache && _cache.promise);
142
188
  }
143
189
 
144
- module.exports = { catalog, sweep, packagesOf, coreDependency, details, validPublisher, setFetch, clearCache, CORE, REGISTRY };
190
+ module.exports = { catalog, sweep, packagesOf, coreDependency, details, validPublisher, setFetch, clearCache, init, refreshing, CORE, REGISTRY, TTL };
@@ -26,7 +26,7 @@ const { execFile, spawn } = require('child_process');
26
26
  const sshDeploy = require('./ssh-deploy');
27
27
 
28
28
  const HELPER = '/usr/local/bin/she-servicectl';
29
- const HELPER_VERSION = 9; // must match VERSION in service/she-servicectl
29
+ const HELPER_VERSION = 10; // must match VERSION in service/she-servicectl
30
30
  /** Default identity for services.hosts[].ssh — one key for all managed hosts (I5). */
31
31
  const DEFAULT_SERVICES_IDENTITY = '~/.she/ssh/services_id_ed25519';
32
32
  /** The helper as shipped with this she version (copied to remote hosts by the deploy route). */
@@ -69,6 +69,21 @@ function parseInfo(val) {
69
69
  return v;
70
70
  }
71
71
 
72
+ /** `<name>/maintenance/stats` payload (core 0.8+): numbers only, anything else → null. */
73
+ function parseStats(v) {
74
+ if (typeof v === 'string') {
75
+ try {
76
+ v = JSON.parse(v);
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+ if (!v || typeof v !== 'object' || Array.isArray(v)) return null;
82
+ const out = {};
83
+ for (const k of ['rss', 'heapUsed', 'heapTotal', 'cpu', 'eventLoopLag', 'uptime', 'ts']) if (typeof v[k] === 'number' && Number.isFinite(v[k])) out[k] = v[k];
84
+ return typeof out.rss === 'number' ? out : null;
85
+ }
86
+
72
87
  /**
73
88
  * Derive the service inventory from the retained MQTT state.
74
89
  *
@@ -80,11 +95,17 @@ function analyzeServices(entries, opts = {}) {
80
95
  const now = opts.now ?? Date.now();
81
96
  const infos = new Map(); // instance → {info, ts}
82
97
  const conns = new Map(); // instance → {connected, ts, lc}
98
+ const stats = new Map(); // instance → {stats, ts} (<name>/maintenance/stats, core 0.8+)
83
99
  const topics = new Set();
84
100
 
85
101
  for (const [topic, obj] of entries) {
86
102
  topics.add(topic);
87
103
  const slash = topic.indexOf('/');
104
+ if (slash > 0 && topic.slice(slash) === '/maintenance/stats') {
105
+ const st = parseStats(obj && obj.val);
106
+ if (st) stats.set(topic.slice(0, slash), { stats: st, ts: obj.ts });
107
+ continue;
108
+ }
88
109
  if (slash <= 0 || topic.indexOf('/', slash + 1) !== -1) continue; // exactly two levels
89
110
  const instance = topic.slice(0, slash);
90
111
  const leaf = topic.slice(slash + 1);
@@ -129,6 +150,7 @@ function analyzeServices(entries, opts = {}) {
129
150
  connectedTs: c ? c.ts : null,
130
151
  connectedLc: c ? (c.lc ?? c.ts) : null,
131
152
  infoTs: i ? i.ts : null,
153
+ stats: stats.has(instance) && connected !== null && connected > 0 ? { ...stats.get(instance).stats, receivedTs: stats.get(instance).ts } : null,
132
154
  statusTopics,
133
155
  info,
134
156
  });
@@ -556,6 +556,19 @@ router.get('/hosts', async (req, res) => {
556
556
  res.json({ hosts: await promise, cached: false });
557
557
  });
558
558
 
559
+ /** CPUUsageNSec samples per host + unit → cpu share (% of one core) between two listings. */
560
+ const _cpuSamples = new Map();
561
+ function withCpuShare(hostName, instances, now = Date.now()) {
562
+ return (instances || []).map((i) => {
563
+ if (typeof i.cpuNs !== 'number') return i;
564
+ const key = hostName + '|' + i.adapter + '@' + i.instance;
565
+ const prev = _cpuSamples.get(key);
566
+ _cpuSamples.set(key, { cpuNs: i.cpuNs, ts: now });
567
+ if (!prev || now - prev.ts < 1000 || i.cpuNs < prev.cpuNs) return { ...i, cpu: null };
568
+ return { ...i, cpu: Math.round(((i.cpuNs - prev.cpuNs) / ((now - prev.ts) * 1e6)) * 1000) / 10 };
569
+ });
570
+ }
571
+
559
572
  async function listHosts(req) {
560
573
  return Promise.all(
561
574
  hostEntries(req).map(async ({ cfg, driver }) => {
@@ -581,7 +594,7 @@ async function listHosts(req) {
581
594
  }
582
595
  }),
583
596
  );
584
- return { ...base, ok: true, hostname: base.hostname || list.hostname, ...list, adapters };
597
+ return { ...base, ok: true, hostname: base.hostname || list.hostname, ...list, instances: withCpuShare(cfg.name, list.instances), adapters };
585
598
  } catch (err) {
586
599
  return { ...base, ok: false, code: err.code || 'ERROR', error: err.message };
587
600
  }
@@ -1278,6 +1291,33 @@ router.post('/hosts/:host/units/:adapter/:instance/file/create', async (req, res
1278
1291
  });
1279
1292
 
1280
1293
  // ── I7: adapter catalog ───────────────────────────────────────────────────────
1294
+ // persisted under the data dir; a daily timer keeps it fresh so the page never waits for npm
1295
+ try {
1296
+ catalog.init({ file: path.join(require('../lib/storage').getStoragePath('services'), 'catalog.json') });
1297
+ } catch {
1298
+ /* memory only */
1299
+ }
1300
+ let _catalogConfigPath = null;
1301
+ router.use((req, res, next) => {
1302
+ if (req.app && req.app.locals && req.app.locals.configPath) _catalogConfigPath = req.app.locals.configPath;
1303
+ next();
1304
+ });
1305
+ const _catalogTimer = setInterval(
1306
+ () => {
1307
+ if (!_catalogConfigPath) return;
1308
+ try {
1309
+ const cfg = JSON.parse(fs.readFileSync(_catalogConfigPath, 'utf8'));
1310
+ const svc = cfg.services || {};
1311
+ if (svc.enabled !== true) return;
1312
+ const list = Array.isArray(svc.trustedPublishers) ? svc.trustedPublishers : ['hobbyquaker'];
1313
+ catalog.catalog(list.filter(catalog.validPublisher)).catch(() => {}); // refreshes only when older than a day
1314
+ } catch {
1315
+ /* unreadable config */
1316
+ }
1317
+ },
1318
+ 60 * 60 * 1000,
1319
+ );
1320
+ _catalogTimer.unref();
1281
1321
 
1282
1322
  const DEFAULT_PUBLISHERS = ['hobbyquaker'];
1283
1323
  function trustedPublishers(req) {
@@ -1289,6 +1329,7 @@ function trustedPublishers(req) {
1289
1329
  // GET /she/services/catalog[?refresh=1]
1290
1330
  router.get('/catalog', async (req, res) => {
1291
1331
  try {
1332
+ // answers from the cache at once; a refresh (older than a day, or ?refresh=1) runs in the background and the client asks again while `refreshing`
1292
1333
  const r = await catalog.catalog(trustedPublishers(req), { force: req.query.refresh === '1' });
1293
1334
  res.json(r);
1294
1335
  } catch (err) {