@camstack/system 1.2.13 → 1.2.14
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 +2 -2
- package/dist/addon-runner.mjs +2 -2
- 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.js +1 -1
- package/dist/builtins/alerts/alerts.addon.mjs +1 -1
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
- package/dist/builtins/console-logging/index.js +1 -1
- package/dist/builtins/console-logging/index.mjs +1 -1
- 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/doorbell/virtual-doorbell.addon.js +1 -1
- package/dist/builtins/doorbell/virtual-doorbell.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/local-auth/local-auth.addon.js +1 -1
- package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
- package/dist/builtins/local-network/local-network.addon.js +1 -1
- package/dist/builtins/local-network/local-network.addon.mjs +1 -1
- package/dist/builtins/loki-logging/index.d.ts +4 -0
- package/dist/builtins/loki-logging/index.js +445 -0
- package/dist/builtins/loki-logging/index.mjs +431 -0
- package/dist/builtins/loki-logging/loki-destination.d.ts +88 -0
- package/dist/builtins/loki-logging/loki-logging.addon.d.ts +36 -0
- package/dist/builtins/loki-logging/loki-payload.d.ts +46 -0
- 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/sqlite-settings.addon.js +1 -1
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +1 -1
- 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 +1 -1
- package/dist/builtins/system-config/system-config.addon.mjs +1 -1
- package/dist/builtins/winston-logging/index.js +1 -1
- package/dist/builtins/winston-logging/index.mjs +1 -1
- package/dist/{dist-DpKeSZel.mjs → dist-eOaraBhT.mjs} +21 -1
- package/dist/{dist-BQOCTD5b.js → dist-yttC1aUI.js} +26 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +128 -15
- package/dist/index.mjs +127 -16
- package/dist/kernel/config-schema.d.ts +38 -1
- package/dist/logging/log-manager.d.ts +14 -4
- package/dist/logging/log-ring-buffer.d.ts +15 -0
- package/dist/logging/partitioned-log-buffer.d.ts +49 -6
- package/dist/{manifest-python-deps-1I7WdPDD.js → manifest-python-deps-DSrmih6E.js} +1 -1
- package/dist/{manifest-python-deps-I2MGvDvo.mjs → manifest-python-deps-bn3qrsxR.mjs} +1 -1
- package/package.json +13 -1
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
import { st as BaseAddon, z as logDestinationCapability } from "../../dist-eOaraBhT.mjs";
|
|
2
|
+
//#region src/builtins/loki-logging/loki-payload.ts
|
|
3
|
+
/**
|
|
4
|
+
* Loki rejects a label name that is not a valid Prometheus label
|
|
5
|
+
* (`[a-zA-Z_][a-zA-Z0-9_]*`), so operator-supplied keys are sanitised rather
|
|
6
|
+
* than passed through — a 400 from the push endpoint on every batch is a much
|
|
7
|
+
* worse outcome than a renamed label.
|
|
8
|
+
*/
|
|
9
|
+
function sanitizeLabelName(raw) {
|
|
10
|
+
const replaced = raw.replaceAll(/[^a-zA-Z0-9_]/g, "_");
|
|
11
|
+
return /^[a-zA-Z_]/.test(replaced) ? replaced : `_${replaced}`;
|
|
12
|
+
}
|
|
13
|
+
/** Parse an operator's `key=value,key2=value2` label string. */
|
|
14
|
+
function parseExtraLabels(raw) {
|
|
15
|
+
const out = {};
|
|
16
|
+
for (const pair of raw.split(",")) {
|
|
17
|
+
const trimmed = pair.trim();
|
|
18
|
+
if (trimmed.length === 0) continue;
|
|
19
|
+
const eq = trimmed.indexOf("=");
|
|
20
|
+
if (eq <= 0) continue;
|
|
21
|
+
const name = sanitizeLabelName(trimmed.slice(0, eq).trim());
|
|
22
|
+
const value = trimmed.slice(eq + 1).trim();
|
|
23
|
+
if (value.length > 0) out[name] = value;
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
/** `Date` → Loki's nanosecond-precision string timestamp. */
|
|
28
|
+
function toUnixNano(timestamp) {
|
|
29
|
+
const ms = timestamp instanceof Date ? timestamp.getTime() : typeof timestamp === "number" ? timestamp : Date.parse(timestamp);
|
|
30
|
+
return `${String(Math.trunc(Number.isFinite(ms) ? ms : 0))}000000`;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The line body: JSON carrying everything that must NOT be a label. Loki can
|
|
34
|
+
* parse this with `| json` at query time, so `scope` and `tags.deviceId` stay
|
|
35
|
+
* filterable without ever being indexed.
|
|
36
|
+
*/
|
|
37
|
+
function formatLokiLine(entry) {
|
|
38
|
+
const line = { message: entry.message };
|
|
39
|
+
if (entry.scope !== void 0 && entry.scope.length > 0) line["scope"] = entry.scope;
|
|
40
|
+
if (entry.tags !== void 0 && Object.keys(entry.tags).length > 0) line["tags"] = entry.tags;
|
|
41
|
+
if (entry.meta !== void 0 && Object.keys(entry.meta).length > 0) line["meta"] = entry.meta;
|
|
42
|
+
return JSON.stringify(line);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Group a batch into one stream per distinct label set, and sort each stream's
|
|
46
|
+
* values by timestamp.
|
|
47
|
+
*
|
|
48
|
+
* The sort is not cosmetic. Loki historically rejected a stream whose entries
|
|
49
|
+
* were not in ascending time order, and even with out-of-order ingestion enabled
|
|
50
|
+
* it is bounded by a window. Entries reach a destination from several concurrent
|
|
51
|
+
* producers, so a batch is NOT naturally ordered — without this, some pushes
|
|
52
|
+
* fail with `entry out of order` and those log lines are simply gone.
|
|
53
|
+
*/
|
|
54
|
+
function buildPushBody(entries, labels) {
|
|
55
|
+
if (entries.length === 0) return null;
|
|
56
|
+
const base = {
|
|
57
|
+
job: labels.job,
|
|
58
|
+
node: labels.node
|
|
59
|
+
};
|
|
60
|
+
for (const [key, value] of Object.entries(labels.extra ?? {})) base[key] = value;
|
|
61
|
+
const byLevel = /* @__PURE__ */ new Map();
|
|
62
|
+
for (const entry of entries) {
|
|
63
|
+
const values = byLevel.get(entry.level) ?? [];
|
|
64
|
+
values.push([toUnixNano(entry.timestamp), formatLokiLine(entry)]);
|
|
65
|
+
byLevel.set(entry.level, values);
|
|
66
|
+
}
|
|
67
|
+
const streams = [];
|
|
68
|
+
for (const [level, values] of byLevel) streams.push({
|
|
69
|
+
stream: {
|
|
70
|
+
...base,
|
|
71
|
+
level
|
|
72
|
+
},
|
|
73
|
+
values: values.toSorted((a, b) => a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)
|
|
74
|
+
});
|
|
75
|
+
return { streams };
|
|
76
|
+
}
|
|
77
|
+
//#endregion
|
|
78
|
+
//#region src/builtins/loki-logging/loki-destination.ts
|
|
79
|
+
var LEVEL_ORDER = {
|
|
80
|
+
debug: 0,
|
|
81
|
+
info: 1,
|
|
82
|
+
warn: 2,
|
|
83
|
+
error: 3
|
|
84
|
+
};
|
|
85
|
+
/**
|
|
86
|
+
* Bounded so an unreachable Loki cannot grow the heap without limit. 10k entries
|
|
87
|
+
* of a few hundred bytes is a handful of MB — enough to ride out a restart of the
|
|
88
|
+
* Loki container, not enough to matter if it never comes back.
|
|
89
|
+
*/
|
|
90
|
+
var MAX_QUEUE = 1e4;
|
|
91
|
+
var LokiDestination = class {
|
|
92
|
+
config = null;
|
|
93
|
+
queue = [];
|
|
94
|
+
timer;
|
|
95
|
+
flushing = false;
|
|
96
|
+
pushed = 0;
|
|
97
|
+
dropped = 0;
|
|
98
|
+
failures = 0;
|
|
99
|
+
lastError = null;
|
|
100
|
+
lastPushAtMs = null;
|
|
101
|
+
fetchFn;
|
|
102
|
+
setIntervalFn;
|
|
103
|
+
clearIntervalFn;
|
|
104
|
+
constructor(deps) {
|
|
105
|
+
this.fetchFn = deps?.fetchFn ?? ((url, init) => globalThis.fetch(url, {
|
|
106
|
+
method: init.method,
|
|
107
|
+
headers: { ...init.headers },
|
|
108
|
+
body: init.body,
|
|
109
|
+
...init.signal ? { signal: init.signal } : {}
|
|
110
|
+
}));
|
|
111
|
+
this.setIntervalFn = deps?.setIntervalFn ?? setInterval;
|
|
112
|
+
this.clearIntervalFn = deps?.clearIntervalFn ?? clearInterval;
|
|
113
|
+
}
|
|
114
|
+
async initialize(config) {
|
|
115
|
+
this.config = config ?? null;
|
|
116
|
+
if (!this.isActive()) return;
|
|
117
|
+
this.timer = this.setIntervalFn(() => {
|
|
118
|
+
this.flush();
|
|
119
|
+
}, Math.max(250, config?.batchIntervalMs ?? 5e3));
|
|
120
|
+
const timer = this.timer;
|
|
121
|
+
if (typeof timer === "object" && timer !== null && "unref" in timer) timer.unref();
|
|
122
|
+
}
|
|
123
|
+
/** Enabled AND pointed somewhere. An enabled destination with no URL is inert. */
|
|
124
|
+
isActive() {
|
|
125
|
+
return this.config !== null && this.config.enabled && this.config.url.trim().length > 0;
|
|
126
|
+
}
|
|
127
|
+
write(entry) {
|
|
128
|
+
if (!this.isActive() || this.config === null) return;
|
|
129
|
+
if (LEVEL_ORDER[entry.level] < LEVEL_ORDER[this.config.level]) return;
|
|
130
|
+
this.queue.push(entry);
|
|
131
|
+
if (this.queue.length > MAX_QUEUE) {
|
|
132
|
+
const overflow = this.queue.length - MAX_QUEUE;
|
|
133
|
+
this.queue.splice(0, overflow);
|
|
134
|
+
this.dropped += overflow;
|
|
135
|
+
}
|
|
136
|
+
if (this.queue.length >= this.config.batchSize) this.flush();
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Push whatever is queued. Never throws and never logs.
|
|
140
|
+
*
|
|
141
|
+
* On failure the batch is put BACK at the front of the queue so a transient
|
|
142
|
+
* Loki outage does not lose lines; `MAX_QUEUE` is what stops that from growing
|
|
143
|
+
* without bound.
|
|
144
|
+
*/
|
|
145
|
+
async flush() {
|
|
146
|
+
if (this.flushing || !this.isActive() || this.config === null) return;
|
|
147
|
+
if (this.queue.length === 0) return;
|
|
148
|
+
this.flushing = true;
|
|
149
|
+
const batch = this.queue;
|
|
150
|
+
this.queue = [];
|
|
151
|
+
try {
|
|
152
|
+
const body = buildPushBody(batch, {
|
|
153
|
+
job: this.config.job,
|
|
154
|
+
node: this.config.node,
|
|
155
|
+
extra: parseExtraLabels(this.config.extraLabels)
|
|
156
|
+
});
|
|
157
|
+
if (body !== null) {
|
|
158
|
+
await this.push(body, this.config);
|
|
159
|
+
this.pushed += batch.length;
|
|
160
|
+
this.lastPushAtMs = Date.now();
|
|
161
|
+
this.lastError = null;
|
|
162
|
+
}
|
|
163
|
+
} catch (error) {
|
|
164
|
+
this.failures += 1;
|
|
165
|
+
this.lastError = error instanceof Error ? error.message : String(error);
|
|
166
|
+
this.queue = [...batch, ...this.queue];
|
|
167
|
+
if (this.queue.length > MAX_QUEUE) {
|
|
168
|
+
const overflow = this.queue.length - MAX_QUEUE;
|
|
169
|
+
this.queue.splice(0, overflow);
|
|
170
|
+
this.dropped += overflow;
|
|
171
|
+
}
|
|
172
|
+
} finally {
|
|
173
|
+
this.flushing = false;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
async push(body, config) {
|
|
177
|
+
const endpoint = `${config.url.replace(/\/+$/, "")}/loki/api/v1/push`;
|
|
178
|
+
const headers = { "content-type": "application/json" };
|
|
179
|
+
if (config.tenantId.trim().length > 0) headers["X-Scope-OrgID"] = config.tenantId.trim();
|
|
180
|
+
if (config.username.length > 0) headers["authorization"] = `Basic ${Buffer.from(`${config.username}:${config.password}`).toString("base64")}`;
|
|
181
|
+
const controller = new AbortController();
|
|
182
|
+
const timeout = setTimeout(() => controller.abort(), Math.max(1e3, config.timeoutMs));
|
|
183
|
+
try {
|
|
184
|
+
const response = await this.fetchFn(endpoint, {
|
|
185
|
+
method: "POST",
|
|
186
|
+
headers,
|
|
187
|
+
body: JSON.stringify(body),
|
|
188
|
+
signal: controller.signal
|
|
189
|
+
});
|
|
190
|
+
if (!response.ok) {
|
|
191
|
+
const detail = await response.text().catch(() => "");
|
|
192
|
+
throw new Error(`Loki push failed: ${String(response.status)}${detail.length > 0 ? ` — ${detail.slice(0, 200)}` : ""}`);
|
|
193
|
+
}
|
|
194
|
+
} finally {
|
|
195
|
+
clearTimeout(timeout);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
getStatus() {
|
|
199
|
+
return {
|
|
200
|
+
enabled: this.config?.enabled === true,
|
|
201
|
+
configured: this.isActive(),
|
|
202
|
+
queued: this.queue.length,
|
|
203
|
+
pushed: this.pushed,
|
|
204
|
+
dropped: this.dropped,
|
|
205
|
+
failures: this.failures,
|
|
206
|
+
lastError: this.lastError,
|
|
207
|
+
lastPushAtMs: this.lastPushAtMs
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* One-shot reachability probe for the Admin UI's "Test connection" button.
|
|
212
|
+
* Pushes a single synthetic entry, which validates the URL, the auth, the
|
|
213
|
+
* tenant header AND the label policy in one go — a bare GET would validate
|
|
214
|
+
* none of them.
|
|
215
|
+
*/
|
|
216
|
+
async testConnection(config) {
|
|
217
|
+
if (config.url.trim().length === 0) return {
|
|
218
|
+
ok: false,
|
|
219
|
+
error: "No Loki URL configured"
|
|
220
|
+
};
|
|
221
|
+
const body = buildPushBody([{
|
|
222
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
223
|
+
level: "info",
|
|
224
|
+
scope: "loki-logging",
|
|
225
|
+
message: "CamStack connection test"
|
|
226
|
+
}], {
|
|
227
|
+
job: config.job,
|
|
228
|
+
node: config.node,
|
|
229
|
+
extra: parseExtraLabels(config.extraLabels)
|
|
230
|
+
});
|
|
231
|
+
if (body === null) return {
|
|
232
|
+
ok: false,
|
|
233
|
+
error: "Nothing to send"
|
|
234
|
+
};
|
|
235
|
+
try {
|
|
236
|
+
await this.push(body, config);
|
|
237
|
+
return {
|
|
238
|
+
ok: true,
|
|
239
|
+
error: null
|
|
240
|
+
};
|
|
241
|
+
} catch (error) {
|
|
242
|
+
return {
|
|
243
|
+
ok: false,
|
|
244
|
+
error: error instanceof Error ? error.message : String(error)
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
async query(_filter) {
|
|
249
|
+
return [];
|
|
250
|
+
}
|
|
251
|
+
async shutdown() {
|
|
252
|
+
if (this.timer !== void 0) {
|
|
253
|
+
this.clearIntervalFn(this.timer);
|
|
254
|
+
this.timer = void 0;
|
|
255
|
+
}
|
|
256
|
+
await this.flush();
|
|
257
|
+
this.config = null;
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
//#endregion
|
|
261
|
+
//#region src/builtins/loki-logging/loki-logging.addon.ts
|
|
262
|
+
var DEFAULTS = {
|
|
263
|
+
enabled: false,
|
|
264
|
+
url: "",
|
|
265
|
+
job: "camstack",
|
|
266
|
+
level: "info",
|
|
267
|
+
batchIntervalMs: 5e3,
|
|
268
|
+
batchSize: 200,
|
|
269
|
+
extraLabels: "",
|
|
270
|
+
tenantId: "",
|
|
271
|
+
username: "",
|
|
272
|
+
password: "",
|
|
273
|
+
timeoutMs: 1e4
|
|
274
|
+
};
|
|
275
|
+
var LokiLoggingAddon = class extends BaseAddon {
|
|
276
|
+
destination = null;
|
|
277
|
+
constructor() {
|
|
278
|
+
super({ ...DEFAULTS });
|
|
279
|
+
}
|
|
280
|
+
async onInitialize() {
|
|
281
|
+
this.destination = new LokiDestination();
|
|
282
|
+
await this.destination.initialize(this.destinationConfig());
|
|
283
|
+
this.ctx.logger.info(this.config.enabled && this.config.url.length > 0 ? `Loki logging initialized → ${this.config.url}` : "Loki logging registered but inactive (set a URL and enable it)");
|
|
284
|
+
return [{
|
|
285
|
+
capability: logDestinationCapability,
|
|
286
|
+
provider: this.destination
|
|
287
|
+
}];
|
|
288
|
+
}
|
|
289
|
+
async onShutdown() {
|
|
290
|
+
await this.destination?.shutdown();
|
|
291
|
+
this.destination = null;
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* `node` is a Loki LABEL, so it must be the node's real identity and must not
|
|
295
|
+
* be operator-editable — a typo would fork the stream and split a node's history
|
|
296
|
+
* in two. `kernel.localNodeId` is the Moleculer node id (`'hub'` on the hub, the
|
|
297
|
+
* agent's own name elsewhere), which is exactly the bounded value wanted here.
|
|
298
|
+
*
|
|
299
|
+
* NOT named `resolveConfig`: `BaseAddon` already has a protected
|
|
300
|
+
* `resolveConfig()` that reloads persisted settings, and shadowing it with a
|
|
301
|
+
* different signature is a type error — usefully, since silently overriding it
|
|
302
|
+
* would have broken this addon's settings reload.
|
|
303
|
+
*/
|
|
304
|
+
destinationConfig() {
|
|
305
|
+
return {
|
|
306
|
+
...this.config,
|
|
307
|
+
node: this.ctx.kernel.localNodeId ?? "hub"
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
getDestination() {
|
|
311
|
+
if (!this.destination) throw new Error("Loki logging not initialized");
|
|
312
|
+
return this.destination;
|
|
313
|
+
}
|
|
314
|
+
globalSettingsSchema() {
|
|
315
|
+
return this.schema({ sections: [{
|
|
316
|
+
id: "loki-logging",
|
|
317
|
+
title: "Loki",
|
|
318
|
+
description: "Ship this node’s logs to a Grafana Loki instance. Independent of the rotated-file and console destinations — enabling this does not disable those.",
|
|
319
|
+
columns: 2,
|
|
320
|
+
fields: [
|
|
321
|
+
this.field({
|
|
322
|
+
type: "boolean",
|
|
323
|
+
key: "enabled",
|
|
324
|
+
label: "Enabled",
|
|
325
|
+
description: "Off by default. Needs a URL below to do anything.",
|
|
326
|
+
default: false
|
|
327
|
+
}),
|
|
328
|
+
this.field({
|
|
329
|
+
type: "text",
|
|
330
|
+
key: "url",
|
|
331
|
+
label: "Loki URL",
|
|
332
|
+
description: "Base URL, e.g. http://loki:3100 — the /loki/api/v1/push path is added.",
|
|
333
|
+
placeholder: "http://loki:3100",
|
|
334
|
+
default: ""
|
|
335
|
+
}),
|
|
336
|
+
this.field({
|
|
337
|
+
type: "select",
|
|
338
|
+
key: "level",
|
|
339
|
+
label: "Minimum level",
|
|
340
|
+
default: "info",
|
|
341
|
+
options: [
|
|
342
|
+
{
|
|
343
|
+
value: "debug",
|
|
344
|
+
label: "Debug"
|
|
345
|
+
},
|
|
346
|
+
{
|
|
347
|
+
value: "info",
|
|
348
|
+
label: "Info"
|
|
349
|
+
},
|
|
350
|
+
{
|
|
351
|
+
value: "warn",
|
|
352
|
+
label: "Warn"
|
|
353
|
+
},
|
|
354
|
+
{
|
|
355
|
+
value: "error",
|
|
356
|
+
label: "Error"
|
|
357
|
+
}
|
|
358
|
+
]
|
|
359
|
+
}),
|
|
360
|
+
this.field({
|
|
361
|
+
type: "text",
|
|
362
|
+
key: "job",
|
|
363
|
+
label: "Job label",
|
|
364
|
+
description: "The Loki `job` label. Keep it fixed — labels are indexed.",
|
|
365
|
+
default: "camstack"
|
|
366
|
+
}),
|
|
367
|
+
this.field({
|
|
368
|
+
type: "text",
|
|
369
|
+
key: "extraLabels",
|
|
370
|
+
label: "Extra labels",
|
|
371
|
+
description: "Static `key=value,key2=value2`. Low-cardinality only: per-entry values belong in the line body, and a high-cardinality label will bring Loki down.",
|
|
372
|
+
placeholder: "env=home,site=garage",
|
|
373
|
+
default: ""
|
|
374
|
+
}),
|
|
375
|
+
this.field({
|
|
376
|
+
type: "text",
|
|
377
|
+
key: "tenantId",
|
|
378
|
+
label: "Tenant ID",
|
|
379
|
+
description: "Sent as X-Scope-OrgID. Required by Grafana Cloud and multi-tenant Loki.",
|
|
380
|
+
default: ""
|
|
381
|
+
}),
|
|
382
|
+
this.field({
|
|
383
|
+
type: "text",
|
|
384
|
+
key: "username",
|
|
385
|
+
label: "Username",
|
|
386
|
+
description: "HTTP basic auth. Leave empty for none.",
|
|
387
|
+
default: ""
|
|
388
|
+
}),
|
|
389
|
+
this.field({
|
|
390
|
+
type: "password",
|
|
391
|
+
key: "password",
|
|
392
|
+
label: "Password",
|
|
393
|
+
default: ""
|
|
394
|
+
}),
|
|
395
|
+
this.field({
|
|
396
|
+
type: "number",
|
|
397
|
+
key: "batchIntervalMs",
|
|
398
|
+
label: "Flush interval",
|
|
399
|
+
description: "How often a partial batch is pushed.",
|
|
400
|
+
min: 250,
|
|
401
|
+
max: 6e4,
|
|
402
|
+
step: 250,
|
|
403
|
+
default: 5e3,
|
|
404
|
+
unit: "ms"
|
|
405
|
+
}),
|
|
406
|
+
this.field({
|
|
407
|
+
type: "number",
|
|
408
|
+
key: "batchSize",
|
|
409
|
+
label: "Batch size",
|
|
410
|
+
description: "Push early once this many entries are queued.",
|
|
411
|
+
min: 1,
|
|
412
|
+
max: 5e3,
|
|
413
|
+
step: 10,
|
|
414
|
+
default: 200
|
|
415
|
+
}),
|
|
416
|
+
this.field({
|
|
417
|
+
type: "number",
|
|
418
|
+
key: "timeoutMs",
|
|
419
|
+
label: "Request timeout",
|
|
420
|
+
min: 1e3,
|
|
421
|
+
max: 6e4,
|
|
422
|
+
step: 500,
|
|
423
|
+
default: 1e4,
|
|
424
|
+
unit: "ms"
|
|
425
|
+
})
|
|
426
|
+
]
|
|
427
|
+
}] });
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
//#endregion
|
|
431
|
+
export { LokiDestination, LokiLoggingAddon, LokiLoggingAddon as default, buildPushBody, formatLokiLine, parseExtraLabels, sanitizeLabelName, toUnixNano };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { ILogDestination, LogEntry, LogFilter, LogLevel } from '@camstack/types';
|
|
2
|
+
export interface LokiConfig {
|
|
3
|
+
readonly enabled: boolean;
|
|
4
|
+
/** Base URL of the Loki instance, e.g. `http://loki:3100`. */
|
|
5
|
+
readonly url: string;
|
|
6
|
+
readonly job: string;
|
|
7
|
+
readonly node: string;
|
|
8
|
+
readonly level: LogLevel;
|
|
9
|
+
/** Flush period in ms. */
|
|
10
|
+
readonly batchIntervalMs: number;
|
|
11
|
+
/** Flush early once this many entries are queued. */
|
|
12
|
+
readonly batchSize: number;
|
|
13
|
+
/** `key=value,key2=value2` static labels. Must stay low-cardinality. */
|
|
14
|
+
readonly extraLabels: string;
|
|
15
|
+
/** Grafana Cloud / multi-tenant Loki: sent as `X-Scope-OrgID`. */
|
|
16
|
+
readonly tenantId: string;
|
|
17
|
+
/** Optional HTTP basic auth. */
|
|
18
|
+
readonly username: string;
|
|
19
|
+
readonly password: string;
|
|
20
|
+
readonly timeoutMs: number;
|
|
21
|
+
}
|
|
22
|
+
export interface LokiStatus {
|
|
23
|
+
readonly enabled: boolean;
|
|
24
|
+
readonly configured: boolean;
|
|
25
|
+
readonly queued: number;
|
|
26
|
+
readonly pushed: number;
|
|
27
|
+
readonly dropped: number;
|
|
28
|
+
readonly failures: number;
|
|
29
|
+
readonly lastError: string | null;
|
|
30
|
+
readonly lastPushAtMs: number | null;
|
|
31
|
+
}
|
|
32
|
+
/** Injected in tests. Matches the shape of global `fetch` that is actually used. */
|
|
33
|
+
export type LokiFetch = (url: string, init: {
|
|
34
|
+
readonly method: string;
|
|
35
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
36
|
+
readonly body: string;
|
|
37
|
+
readonly signal?: AbortSignal;
|
|
38
|
+
}) => Promise<{
|
|
39
|
+
readonly ok: boolean;
|
|
40
|
+
readonly status: number;
|
|
41
|
+
text(): Promise<string>;
|
|
42
|
+
}>;
|
|
43
|
+
export interface LokiDestinationDeps {
|
|
44
|
+
readonly fetchFn?: LokiFetch;
|
|
45
|
+
readonly setIntervalFn?: typeof setInterval;
|
|
46
|
+
readonly clearIntervalFn?: typeof clearInterval;
|
|
47
|
+
}
|
|
48
|
+
export declare class LokiDestination implements ILogDestination {
|
|
49
|
+
private config;
|
|
50
|
+
private queue;
|
|
51
|
+
private timer;
|
|
52
|
+
private flushing;
|
|
53
|
+
private pushed;
|
|
54
|
+
private dropped;
|
|
55
|
+
private failures;
|
|
56
|
+
private lastError;
|
|
57
|
+
private lastPushAtMs;
|
|
58
|
+
private readonly fetchFn;
|
|
59
|
+
private readonly setIntervalFn;
|
|
60
|
+
private readonly clearIntervalFn;
|
|
61
|
+
constructor(deps?: LokiDestinationDeps);
|
|
62
|
+
initialize(config?: LokiConfig): Promise<void>;
|
|
63
|
+
/** Enabled AND pointed somewhere. An enabled destination with no URL is inert. */
|
|
64
|
+
private isActive;
|
|
65
|
+
write(entry: LogEntry): void;
|
|
66
|
+
/**
|
|
67
|
+
* Push whatever is queued. Never throws and never logs.
|
|
68
|
+
*
|
|
69
|
+
* On failure the batch is put BACK at the front of the queue so a transient
|
|
70
|
+
* Loki outage does not lose lines; `MAX_QUEUE` is what stops that from growing
|
|
71
|
+
* without bound.
|
|
72
|
+
*/
|
|
73
|
+
flush(): Promise<void>;
|
|
74
|
+
private push;
|
|
75
|
+
getStatus(): LokiStatus;
|
|
76
|
+
/**
|
|
77
|
+
* One-shot reachability probe for the Admin UI's "Test connection" button.
|
|
78
|
+
* Pushes a single synthetic entry, which validates the URL, the auth, the
|
|
79
|
+
* tenant header AND the label policy in one go — a bare GET would validate
|
|
80
|
+
* none of them.
|
|
81
|
+
*/
|
|
82
|
+
testConnection(config: LokiConfig): Promise<{
|
|
83
|
+
ok: boolean;
|
|
84
|
+
error: string | null;
|
|
85
|
+
}>;
|
|
86
|
+
query(_filter: LogFilter): Promise<readonly LogEntry[]>;
|
|
87
|
+
shutdown(): Promise<void>;
|
|
88
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { LogLevel, ProviderRegistration, BaseAddon } from '@camstack/types';
|
|
2
|
+
import { LokiDestination } from './loki-destination.js';
|
|
3
|
+
interface LokiAddonConfig {
|
|
4
|
+
readonly enabled: boolean;
|
|
5
|
+
readonly url: string;
|
|
6
|
+
readonly job: string;
|
|
7
|
+
readonly level: LogLevel;
|
|
8
|
+
readonly batchIntervalMs: number;
|
|
9
|
+
readonly batchSize: number;
|
|
10
|
+
readonly extraLabels: string;
|
|
11
|
+
readonly tenantId: string;
|
|
12
|
+
readonly username: string;
|
|
13
|
+
readonly password: string;
|
|
14
|
+
readonly timeoutMs: number;
|
|
15
|
+
}
|
|
16
|
+
export declare class LokiLoggingAddon extends BaseAddon<LokiAddonConfig> {
|
|
17
|
+
private destination;
|
|
18
|
+
constructor();
|
|
19
|
+
protected onInitialize(): Promise<ProviderRegistration[]>;
|
|
20
|
+
protected onShutdown(): Promise<void>;
|
|
21
|
+
/**
|
|
22
|
+
* `node` is a Loki LABEL, so it must be the node's real identity and must not
|
|
23
|
+
* be operator-editable — a typo would fork the stream and split a node's history
|
|
24
|
+
* in two. `kernel.localNodeId` is the Moleculer node id (`'hub'` on the hub, the
|
|
25
|
+
* agent's own name elsewhere), which is exactly the bounded value wanted here.
|
|
26
|
+
*
|
|
27
|
+
* NOT named `resolveConfig`: `BaseAddon` already has a protected
|
|
28
|
+
* `resolveConfig()` that reloads persisted settings, and shadowing it with a
|
|
29
|
+
* different signature is a type error — usefully, since silently overriding it
|
|
30
|
+
* would have broken this addon's settings reload.
|
|
31
|
+
*/
|
|
32
|
+
private destinationConfig;
|
|
33
|
+
getDestination(): LokiDestination;
|
|
34
|
+
protected globalSettingsSchema(): import('@camstack/types').ConfigUISchema;
|
|
35
|
+
}
|
|
36
|
+
export {};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { LogEntry } from '@camstack/types';
|
|
2
|
+
/** One `[unixNano, line]` pair, exactly as Loki's push API expects. */
|
|
3
|
+
export type LokiValue = readonly [string, string];
|
|
4
|
+
export interface LokiStream {
|
|
5
|
+
readonly stream: Readonly<Record<string, string>>;
|
|
6
|
+
readonly values: readonly LokiValue[];
|
|
7
|
+
}
|
|
8
|
+
export interface LokiPushBody {
|
|
9
|
+
readonly streams: readonly LokiStream[];
|
|
10
|
+
}
|
|
11
|
+
export interface LokiLabelInput {
|
|
12
|
+
/** Fixed `job` label. Bounded by configuration, never per-entry. */
|
|
13
|
+
readonly job: string;
|
|
14
|
+
/** This node's id. Bounded by cluster size. */
|
|
15
|
+
readonly node: string;
|
|
16
|
+
/** Extra static labels from configuration. Values must be low-cardinality. */
|
|
17
|
+
readonly extra?: Readonly<Record<string, string>>;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Loki rejects a label name that is not a valid Prometheus label
|
|
21
|
+
* (`[a-zA-Z_][a-zA-Z0-9_]*`), so operator-supplied keys are sanitised rather
|
|
22
|
+
* than passed through — a 400 from the push endpoint on every batch is a much
|
|
23
|
+
* worse outcome than a renamed label.
|
|
24
|
+
*/
|
|
25
|
+
export declare function sanitizeLabelName(raw: string): string;
|
|
26
|
+
/** Parse an operator's `key=value,key2=value2` label string. */
|
|
27
|
+
export declare function parseExtraLabels(raw: string): Readonly<Record<string, string>>;
|
|
28
|
+
/** `Date` → Loki's nanosecond-precision string timestamp. */
|
|
29
|
+
export declare function toUnixNano(timestamp: Date | string | number): string;
|
|
30
|
+
/**
|
|
31
|
+
* The line body: JSON carrying everything that must NOT be a label. Loki can
|
|
32
|
+
* parse this with `| json` at query time, so `scope` and `tags.deviceId` stay
|
|
33
|
+
* filterable without ever being indexed.
|
|
34
|
+
*/
|
|
35
|
+
export declare function formatLokiLine(entry: LogEntry): string;
|
|
36
|
+
/**
|
|
37
|
+
* Group a batch into one stream per distinct label set, and sort each stream's
|
|
38
|
+
* values by timestamp.
|
|
39
|
+
*
|
|
40
|
+
* The sort is not cosmetic. Loki historically rejected a stream whose entries
|
|
41
|
+
* were not in ascending time order, and even with out-of-order ingestion enabled
|
|
42
|
+
* it is bounded by a window. Entries reach a destination from several concurrent
|
|
43
|
+
* producers, so a batch is NOT naturally ordered — without this, some pushes
|
|
44
|
+
* fail with `entry out of order` and those log lines are simply gone.
|
|
45
|
+
*/
|
|
46
|
+
export declare function buildPushBody(entries: readonly LogEntry[], labels: LokiLabelInput): LokiPushBody | null;
|
|
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
|
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
5
|
const require_chunk = require("../../chunk-Cek0wNdY.js");
|
|
6
|
-
const require_dist = require("../../dist-
|
|
6
|
+
const require_dist = require("../../dist-yttC1aUI.js");
|
|
7
7
|
let node_fs = require("node:fs");
|
|
8
8
|
node_fs = require_chunk.__toESM(node_fs);
|
|
9
9
|
let node_child_process = require("node:child_process");
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { H as metricsProviderCapability, Mt as EventCategory, bt as createEvent, st as BaseAddon } from "../../dist-eOaraBhT.mjs";
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import { execFile, execFileSync } from "node:child_process";
|
|
4
4
|
import { promisify } from "node:util";
|
|
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
|
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
5
|
const require_chunk = require("../../chunk-Cek0wNdY.js");
|
|
6
|
-
const require_dist = require("../../dist-
|
|
6
|
+
const require_dist = require("../../dist-yttC1aUI.js");
|
|
7
7
|
let node_fs = require("node:fs");
|
|
8
8
|
node_fs = require_chunk.__toESM(node_fs);
|
|
9
9
|
let node_child_process = require("node:child_process");
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Mt as EventCategory, St as emitReadiness, T as enumerateInferenceDevices, X as scoreRuntimes, ot as errMsg, q as platformProbeCapability, st as BaseAddon } from "../../dist-eOaraBhT.mjs";
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import { execFile } from "node:child_process";
|
|
4
4
|
import { promisify } from "node:util";
|
|
@@ -3,7 +3,7 @@ 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-yttC1aUI.js");
|
|
7
7
|
//#region src/builtins/remote-access-orchestrator/enabled-providers-reconcile.ts
|
|
8
8
|
/**
|
|
9
9
|
* Reconcile the durable `enabledProviders` set against authoritative
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Mt as EventCategory, st as BaseAddon } from "../../dist-eOaraBhT.mjs";
|
|
2
2
|
//#region src/builtins/remote-access-orchestrator/enabled-providers-reconcile.ts
|
|
3
3
|
/**
|
|
4
4
|
* Reconcile the durable `enabledProviders` set against authoritative
|
|
@@ -3,7 +3,7 @@ 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-yttC1aUI.js");
|
|
7
7
|
let node_child_process = require("node:child_process");
|
|
8
8
|
//#region src/builtins/snapshot/snapshot-coalescing.ts
|
|
9
9
|
/**
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { $ as snapshotCapability, U as nodePin, dt as DeviceFeature, i as BatteryStatusSchema, nt as streamQualityLabel, ot as errMsg, pt as DeviceType, st as BaseAddon } from "../../dist-eOaraBhT.mjs";
|
|
2
2
|
import { execFile } from "node:child_process";
|
|
3
3
|
//#region src/builtins/snapshot/snapshot-coalescing.ts
|
|
4
4
|
/**
|
|
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
|
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
5
|
const require_chunk = require("../../chunk-Cek0wNdY.js");
|
|
6
|
-
const require_dist = require("../../dist-
|
|
6
|
+
const require_dist = require("../../dist-yttC1aUI.js");
|
|
7
7
|
let node_fs = require("node:fs");
|
|
8
8
|
node_fs = require_chunk.__toESM(node_fs);
|
|
9
9
|
let node_path = require("node:path");
|