@camstack/server 1.2.606 → 1.2.608
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/agent/agent-service.js +3 -1
- package/dist/api/core/cap-providers.js +38 -1
- package/dist/api/core/integration-secrets.js +22 -1
- package/dist/api/core/logs.router.js +219 -49
- package/dist/api/trpc/generated-cap-routers.js +51 -4
- package/dist/api/trpc/trpc.router.js +1 -1
- package/dist/core/logging/log-history.js +119 -0
- package/dist/core/logging/logging.service.js +38 -1
- package/package.json +14 -14
|
@@ -40,6 +40,7 @@ const fs = __importStar(require("node:fs"));
|
|
|
40
40
|
const os = __importStar(require("node:os"));
|
|
41
41
|
const path = __importStar(require("node:path"));
|
|
42
42
|
const system_1 = require("@camstack/system");
|
|
43
|
+
const types_1 = require("@camstack/types");
|
|
43
44
|
const moleculer_1 = require("moleculer");
|
|
44
45
|
const agent_deploy_swap_js_1 = require("./agent-deploy-swap.js");
|
|
45
46
|
const agent_undeploy_js_1 = require("./agent-undeploy.js");
|
|
@@ -68,7 +69,8 @@ function buildDecodeDetectionSlice() {
|
|
|
68
69
|
detectionDevices.push(inference.gpu.type);
|
|
69
70
|
if (inference.npu !== null)
|
|
70
71
|
detectionDevices.push(inference.npu.type);
|
|
71
|
-
|
|
72
|
+
// Only a Coral with a usable unit detects; a driverless one is reported by the probe.
|
|
73
|
+
if (inference.coral != null && (0, types_1.coralUsableUnits)(inference.coral).length > 0)
|
|
72
74
|
detectionDevices.push(inference.coral.type);
|
|
73
75
|
return {
|
|
74
76
|
decode: {
|
|
@@ -719,6 +719,28 @@ function buildNodesProvider(agentRegistry, moleculer, addonRegistry, getNodeRoot
|
|
|
719
719
|
function ownerSecrets(ar, owner) {
|
|
720
720
|
return typeof ar.secretsForOwner === 'function' ? ar.secretsForOwner(owner) : null;
|
|
721
721
|
}
|
|
722
|
+
/**
|
|
723
|
+
* The integration settings keys the addon's own form stores in the keychain
|
|
724
|
+
* (by schema type, D783) — so the admin read can withhold a plaintext under one
|
|
725
|
+
* of them. Empty when the schema cannot be read (logged): markers are still
|
|
726
|
+
* withheld, and a coerced marker is caught without a schema.
|
|
727
|
+
*/
|
|
728
|
+
async function integrationSecretKeys(ar, addonId, logger) {
|
|
729
|
+
try {
|
|
730
|
+
const provider = ar
|
|
731
|
+
.getCapabilityRegistry()
|
|
732
|
+
.getSingleton('addon-settings');
|
|
733
|
+
const schema = await provider?.getGlobalSettings({ addonId, nodeId: 'hub' });
|
|
734
|
+
return (0, types_1.collectKeychainStoredKeys)(schema ?? null);
|
|
735
|
+
}
|
|
736
|
+
catch (err) {
|
|
737
|
+
logger.warn('integration settings: addon schema unreadable — plaintext secrets not detectable', {
|
|
738
|
+
tags: { addonId },
|
|
739
|
+
meta: { phase: 'getSettings', error: (0, types_1.errMsg)(err) },
|
|
740
|
+
});
|
|
741
|
+
return new Set();
|
|
742
|
+
}
|
|
743
|
+
}
|
|
722
744
|
function requireIntegrationRegistry(ar) {
|
|
723
745
|
const reg = ar.getIntegrationRegistry();
|
|
724
746
|
if (!reg)
|
|
@@ -1143,7 +1165,22 @@ function buildIntegrationsProvider(ar, eb, loggingService, capabilityRegistry) {
|
|
|
1143
1165
|
const caller = Reflect.get(input, 'callerAddonId');
|
|
1144
1166
|
const forOwner = typeof caller === 'string' &&
|
|
1145
1167
|
(caller === integration.addonId || ar.runnerIdFor(integration.addonId) === caller);
|
|
1146
|
-
|
|
1168
|
+
// The schema is asked only for the admin read: the owner reads this at
|
|
1169
|
+
// its own boot, and asking it for its schema then would call back into it.
|
|
1170
|
+
const secretKeys = forOwner
|
|
1171
|
+
? undefined
|
|
1172
|
+
: await integrationSecretKeys(ar, integration.addonId, logger);
|
|
1173
|
+
return (0, integration_secrets_1.readIntegrationSettings)({ registry: reg, secrets: forOwner ? ownerSecrets(ar, integration.addonId) : null }, {
|
|
1174
|
+
integrationId: input.id,
|
|
1175
|
+
forOwner,
|
|
1176
|
+
...(secretKeys !== undefined ? { secretKeys } : {}),
|
|
1177
|
+
onSuspectSecret: (suspect) => logger.error(suspect.kind === 'coerced-marker'
|
|
1178
|
+
? 'integration secret is "[object Object]" — a keychain marker was coerced to text; the addon is sending it as the credential (repair: scripts/hub/repair-integration-secret-markers.ts, or re-type it)'
|
|
1179
|
+
: 'integration secret stored as PLAINTEXT under a keychain-declared key — withheld from the form; re-type it to move it into the keychain', {
|
|
1180
|
+
tags: { integrationId: input.id, addonId: integration.addonId },
|
|
1181
|
+
meta: { phase: 'getSettings', key: suspect.key, kind: suspect.kind, forOwner },
|
|
1182
|
+
}),
|
|
1183
|
+
});
|
|
1147
1184
|
},
|
|
1148
1185
|
setSettings: async (input) => {
|
|
1149
1186
|
const reg = requireIntegrationRegistry(ar);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.COERCED_MARKER_TEXT = void 0;
|
|
3
4
|
exports.writeIntegrationSettings = writeIntegrationSettings;
|
|
4
5
|
exports.readIntegrationSettings = readIntegrationSettings;
|
|
5
6
|
/**
|
|
@@ -45,16 +46,36 @@ async function writeIntegrationSettings(deps, input) {
|
|
|
45
46
|
if (orphaned.length > 0)
|
|
46
47
|
deps.onOrphaned?.(orphaned);
|
|
47
48
|
}
|
|
49
|
+
/** The text `String(...)` makes of an object — a keychain marker some copy coerced. */
|
|
50
|
+
exports.COERCED_MARKER_TEXT = '[object Object]';
|
|
51
|
+
function suspectKind(key, value, secretKeys) {
|
|
52
|
+
if (value === exports.COERCED_MARKER_TEXT)
|
|
53
|
+
return 'coerced-marker';
|
|
54
|
+
if (secretKeys?.has(key) === true && typeof value === 'string' && value.length > 0) {
|
|
55
|
+
return 'plaintext';
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
48
59
|
async function readIntegrationSettings(deps, input) {
|
|
49
60
|
const stored = await deps.registry.getIntegrationSettings(input.integrationId);
|
|
61
|
+
const suspects = new Set();
|
|
62
|
+
for (const [key, value] of Object.entries(stored)) {
|
|
63
|
+
const kind = suspectKind(key, value, input.secretKeys);
|
|
64
|
+
if (kind === null)
|
|
65
|
+
continue;
|
|
66
|
+
suspects.add(key);
|
|
67
|
+
input.onSuspectSecret?.({ key, kind });
|
|
68
|
+
}
|
|
50
69
|
if (input.forOwner) {
|
|
51
70
|
if (deps.secrets === null)
|
|
52
71
|
return stored;
|
|
53
72
|
return (await (0, types_1.resolveSecretMarkers)(stored, deps.secrets)).value;
|
|
54
73
|
}
|
|
74
|
+
// Anyone but the owner — the admin form — never receives a secret's text:
|
|
75
|
+
// a marker becomes its "set" view, and so does a suspect plaintext.
|
|
55
76
|
const out = {};
|
|
56
77
|
for (const [key, value] of Object.entries(stored)) {
|
|
57
|
-
out[key] = (0, types_1.isSecretMarker)(value) ? (0, types_1.toSecretFieldView)(value) : value;
|
|
78
|
+
out[key] = (0, types_1.isSecretMarker)(value) || suspects.has(key) ? (0, types_1.toSecretFieldView)(value) : value;
|
|
58
79
|
}
|
|
59
80
|
return out;
|
|
60
81
|
}
|
|
@@ -11,7 +11,9 @@ exports.createLogsRouter = createLogsRouter;
|
|
|
11
11
|
*/
|
|
12
12
|
const zod_1 = require("zod");
|
|
13
13
|
const trpc_middleware_js_1 = require("../trpc/trpc.middleware.js");
|
|
14
|
-
const
|
|
14
|
+
const node_crypto_1 = require("node:crypto");
|
|
15
|
+
const system_1 = require("@camstack/system");
|
|
16
|
+
const log_history_js_1 = require("../../core/logging/log-history.js");
|
|
15
17
|
const LogLevelSchema = zod_1.z.enum(['debug', 'info', 'warn', 'error']);
|
|
16
18
|
const LogTagsSchema = zod_1.z.object({
|
|
17
19
|
agentId: zod_1.z.string().optional(),
|
|
@@ -39,6 +41,8 @@ const LogTagsSchema = zod_1.z.object({
|
|
|
39
41
|
});
|
|
40
42
|
/** Wire shape of a log entry — timestamp is serialised to ISO string by tRPC. */
|
|
41
43
|
const LogEntrySchema = zod_1.z.object({
|
|
44
|
+
/** `<bootId>.<seq>` — identical live and on disk; the client's dedupe key (D898). */
|
|
45
|
+
id: zod_1.z.string().optional(),
|
|
42
46
|
timestamp: zod_1.z.string(),
|
|
43
47
|
level: zod_1.z.string(),
|
|
44
48
|
scope: zod_1.z.string().optional(),
|
|
@@ -53,63 +57,214 @@ const LogEntrySchema = zod_1.z.object({
|
|
|
53
57
|
meta: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()).optional(),
|
|
54
58
|
});
|
|
55
59
|
const LogEntryArraySchema = zod_1.z.array(LogEntrySchema);
|
|
56
|
-
|
|
60
|
+
/** Wire shape of a history page (D898). */
|
|
61
|
+
const LogPageSchema = zod_1.z.object({
|
|
62
|
+
entries: LogEntryArraySchema,
|
|
63
|
+
/** Pass back as `before` to load the next OLDER page; null = nothing older. */
|
|
64
|
+
nextBefore: zod_1.z.string().nullable(),
|
|
65
|
+
nextAfter: zod_1.z.string().nullable(),
|
|
66
|
+
/** Bytes the source scanned for this slice — the "N MB scanned" indicator. */
|
|
67
|
+
scannedBytes: zod_1.z.number().int().nonnegative(),
|
|
68
|
+
/**
|
|
69
|
+
* True when the source has nothing older. False with fewer than `limit`
|
|
70
|
+
* entries = the slice's time budget ran out first; ask again with
|
|
71
|
+
* `nextBefore` to keep searching.
|
|
72
|
+
*/
|
|
73
|
+
done: zod_1.z.boolean(),
|
|
74
|
+
cancelled: zod_1.z.boolean().optional(),
|
|
75
|
+
/** The source that answered — `ring` when the requested one cannot read. */
|
|
76
|
+
source: zod_1.z.string(),
|
|
77
|
+
});
|
|
78
|
+
const LogSourceSchema = zod_1.z.object({
|
|
79
|
+
addonId: zod_1.z.string(),
|
|
80
|
+
readable: zod_1.z.boolean(),
|
|
81
|
+
reason: zod_1.z.string().nullable(),
|
|
82
|
+
isDefault: zod_1.z.boolean(),
|
|
83
|
+
retentionDays: zod_1.z.number().nullable(),
|
|
84
|
+
filters: zod_1.z.object({
|
|
85
|
+
deviceId: zod_1.z.boolean(),
|
|
86
|
+
addonId: zod_1.z.boolean(),
|
|
87
|
+
nodeId: zod_1.z.boolean(),
|
|
88
|
+
tags: zod_1.z.boolean(),
|
|
89
|
+
level: zod_1.z.boolean(),
|
|
90
|
+
text: zod_1.z.boolean(),
|
|
91
|
+
timeRange: zod_1.z.boolean(),
|
|
92
|
+
}),
|
|
93
|
+
});
|
|
94
|
+
/** `tags` (the LogStream scope) → the read contract's indexed fields + the rest. */
|
|
95
|
+
function readFiltersFromTags(tags) {
|
|
96
|
+
if (tags === undefined)
|
|
97
|
+
return {};
|
|
98
|
+
const rest = {};
|
|
99
|
+
let deviceId;
|
|
100
|
+
let addonId;
|
|
101
|
+
let nodeId;
|
|
102
|
+
for (const [key, raw] of Object.entries(tags)) {
|
|
103
|
+
if (raw === undefined)
|
|
104
|
+
continue;
|
|
105
|
+
if (key === 'deviceId') {
|
|
106
|
+
const n = typeof raw === 'number' ? raw : Number(raw);
|
|
107
|
+
if (Number.isInteger(n))
|
|
108
|
+
deviceId = n;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (key === 'addonId' && typeof raw === 'string') {
|
|
112
|
+
addonId = raw;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (key === 'agentId' && typeof raw === 'string') {
|
|
116
|
+
nodeId = raw;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (key === 'containerDeviceId') {
|
|
120
|
+
const n = typeof raw === 'number' ? raw : Number(raw);
|
|
121
|
+
rest[key] = Number.isInteger(n) ? n : String(raw);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
rest[key] = raw;
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
...(deviceId !== undefined ? { deviceId } : {}),
|
|
128
|
+
...(addonId !== undefined ? { fromAddonId: addonId } : {}),
|
|
129
|
+
...(nodeId !== undefined ? { fromNodeId: nodeId } : {}),
|
|
130
|
+
...(Object.keys(rest).length > 0 ? { tags: rest } : {}),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
function toWire(entry) {
|
|
134
|
+
return {
|
|
135
|
+
...(entry.id !== undefined ? { id: entry.id } : {}),
|
|
136
|
+
timestamp: new Date(entry.timestamp).toISOString(),
|
|
137
|
+
level: entry.level,
|
|
138
|
+
...(entry.scope !== undefined ? { scope: entry.scope } : {}),
|
|
139
|
+
message: entry.message,
|
|
140
|
+
...(entry.tags !== undefined
|
|
141
|
+
? {
|
|
142
|
+
tags: Object.fromEntries(Object.entries(entry.tags).map(([k, v]) => [k, String(v)])),
|
|
143
|
+
}
|
|
144
|
+
: {}),
|
|
145
|
+
...(entry.meta !== undefined ? { meta: entry.meta } : {}),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
/** Hub-local providers only: an agent's own files hold lines the hub already has. */
|
|
149
|
+
function hubLocalDestinations(registry) {
|
|
150
|
+
if (registry === null)
|
|
151
|
+
return [];
|
|
152
|
+
return registry
|
|
153
|
+
.getCollectionEntries('log-destination')
|
|
154
|
+
.filter(([addonId]) => !addonId.includes('@'));
|
|
155
|
+
}
|
|
156
|
+
const NOT_READABLE = {
|
|
157
|
+
readable: false,
|
|
158
|
+
reason: 'This destination only ships lines',
|
|
159
|
+
retentionDays: null,
|
|
160
|
+
filters: {
|
|
161
|
+
deviceId: false,
|
|
162
|
+
addonId: false,
|
|
163
|
+
nodeId: false,
|
|
164
|
+
tags: false,
|
|
165
|
+
level: false,
|
|
166
|
+
text: false,
|
|
167
|
+
timeRange: false,
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
function createLogsRouter(logging, capabilityRegistry = null) {
|
|
57
171
|
return (0, trpc_middleware_js_1.trpcRouter)({
|
|
172
|
+
/**
|
|
173
|
+
* One page of history (D898): newest first, from the source the client
|
|
174
|
+
* picked (default the hub's rotated files), every filter applied by the
|
|
175
|
+
* source BEFORE the limit. Page older with `before: nextBefore`. A slice
|
|
176
|
+
* is time-bounded — `done: false` with a short page means "still
|
|
177
|
+
* searching", and aborting the request cancels the slice in flight.
|
|
178
|
+
*/
|
|
58
179
|
query: trpc_middleware_js_1.adminProcedure
|
|
59
180
|
.input(zod_1.z.object({
|
|
181
|
+
/** A read-capable log-destination addonId, or `ring`. Default `winston-logging`. */
|
|
182
|
+
source: zod_1.z.string().min(1).max(200).optional(),
|
|
60
183
|
level: LogLevelSchema.optional(),
|
|
61
184
|
since: zod_1.z.number().optional(),
|
|
62
185
|
until: zod_1.z.number().optional(),
|
|
63
|
-
limit: zod_1.z.number().optional(),
|
|
186
|
+
limit: zod_1.z.number().int().min(1).max(1000).optional(),
|
|
64
187
|
tags: LogTagsSchema.optional(),
|
|
188
|
+
/** Case-insensitive substring over message, scope, tags and meta. */
|
|
189
|
+
text: zod_1.z.string().max(200).optional(),
|
|
190
|
+
before: zod_1.z.string().min(1).max(1024).optional(),
|
|
191
|
+
after: zod_1.z.string().min(1).max(1024).optional(),
|
|
192
|
+
budgetMs: zod_1.z.number().int().min(50).max(10_000).optional(),
|
|
65
193
|
}))
|
|
66
|
-
.output(
|
|
67
|
-
.query(({ input,
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
194
|
+
.output(LogPageSchema)
|
|
195
|
+
.query(async ({ input, signal }) => {
|
|
196
|
+
const sourceId = input.source ?? log_history_js_1.DEFAULT_LOG_SOURCE;
|
|
197
|
+
const provider = sourceId === log_history_js_1.RING_LOG_SOURCE
|
|
198
|
+
? null
|
|
199
|
+
: (hubLocalDestinations(capabilityRegistry).find(([id]) => id === sourceId)?.[1] ??
|
|
200
|
+
null);
|
|
201
|
+
const requestId = (0, node_crypto_1.randomUUID)();
|
|
202
|
+
const text = input.text?.trim();
|
|
203
|
+
const query = {
|
|
204
|
+
requestId,
|
|
205
|
+
...readFiltersFromTags(input.tags),
|
|
206
|
+
...(input.level !== undefined ? { level: input.level } : {}),
|
|
207
|
+
...(text !== undefined && text.length > 0 ? { text } : {}),
|
|
208
|
+
...(input.since !== undefined ? { since: input.since } : {}),
|
|
209
|
+
...(input.until !== undefined ? { until: input.until } : {}),
|
|
210
|
+
...(input.before !== undefined ? { before: input.before } : {}),
|
|
211
|
+
...(input.after !== undefined ? { after: input.after } : {}),
|
|
212
|
+
...(input.budgetMs !== undefined ? { budgetMs: input.budgetMs } : {}),
|
|
213
|
+
limit: input.limit ?? log_history_js_1.DEFAULT_PAGE_LIMIT,
|
|
214
|
+
};
|
|
215
|
+
const onAbort = () => {
|
|
216
|
+
void provider?.cancelRead?.({ requestId }).catch(() => undefined);
|
|
217
|
+
};
|
|
218
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
219
|
+
try {
|
|
220
|
+
const page = await (0, log_history_js_1.readLogHistory)({
|
|
221
|
+
query,
|
|
222
|
+
source: sourceId,
|
|
223
|
+
tail: logging.query({}),
|
|
224
|
+
provider,
|
|
225
|
+
});
|
|
226
|
+
return {
|
|
227
|
+
entries: page.entries.map(toWire),
|
|
228
|
+
nextBefore: page.nextBefore,
|
|
229
|
+
nextAfter: page.nextAfter,
|
|
230
|
+
scannedBytes: page.scannedBytes,
|
|
231
|
+
done: page.done,
|
|
232
|
+
...(page.cancelled !== undefined ? { cancelled: page.cancelled } : {}),
|
|
233
|
+
source: page.source,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
finally {
|
|
237
|
+
signal?.removeEventListener('abort', onAbort);
|
|
238
|
+
}
|
|
239
|
+
}),
|
|
240
|
+
/**
|
|
241
|
+
* The history sources the Logs page can offer: every hub-local
|
|
242
|
+
* log-destination with what it can read. A destination without
|
|
243
|
+
* `describeRead` is listed as not readable, never omitted — "ships only"
|
|
244
|
+
* is an answer, "not installed" is a different one.
|
|
245
|
+
*/
|
|
246
|
+
sources: trpc_middleware_js_1.adminProcedure.output(zod_1.z.array(LogSourceSchema)).query(async () => {
|
|
247
|
+
const out = [];
|
|
248
|
+
for (const [addonId, dest] of hubLocalDestinations(capabilityRegistry)) {
|
|
249
|
+
const isDefault = addonId === log_history_js_1.DEFAULT_LOG_SOURCE;
|
|
250
|
+
if (dest.describeRead === undefined) {
|
|
251
|
+
out.push({ addonId, isDefault, ...NOT_READABLE });
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
try {
|
|
255
|
+
const caps = await dest.describeRead();
|
|
256
|
+
out.push({ addonId, isDefault, ...caps });
|
|
93
257
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
// the same normalisation applied in `subscribe` below.
|
|
105
|
-
tags: e.tags
|
|
106
|
-
? Object.fromEntries(Object.entries(e.tags).map(([k, v]) => [
|
|
107
|
-
k,
|
|
108
|
-
v === undefined ? undefined : String(v),
|
|
109
|
-
]))
|
|
110
|
-
: undefined,
|
|
111
|
-
...(e.meta !== undefined ? { meta: e.meta } : {}),
|
|
112
|
-
}));
|
|
258
|
+
catch (error) {
|
|
259
|
+
out.push({
|
|
260
|
+
addonId,
|
|
261
|
+
isDefault,
|
|
262
|
+
...NOT_READABLE,
|
|
263
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return out;
|
|
113
268
|
}),
|
|
114
269
|
/**
|
|
115
270
|
* Drop matching entries from the in-memory ring buffer. Powers
|
|
@@ -149,13 +304,27 @@ function createLogsRouter(logging) {
|
|
|
149
304
|
.input(zod_1.z.object({
|
|
150
305
|
level: LogLevelSchema.optional(),
|
|
151
306
|
tags: LogTagsSchema.optional(),
|
|
307
|
+
/** Same text filter as `query`, applied before the push (D898). */
|
|
308
|
+
text: zod_1.z.string().max(200).optional(),
|
|
152
309
|
}))
|
|
153
310
|
.subscription(({ input }) => {
|
|
311
|
+
const needle = input.text?.trim().toLowerCase() ?? '';
|
|
154
312
|
return (0, trpc_middleware_js_1.iterableSubscription)((push) => {
|
|
155
313
|
return logging.subscribe({
|
|
156
314
|
level: input.level,
|
|
157
315
|
tags: input.tags,
|
|
158
316
|
}, (entry) => {
|
|
317
|
+
if (needle.length > 0 &&
|
|
318
|
+
!(0, system_1.lineContainsText)({
|
|
319
|
+
timestamp: 0,
|
|
320
|
+
level: entry.level,
|
|
321
|
+
message: entry.message,
|
|
322
|
+
scope: entry.scope,
|
|
323
|
+
tags: entry.tags,
|
|
324
|
+
meta: entry.meta,
|
|
325
|
+
}, needle)) {
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
159
328
|
const stringifiedTags = entry.tags
|
|
160
329
|
? Object.fromEntries(Object.entries(entry.tags).map(([k, v]) => [
|
|
161
330
|
k,
|
|
@@ -163,6 +332,7 @@ function createLogsRouter(logging) {
|
|
|
163
332
|
]))
|
|
164
333
|
: undefined;
|
|
165
334
|
push({
|
|
335
|
+
...(entry.id !== undefined ? { id: entry.id } : {}),
|
|
166
336
|
timestamp: entry.timestamp instanceof Date
|
|
167
337
|
? entry.timestamp.toISOString()
|
|
168
338
|
: String(entry.timestamp),
|
|
@@ -2959,6 +2959,16 @@ function createCapRouter_deviceHistory(getProvider, createRemoteProxy) {
|
|
|
2959
2959
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
|
|
2960
2960
|
return p.queryNode(methodInput);
|
|
2961
2961
|
}),
|
|
2962
|
+
queryMany: trpc_middleware_js_1.adminProcedure
|
|
2963
|
+
.input(types_50.deviceHistoryCapability.methods.queryMany.input.loose())
|
|
2964
|
+
.output(types_50.deviceHistoryCapability.methods.queryMany.output)
|
|
2965
|
+
.query(async ({ input, ctx }) => {
|
|
2966
|
+
const parsedInput = ctx.req !== undefined ? (0, types_3.withoutCallerStamp)(input) : input;
|
|
2967
|
+
const { nodeId, ...methodInput } = parsedInput;
|
|
2968
|
+
const p = resolveProvider('device-history', nodeId, () => getProvider(ctx), createRemoteProxy);
|
|
2969
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
|
|
2970
|
+
return p.queryMany(methodInput);
|
|
2971
|
+
}),
|
|
2962
2972
|
declareSeries: trpc_middleware_js_1.adminProcedure
|
|
2963
2973
|
.input(types_50.deviceHistoryCapability.methods.declareSeries.input.loose())
|
|
2964
2974
|
.output(types_50.deviceHistoryCapability.methods.declareSeries.output)
|
|
@@ -5760,14 +5770,51 @@ function createCapRouter_logDestination(getProvider, createRemoteProxy) {
|
|
|
5760
5770
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
|
|
5761
5771
|
return p.write(methodInput);
|
|
5762
5772
|
}),
|
|
5763
|
-
|
|
5764
|
-
.input(
|
|
5765
|
-
.output(types_75.logDestinationCapability.methods.
|
|
5773
|
+
describeRead: trpc_middleware_js_1.adminProcedure
|
|
5774
|
+
.input(zod_1.z.object({ nodeId: zod_1.z.string().optional(), addonId: zod_1.z.string().optional() }).optional())
|
|
5775
|
+
.output(types_75.logDestinationCapability.methods.describeRead.output)
|
|
5766
5776
|
.query(async ({ input, ctx }) => {
|
|
5777
|
+
const p = resolveProvider('log-destination', input?.nodeId, () => getProvider(ctx, input?.addonId), createRemoteProxy);
|
|
5778
|
+
const fn = p.describeRead;
|
|
5779
|
+
if (typeof fn !== 'function') {
|
|
5780
|
+
throw new server_1.TRPCError({
|
|
5781
|
+
code: 'NOT_IMPLEMENTED',
|
|
5782
|
+
message: 'Capability "log-destination" provider does not implement "describeRead"',
|
|
5783
|
+
});
|
|
5784
|
+
}
|
|
5785
|
+
return fn.call(p);
|
|
5786
|
+
}),
|
|
5787
|
+
read: trpc_middleware_js_1.adminProcedure
|
|
5788
|
+
.input(types_75.logDestinationCapability.methods.read.input.loose())
|
|
5789
|
+
.output(types_75.logDestinationCapability.methods.read.output)
|
|
5790
|
+
.query(async ({ input, ctx }) => {
|
|
5791
|
+
const { nodeId, addonId, ...methodInput } = input;
|
|
5792
|
+
const p = resolveProvider('log-destination', nodeId, () => getProvider(ctx, addonId), createRemoteProxy);
|
|
5793
|
+
const fn = p.read;
|
|
5794
|
+
if (typeof fn !== 'function') {
|
|
5795
|
+
throw new server_1.TRPCError({
|
|
5796
|
+
code: 'NOT_IMPLEMENTED',
|
|
5797
|
+
message: 'Capability "log-destination" provider does not implement "read"',
|
|
5798
|
+
});
|
|
5799
|
+
}
|
|
5800
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
|
|
5801
|
+
return fn.call(p, methodInput);
|
|
5802
|
+
}),
|
|
5803
|
+
cancelRead: trpc_middleware_js_1.adminProcedure
|
|
5804
|
+
.input(types_75.logDestinationCapability.methods.cancelRead.input.loose())
|
|
5805
|
+
.output(types_75.logDestinationCapability.methods.cancelRead.output)
|
|
5806
|
+
.mutation(async ({ input, ctx }) => {
|
|
5767
5807
|
const { nodeId, addonId, ...methodInput } = input;
|
|
5768
5808
|
const p = resolveProvider('log-destination', nodeId, () => getProvider(ctx, addonId), createRemoteProxy);
|
|
5809
|
+
const fn = p.cancelRead;
|
|
5810
|
+
if (typeof fn !== 'function') {
|
|
5811
|
+
throw new server_1.TRPCError({
|
|
5812
|
+
code: 'NOT_IMPLEMENTED',
|
|
5813
|
+
message: 'Capability "log-destination" provider does not implement "cancelRead"',
|
|
5814
|
+
});
|
|
5815
|
+
}
|
|
5769
5816
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
|
|
5770
|
-
return
|
|
5817
|
+
return fn.call(p, methodInput);
|
|
5771
5818
|
}),
|
|
5772
5819
|
});
|
|
5773
5820
|
}
|
|
@@ -331,7 +331,7 @@ function buildCapabilityRouters(services) {
|
|
|
331
331
|
repl: (0, repl_router_js_1.createReplRouter)(services.replEngine),
|
|
332
332
|
systemEvents: (0, system_events_router_js_1.createSystemEventsRouter)(services.eventBus),
|
|
333
333
|
capabilities: (0, capabilities_router_js_1.createCapabilitiesRouter)(services.capabilityRegistry, services.configService),
|
|
334
|
-
logs: (0, logs_router_js_1.createLogsRouter)(services.loggingService),
|
|
334
|
+
logs: (0, logs_router_js_1.createLogsRouter)(services.loggingService, services.capabilityRegistry),
|
|
335
335
|
live: (0, live_events_router_js_1.createLiveEventsRouter)(services.eventBus, services.addonRegistry),
|
|
336
336
|
// stream-probe — fixed core API (ffprobe wrapper), not a cap.
|
|
337
337
|
streamProbe: (0, stream_probe_router_js_1.createStreamProbeRouter)(services.streamProbe),
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DEFAULT_PAGE_LIMIT = exports.RING_LOG_SOURCE = exports.DEFAULT_LOG_SOURCE = void 0;
|
|
4
|
+
exports.readLogHistory = readLogHistory;
|
|
5
|
+
/**
|
|
6
|
+
* `logs.query` — one page of history from the source the operator picked,
|
|
7
|
+
* merged with the hub's in-memory tail (D898).
|
|
8
|
+
*
|
|
9
|
+
* The tail is short (a couple of thousand lines) and exists for two things:
|
|
10
|
+
* the boot replay into destinations that register late, and the lines a file
|
|
11
|
+
* destination has accepted but not yet flushed. Everything older comes from
|
|
12
|
+
* the source. Both carry the same `id`, so the merge dedupes exactly.
|
|
13
|
+
*/
|
|
14
|
+
const system_1 = require("@camstack/system");
|
|
15
|
+
/** The default history source: the hub's rotated files. */
|
|
16
|
+
exports.DEFAULT_LOG_SOURCE = 'winston-logging';
|
|
17
|
+
/** The in-memory tail, addressed as a source when no destination can read. */
|
|
18
|
+
exports.RING_LOG_SOURCE = 'ring';
|
|
19
|
+
exports.DEFAULT_PAGE_LIMIT = 200;
|
|
20
|
+
function toReadable(entry) {
|
|
21
|
+
return {
|
|
22
|
+
timestamp: entryTime(entry),
|
|
23
|
+
level: entry.level,
|
|
24
|
+
message: entry.message,
|
|
25
|
+
scope: entry.scope,
|
|
26
|
+
tags: entry.tags,
|
|
27
|
+
meta: entry.meta,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function entryTime(entry) {
|
|
31
|
+
const ts = entry.timestamp;
|
|
32
|
+
if (ts instanceof Date)
|
|
33
|
+
return ts.getTime();
|
|
34
|
+
if (typeof ts === 'number')
|
|
35
|
+
return ts;
|
|
36
|
+
if (typeof ts === 'string')
|
|
37
|
+
return Date.parse(ts);
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
40
|
+
function toReadEntry(entry) {
|
|
41
|
+
const tags = {};
|
|
42
|
+
for (const [key, value] of Object.entries(entry.tags ?? {})) {
|
|
43
|
+
if (typeof value === 'string' || typeof value === 'number')
|
|
44
|
+
tags[key] = value;
|
|
45
|
+
}
|
|
46
|
+
const level = entry.level;
|
|
47
|
+
return {
|
|
48
|
+
...(entry.id !== undefined ? { id: entry.id } : {}),
|
|
49
|
+
timestamp: entryTime(entry),
|
|
50
|
+
level,
|
|
51
|
+
...(entry.scope !== undefined ? { scope: entry.scope } : {}),
|
|
52
|
+
message: entry.message,
|
|
53
|
+
...(Object.keys(tags).length > 0 ? { tags } : {}),
|
|
54
|
+
...(entry.meta !== undefined ? { meta: entry.meta } : {}),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
function tailMatching(tail, filter, limit) {
|
|
58
|
+
const out = [];
|
|
59
|
+
for (const entry of tail) {
|
|
60
|
+
if (!(0, system_1.lineMatches)(toReadable(entry), filter))
|
|
61
|
+
continue;
|
|
62
|
+
out.push(toReadEntry(entry));
|
|
63
|
+
if (out.length >= limit)
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
function entryKey(entry) {
|
|
69
|
+
return (entry.id ?? `${String(entry.timestamp)}|${entry.level}|${entry.scope ?? ''}|${entry.message}`);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The first page (no cursor) merges the tail; a cursor page is the source's
|
|
73
|
+
* alone — the tail only ever holds the NEWEST lines, which the first page
|
|
74
|
+
* already showed.
|
|
75
|
+
*/
|
|
76
|
+
async function readLogHistory(args) {
|
|
77
|
+
const { query, tail, provider } = args;
|
|
78
|
+
const limit = query.limit ?? exports.DEFAULT_PAGE_LIMIT;
|
|
79
|
+
const filter = (0, system_1.compileLogFilter)(query);
|
|
80
|
+
const isFirstPage = query.before === undefined && query.after === undefined;
|
|
81
|
+
if (provider === null || provider.read === undefined) {
|
|
82
|
+
if (!isFirstPage) {
|
|
83
|
+
return {
|
|
84
|
+
entries: [],
|
|
85
|
+
nextBefore: null,
|
|
86
|
+
nextAfter: null,
|
|
87
|
+
scannedBytes: 0,
|
|
88
|
+
done: true,
|
|
89
|
+
source: exports.RING_LOG_SOURCE,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
entries: tailMatching(tail, filter, limit),
|
|
94
|
+
nextBefore: null,
|
|
95
|
+
nextAfter: null,
|
|
96
|
+
scannedBytes: 0,
|
|
97
|
+
done: true,
|
|
98
|
+
source: exports.RING_LOG_SOURCE,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const page = await provider.read({ ...query, limit });
|
|
102
|
+
if (!isFirstPage)
|
|
103
|
+
return { ...page, source: args.source };
|
|
104
|
+
// Tail lines newer than the oldest line the source returned (or all of them
|
|
105
|
+
// when the source returned nothing yet): the unflushed lines, and the levels
|
|
106
|
+
// the file destination does not keep.
|
|
107
|
+
const oldest = page.entries.at(-1)?.timestamp ?? Number.NEGATIVE_INFINITY;
|
|
108
|
+
const byKey = new Map();
|
|
109
|
+
for (const entry of page.entries)
|
|
110
|
+
byKey.set(entryKey(entry), entry);
|
|
111
|
+
for (const entry of tailMatching(tail, filter, limit)) {
|
|
112
|
+
if (entry.timestamp < oldest)
|
|
113
|
+
continue;
|
|
114
|
+
if (!byKey.has(entryKey(entry)))
|
|
115
|
+
byKey.set(entryKey(entry), entry);
|
|
116
|
+
}
|
|
117
|
+
const entries = [...byKey.values()].toSorted((a, b) => b.timestamp - a.timestamp);
|
|
118
|
+
return { ...page, entries, source: args.source };
|
|
119
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.LoggingService = void 0;
|
|
3
|
+
exports.LoggingService = exports.HUB_LOG_TAIL_ENTRIES = exports.HISTORY_DESTINATION_ADDON_ID = void 0;
|
|
4
4
|
const system_1 = require("@camstack/system");
|
|
5
5
|
const types_1 = require("@camstack/types");
|
|
6
6
|
/** Baseline tags every hub-local logger gets, so console output never
|
|
@@ -13,6 +13,19 @@ const types_1 = require("@camstack/types");
|
|
|
13
13
|
* forked workers via `$hub.log` carry their own `tags.pid` which
|
|
14
14
|
* wins over this baseline (right-biased merge in `writeFromWorker`). */
|
|
15
15
|
const HUB_BASELINE_TAGS = { agentId: 'hub', nodeId: 'hub', pid: process.pid };
|
|
16
|
+
/**
|
|
17
|
+
* The destination whose files ARE the hub's log history (D898). Once it has
|
|
18
|
+
* registered — and received the boot replay — the in-memory buffer only has
|
|
19
|
+
* to hold a short tail: the instant first page and the lines the file stream
|
|
20
|
+
* has accepted but not yet flushed.
|
|
21
|
+
*/
|
|
22
|
+
exports.HISTORY_DESTINATION_ADDON_ID = 'winston-logging';
|
|
23
|
+
/**
|
|
24
|
+
* The tail, in entries, across every addon. ~2k lines is tens of seconds of a
|
|
25
|
+
* busy hub — enough for the first page and the unflushed window, ~0.6 MB.
|
|
26
|
+
* `eventBus.logTailSize` overrides it.
|
|
27
|
+
*/
|
|
28
|
+
exports.HUB_LOG_TAIL_ENTRIES = 2000;
|
|
16
29
|
class LoggingService extends system_1.LogManager {
|
|
17
30
|
/**
|
|
18
31
|
* Device-name cache consulted by the formatter when a log line
|
|
@@ -21,6 +34,9 @@ class LoggingService extends system_1.LogManager {
|
|
|
21
34
|
* updated / removed events. Missing entries fall back to `#<id>`.
|
|
22
35
|
*/
|
|
23
36
|
deviceNames = new Map();
|
|
37
|
+
/** Tail size applied once the history destination registers. */
|
|
38
|
+
tailEntries;
|
|
39
|
+
shrunkToTail = false;
|
|
24
40
|
constructor(configService) {
|
|
25
41
|
// The log buffer is now partitioned per addonId (see PartitionedLogBuffer):
|
|
26
42
|
// this value caps EACH addon's bucket, not the total. A chatty addon evicts
|
|
@@ -39,12 +55,33 @@ class LoggingService extends system_1.LogManager {
|
|
|
39
55
|
// Only entries at or below this level are ever discarded to meet the total.
|
|
40
56
|
const pruneLevel = configService.get('eventBus.logBufferPruneLevel') ?? 'debug';
|
|
41
57
|
super(perAddonCapacity, { maxTotalEntries, pruneLevel });
|
|
58
|
+
this.tailEntries = configService.get('eventBus.logTailSize') ?? exports.HUB_LOG_TAIL_ENTRIES;
|
|
42
59
|
// Enriches every emitted LogEntry with `tags.deviceName` before
|
|
43
60
|
// destinations / subscribers see it — works across bundled copies
|
|
44
61
|
// of `@camstack/system` (addon packages) because the mutation
|
|
45
62
|
// happens upstream of their formatters.
|
|
46
63
|
this.setDeviceNameLookup((id) => this.deviceNames.get(id) ?? null);
|
|
47
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* Adds the destination, and — the first time the HISTORY destination
|
|
67
|
+
* arrives — shrinks the buffer to the tail AFTER its boot replay, so the
|
|
68
|
+
* files start with the first boot line however long the boot was.
|
|
69
|
+
*
|
|
70
|
+
* The tail prunes `info` and below first (the lines are on disk); a live
|
|
71
|
+
* addon's `warn`/`error` still outlive them, and whole idle buckets go
|
|
72
|
+
* last, exactly as the buffer's own policy says.
|
|
73
|
+
*/
|
|
74
|
+
addDestination(dest, opts = {}) {
|
|
75
|
+
super.addDestination(dest, opts);
|
|
76
|
+
if (!this.shrunkToTail && opts.addonId === exports.HISTORY_DESTINATION_ADDON_ID) {
|
|
77
|
+
this.shrunkToTail = true;
|
|
78
|
+
this.reconfigureBuffer({
|
|
79
|
+
perAddonCapacity: this.tailEntries,
|
|
80
|
+
maxTotalEntries: this.tailEntries,
|
|
81
|
+
pruneLevel: 'info',
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
48
85
|
/** Bulk-refresh from device-manager snapshot. Replaces every entry. */
|
|
49
86
|
setDeviceNames(entries) {
|
|
50
87
|
this.deviceNames.clear();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/server",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.608",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"private": false,
|
|
6
6
|
"files": [
|
|
@@ -38,19 +38,19 @@
|
|
|
38
38
|
]
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@camstack/addon-admin-ui": "1.2.
|
|
42
|
-
"@camstack/addon-agent-ui": "1.2.
|
|
43
|
-
"@camstack/addon-auth": "1.2.
|
|
44
|
-
"@camstack/addon-decoder-nodeav": "1.2.
|
|
45
|
-
"@camstack/addon-notifiers": "1.2.
|
|
46
|
-
"@camstack/addon-pipeline": "1.2.
|
|
47
|
-
"@camstack/addon-pipeline-orchestrator": "1.2.
|
|
48
|
-
"@camstack/addon-post-analysis": "1.2.
|
|
49
|
-
"@camstack/develop": "0.1.
|
|
50
|
-
"@camstack/sdk": "1.2.
|
|
51
|
-
"@camstack/system": "1.2.
|
|
52
|
-
"@camstack/types": "1.2.
|
|
53
|
-
"@camstack/ui-library": "1.2.
|
|
41
|
+
"@camstack/addon-admin-ui": "1.2.444",
|
|
42
|
+
"@camstack/addon-agent-ui": "1.2.250",
|
|
43
|
+
"@camstack/addon-auth": "1.2.155",
|
|
44
|
+
"@camstack/addon-decoder-nodeav": "1.2.129",
|
|
45
|
+
"@camstack/addon-notifiers": "1.2.607",
|
|
46
|
+
"@camstack/addon-pipeline": "1.2.607",
|
|
47
|
+
"@camstack/addon-pipeline-orchestrator": "1.2.608",
|
|
48
|
+
"@camstack/addon-post-analysis": "1.2.613",
|
|
49
|
+
"@camstack/develop": "0.1.19",
|
|
50
|
+
"@camstack/sdk": "1.2.139",
|
|
51
|
+
"@camstack/system": "1.2.458",
|
|
52
|
+
"@camstack/types": "1.2.389",
|
|
53
|
+
"@camstack/ui-library": "1.2.306",
|
|
54
54
|
"@fastify/compress": "^9.0.0",
|
|
55
55
|
"@fastify/cookie": "^11.0.2",
|
|
56
56
|
"@fastify/cors": "^11.2.0",
|