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