@camstack/system 1.2.440 → 1.2.442

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.
Files changed (85) hide show
  1. package/dist/addon-runner.js +72 -2
  2. package/dist/addon-runner.mjs +72 -2
  3. package/dist/auth/totp-manager.d.ts +46 -1
  4. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  5. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  6. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  7. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  8. package/dist/builtins/alerts/alerts.addon.d.ts +41 -1
  9. package/dist/builtins/alerts/alerts.addon.js +638 -26
  10. package/dist/builtins/alerts/alerts.addon.mjs +638 -26
  11. package/dist/builtins/alerts/critical-push.d.ts +86 -0
  12. package/dist/builtins/alerts/runner-alert.d.ts +71 -0
  13. package/dist/builtins/autotrack/index.js +1 -1
  14. package/dist/builtins/autotrack/index.mjs +1 -1
  15. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +2 -2
  16. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +2 -2
  17. package/dist/builtins/calendars/calendars.addon.js +1 -1
  18. package/dist/builtins/calendars/calendars.addon.mjs +1 -1
  19. package/dist/builtins/camera-grid/index.js +1 -1
  20. package/dist/builtins/camera-grid/index.mjs +1 -1
  21. package/dist/builtins/composer/composer.addon.js +2 -2
  22. package/dist/builtins/composer/composer.addon.mjs +2 -2
  23. package/dist/builtins/console-logging/index.js +1 -1
  24. package/dist/builtins/console-logging/index.mjs +1 -1
  25. package/dist/builtins/core-blocks/core-blocks.addon.js +2 -2
  26. package/dist/builtins/core-blocks/core-blocks.addon.mjs +2 -2
  27. package/dist/builtins/device-manager/device-manager.addon.js +1 -1
  28. package/dist/builtins/device-manager/device-manager.addon.mjs +1 -1
  29. package/dist/builtins/hub-forwarder/index.js +1 -1
  30. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  31. package/dist/builtins/keychain/index.js +3 -3
  32. package/dist/builtins/keychain/index.mjs +3 -3
  33. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  34. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  35. package/dist/builtins/local-auth/auth-schema.d.ts +1 -1
  36. package/dist/builtins/local-auth/local-auth.addon.js +226 -17
  37. package/dist/builtins/local-auth/local-auth.addon.mjs +226 -17
  38. package/dist/builtins/local-network/local-network.addon.js +2 -2
  39. package/dist/builtins/local-network/local-network.addon.mjs +2 -2
  40. package/dist/builtins/loki-logging/index.js +1 -1
  41. package/dist/builtins/loki-logging/index.mjs +1 -1
  42. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  43. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  44. package/dist/builtins/platform-probe/index.js +1 -1
  45. package/dist/builtins/platform-probe/index.mjs +1 -1
  46. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  47. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  48. package/dist/builtins/snapshot/index.js +1 -1
  49. package/dist/builtins/snapshot/index.mjs +1 -1
  50. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  51. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  52. package/dist/builtins/sqlite-storage/index.js +1 -1
  53. package/dist/builtins/sqlite-storage/index.mjs +1 -1
  54. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +2 -2
  55. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +2 -2
  56. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  57. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  58. package/dist/builtins/system-config/system-config.addon.js +2 -2
  59. package/dist/builtins/system-config/system-config.addon.mjs +283 -283
  60. package/dist/builtins/winston-logging/index.js +1 -1
  61. package/dist/builtins/winston-logging/index.mjs +1 -1
  62. package/dist/{composition-sources-CS0D7imL.mjs → composition-sources-CftPpY4O.mjs} +1 -1
  63. package/dist/{composition-sources-cBG8fBTu.js → composition-sources-DmSEMtUD.js} +1 -1
  64. package/dist/{constants-CIzPjw7L.mjs → constants-2iWQ-Nx-.mjs} +1 -1
  65. package/dist/{constants-CRI3QzT4.js → constants-b7kpjuO4.js} +1 -1
  66. package/dist/{device-ca-DLKXYseS.mjs → device-ca-BJpYTAPg.mjs} +1 -1
  67. package/dist/{device-ca-DNHjts4_.js → device-ca-tKY_ZS9H.js} +1 -1
  68. package/dist/{dist-BKhlcYz6.js → dist-CyIHDLCg.js} +169 -10
  69. package/dist/{dist-9fcsrFU3.mjs → dist-DsrQYcLm.mjs} +164 -11
  70. package/dist/file-host-worker.js +1 -1
  71. package/dist/file-host-worker.mjs +1 -1
  72. package/dist/{hub-mdns-ciao-Dy2YjRqC.mjs → hub-mdns-ciao-BS2akjWx.mjs} +1 -1
  73. package/dist/{hub-mdns-ciao-3Zc9G9MS.js → hub-mdns-ciao-Da-0NEyr.js} +1 -1
  74. package/dist/index.js +142 -13
  75. package/dist/index.mjs +137 -14
  76. package/dist/kernel/addon-refused-error.d.ts +30 -0
  77. package/dist/kernel/index.d.ts +3 -0
  78. package/dist/kernel/legacy-blob-guard.d.ts +6 -1
  79. package/dist/kernel/moleculer/process-service.d.ts +16 -1
  80. package/dist/kernel/moleculer/runner-refusal.d.ts +27 -0
  81. package/dist/{restore-archive-49aZwkp8.js → restore-archive-Cdf09dHm.js} +1 -1
  82. package/dist/{restore-archive-DgvDecXM.mjs → restore-archive-RaA3uZ7H.mjs} +82 -82
  83. package/dist/{wal-maintenance-C041zn6V.js → wal-maintenance-C-bmkDhm.js} +0 -0
  84. package/dist/{wal-maintenance-BZGmF-3U.mjs → wal-maintenance-DE8QrreK.mjs} +0 -0
  85. package/package.json +1 -1
@@ -1,4 +1,108 @@
1
- import { Ar as errMsg, Si as EventCategory, i as AlertSchema, jr as BaseAddon, qr as createEvent, st as alertsCapability } from "../../dist-9fcsrFU3.mjs";
1
+ import { Ar as errMsg, Ci as EventCategory, i as AlertSchema, jr as BaseAddon, qr as createEvent, st as alertsCapability } from "../../dist-DsrQYcLm.mjs";
2
+ //#region src/builtins/alerts/critical-push.ts
3
+ /** Alert categories that are critical system findings. */
4
+ var CRITICAL_CATEGORIES = new Set([
5
+ "addon.runner-failed",
6
+ "addon.runner-refused",
7
+ "system.runner-check"
8
+ ]);
9
+ function isPushState(value) {
10
+ if (typeof value !== "object" || value === null) return false;
11
+ const rec = value;
12
+ return (rec["state"] === "pending" || rec["state"] === "delivered" || rec["state"] === "disabled") && typeof rec["key"] === "string" && typeof rec["attempts"] === "number";
13
+ }
14
+ function selectPushTargets(targets, policy) {
15
+ const enabled = targets.filter((t) => t.enabled);
16
+ if (policy.targetIds.length > 0) return enabled.filter((t) => policy.targetIds.includes(t.id));
17
+ return enabled.filter((t) => t.ownerUserId === void 0);
18
+ }
19
+ var MAX_DIGEST_LINES = 20;
20
+ /** The one message a target gets for a set of findings. */
21
+ function composeDigest(items) {
22
+ const first = items[0];
23
+ if (items.length === 1 && first !== void 0) return {
24
+ title: first.title,
25
+ body: first.line
26
+ };
27
+ const shown = items.slice(0, MAX_DIGEST_LINES).map((i) => i.line);
28
+ const more = items.length - shown.length;
29
+ return {
30
+ title: `${String(items.length)} addons need attention`,
31
+ body: [...shown, ...more > 0 ? [`... and ${String(more)} more`] : []].join("\n")
32
+ };
33
+ }
34
+ /**
35
+ * Push every item that still needs it, as ONE message per target. Returns the new
36
+ * state of each item whose state changed (an item already delivered/disabled for
37
+ * its key is absent from the result).
38
+ */
39
+ async function deliverDigest(input) {
40
+ const out = /* @__PURE__ */ new Map();
41
+ const work = input.items.filter((i) => !(i.previous !== null && i.previous.key === i.key && i.previous.state !== "pending"));
42
+ if (work.length === 0) return out;
43
+ const sameKey = (i) => i.previous !== null && i.previous.key === i.key ? i.previous : null;
44
+ const attemptsOf = (i) => (sameKey(i)?.attempts ?? 0) + 1;
45
+ const sentOf = (i) => sameKey(i)?.sentTo ?? [];
46
+ if (!input.policy.enabled) {
47
+ for (const i of work) out.set(i.alertId, {
48
+ state: "disabled",
49
+ key: i.key,
50
+ attempts: attemptsOf(i)
51
+ });
52
+ return out;
53
+ }
54
+ const fail = (error) => {
55
+ for (const i of work) out.set(i.alertId, {
56
+ state: "pending",
57
+ key: i.key,
58
+ attempts: attemptsOf(i),
59
+ lastError: error,
60
+ ...sentOf(i).length > 0 ? { sentTo: sentOf(i) } : {}
61
+ });
62
+ return out;
63
+ };
64
+ let targets;
65
+ try {
66
+ targets = selectPushTargets(await input.ports.listTargets(), input.policy);
67
+ } catch (err) {
68
+ return fail(`notifiers unavailable: ${err instanceof Error ? err.message : String(err)}`);
69
+ }
70
+ if (targets.length === 0) return fail("no eligible notifier target");
71
+ const sent = new Map(work.map((i) => [i.alertId, [...sentOf(i)]]));
72
+ const errors = new Map(work.map((i) => [i.alertId, []]));
73
+ for (const target of targets) {
74
+ const todo = work.filter((i) => !(sent.get(i.alertId) ?? []).includes(target.id));
75
+ if (todo.length === 0) continue;
76
+ let failure = null;
77
+ try {
78
+ const result = await input.ports.send(target, composeDigest(todo));
79
+ if (!result.success) failure = `${target.id}: ${result.error ?? "send failed"}`;
80
+ } catch (err) {
81
+ failure = `${target.id}: ${err instanceof Error ? err.message : String(err)}`;
82
+ }
83
+ for (const i of todo) if (failure === null) sent.get(i.alertId)?.push(target.id);
84
+ else errors.get(i.alertId)?.push(failure);
85
+ }
86
+ for (const i of work) {
87
+ const sentTo = sent.get(i.alertId) ?? [];
88
+ const complete = targets.every((t) => sentTo.includes(t.id));
89
+ out.set(i.alertId, complete ? {
90
+ state: "delivered",
91
+ key: i.key,
92
+ at: input.now,
93
+ attempts: attemptsOf(i),
94
+ sentTo
95
+ } : {
96
+ state: "pending",
97
+ key: i.key,
98
+ attempts: attemptsOf(i),
99
+ lastError: (errors.get(i.alertId) ?? []).join("; "),
100
+ ...sentTo.length > 0 ? { sentTo } : {}
101
+ });
102
+ }
103
+ return out;
104
+ }
105
+ //#endregion
2
106
  //#region src/builtins/alerts/device-label.ts
3
107
  /**
4
108
  * Operator-facing device label for alert messages.
@@ -42,6 +146,137 @@ function selectOverflow(alerts, maxAlerts) {
42
146
  return [...byAge.filter((a) => a.read), ...byAge.filter((a) => !a.read)].slice(0, excess).map((a) => a.id);
43
147
  }
44
148
  //#endregion
149
+ //#region src/builtins/alerts/runner-alert.ts
150
+ var RUNNER_ALERT_CATEGORIES = [
151
+ "process.crashed",
152
+ "addon.crashed",
153
+ "addon.runner-failed",
154
+ "addon.runner-refused"
155
+ ];
156
+ function isRunnerAlertCategory(category) {
157
+ return RUNNER_ALERT_CATEGORIES.includes(category);
158
+ }
159
+ /** Stable alert id of a runner on a node. */
160
+ function runnerAlertId(nodeId, runnerId) {
161
+ return `runner:${nodeId}:${runnerId}`;
162
+ }
163
+ function str(data, key) {
164
+ const value = data[key];
165
+ return typeof value === "string" && value.length > 0 ? value : void 0;
166
+ }
167
+ function strList(data, key) {
168
+ const value = data[key];
169
+ return Array.isArray(value) ? value.filter((v) => typeof v === "string") : [];
170
+ }
171
+ /** Which runner an event is about; `null` when it names none. */
172
+ function runnerKeyOf(category, data, source) {
173
+ const nodeId = str(data, "nodeId") ?? source?.nodeId ?? "hub";
174
+ const runnerId = category === "process.crashed" ? str(data, "processId") ?? source?.id ?? "unknown" : category === "addon.crashed" ? str(data, "addonId") ?? source?.id ?? "unknown" : str(data, "runnerId");
175
+ return runnerId === void 0 ? null : {
176
+ nodeId,
177
+ runnerId
178
+ };
179
+ }
180
+ /** Fold one event into the runner's record. */
181
+ function foldRunnerEvent(previous, category, data, key, now) {
182
+ const base = previous ?? {
183
+ nodeId: key.nodeId,
184
+ runnerId: key.runnerId,
185
+ state: "crashed",
186
+ since: now,
187
+ count: 0,
188
+ addonIds: []
189
+ };
190
+ const addonIds = [...new Set([...base.addonIds, ...strList(data, "addonIds")])];
191
+ const count = base.count + 1;
192
+ switch (category) {
193
+ case "addon.runner-refused": return {
194
+ ...base,
195
+ state: "refused",
196
+ count,
197
+ addonIds,
198
+ reason: str(data, "reason") ?? "refused to start",
199
+ ...str(data, "remedy") !== void 0 ? { remedy: str(data, "remedy") } : {}
200
+ };
201
+ case "addon.runner-failed": {
202
+ if (base.state === "refused") return {
203
+ ...base,
204
+ count,
205
+ addonIds
206
+ };
207
+ const crashes = data["crashesInWindow"];
208
+ return {
209
+ ...base,
210
+ state: "failed",
211
+ count,
212
+ addonIds,
213
+ ...typeof crashes === "number" ? { crashesInWindow: crashes } : {}
214
+ };
215
+ }
216
+ case "addon.crashed": {
217
+ const error = str(data, "error");
218
+ const addonId = str(data, "addonId");
219
+ return {
220
+ ...base,
221
+ count,
222
+ addonIds: addonId !== void 0 ? [...new Set([...addonIds, addonId])] : addonIds,
223
+ ...error !== void 0 ? { lastError: error } : {}
224
+ };
225
+ }
226
+ case "process.crashed": return {
227
+ ...base,
228
+ count,
229
+ addonIds
230
+ };
231
+ }
232
+ }
233
+ function renderRunnerAlert(record) {
234
+ const where = record.nodeId === "hub" ? record.runnerId : `${record.runnerId} on ${record.nodeId}`;
235
+ switch (record.state) {
236
+ case "refused": return {
237
+ severity: "error",
238
+ title: "Addon refused to start",
239
+ message: `${where}: refused - ${record.reason ?? "no reason reported"}${record.remedy !== void 0 ? ` ${record.remedy}` : ""}`
240
+ };
241
+ case "failed": return {
242
+ severity: "error",
243
+ title: "Addon crash loop",
244
+ message: `Runner "${where}" gave up${record.crashesInWindow !== void 0 ? ` after ${String(record.crashesInWindow)} crashes` : ""}${record.addonIds.length > 0 ? ` (addons: ${record.addonIds.join(", ")})` : ""} - ${String(record.count)} events`
245
+ };
246
+ case "crashed": return {
247
+ severity: "error",
248
+ title: "Addon crashed",
249
+ message: `Addon "${where}" crashed${record.lastError !== void 0 ? `: ${record.lastError}` : ""}${record.count > 1 ? ` (${String(record.count)} events)` : ""}`
250
+ };
251
+ }
252
+ }
253
+ /** Is the record in a state an operator must act on (terminal)? */
254
+ function isTerminalRunnerState(state) {
255
+ return state === "failed" || state === "refused";
256
+ }
257
+ function hash(text) {
258
+ let h = 5381;
259
+ for (let i = 0; i < text.length; i++) h = (h << 5) + h + text.charCodeAt(i) | 0;
260
+ return (h >>> 0).toString(36);
261
+ }
262
+ /**
263
+ * The push key of a record: its episode and its cause. The same refusal of the
264
+ * same episode is never pushed twice; a refusal with another reason, a breaker
265
+ * trip, or any failure after a recovery (a new episode) is a new push.
266
+ */
267
+ function runnerPushKey(record) {
268
+ const cause = record.state === "refused" ? hash(`${record.reason ?? ""}|${record.remedy ?? ""}`) : "breaker";
269
+ return `runner:${record.state}:${String(record.since)}:${cause}`;
270
+ }
271
+ /**
272
+ * The one line a push carries: runner, state and the remedy. Deliberately NOT
273
+ * the raw reason or error text - those stay on the alert.
274
+ */
275
+ function runnerPushLine(record) {
276
+ const where = record.nodeId === "hub" ? record.runnerId : `${record.runnerId} on ${record.nodeId}`;
277
+ return record.state === "refused" ? `${where}: refused to start. ${record.remedy ?? "See the Addons page."}` : `${where}: stopped after repeated crashes. Restart it from the Addons page.`;
278
+ }
279
+ //#endregion
45
280
  //#region src/builtins/alerts/alerts.addon.ts
46
281
  var CATEGORY_MAPPINGS = {
47
282
  "addon.crashed": {
@@ -52,6 +287,14 @@ var CATEGORY_MAPPINGS = {
52
287
  severity: "error",
53
288
  titleTemplate: "Addon crash loop"
54
289
  },
290
+ "addon.runner-refused": {
291
+ severity: "error",
292
+ titleTemplate: "Addon refused to start"
293
+ },
294
+ "system.runner-check": {
295
+ severity: "error",
296
+ titleTemplate: "Runners degraded"
297
+ },
55
298
  "addon.error": {
56
299
  severity: "error",
57
300
  titleTemplate: "Addon error"
@@ -135,6 +378,8 @@ var CATEGORY_MAPPINGS = {
135
378
  * {@link CATEGORY_MAPPINGS}.
136
379
  */
137
380
  var SILENT_TRACKED_CATEGORIES = [
381
+ "addon.started",
382
+ "process.state-changed",
138
383
  "addon.load-recovered",
139
384
  "system.liveness-recovered",
140
385
  "stream.online"
@@ -156,6 +401,28 @@ var TRACKED_CATEGORIES = [...Object.keys(CATEGORY_MAPPINGS), ...SILENT_TRACKED_C
156
401
  */
157
402
  var RATE_LIMIT_WINDOW_MS = 6e4;
158
403
  var RATE_LIMIT_MAX_PER_SOURCE = 5;
404
+ /** The alert `category` a runner record carries - the state it is in. */
405
+ function runnerCategoryOf(state) {
406
+ return state === "refused" ? "addon.runner-refused" : state === "failed" ? "addon.runner-failed" : "addon.crashed";
407
+ }
408
+ function parseRunnerRecord(value) {
409
+ if (typeof value !== "object" || value === null) return null;
410
+ const rec = value;
411
+ const state = rec["state"];
412
+ if (typeof rec["nodeId"] !== "string" || typeof rec["runnerId"] !== "string" || state !== "crashed" && state !== "failed" && state !== "refused" || typeof rec["count"] !== "number" || !Array.isArray(rec["addonIds"])) return null;
413
+ return {
414
+ nodeId: rec["nodeId"],
415
+ runnerId: rec["runnerId"],
416
+ state,
417
+ since: typeof rec["since"] === "number" ? rec["since"] : 0,
418
+ count: rec["count"],
419
+ addonIds: rec["addonIds"].filter((x) => typeof x === "string"),
420
+ ...typeof rec["lastError"] === "string" ? { lastError: rec["lastError"] } : {},
421
+ ...typeof rec["reason"] === "string" ? { reason: rec["reason"] } : {},
422
+ ...typeof rec["remedy"] === "string" ? { remedy: rec["remedy"] } : {},
423
+ ...typeof rec["crashesInWindow"] === "number" ? { crashesInWindow: rec["crashesInWindow"] } : {}
424
+ };
425
+ }
159
426
  /** Parse a raw settings record into a validated Alert. */
160
427
  function parseAlert(record) {
161
428
  return AlertSchema.parse({
@@ -165,8 +432,14 @@ function parseAlert(record) {
165
432
  }
166
433
  var DEFAULT_CONFIG = {
167
434
  maxAlerts: 500,
168
- retentionDays: 30
435
+ retentionDays: 30,
436
+ criticalPushEnabled: true,
437
+ criticalPushTargetIds: ""
169
438
  };
439
+ /** One burst of critical findings (a whole boot of refused runners) is ONE digest push. */
440
+ var PUSH_COALESCE_MS = 2e3;
441
+ /** Addons whose (re)start is a reason to retry pending critical pushes. */
442
+ var PUSH_RECONCILE_ADDONS = new Set(["notifiers", "notification-center"]);
170
443
  var ALERTS_COLLECTION = "alerts";
171
444
  var EVENT_SOURCE = {
172
445
  type: "addon",
@@ -189,8 +462,11 @@ function toPrunable(record) {
189
462
  }
190
463
  var AlertCenterAddon = class extends BaseAddon {
191
464
  unsubscribers = [];
192
- constructor() {
465
+ /** How long a burst of critical findings is gathered into one digest push. */
466
+ pushCoalesceMs;
467
+ constructor(options) {
193
468
  super({ ...DEFAULT_CONFIG });
469
+ this.pushCoalesceMs = options?.pushCoalesceMs ?? PUSH_COALESCE_MS;
194
470
  }
195
471
  /**
196
472
  * Per `(category, source.id)` counter used to rate-limit chatty
@@ -216,6 +492,7 @@ var AlertCenterAddon = class extends BaseAddon {
216
492
  unsubs.push(unsub);
217
493
  }
218
494
  this.unsubscribers = unsubs;
495
+ this.reconcilePendingPushes();
219
496
  this.ctx.logger.info("Initialized", { meta: { trackedCategoriesCount: TRACKED_CATEGORIES.length } });
220
497
  return [{
221
498
  capability: alertsCapability,
@@ -234,29 +511,46 @@ var AlertCenterAddon = class extends BaseAddon {
234
511
  description: "Configure alert storage and retention.",
235
512
  descriptionKey: "server:globalSettings.s_alerts.description",
236
513
  columns: 1,
237
- fields: [this.field({
238
- type: "number",
239
- key: "maxAlerts",
240
- label: "Maximum stored alerts",
241
- labelKey: "server:globalSettings.s_alerts.maxAlerts.label",
242
- description: "Oldest read alerts are deleted when this limit is exceeded.",
243
- descriptionKey: "server:globalSettings.s_alerts.maxAlerts.description",
244
- min: 50,
245
- max: 5e3,
246
- step: 50,
247
- default: 500
248
- }), this.field({
249
- type: "number",
250
- key: "retentionDays",
251
- label: "Alert retention (days)",
252
- labelKey: "server:globalSettings.s_alerts.retentionDays.label",
253
- description: "Alerts older than this are automatically removed.",
254
- descriptionKey: "server:globalSettings.s_alerts.retentionDays.description",
255
- min: 1,
256
- max: 365,
257
- step: 1,
258
- default: 30
259
- })]
514
+ fields: [
515
+ this.field({
516
+ type: "number",
517
+ key: "maxAlerts",
518
+ label: "Maximum stored alerts",
519
+ labelKey: "server:globalSettings.s_alerts.maxAlerts.label",
520
+ description: "Oldest read alerts are deleted when this limit is exceeded.",
521
+ descriptionKey: "server:globalSettings.s_alerts.maxAlerts.description",
522
+ min: 50,
523
+ max: 5e3,
524
+ step: 50,
525
+ default: 500
526
+ }),
527
+ this.field({
528
+ type: "number",
529
+ key: "retentionDays",
530
+ label: "Alert retention (days)",
531
+ labelKey: "server:globalSettings.s_alerts.retentionDays.label",
532
+ description: "Alerts older than this are automatically removed.",
533
+ descriptionKey: "server:globalSettings.s_alerts.retentionDays.description",
534
+ min: 1,
535
+ max: 365,
536
+ step: 1,
537
+ default: 30
538
+ }),
539
+ this.field({
540
+ type: "boolean",
541
+ key: "criticalPushEnabled",
542
+ label: "Push critical system alerts",
543
+ description: "A runner that failed or refused to start, or a node whose runners are degraded, is pushed straight to the notifier targets - even when notification-center is down.",
544
+ default: true
545
+ }),
546
+ this.field({
547
+ type: "text",
548
+ key: "criticalPushTargetIds",
549
+ label: "Critical push targets",
550
+ description: "Comma-separated notifier target ids. Empty = every enabled shared (not personal) target.",
551
+ default: ""
552
+ })
553
+ ]
260
554
  }] });
261
555
  }
262
556
  async listAlerts(filter) {
@@ -316,6 +610,23 @@ var AlertCenterAddon = class extends BaseAddon {
316
610
  await this.handleModelDownload(event);
317
611
  return;
318
612
  }
613
+ if (isRunnerAlertCategory(category)) {
614
+ await this.handleRunnerEvent(category, event);
615
+ return;
616
+ }
617
+ if (category === EventCategory.ProcessStateChanged) {
618
+ await this.handleRunnerStateChanged(event);
619
+ return;
620
+ }
621
+ if (category === EventCategory.SystemRunnerCheck) {
622
+ await this.handleRunnerCheck(event);
623
+ return;
624
+ }
625
+ if (category === EventCategory.AddonStarted) {
626
+ const addonId = event.data["addonId"];
627
+ if (typeof addonId === "string" && PUSH_RECONCILE_ADDONS.has(addonId)) await this.reconcilePendingPushes();
628
+ return;
629
+ }
319
630
  if (category === EventCategory.AddonLoadFailed) {
320
631
  await this.handleAddonLoadFailed(event);
321
632
  return;
@@ -374,6 +685,307 @@ var AlertCenterAddon = class extends BaseAddon {
374
685
  } });
375
686
  }
376
687
  }
688
+ async readRunnerRecord(alertId) {
689
+ const backend = this.ctx.api.settingsStore;
690
+ if (!backend) return null;
691
+ const raw = await backend.get.query({
692
+ collection: ALERTS_COLLECTION,
693
+ key: alertId
694
+ });
695
+ if (raw === null || raw === void 0 || typeof raw !== "object") return null;
696
+ const alert = AlertSchema.safeParse(raw);
697
+ if (!alert.success || alert.data.status === "dismissed") return null;
698
+ return parseRunnerRecord(alert.data.metadata?.["runner"]);
699
+ }
700
+ async handleRunnerEvent(category, event) {
701
+ if (!isRunnerAlertCategory(category)) return;
702
+ const key = runnerKeyOf(category, event.data, event.source ? {
703
+ id: String(event.source.id),
704
+ ...typeof event.source.nodeId === "string" ? { nodeId: event.source.nodeId } : {}
705
+ } : void 0);
706
+ if (key === null) return;
707
+ const alertId = runnerAlertId(key.nodeId, key.runnerId);
708
+ const previous = await this.readRunnerRecord(alertId);
709
+ const record = foldRunnerEvent(previous, category, event.data, key, Date.now());
710
+ const rendered = renderRunnerAlert(record);
711
+ const existing = await this.readAlertRaw(alertId);
712
+ const now = Date.now();
713
+ const alert = {
714
+ id: alertId,
715
+ category: runnerCategoryOf(record.state),
716
+ severity: rendered.severity,
717
+ title: rendered.title,
718
+ message: rendered.message,
719
+ status: "active",
720
+ read: previous !== null && previous.state === record.state ? existing?.read === true : false,
721
+ createdAt: previous !== null ? existing?.createdAt ?? now : now,
722
+ updatedAt: now,
723
+ source: event.source ? {
724
+ type: event.source.type,
725
+ id: String(event.source.id)
726
+ } : {
727
+ type: "runner",
728
+ id: record.runnerId
729
+ },
730
+ metadata: {
731
+ runner: record,
732
+ pushLine: runnerPushLine(record),
733
+ ...existing?.push !== void 0 ? { push: existing.push } : {}
734
+ }
735
+ };
736
+ await this.persistAlert(alert);
737
+ if (previous === null) this.emitAlertCreatedEvent(alert);
738
+ else this.emitAlertUpdatedEvent(alertId, {
739
+ message: alert.message,
740
+ title: alert.title
741
+ });
742
+ await this.enforceMaxAlerts();
743
+ if (isTerminalRunnerState(record.state)) this.queuePush({
744
+ alertId,
745
+ key: runnerPushKey(record),
746
+ title: rendered.title,
747
+ line: runnerPushLine(record)
748
+ });
749
+ }
750
+ /**
751
+ * A runner reached `running`: its episode is over. Close the runner alert so a
752
+ * later failure opens a NEW episode (new push key), instead of folding into a
753
+ * stale `refused` record that would never push again.
754
+ */
755
+ async handleRunnerStateChanged(event) {
756
+ if (event.data["state"] !== "running") return;
757
+ const runnerId = typeof event.data["runnerId"] === "string" ? event.data["runnerId"] : null;
758
+ if (runnerId === null) return;
759
+ const alertId = runnerAlertId(typeof event.data["nodeId"] === "string" ? event.data["nodeId"] : "hub", runnerId);
760
+ if (await this.readRunnerRecord(alertId) === null) return;
761
+ await this.dismiss(alertId);
762
+ }
763
+ /** The node's one-shot runner boot check: one alert, dismissed when the node is clean. */
764
+ async handleRunnerCheck(event) {
765
+ const nodeId = typeof event.data["nodeId"] === "string" ? event.data["nodeId"] : "hub";
766
+ const alertId = `runner-check:${nodeId}`;
767
+ if (event.data["degraded"] !== true) {
768
+ await this.dismiss(alertId);
769
+ return;
770
+ }
771
+ const summary = typeof event.data["summary"] === "string" ? event.data["summary"] : "degraded";
772
+ const ids = (value) => Array.isArray(value) ? value.filter((v) => typeof v === "string") : [];
773
+ const refusedIds = Array.isArray(event.data["refused"]) ? event.data["refused"].flatMap((r) => {
774
+ const id = typeof r === "object" && r !== null ? r["runnerId"] : void 0;
775
+ return typeof id === "string" ? [id] : [];
776
+ }) : [];
777
+ const failedIds = ids(event.data["failed"]);
778
+ const missingIds = ids(event.data["missing"]);
779
+ const pushLine = `${nodeId}: runners degraded - ${[
780
+ ...failedIds.length > 0 ? [`failed: ${failedIds.join(", ")}`] : [],
781
+ ...refusedIds.length > 0 ? [`refused: ${refusedIds.join(", ")}`] : [],
782
+ ...missingIds.length > 0 ? [`missing: ${missingIds.join(", ")}`] : []
783
+ ].join("; ")}. See the Addons page.`;
784
+ const pushKey = `check:${[
785
+ ...failedIds,
786
+ ...refusedIds,
787
+ ...missingIds
788
+ ].sort().join(",")}`;
789
+ const now = Date.now();
790
+ const existing = await this.readAlertRaw(alertId);
791
+ const alert = {
792
+ id: alertId,
793
+ category: "system.runner-check",
794
+ severity: "error",
795
+ title: "Runners degraded",
796
+ message: `${nodeId}: ${summary}`,
797
+ status: "active",
798
+ read: false,
799
+ createdAt: existing?.createdAt ?? now,
800
+ updatedAt: now,
801
+ source: {
802
+ type: "node",
803
+ id: nodeId
804
+ },
805
+ metadata: {
806
+ nodeId,
807
+ failed: event.data["failed"],
808
+ refused: event.data["refused"],
809
+ missing: event.data["missing"],
810
+ pushLine,
811
+ ...existing?.push !== void 0 ? { push: existing.push } : {}
812
+ }
813
+ };
814
+ await this.persistAlert(alert);
815
+ this.emitAlertCreatedEvent(alert);
816
+ this.queuePush({
817
+ alertId,
818
+ key: pushKey,
819
+ title: alert.title,
820
+ line: pushLine
821
+ });
822
+ }
823
+ async readAlertRaw(alertId) {
824
+ const backend = this.ctx.api.settingsStore;
825
+ if (!backend) return null;
826
+ const raw = await backend.get.query({
827
+ collection: ALERTS_COLLECTION,
828
+ key: alertId
829
+ });
830
+ if (raw === null || raw === void 0 || typeof raw !== "object") return null;
831
+ const rec = Object.fromEntries(Object.entries(raw));
832
+ const meta = rec["metadata"];
833
+ const push = typeof meta === "object" && meta !== null && !Array.isArray(meta) ? meta["push"] : void 0;
834
+ return {
835
+ ...typeof rec["createdAt"] === "number" ? { createdAt: rec["createdAt"] } : {},
836
+ ...typeof rec["read"] === "boolean" ? { read: rec["read"] } : {},
837
+ ...isPushState(push) ? { push } : {}
838
+ };
839
+ }
840
+ /** Pushes run one at a time so a burst can never send the same state twice. */
841
+ pushChain = Promise.resolve();
842
+ /** Findings waiting for the next digest, by alert id. */
843
+ pushQueue = /* @__PURE__ */ new Map();
844
+ pushTimer = null;
845
+ pushPolicy() {
846
+ return {
847
+ enabled: this.config.criticalPushEnabled,
848
+ targetIds: this.config.criticalPushTargetIds.split(",").map((id) => id.trim()).filter((id) => id.length > 0)
849
+ };
850
+ }
851
+ pushPorts() {
852
+ const api = this.ctx.api;
853
+ return {
854
+ listTargets: async () => {
855
+ return (await api.notificationOutput.listTargets.query({})).map((t) => {
856
+ const owner = t.config["ownerUserId"];
857
+ return {
858
+ id: t.id,
859
+ addonId: t.addonId,
860
+ enabled: t.enabled,
861
+ ...typeof owner === "string" && owner.length > 0 ? { ownerUserId: owner } : {}
862
+ };
863
+ });
864
+ },
865
+ send: async (target, message) => api.notificationOutput.send.mutate({
866
+ addonId: target.addonId,
867
+ targetId: target.id,
868
+ notification: {
869
+ title: message.title,
870
+ body: message.body,
871
+ format: "text",
872
+ priority: 5
873
+ }
874
+ })
875
+ };
876
+ }
877
+ /**
878
+ * Queue a critical finding for the next digest. A burst (every runner of a boot)
879
+ * arrives within the coalesce window and leaves as ONE message per target.
880
+ */
881
+ queuePush(item) {
882
+ this.pushQueue.set(item.alertId, item);
883
+ if (this.pushTimer !== null) return;
884
+ this.pushTimer = setTimeout(() => {
885
+ this.pushTimer = null;
886
+ this.flushPushes();
887
+ }, this.pushCoalesceMs);
888
+ this.pushTimer.unref?.();
889
+ }
890
+ /** Send the queued findings now (also the reconcile path). */
891
+ flushPushes() {
892
+ this.pushChain = this.pushChain.then(async () => {
893
+ const queued = [...this.pushQueue.values()];
894
+ this.pushQueue.clear();
895
+ if (queued.length === 0) return;
896
+ try {
897
+ const items = [];
898
+ for (const q of queued) {
899
+ const existing = await this.readAlertRaw(q.alertId);
900
+ items.push({
901
+ ...q,
902
+ previous: existing?.push ?? null
903
+ });
904
+ }
905
+ const states = await deliverDigest({
906
+ items,
907
+ policy: this.pushPolicy(),
908
+ ports: this.pushPorts(),
909
+ now: Date.now()
910
+ });
911
+ for (const [alertId, state] of states) {
912
+ await this.storePushState(alertId, state);
913
+ this.ctx.logger?.info("critical alert push", { meta: {
914
+ alertId,
915
+ state: state.state,
916
+ attempts: state.attempts,
917
+ error: state.lastError
918
+ } });
919
+ }
920
+ } catch (err) {
921
+ this.ctx.logger?.warn("critical alert push failed unexpectedly", { meta: { error: errMsg(err) } });
922
+ }
923
+ });
924
+ return this.pushChain;
925
+ }
926
+ async storePushState(alertId, push) {
927
+ const backend = this.ctx.api.settingsStore;
928
+ if (!backend) return;
929
+ const raw = await backend.get.query({
930
+ collection: ALERTS_COLLECTION,
931
+ key: alertId
932
+ });
933
+ if (raw === null || raw === void 0 || typeof raw !== "object") return;
934
+ const existing = Object.fromEntries(Object.entries(raw));
935
+ const meta = existing["metadata"];
936
+ const metadata = typeof meta === "object" && meta !== null && !Array.isArray(meta) ? meta : {};
937
+ await backend.set.mutate({
938
+ collection: ALERTS_COLLECTION,
939
+ key: alertId,
940
+ value: {
941
+ ...existing,
942
+ metadata: {
943
+ ...metadata,
944
+ push
945
+ }
946
+ }
947
+ });
948
+ }
949
+ /**
950
+ * Retry every critical alert whose push is still pending - at boot and when
951
+ * the notifiers or notification-center start. An alert whose state was already
952
+ * delivered is skipped by its key, so reconciling never sends twice.
953
+ */
954
+ async reconcilePendingPushes() {
955
+ const backend = this.ctx.api.settingsStore;
956
+ if (!backend) return;
957
+ try {
958
+ const rows = await backend.query.query({
959
+ collection: ALERTS_COLLECTION,
960
+ filter: {
961
+ orderBy: {
962
+ field: "updatedAt",
963
+ direction: "desc"
964
+ },
965
+ limit: 500
966
+ }
967
+ });
968
+ for (const row of rows) {
969
+ const parsed = AlertSchema.safeParse({
970
+ id: row.id,
971
+ ...row.data
972
+ });
973
+ if (!parsed.success || parsed.data.status !== "active") continue;
974
+ if (!CRITICAL_CATEGORIES.has(parsed.data.category)) continue;
975
+ const push = parsed.data.metadata?.["push"];
976
+ if (!isPushState(push) || push.state !== "pending") continue;
977
+ this.pushQueue.set(parsed.data.id, {
978
+ alertId: parsed.data.id,
979
+ key: push.key,
980
+ title: parsed.data.title,
981
+ line: String(parsed.data.metadata?.["pushLine"] ?? parsed.data.title)
982
+ });
983
+ }
984
+ await this.flushPushes();
985
+ } catch (err) {
986
+ this.ctx.logger?.warn("pending critical push reconcile failed", { meta: { error: errMsg(err) } });
987
+ }
988
+ }
377
989
  async handleModelDownload(event) {
378
990
  const data = event.data;
379
991
  const modelId = data.modelId ?? "unknown";