@camstack/server 0.1.8 → 0.2.1
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/package.json +9 -7
- package/src/__tests__/addon-install-e2e.test.ts +0 -1
- package/src/__tests__/addon-pages-e2e.test.ts +40 -18
- package/src/__tests__/addon-settings-router.spec.ts +6 -1
- package/src/__tests__/addon-upload.spec.ts +91 -29
- package/src/__tests__/agent-registry.spec.ts +26 -9
- package/src/__tests__/agent-status-page.spec.ts +1 -3
- package/src/__tests__/auth-session-cookie.test.ts +28 -1
- package/src/__tests__/bulk-update-coordinator.spec.ts +48 -31
- package/src/__tests__/cap-ownership-authority.spec.ts +39 -8
- package/src/__tests__/cap-providers/cap-providers-location-import.spec.ts +24 -4
- package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +17 -3
- package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +57 -11
- package/src/__tests__/cap-providers/integrations-delete-cascade.spec.ts +64 -15
- package/src/__tests__/cap-providers-bulk-update.spec.ts +27 -7
- package/src/__tests__/cap-route-adapter.spec.ts +28 -15
- package/src/__tests__/cap-routers/_meta.spec.ts +6 -7
- package/src/__tests__/cap-routers/addon-settings.router.spec.ts +19 -10
- package/src/__tests__/cap-routers/broker-routing.router.spec.ts +14 -6
- package/src/__tests__/cap-routers/cap-route-error-formatter.spec.ts +3 -1
- package/src/__tests__/cap-routers/capabilities-node.spec.ts +18 -5
- package/src/__tests__/cap-routers/device-link-overlay.spec.ts +11 -6
- package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +72 -20
- package/src/__tests__/cap-routers/harness.ts +11 -7
- package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +17 -3
- package/src/__tests__/cap-routers/null-provider-guard.spec.ts +5 -7
- package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +35 -11
- package/src/__tests__/cap-routers/settings-store.router.spec.ts +59 -15
- package/src/__tests__/capability-e2e.test.ts +9 -11
- package/src/__tests__/cli-e2e.test.ts +80 -59
- package/src/__tests__/core-cap-bridge.spec.ts +3 -1
- package/src/__tests__/dev-bootstrap-shm-ring.spec.ts +12 -2
- package/src/__tests__/device-settings-contribution-dispatch.spec.ts +61 -30
- package/src/__tests__/embedded-deps-e2e.test.ts +35 -19
- package/src/__tests__/event-bus-proxy-router.spec.ts +3 -0
- package/src/__tests__/framework-allowlist.spec.ts +5 -4
- package/src/__tests__/https-e2e.test.ts +12 -6
- package/src/__tests__/lifecycle-e2e.test.ts +60 -11
- package/src/__tests__/live-events-subscription.spec.ts +17 -18
- package/src/__tests__/moleculer/uds-readiness.spec.ts +11 -4
- package/src/__tests__/moleculer/uds-topology.spec.ts +39 -11
- package/src/__tests__/moleculer/uds-unowned-call.spec.ts +71 -17
- package/src/__tests__/moleculer-register-node-idempotency.spec.ts +16 -7
- package/src/__tests__/native-cap-route.spec.ts +42 -19
- package/src/__tests__/oauth2-account-linking.spec.ts +63 -17
- package/src/__tests__/singleton-contention.test.ts +23 -11
- package/src/__tests__/streaming-diagnostic.test.ts +156 -53
- package/src/__tests__/streaming-scale.test.ts +69 -35
- package/src/__tests__/uds-addon-call-wiring.spec.ts +6 -1
- package/src/agent-status-page.ts +4 -3
- package/src/api/__tests__/addons-custom.spec.ts +22 -8
- package/src/api/__tests__/capabilities.router.test.ts +18 -9
- package/src/api/addon-upload.ts +46 -15
- package/src/api/addons-custom.router.ts +7 -6
- package/src/api/auth-whoami.ts +3 -1
- package/src/api/bridge-addons.router.ts +3 -1
- package/src/api/capabilities.router.ts +117 -78
- package/src/api/core/__tests__/auth-router-totp.spec.ts +57 -16
- package/src/api/core/addon-settings.router.ts +4 -1
- package/src/api/core/agents.router.ts +52 -53
- package/src/api/core/auth.router.ts +55 -36
- package/src/api/core/bulk-update-coordinator.ts +25 -22
- package/src/api/core/cap-providers.ts +346 -202
- package/src/api/core/capabilities.router.ts +30 -23
- package/src/api/core/hwaccel.router.ts +37 -10
- package/src/api/core/live-events.router.ts +16 -9
- package/src/api/core/logs.router.ts +54 -25
- package/src/api/core/notifications.router.ts +2 -1
- package/src/api/core/repl.router.ts +1 -3
- package/src/api/core/settings-backend.router.ts +68 -70
- package/src/api/core/system-events.router.ts +41 -32
- package/src/api/health/health.routes.ts +7 -13
- package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +12 -2
- package/src/api/oauth2/consent-page.ts +4 -3
- package/src/api/oauth2/oauth2-routes.ts +41 -12
- package/src/api/trpc/__tests__/scope-access-device.spec.ts +68 -23
- package/src/api/trpc/__tests__/scope-access.spec.ts +8 -13
- package/src/api/trpc/__tests__/webrtc-session-ua-enrich.spec.ts +10 -2
- package/src/api/trpc/cap-mount-helpers.ts +64 -55
- package/src/api/trpc/cap-route-error-formatter.ts +17 -9
- package/src/api/trpc/core-cap-bridge.ts +3 -1
- package/src/api/trpc/generated-cap-mounts.ts +593 -351
- package/src/api/trpc/generated-cap-routers.ts +3680 -579
- package/src/api/trpc/scope-access.ts +7 -7
- package/src/api/trpc/trpc.context.ts +7 -4
- package/src/api/trpc/trpc.middleware.ts +4 -2
- package/src/api/trpc/trpc.router.ts +79 -46
- package/src/auth/session-cookie.ts +10 -0
- package/src/boot/__tests__/integration-id-backfill.spec.ts +21 -6
- package/src/boot/boot-config.ts +103 -122
- package/src/boot/post-boot.service.ts +5 -3
- package/src/core/addon/__tests__/addon-registry-capability.test.ts +12 -3
- package/src/core/addon/addon-call-gateway.ts +20 -6
- package/src/core/addon/addon-package.service.ts +183 -89
- package/src/core/addon/addon-registry.service.ts +1163 -1305
- package/src/core/addon/addon-search.service.ts +2 -1
- package/src/core/addon/addon-settings-provider.ts +27 -7
- package/src/core/addon-bridge/addon-bridge.service.ts +11 -6
- package/src/core/addon-pages/addon-pages.service.ts +3 -1
- package/src/core/addon-widgets/addon-widgets.service.ts +5 -2
- package/src/core/agent/agent-registry.service.ts +60 -38
- package/src/core/auth/auth.service.spec.ts +6 -8
- package/src/core/config/config.service.spec.ts +1 -1
- package/src/core/events/event-bus.service.spec.ts +44 -21
- package/src/core/events/event-bus.service.ts +5 -1
- package/src/core/feature/feature.service.spec.ts +4 -1
- package/src/core/lifecycle/lifecycle-state-machine.spec.ts +8 -10
- package/src/core/logging/logging.service.spec.ts +61 -21
- package/src/core/logging/logging.service.ts +12 -3
- package/src/core/moleculer/cap-call-fn.spec.ts +17 -10
- package/src/core/moleculer/cap-call-fn.ts +5 -1
- package/src/core/moleculer/cap-route-authority.ts +18 -6
- package/src/core/moleculer/moleculer.service.ts +120 -32
- package/src/core/network/network-quality.service.spec.ts +6 -1
- package/src/core/notification/notification-wrapper.service.ts +1 -3
- package/src/core/notification/toast-wrapper.service.ts +1 -5
- package/src/core/repl/repl-engine.service.spec.ts +66 -39
- package/src/core/repl/repl-engine.service.ts +11 -12
- package/src/core/storage/storage-location-manager.spec.ts +12 -3
- package/src/core/streaming/stream-probe.service.ts +22 -13
- package/src/core/topology/topology-emitter.service.ts +5 -1
- package/src/launcher.ts +14 -9
- package/src/main.ts +602 -531
- package/src/manual-boot.ts +133 -154
- package/tsconfig.json +20 -8
|
@@ -62,7 +62,8 @@ export class AddonSearchService {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
// npm registry v1 search: packages with BOTH "camstack" and "addon" keywords
|
|
65
|
-
const url =
|
|
65
|
+
const url =
|
|
66
|
+
'https://registry.npmjs.org/-/v1/search?text=keywords:camstack+keywords:addon&size=250'
|
|
66
67
|
|
|
67
68
|
try {
|
|
68
69
|
const response = await fetch(url, {
|
|
@@ -154,10 +154,15 @@ function createAddonSettingsProvider(deps: AddonSettingsProviderDeps): IAddonSet
|
|
|
154
154
|
const result = await addon.getGlobalSettings(input.overlay, input.cap)
|
|
155
155
|
return result ? reshapeForOutput(result) : null
|
|
156
156
|
}
|
|
157
|
-
return forkedGet(
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
157
|
+
return forkedGet(
|
|
158
|
+
input.addonId,
|
|
159
|
+
'getGlobalSettings',
|
|
160
|
+
{
|
|
161
|
+
...(input.overlay ? { overlay: input.overlay } : {}),
|
|
162
|
+
...(input.cap ? { cap: input.cap } : {}),
|
|
163
|
+
},
|
|
164
|
+
input.nodeId,
|
|
165
|
+
)
|
|
161
166
|
},
|
|
162
167
|
|
|
163
168
|
async updateGlobalSettings(input: AddonPatchInput) {
|
|
@@ -169,7 +174,12 @@ function createAddonSettingsProvider(deps: AddonSettingsProviderDeps): IAddonSet
|
|
|
169
174
|
await addon.updateGlobalSettings(input.patch)
|
|
170
175
|
return { success: true as const }
|
|
171
176
|
}
|
|
172
|
-
return forkedUpdate(
|
|
177
|
+
return forkedUpdate(
|
|
178
|
+
input.addonId,
|
|
179
|
+
'updateGlobalSettings',
|
|
180
|
+
{ patch: input.patch },
|
|
181
|
+
input.nodeId,
|
|
182
|
+
)
|
|
173
183
|
},
|
|
174
184
|
|
|
175
185
|
async getDeviceSettings(input: DeviceGetInput) {
|
|
@@ -179,7 +189,12 @@ function createAddonSettingsProvider(deps: AddonSettingsProviderDeps): IAddonSet
|
|
|
179
189
|
const result = await addon.getDeviceSettings(input.deviceId)
|
|
180
190
|
return result ? reshapeForOutput(result) : null
|
|
181
191
|
}
|
|
182
|
-
return forkedGet(
|
|
192
|
+
return forkedGet(
|
|
193
|
+
input.addonId,
|
|
194
|
+
'getDeviceSettings',
|
|
195
|
+
{ deviceId: input.deviceId },
|
|
196
|
+
input.nodeId,
|
|
197
|
+
)
|
|
183
198
|
},
|
|
184
199
|
|
|
185
200
|
async updateDeviceSettings(input: DeviceUpdateInput) {
|
|
@@ -191,7 +206,12 @@ function createAddonSettingsProvider(deps: AddonSettingsProviderDeps): IAddonSet
|
|
|
191
206
|
await addon.updateDeviceSettings(input.deviceId, input.patch)
|
|
192
207
|
return { success: true as const }
|
|
193
208
|
}
|
|
194
|
-
return forkedUpdate(
|
|
209
|
+
return forkedUpdate(
|
|
210
|
+
input.addonId,
|
|
211
|
+
'updateDeviceSettings',
|
|
212
|
+
{ deviceId: input.deviceId, patch: input.patch },
|
|
213
|
+
input.nodeId,
|
|
214
|
+
)
|
|
195
215
|
},
|
|
196
216
|
}
|
|
197
217
|
}
|
|
@@ -37,9 +37,7 @@ export class AddonBridgeService {
|
|
|
37
37
|
/** Whether the bridge initialised successfully */
|
|
38
38
|
private available = false
|
|
39
39
|
|
|
40
|
-
constructor(
|
|
41
|
-
private readonly loggingService: LoggingService,
|
|
42
|
-
) {
|
|
40
|
+
constructor(private readonly loggingService: LoggingService) {
|
|
43
41
|
this.logger = this.loggingService.createLogger('AddonBridge')
|
|
44
42
|
|
|
45
43
|
// Initialize installer eagerly in constructor (no async needed).
|
|
@@ -49,7 +47,10 @@ export class AddonBridgeService {
|
|
|
49
47
|
const dataDir = process.env.CAMSTACK_DATA ?? 'camstack-data'
|
|
50
48
|
const addonsDir = path.resolve(dataDir, 'addons')
|
|
51
49
|
const workspacePackagesDir = kernel.detectWorkspacePackagesDir(__dirname)
|
|
52
|
-
this.installer = new kernel.AddonInstaller({
|
|
50
|
+
this.installer = new kernel.AddonInstaller({
|
|
51
|
+
addonsDir,
|
|
52
|
+
workspacePackagesDir: workspacePackagesDir ?? undefined,
|
|
53
|
+
})
|
|
53
54
|
kernel.ensureDir(addonsDir)
|
|
54
55
|
} catch (error: unknown) {
|
|
55
56
|
const msg = errMsg(error)
|
|
@@ -70,10 +71,14 @@ export class AddonBridgeService {
|
|
|
70
71
|
await this.loader.loadFromDirectory(addonsDir)
|
|
71
72
|
|
|
72
73
|
this.available = true
|
|
73
|
-
this.logger.info('Addon bridge initialized', {
|
|
74
|
+
this.logger.info('Addon bridge initialized', {
|
|
75
|
+
meta: { count: this.loader.listAddons().length },
|
|
76
|
+
})
|
|
74
77
|
} catch (error: unknown) {
|
|
75
78
|
const msg = errMsg(error)
|
|
76
|
-
this.logger.warn('Addon bridge loader failed — install/uninstall still available', {
|
|
79
|
+
this.logger.warn('Addon bridge loader failed — install/uninstall still available', {
|
|
80
|
+
meta: { error: msg },
|
|
81
|
+
})
|
|
77
82
|
}
|
|
78
83
|
}
|
|
79
84
|
|
|
@@ -47,7 +47,9 @@ export class AddonPagesService {
|
|
|
47
47
|
const providers = this.caps.getCollection<IAddonPageProvider>('addon-pages-source')
|
|
48
48
|
const isRegistered = providers.some((p) => p.id === addonId)
|
|
49
49
|
if (!isRegistered) {
|
|
50
|
-
this.logger.warn('Bundle resolve failed: addon not registered as page provider', {
|
|
50
|
+
this.logger.warn('Bundle resolve failed: addon not registered as page provider', {
|
|
51
|
+
tags: { addonId },
|
|
52
|
+
})
|
|
51
53
|
return null
|
|
52
54
|
}
|
|
53
55
|
|
|
@@ -60,10 +60,13 @@ export class AddonWidgetsService {
|
|
|
60
60
|
// the bare manifest id, so the route always carries the bare
|
|
61
61
|
// `addonId` — match it against each registry key with the
|
|
62
62
|
// `@<nodeId>` suffix stripped.
|
|
63
|
-
const entries =
|
|
63
|
+
const entries =
|
|
64
|
+
this.caps.getCollectionEntries<IAddonWidgetsSourceProvider>('addon-widgets-source')
|
|
64
65
|
const isRegistered = entries.some(([id]) => stripNodeSuffix(id) === addonId)
|
|
65
66
|
if (!isRegistered) {
|
|
66
|
-
this.logger.warn('Bundle resolve failed: addon not registered as widget provider', {
|
|
67
|
+
this.logger.warn('Bundle resolve failed: addon not registered as widget provider', {
|
|
68
|
+
tags: { addonId },
|
|
69
|
+
})
|
|
67
70
|
return null
|
|
68
71
|
}
|
|
69
72
|
|
|
@@ -181,9 +181,7 @@ export class AgentRegistryService {
|
|
|
181
181
|
| { getNodeList?: (opts: { onlyAvailable: boolean }) => readonly { id: string }[] }
|
|
182
182
|
| undefined
|
|
183
183
|
const nodes = registry?.getNodeList?.({ onlyAvailable: true }) ?? []
|
|
184
|
-
const agentIds = nodes
|
|
185
|
-
.map((n) => n.id)
|
|
186
|
-
.filter((id) => id !== 'hub' && !id.includes('/'))
|
|
184
|
+
const agentIds = nodes.map((n) => n.id).filter((id) => id !== 'hub' && !id.includes('/'))
|
|
187
185
|
if (agentIds.length === 0) return
|
|
188
186
|
console.log(`[agent-registry] Boot reconcile: ${agentIds.length} connected agent(s)`)
|
|
189
187
|
for (const agentId of agentIds) {
|
|
@@ -217,10 +215,14 @@ export class AgentRegistryService {
|
|
|
217
215
|
}
|
|
218
216
|
try {
|
|
219
217
|
const broker = this.broker
|
|
220
|
-
const statusRaw = await broker.call(
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
218
|
+
const statusRaw = await broker.call(
|
|
219
|
+
'$agent.status',
|
|
220
|
+
{},
|
|
221
|
+
{
|
|
222
|
+
nodeID: agentId,
|
|
223
|
+
timeout: AGENT_RECONCILE_RPC_TIMEOUT_MS,
|
|
224
|
+
},
|
|
225
|
+
)
|
|
224
226
|
const agentAddons = this.extractAgentAddons(statusRaw)
|
|
225
227
|
if (agentAddons.length === 0) return
|
|
226
228
|
|
|
@@ -243,7 +245,9 @@ export class AgentRegistryService {
|
|
|
243
245
|
})
|
|
244
246
|
|
|
245
247
|
if (stale.length === 0) {
|
|
246
|
-
console.log(
|
|
248
|
+
console.log(
|
|
249
|
+
`[agent-registry] Reconcile ${agentId}: no stale addons (${agentAddons.length} checked)`,
|
|
250
|
+
)
|
|
247
251
|
return
|
|
248
252
|
}
|
|
249
253
|
|
|
@@ -252,11 +256,17 @@ export class AgentRegistryService {
|
|
|
252
256
|
? 'placement is hub-only'
|
|
253
257
|
: 'not installed on hub'
|
|
254
258
|
try {
|
|
255
|
-
await broker.call(
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
259
|
+
await broker.call(
|
|
260
|
+
'$agent.undeploy',
|
|
261
|
+
{ addonId: addon.id },
|
|
262
|
+
{
|
|
263
|
+
nodeID: agentId,
|
|
264
|
+
timeout: AGENT_RECONCILE_RPC_TIMEOUT_MS,
|
|
265
|
+
},
|
|
266
|
+
)
|
|
267
|
+
console.log(
|
|
268
|
+
`[agent-registry] Reconcile ${agentId}: undeployed stale addon "${addon.id}" (${reason})`,
|
|
269
|
+
)
|
|
260
270
|
this.eventBus.emit({
|
|
261
271
|
id: randomUUID(),
|
|
262
272
|
timestamp: new Date(),
|
|
@@ -303,7 +313,10 @@ export class AgentRegistryService {
|
|
|
303
313
|
// Get child processes for hub via $process.list
|
|
304
314
|
let hubProcesses: readonly unknown[] = []
|
|
305
315
|
try {
|
|
306
|
-
const processes = await this.broker.call('$process.list') as readonly Record<
|
|
316
|
+
const processes = (await this.broker.call('$process.list')) as readonly Record<
|
|
317
|
+
string,
|
|
318
|
+
unknown
|
|
319
|
+
>[]
|
|
307
320
|
hubProcesses = processes.map((p) => ({
|
|
308
321
|
pid: (p.pid as number) ?? 0,
|
|
309
322
|
name: (p.name as string) ?? '',
|
|
@@ -333,18 +346,26 @@ export class AgentRegistryService {
|
|
|
333
346
|
if (nodeId === 'hub' || nodeId.includes('/')) continue
|
|
334
347
|
|
|
335
348
|
try {
|
|
336
|
-
const status = await this.broker.call(
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
349
|
+
const status = (await this.broker.call(
|
|
350
|
+
'$agent.status',
|
|
351
|
+
{},
|
|
352
|
+
{
|
|
353
|
+
nodeID: nodeId,
|
|
354
|
+
timeout: 5000,
|
|
355
|
+
},
|
|
356
|
+
)) as Record<string, unknown>
|
|
340
357
|
|
|
341
358
|
// Get real sub-process stats from the agent's $process.list
|
|
342
359
|
let subProcesses: readonly unknown[] = []
|
|
343
360
|
try {
|
|
344
|
-
const processes = await this.broker.call(
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
361
|
+
const processes = (await this.broker.call(
|
|
362
|
+
'$process.list',
|
|
363
|
+
{},
|
|
364
|
+
{
|
|
365
|
+
nodeID: nodeId,
|
|
366
|
+
timeout: 5000,
|
|
367
|
+
},
|
|
368
|
+
)) as readonly Record<string, unknown>[]
|
|
348
369
|
subProcesses = processes.map((p) => ({
|
|
349
370
|
pid: (p.pid as number) ?? 0,
|
|
350
371
|
name: (p.name as string) ?? '',
|
|
@@ -358,20 +379,21 @@ export class AgentRegistryService {
|
|
|
358
379
|
}))
|
|
359
380
|
} catch {
|
|
360
381
|
// Fall back to addon list from $agent.status (no stats)
|
|
361
|
-
subProcesses =
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
382
|
+
subProcesses =
|
|
383
|
+
(status.addons as readonly Record<string, unknown>[] | undefined)?.map((a) => ({
|
|
384
|
+
pid: 0,
|
|
385
|
+
name: (a.id as string) ?? '',
|
|
386
|
+
command: 'moleculer-service',
|
|
387
|
+
state: ((a.status as string) ?? 'running') as 'running' | 'stopped' | 'crashed',
|
|
388
|
+
cpuPercent: 0,
|
|
389
|
+
memoryRss: 0,
|
|
390
|
+
uptimeSeconds: 0,
|
|
391
|
+
})) ?? []
|
|
370
392
|
}
|
|
371
393
|
|
|
372
394
|
// Extract addon IDs from $agent.status
|
|
373
|
-
const agentAddons: readonly string[] =
|
|
374
|
-
?.map(a => a.id) ?? []
|
|
395
|
+
const agentAddons: readonly string[] =
|
|
396
|
+
(status.addons as readonly { id: string }[] | undefined)?.map((a) => a.id) ?? []
|
|
375
397
|
|
|
376
398
|
const hostname = typeof status.hostname === 'string' ? status.hostname : null
|
|
377
399
|
const agentName = typeof status.name === 'string' ? status.name : nodeId
|
|
@@ -387,7 +409,7 @@ export class AgentRegistryService {
|
|
|
387
409
|
memoryMB: (status.totalMemoryMB as number) ?? 0,
|
|
388
410
|
cpuModel: status.cpuModel as string | undefined,
|
|
389
411
|
},
|
|
390
|
-
localIps: Array.isArray(status.localIps) ? status.localIps as string[] : [],
|
|
412
|
+
localIps: Array.isArray(status.localIps) ? (status.localIps as string[]) : [],
|
|
391
413
|
status: {
|
|
392
414
|
activeCameras: 0,
|
|
393
415
|
cpuPercent: (status.cpuPercent as number) ?? 0,
|
|
@@ -395,14 +417,14 @@ export class AgentRegistryService {
|
|
|
395
417
|
fps: {},
|
|
396
418
|
errors: [],
|
|
397
419
|
},
|
|
398
|
-
connectedSince:
|
|
399
|
-
|
|
400
|
-
|
|
420
|
+
connectedSince:
|
|
421
|
+
typeof status.uptime === 'number'
|
|
422
|
+
? Date.now() - (status.uptime as number) * 1000
|
|
423
|
+
: Date.now(),
|
|
401
424
|
isHub: false,
|
|
402
425
|
subProcesses,
|
|
403
426
|
agentAddons,
|
|
404
427
|
})
|
|
405
|
-
|
|
406
428
|
} catch {
|
|
407
429
|
// Skip nodes without $agent service
|
|
408
430
|
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
|
|
2
1
|
import { describe, it, expect, beforeEach } from 'vitest'
|
|
3
2
|
import { AuthService } from './auth.service'
|
|
4
3
|
import type { ConfigService } from '../config/config.service'
|
|
@@ -28,20 +27,19 @@ describe('AuthService', () => {
|
|
|
28
27
|
}
|
|
29
28
|
|
|
30
29
|
const token = service.signToken(payload)
|
|
31
|
-
|
|
30
|
+
|
|
32
31
|
const decoded = service.verifyToken(token)
|
|
33
32
|
|
|
34
|
-
|
|
35
33
|
expect(decoded.userId).toBe('user-1')
|
|
36
|
-
|
|
34
|
+
|
|
37
35
|
expect(decoded.username).toBe('admin')
|
|
38
|
-
|
|
36
|
+
|
|
39
37
|
expect(decoded.role).toBe('admin')
|
|
40
|
-
|
|
38
|
+
|
|
41
39
|
expect(decoded.allowedProviders).toBe('*')
|
|
42
|
-
|
|
40
|
+
|
|
43
41
|
expect(decoded.iat).toBeDefined()
|
|
44
|
-
|
|
42
|
+
|
|
45
43
|
expect(decoded.exp).toBeDefined()
|
|
46
44
|
})
|
|
47
45
|
|
|
@@ -92,7 +92,7 @@ describe('ConfigService', () => {
|
|
|
92
92
|
expect(raw.features.objectDetection).toBe(false)
|
|
93
93
|
expect(raw.storage.provider).toBe('sqlite-storage')
|
|
94
94
|
expect(raw.logging.level).toBe('info')
|
|
95
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access --
|
|
95
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access --
|
|
96
96
|
expect((raw.logging as any).retentionDays).toBe(30)
|
|
97
97
|
expect(raw.eventBus.ringBufferSize).toBe(10000)
|
|
98
98
|
// addons.enabled removed — installed = active
|
|
@@ -10,7 +10,7 @@ import type { ISettingsStore } from '@camstack/kernel'
|
|
|
10
10
|
import type { SystemEvent } from '@camstack/types'
|
|
11
11
|
|
|
12
12
|
/** Flush pending queueMicrotask callbacks */
|
|
13
|
-
const flush = () => new Promise<void>(resolve => queueMicrotask(resolve))
|
|
13
|
+
const flush = () => new Promise<void>((resolve) => queueMicrotask(resolve))
|
|
14
14
|
|
|
15
15
|
const makeEvent = (category: string, overrides: Partial<SystemEvent> = {}): SystemEvent => ({
|
|
16
16
|
id: `evt-${Math.random().toString(36).slice(2, 8)}`,
|
|
@@ -35,19 +35,43 @@ class InMemorySettingsStore implements ISettingsStore {
|
|
|
35
35
|
this.system = { ...seed }
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
getSystem(key: string): unknown {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
38
|
+
getSystem(key: string): unknown {
|
|
39
|
+
return this.system[key]
|
|
40
|
+
}
|
|
41
|
+
setSystem(key: string, value: unknown): void {
|
|
42
|
+
this.system[key] = value
|
|
43
|
+
}
|
|
44
|
+
getAllSystem(): Record<string, unknown> {
|
|
45
|
+
return { ...this.system }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
getAllAddon(_addonId: string): Record<string, unknown> {
|
|
49
|
+
return {}
|
|
50
|
+
}
|
|
51
|
+
setAllAddon(_addonId: string, _config: Record<string, unknown>): void {
|
|
52
|
+
/* no-op */
|
|
53
|
+
}
|
|
54
|
+
getAllProvider(_providerId: string): Record<string, unknown> {
|
|
55
|
+
return {}
|
|
56
|
+
}
|
|
57
|
+
setProvider(_providerId: string, _key: string, _value: unknown): void {
|
|
58
|
+
/* no-op */
|
|
59
|
+
}
|
|
60
|
+
getAllDevice(_deviceId: string): Record<string, unknown> {
|
|
61
|
+
return {}
|
|
62
|
+
}
|
|
63
|
+
setDevice(_deviceId: string, _key: string, _value: unknown): void {
|
|
64
|
+
/* no-op */
|
|
65
|
+
}
|
|
66
|
+
getAddonDevice(_addonId: string, _deviceId: string): Record<string, unknown> {
|
|
67
|
+
return {}
|
|
68
|
+
}
|
|
69
|
+
setAddonDevice(_addonId: string, _deviceId: string, _values: Record<string, unknown>): void {
|
|
70
|
+
/* no-op */
|
|
71
|
+
}
|
|
72
|
+
clearAddonDevice(_addonId: string, _deviceId: string): void {
|
|
73
|
+
/* no-op */
|
|
74
|
+
}
|
|
51
75
|
}
|
|
52
76
|
|
|
53
77
|
describe('EventBusService', () => {
|
|
@@ -80,9 +104,11 @@ describe('EventBusService', () => {
|
|
|
80
104
|
'utf-8',
|
|
81
105
|
)
|
|
82
106
|
const configService = new ConfigService(configPath)
|
|
83
|
-
configService.setSettingsStore(
|
|
84
|
-
|
|
85
|
-
|
|
107
|
+
configService.setSettingsStore(
|
|
108
|
+
new InMemorySettingsStore({
|
|
109
|
+
'eventBus.ringBufferSize': bufferSize,
|
|
110
|
+
}),
|
|
111
|
+
)
|
|
86
112
|
const service = new EventBusService(configService)
|
|
87
113
|
// EventBusService is a delegate that needs a broker to dispatch
|
|
88
114
|
// through. Use a unique nodeID per call so each test gets its own
|
|
@@ -152,10 +178,7 @@ describe('EventBusService', () => {
|
|
|
152
178
|
const service = await createService()
|
|
153
179
|
const received: SystemEvent[] = []
|
|
154
180
|
|
|
155
|
-
service.subscribe(
|
|
156
|
-
{ source: { type: 'addon', id: 'frigate' } },
|
|
157
|
-
(event) => received.push(event),
|
|
158
|
-
)
|
|
181
|
+
service.subscribe({ source: { type: 'addon', id: 'frigate' } }, (event) => received.push(event))
|
|
159
182
|
|
|
160
183
|
service.emit(makeEvent('addon.started', { source: { type: 'addon', id: 'frigate' } }))
|
|
161
184
|
service.emit(makeEvent('addon.started', { source: { type: 'addon', id: 'scrypted' } }))
|
|
@@ -27,7 +27,11 @@ export class EventBusService implements IEventBus {
|
|
|
27
27
|
// before `attachBroker` runs (NestJS-era boot legacy). Drained into
|
|
28
28
|
// the real bus on attach.
|
|
29
29
|
private pending: SystemEvent[] = []
|
|
30
|
-
private deferredSubs: Array<{
|
|
30
|
+
private deferredSubs: Array<{
|
|
31
|
+
filter: EventFilter
|
|
32
|
+
handler: (event: SystemEvent) => void
|
|
33
|
+
unsub?: () => void
|
|
34
|
+
}> = []
|
|
31
35
|
|
|
32
36
|
constructor(_configService: ConfigService) {
|
|
33
37
|
// The shared bus owns the ring-buffer size — hub-side config is
|
|
@@ -14,7 +14,10 @@ import type { FeatureManifest } from '@camstack/types'
|
|
|
14
14
|
* ConfigManager constructor succeeds.
|
|
15
15
|
*/
|
|
16
16
|
class StaticFeatureConfigService extends ConfigService {
|
|
17
|
-
constructor(
|
|
17
|
+
constructor(
|
|
18
|
+
configPath: string,
|
|
19
|
+
private readonly staticFeatures: FeatureManifest,
|
|
20
|
+
) {
|
|
18
21
|
super(configPath)
|
|
19
22
|
}
|
|
20
23
|
|
|
@@ -27,9 +27,8 @@ describe('LifecycleStateMachine', () => {
|
|
|
27
27
|
let machine: LifecycleStateMachine
|
|
28
28
|
|
|
29
29
|
beforeEach(() => {
|
|
30
|
-
|
|
31
30
|
eventBus = createMockEventBus()
|
|
32
|
-
|
|
31
|
+
|
|
33
32
|
logger = createMockLogger()
|
|
34
33
|
machine = new LifecycleStateMachine('test-element', 'device', eventBus, logger)
|
|
35
34
|
})
|
|
@@ -55,7 +54,7 @@ describe('LifecycleStateMachine', () => {
|
|
|
55
54
|
const result = machine.transition('running')
|
|
56
55
|
expect(result).toBe(false)
|
|
57
56
|
expect(machine.state).toBe('stopped')
|
|
58
|
-
|
|
57
|
+
|
|
59
58
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
60
59
|
expect.stringContaining('Invalid state transition'),
|
|
61
60
|
expect.anything(),
|
|
@@ -66,21 +65,20 @@ describe('LifecycleStateMachine', () => {
|
|
|
66
65
|
machine.transition('starting')
|
|
67
66
|
machine.transition('running')
|
|
68
67
|
|
|
69
|
-
|
|
70
68
|
expect(eventBus.emit).toHaveBeenCalledTimes(2)
|
|
71
|
-
|
|
69
|
+
|
|
72
70
|
expect(eventBus.emitted[0]!.category).toBe('device.state.starting')
|
|
73
|
-
|
|
71
|
+
|
|
74
72
|
expect(eventBus.emitted[0]!.source).toEqual({ type: 'device', id: 'test-element' })
|
|
75
|
-
|
|
73
|
+
|
|
76
74
|
expect(eventBus.emitted[0]!.data).toMatchObject({
|
|
77
75
|
from: 'stopped',
|
|
78
76
|
to: 'starting',
|
|
79
77
|
elementId: 'test-element',
|
|
80
78
|
})
|
|
81
|
-
|
|
79
|
+
|
|
82
80
|
expect(eventBus.emitted[1]!.category).toBe('device.state.running')
|
|
83
|
-
|
|
81
|
+
|
|
84
82
|
expect(eventBus.emitted[1]!.data).toMatchObject({
|
|
85
83
|
from: 'starting',
|
|
86
84
|
to: 'running',
|
|
@@ -89,7 +87,7 @@ describe('LifecycleStateMachine', () => {
|
|
|
89
87
|
|
|
90
88
|
it('does not emit event on invalid transition', () => {
|
|
91
89
|
machine.transition('running')
|
|
92
|
-
|
|
90
|
+
|
|
93
91
|
expect(eventBus.emit).not.toHaveBeenCalled()
|
|
94
92
|
})
|
|
95
93
|
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
|
|
2
1
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
3
2
|
import * as fs from 'node:fs'
|
|
4
3
|
import * as path from 'node:path'
|
|
@@ -25,19 +24,43 @@ class InMemorySettingsStore implements ISettingsStore {
|
|
|
25
24
|
this.system = { ...seed }
|
|
26
25
|
}
|
|
27
26
|
|
|
28
|
-
getSystem(key: string): unknown {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
27
|
+
getSystem(key: string): unknown {
|
|
28
|
+
return this.system[key]
|
|
29
|
+
}
|
|
30
|
+
setSystem(key: string, value: unknown): void {
|
|
31
|
+
this.system[key] = value
|
|
32
|
+
}
|
|
33
|
+
getAllSystem(): Record<string, unknown> {
|
|
34
|
+
return { ...this.system }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
getAllAddon(_addonId: string): Record<string, unknown> {
|
|
38
|
+
return {}
|
|
39
|
+
}
|
|
40
|
+
setAllAddon(_addonId: string, _config: Record<string, unknown>): void {
|
|
41
|
+
/* no-op */
|
|
42
|
+
}
|
|
43
|
+
getAllProvider(_providerId: string): Record<string, unknown> {
|
|
44
|
+
return {}
|
|
45
|
+
}
|
|
46
|
+
setProvider(_providerId: string, _key: string, _value: unknown): void {
|
|
47
|
+
/* no-op */
|
|
48
|
+
}
|
|
49
|
+
getAllDevice(_deviceId: string): Record<string, unknown> {
|
|
50
|
+
return {}
|
|
51
|
+
}
|
|
52
|
+
setDevice(_deviceId: string, _key: string, _value: unknown): void {
|
|
53
|
+
/* no-op */
|
|
54
|
+
}
|
|
55
|
+
getAddonDevice(_addonId: string, _deviceId: string): Record<string, unknown> {
|
|
56
|
+
return {}
|
|
57
|
+
}
|
|
58
|
+
setAddonDevice(_addonId: string, _deviceId: string, _values: Record<string, unknown>): void {
|
|
59
|
+
/* no-op */
|
|
60
|
+
}
|
|
61
|
+
clearAddonDevice(_addonId: string, _deviceId: string): void {
|
|
62
|
+
/* no-op */
|
|
63
|
+
}
|
|
41
64
|
}
|
|
42
65
|
|
|
43
66
|
describe('ScopedLogger', () => {
|
|
@@ -147,13 +170,28 @@ describe('LogRingBuffer', () => {
|
|
|
147
170
|
it('filters by tags (addonId exact match)', () => {
|
|
148
171
|
const buffer = new LogRingBuffer(100)
|
|
149
172
|
|
|
150
|
-
buffer.push({
|
|
151
|
-
|
|
152
|
-
|
|
173
|
+
buffer.push({
|
|
174
|
+
timestamp: new Date(),
|
|
175
|
+
level: 'info',
|
|
176
|
+
message: 'a',
|
|
177
|
+
tags: { addonId: 'stream-broker' },
|
|
178
|
+
})
|
|
179
|
+
buffer.push({
|
|
180
|
+
timestamp: new Date(),
|
|
181
|
+
level: 'info',
|
|
182
|
+
message: 'b',
|
|
183
|
+
tags: { addonId: 'provider-rtsp' },
|
|
184
|
+
})
|
|
185
|
+
buffer.push({
|
|
186
|
+
timestamp: new Date(),
|
|
187
|
+
level: 'info',
|
|
188
|
+
message: 'c',
|
|
189
|
+
tags: { addonId: 'stream-broker' },
|
|
190
|
+
})
|
|
153
191
|
|
|
154
192
|
const result = buffer.query({ tags: { addonId: 'stream-broker' } })
|
|
155
193
|
expect(result).toHaveLength(2)
|
|
156
|
-
expect(result.map((e) => e.message).
|
|
194
|
+
expect(result.map((e) => e.message).toSorted()).toEqual(['a', 'c'])
|
|
157
195
|
})
|
|
158
196
|
|
|
159
197
|
it('respects limit', () => {
|
|
@@ -190,9 +228,11 @@ describe('LoggingService', () => {
|
|
|
190
228
|
'utf-8',
|
|
191
229
|
)
|
|
192
230
|
const configService = new ConfigService(configPath)
|
|
193
|
-
configService.setSettingsStore(
|
|
194
|
-
|
|
195
|
-
|
|
231
|
+
configService.setSettingsStore(
|
|
232
|
+
new InMemorySettingsStore({
|
|
233
|
+
'eventBus.ringBufferSize': bufferSize,
|
|
234
|
+
}),
|
|
235
|
+
)
|
|
196
236
|
return new LoggingService(configService)
|
|
197
237
|
}
|
|
198
238
|
|