@camstack/server 0.1.7 → 0.2.0
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 +11 -9
- 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 +206 -0
- 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 +292 -0
- 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 +177 -0
- 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 +137 -0
- 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 +265 -5
- 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/__tests__/integration-markers.spec.ts +10 -0
- 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 +459 -166
- 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 +58 -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__/client-ip.spec.ts +27 -1
- 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 +136 -0
- package/src/api/trpc/cap-mount-helpers.ts +64 -44
- package/src/api/trpc/cap-route-error-formatter.ts +17 -9
- package/src/api/trpc/client-ip.ts +17 -0
- package/src/api/trpc/core-cap-bridge.ts +3 -1
- package/src/api/trpc/generated-cap-mounts.ts +801 -286
- package/src/api/trpc/generated-cap-routers.ts +5723 -719
- 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 +117 -48
- package/src/auth/session-cookie.ts +10 -0
- package/src/boot/__tests__/integration-id-backfill.spec.ts +131 -0
- package/src/boot/boot-config.ts +103 -122
- package/src/boot/integration-id-backfill.ts +109 -0
- 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/__tests__/addon-row-manifest.spec.ts +62 -0
- 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 +1212 -1267
- package/src/core/addon/addon-row-manifest.ts +29 -0
- 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 +19 -5
- 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 +145 -29
- package/src/core/network/network-quality.service.spec.ts +7 -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 +658 -495
- package/src/manual-boot.ts +133 -154
- package/tsconfig.json +20 -8
- package/src/core/storage/settings-store.spec.ts +0 -213
- package/src/core/storage/settings-store.ts +0 -2
- package/src/core/storage/sql-schema.spec.ts +0 -140
- package/src/core/storage/sql-schema.ts +0 -3
|
@@ -41,9 +41,10 @@ import type {
|
|
|
41
41
|
Integration,
|
|
42
42
|
IIntegrationRegistry,
|
|
43
43
|
IDeviceProvider,
|
|
44
|
+
IBrokerProvider,
|
|
44
45
|
CapabilityMethodAuth,
|
|
45
46
|
} from '@camstack/types'
|
|
46
|
-
import { asJsonObject, asJsonArray, errMsg } from '@camstack/types'
|
|
47
|
+
import { asJsonObject, asJsonArray, errMsg, EventCategory } from '@camstack/types'
|
|
47
48
|
import type { CapabilityRegistry } from '@camstack/kernel'
|
|
48
49
|
import { getCapUsageRegistry } from '@camstack/kernel'
|
|
49
50
|
import type { ToastService, NotificationService } from '@camstack/core'
|
|
@@ -57,6 +58,7 @@ import type { AddonRegistryService } from '../../core/addon/addon-registry.servi
|
|
|
57
58
|
import type { AddonPackageService } from '../../core/addon/addon-package.service'
|
|
58
59
|
import type { NetworkQualityService } from '../../core/network/network-quality.service'
|
|
59
60
|
import type { ConfigService } from '../../core/config/config.service'
|
|
61
|
+
import { planDeleteTimeStamps } from '../../boot/integration-id-backfill'
|
|
60
62
|
import { persistCollectionDisabled } from './collection-preference.js'
|
|
61
63
|
import { BulkUpdateCoordinator } from './bulk-update-coordinator.js'
|
|
62
64
|
import { FRAMEWORK_PACKAGE_ALLOWLIST } from '../../core/addon/addon-package.service.js'
|
|
@@ -66,7 +68,9 @@ const execFileAsync = promisify(execFile)
|
|
|
66
68
|
// ── system ──────────────────────────────────────────────────────────
|
|
67
69
|
|
|
68
70
|
function getRetention(registry: CapabilityRegistry | null) {
|
|
69
|
-
return
|
|
71
|
+
return (
|
|
72
|
+
registry?.getSingleton<IAnalysisDataPersistence>('analysis-data-persistence')?.retention ?? null
|
|
73
|
+
)
|
|
70
74
|
}
|
|
71
75
|
|
|
72
76
|
export function buildSystemProvider(
|
|
@@ -105,9 +109,7 @@ export function buildSystemProvider(
|
|
|
105
109
|
|
|
106
110
|
// ── network-quality ─────────────────────────────────────────────────
|
|
107
111
|
|
|
108
|
-
export function buildNetworkQualityProvider(
|
|
109
|
-
nq: NetworkQualityService,
|
|
110
|
-
): INetworkQualityProvider {
|
|
112
|
+
export function buildNetworkQualityProvider(nq: NetworkQualityService): INetworkQualityProvider {
|
|
111
113
|
return {
|
|
112
114
|
getDeviceStats: async (input) => nq.getDeviceStats(input.deviceId),
|
|
113
115
|
getAllStats: async () => nq.getAllStats(),
|
|
@@ -116,6 +118,7 @@ export function buildNetworkQualityProvider(
|
|
|
116
118
|
rttMs: input.rttMs,
|
|
117
119
|
jitterMs: input.jitterMs,
|
|
118
120
|
estimatedBandwidthKbps: input.estimatedBandwidthKbps,
|
|
121
|
+
packetLossPercent: input.packetLossPercent,
|
|
119
122
|
})
|
|
120
123
|
},
|
|
121
124
|
}
|
|
@@ -138,7 +141,9 @@ export function buildToastProvider(
|
|
|
138
141
|
if (!toastService) return () => {}
|
|
139
142
|
const userId = ctx.user?.id ?? 'anonymous'
|
|
140
143
|
const connectionId = randomUUID()
|
|
141
|
-
const unsubscribe = toastService.subscribe(connectionId, userId, (toast: Toast) =>
|
|
144
|
+
const unsubscribe = toastService.subscribe(connectionId, userId, (toast: Toast) =>
|
|
145
|
+
push(toast),
|
|
146
|
+
)
|
|
142
147
|
return unsubscribe ?? (() => {})
|
|
143
148
|
},
|
|
144
149
|
}
|
|
@@ -181,14 +186,14 @@ export async function computeTopology(
|
|
|
181
186
|
): Promise<readonly TopologyNode[]> {
|
|
182
187
|
const nodes = await agentRegistry.listNodes()
|
|
183
188
|
const allAddons = addonRegistry?.listAddons() ?? []
|
|
184
|
-
const getInGroupAddonIds = (node: typeof nodes[number]): readonly string[] => {
|
|
189
|
+
const getInGroupAddonIds = (node: (typeof nodes)[number]): readonly string[] => {
|
|
185
190
|
const subs = (node.subProcesses ?? []) as readonly SubProcessLite[]
|
|
186
191
|
return subs.flatMap((p) => p.addonIds ?? [])
|
|
187
192
|
}
|
|
188
193
|
const addonCaps = new Map<string, readonly string[]>()
|
|
189
194
|
for (const a of allAddons) {
|
|
190
195
|
const id = a.manifest?.id ?? ''
|
|
191
|
-
const caps = a.declaration?.capabilities?.map(c => typeof c === 'string' ? c : c.name) ?? []
|
|
196
|
+
const caps = a.declaration?.capabilities?.map((c) => (typeof c === 'string' ? c : c.name)) ?? []
|
|
192
197
|
addonCaps.set(id, caps)
|
|
193
198
|
}
|
|
194
199
|
const addonCategory = new Map<string, string>()
|
|
@@ -199,7 +204,8 @@ export async function computeTopology(
|
|
|
199
204
|
}
|
|
200
205
|
return nodes.map((node) => {
|
|
201
206
|
const inGroupAddonIds = new Set(getInGroupAddonIds(node))
|
|
202
|
-
const agentAddonIds: readonly string[] =
|
|
207
|
+
const agentAddonIds: readonly string[] =
|
|
208
|
+
(node as { agentAddons?: readonly string[] }).agentAddons ?? []
|
|
203
209
|
type NodeAddonEntry = { id: string; capabilities: readonly string[]; status: 'running' }
|
|
204
210
|
const allNodeAddons: NodeAddonEntry[] = node.isHub
|
|
205
211
|
? allAddons.map((a) => {
|
|
@@ -212,31 +218,33 @@ export async function computeTopology(
|
|
|
212
218
|
status: 'running' as const,
|
|
213
219
|
}))
|
|
214
220
|
const inProcessAddons = allNodeAddons.filter((a) => !inGroupAddonIds.has(a.id))
|
|
215
|
-
const isolatedProcesses = (
|
|
216
|
-
const mainProcessServices = inProcessAddons.map(a => ({
|
|
221
|
+
const isolatedProcesses = (node.subProcesses ?? []) as readonly SubProcessLite[]
|
|
222
|
+
const mainProcessServices = inProcessAddons.map((a) => ({
|
|
217
223
|
addonId: a.id,
|
|
218
224
|
capabilities: a.capabilities,
|
|
219
225
|
status: a.status,
|
|
220
226
|
}))
|
|
221
|
-
const mainProcess = node.isHub
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
227
|
+
const mainProcess = node.isHub
|
|
228
|
+
? {
|
|
229
|
+
pid: process.pid,
|
|
230
|
+
name: 'hub (core)',
|
|
231
|
+
state: 'running' as const,
|
|
232
|
+
cpuPercent: node.status?.cpuPercent ?? 0,
|
|
233
|
+
memoryRss: process.memoryUsage().rss,
|
|
234
|
+
uptimeSeconds: Math.floor(process.uptime()),
|
|
235
|
+
services: mainProcessServices,
|
|
236
|
+
}
|
|
237
|
+
: {
|
|
238
|
+
pid: 0,
|
|
239
|
+
name: `${node.info.id} (core)`,
|
|
240
|
+
state: 'running' as const,
|
|
241
|
+
cpuPercent: node.status?.cpuPercent ?? 0,
|
|
242
|
+
memoryRss: 0,
|
|
243
|
+
uptimeSeconds: Math.floor((Date.now() - node.connectedSince) / 1000),
|
|
244
|
+
services: mainProcessServices,
|
|
245
|
+
}
|
|
238
246
|
const childProcesses = isolatedProcesses.map((p) => {
|
|
239
|
-
const memberIds =
|
|
247
|
+
const memberIds = p.addonIds && p.addonIds.length > 0 ? p.addonIds : [p.name]
|
|
240
248
|
return {
|
|
241
249
|
pid: p.pid,
|
|
242
250
|
name: p.name,
|
|
@@ -255,16 +263,23 @@ export async function computeTopology(
|
|
|
255
263
|
// Aggregate node-local addons by category. `allNodeAddons` already
|
|
256
264
|
// contains the per-node addon roster (hub uses every installed
|
|
257
265
|
// addon; agents use their assigned agentAddons subset).
|
|
258
|
-
const byCategory = new Map<
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
266
|
+
const byCategory = new Map<
|
|
267
|
+
string,
|
|
268
|
+
{
|
|
269
|
+
category: string
|
|
270
|
+
total: number
|
|
271
|
+
healthy: number
|
|
272
|
+
addons: { id: string; status: string; cpuPercent: number; memoryRss: number }[]
|
|
273
|
+
}
|
|
274
|
+
>()
|
|
264
275
|
const procByAddon = new Map<string, { cpuPercent: number; memoryRss: number; state: string }>()
|
|
265
276
|
for (const p of (node.subProcesses ?? []) as readonly SubProcessLite[]) {
|
|
266
|
-
for (const addonId of
|
|
267
|
-
procByAddon.set(addonId, {
|
|
277
|
+
for (const addonId of p.addonIds ?? []) {
|
|
278
|
+
procByAddon.set(addonId, {
|
|
279
|
+
cpuPercent: p.cpuPercent,
|
|
280
|
+
memoryRss: p.memoryRss,
|
|
281
|
+
state: p.state,
|
|
282
|
+
})
|
|
268
283
|
}
|
|
269
284
|
}
|
|
270
285
|
for (const a of allNodeAddons) {
|
|
@@ -301,10 +316,7 @@ export async function computeTopology(
|
|
|
301
316
|
lastSeen: new Date().toISOString(),
|
|
302
317
|
localIps: node.isHub ? getLocalIps() : (node.localIps ?? []),
|
|
303
318
|
addons: allNodeAddons,
|
|
304
|
-
processes: [
|
|
305
|
-
mainProcess,
|
|
306
|
-
...childProcesses,
|
|
307
|
-
],
|
|
319
|
+
processes: [mainProcess, ...childProcesses],
|
|
308
320
|
categories: categoriesProjection,
|
|
309
321
|
}
|
|
310
322
|
})
|
|
@@ -319,7 +331,11 @@ export async function computeTopology(
|
|
|
319
331
|
* `addon-registry.service.ts`; consider hoisting if a third call site appears.
|
|
320
332
|
*/
|
|
321
333
|
interface BrokerLike {
|
|
322
|
-
call<T = unknown>(
|
|
334
|
+
call<T = unknown>(
|
|
335
|
+
action: string,
|
|
336
|
+
params?: unknown,
|
|
337
|
+
opts?: { nodeID?: string; timeout?: number },
|
|
338
|
+
): Promise<T>
|
|
323
339
|
}
|
|
324
340
|
|
|
325
341
|
export function buildNodesProvider(
|
|
@@ -335,9 +351,13 @@ export function buildNodesProvider(
|
|
|
335
351
|
return { success: true }
|
|
336
352
|
},
|
|
337
353
|
undeployAddon: async (input) => {
|
|
338
|
-
await broker.call(
|
|
339
|
-
|
|
340
|
-
|
|
354
|
+
await broker.call(
|
|
355
|
+
'$agent.undeploy',
|
|
356
|
+
{
|
|
357
|
+
addonId: input.addonId,
|
|
358
|
+
},
|
|
359
|
+
{ nodeID: input.nodeId, timeout: 30_000 },
|
|
360
|
+
)
|
|
341
361
|
return { success: true }
|
|
342
362
|
},
|
|
343
363
|
restartAddon: async (input) => {
|
|
@@ -350,31 +370,47 @@ export function buildNodesProvider(
|
|
|
350
370
|
return { success: true }
|
|
351
371
|
}
|
|
352
372
|
const agentNodeId = input.nodeId.includes('/') ? input.nodeId.split('/')[0]! : input.nodeId
|
|
353
|
-
await broker.call(
|
|
354
|
-
|
|
355
|
-
|
|
373
|
+
await broker.call(
|
|
374
|
+
'$agent.restart',
|
|
375
|
+
{
|
|
376
|
+
addonId: input.addonId,
|
|
377
|
+
},
|
|
378
|
+
{ nodeID: agentNodeId, timeout: 30_000 },
|
|
379
|
+
)
|
|
356
380
|
return { success: true }
|
|
357
381
|
},
|
|
358
382
|
restartProcess: async (input) => {
|
|
359
|
-
return await broker.call(
|
|
360
|
-
|
|
361
|
-
|
|
383
|
+
return (await broker.call(
|
|
384
|
+
'$process.restart',
|
|
385
|
+
{
|
|
386
|
+
name: input.processName,
|
|
387
|
+
},
|
|
388
|
+
{ nodeID: input.nodeId, timeout: 30_000 },
|
|
389
|
+
)) as { success: boolean; reason?: string }
|
|
362
390
|
},
|
|
363
391
|
restartNode: async (input) => {
|
|
364
|
-
return await broker.call(
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
392
|
+
return (await broker.call(
|
|
393
|
+
'$process.restartAll',
|
|
394
|
+
{},
|
|
395
|
+
{
|
|
396
|
+
nodeID: input.nodeId,
|
|
397
|
+
timeout: 60_000,
|
|
398
|
+
},
|
|
399
|
+
)) as { restarted: readonly string[]; failed: readonly string[] }
|
|
368
400
|
},
|
|
369
401
|
shutdownNode: async (input) => {
|
|
370
402
|
if (input.nodeId === 'hub') {
|
|
371
403
|
setTimeout(() => process.exit(0), 500)
|
|
372
404
|
return { success: true }
|
|
373
405
|
}
|
|
374
|
-
await broker.call(
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
406
|
+
await broker.call(
|
|
407
|
+
'$agent.shutdown',
|
|
408
|
+
{},
|
|
409
|
+
{
|
|
410
|
+
nodeID: input.nodeId,
|
|
411
|
+
timeout: 10_000,
|
|
412
|
+
},
|
|
413
|
+
)
|
|
378
414
|
return { success: true }
|
|
379
415
|
},
|
|
380
416
|
renameNode: async (input) => {
|
|
@@ -387,9 +423,13 @@ export function buildNodesProvider(
|
|
|
387
423
|
value: trimmed,
|
|
388
424
|
})
|
|
389
425
|
} else {
|
|
390
|
-
await broker.call(
|
|
391
|
-
|
|
392
|
-
|
|
426
|
+
await broker.call(
|
|
427
|
+
'$agent.rename',
|
|
428
|
+
{
|
|
429
|
+
name: trimmed,
|
|
430
|
+
},
|
|
431
|
+
{ nodeID: input.nodeId, timeout: 10_000 },
|
|
432
|
+
)
|
|
393
433
|
agentRegistry.updateAgentName(input.nodeId, trimmed)
|
|
394
434
|
}
|
|
395
435
|
return { nodeId: input.nodeId, name: trimmed }
|
|
@@ -436,17 +476,50 @@ export function buildNodesProvider(
|
|
|
436
476
|
const remoteStatuses = await Promise.all(
|
|
437
477
|
remoteNodes.map(async (node) => {
|
|
438
478
|
try {
|
|
439
|
-
const status = await broker.call(
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
479
|
+
const status = (await broker.call(
|
|
480
|
+
'$agent.status',
|
|
481
|
+
{},
|
|
482
|
+
{
|
|
483
|
+
nodeID: node.info.id,
|
|
484
|
+
timeout: 5_000,
|
|
485
|
+
},
|
|
486
|
+
)) as {
|
|
487
|
+
addons?: readonly {
|
|
488
|
+
id: string
|
|
489
|
+
status: string
|
|
490
|
+
version?: string
|
|
491
|
+
packageName?: string
|
|
492
|
+
}[]
|
|
493
|
+
}
|
|
494
|
+
return {
|
|
495
|
+
nodeId: node.info.id,
|
|
496
|
+
name: node.info.name,
|
|
497
|
+
online: true,
|
|
498
|
+
addons: status.addons ?? [],
|
|
499
|
+
}
|
|
443
500
|
} catch {
|
|
444
|
-
return {
|
|
501
|
+
return {
|
|
502
|
+
nodeId: node.info.id,
|
|
503
|
+
name: node.info.name,
|
|
504
|
+
online: false,
|
|
505
|
+
addons: [] as readonly {
|
|
506
|
+
id: string
|
|
507
|
+
status: string
|
|
508
|
+
version?: string
|
|
509
|
+
packageName?: string
|
|
510
|
+
}[],
|
|
511
|
+
}
|
|
445
512
|
}
|
|
446
513
|
}),
|
|
447
514
|
)
|
|
448
515
|
|
|
449
|
-
type NodeDeployment = {
|
|
516
|
+
type NodeDeployment = {
|
|
517
|
+
nodeId: string
|
|
518
|
+
name: string
|
|
519
|
+
version: string
|
|
520
|
+
status: string
|
|
521
|
+
synced: boolean
|
|
522
|
+
}
|
|
450
523
|
const result: Record<string, { hubVersion: string; nodes: NodeDeployment[] }> = {}
|
|
451
524
|
|
|
452
525
|
for (const [addonId, hubAddon] of hubMap) {
|
|
@@ -486,9 +559,13 @@ export function buildNodesProvider(
|
|
|
486
559
|
// also work — both are safe to run in parallel during Phase E.
|
|
487
560
|
const reachedViaUds = moleculer.setChildLogLevelByNodeId(input.nodeId, input.level)
|
|
488
561
|
if (!reachedViaUds) {
|
|
489
|
-
await broker.call(
|
|
490
|
-
|
|
491
|
-
|
|
562
|
+
await broker.call(
|
|
563
|
+
'$node-mgmt.setLogLevel',
|
|
564
|
+
{
|
|
565
|
+
level: input.level,
|
|
566
|
+
},
|
|
567
|
+
{ nodeID: input.nodeId, timeout: 5_000 },
|
|
568
|
+
)
|
|
492
569
|
}
|
|
493
570
|
return { success: true }
|
|
494
571
|
},
|
|
@@ -515,9 +592,11 @@ function requireIntegrationRegistry(ar: AddonRegistryService): IIntegrationRegis
|
|
|
515
592
|
}
|
|
516
593
|
|
|
517
594
|
function isDeviceProvider(value: unknown): value is IDeviceProvider {
|
|
518
|
-
return
|
|
519
|
-
|
|
520
|
-
|
|
595
|
+
return (
|
|
596
|
+
value !== null &&
|
|
597
|
+
typeof value === 'object' &&
|
|
598
|
+
typeof Reflect.get(value, 'discoverDevices') === 'function'
|
|
599
|
+
)
|
|
521
600
|
}
|
|
522
601
|
|
|
523
602
|
function getDeviceProvider(ar: AddonRegistryService, addonId: string): IDeviceProvider | null {
|
|
@@ -525,17 +604,33 @@ function getDeviceProvider(ar: AddonRegistryService, addonId: string): IDevicePr
|
|
|
525
604
|
return isDeviceProvider(provider) ? provider : null
|
|
526
605
|
}
|
|
527
606
|
|
|
607
|
+
/**
|
|
608
|
+
* Marker caps that flag an addon as a creatable integration type:
|
|
609
|
+
* - `device-provider` — classic providers (Reolink/ONVIF/Frigate)
|
|
610
|
+
* that expose `createDevice` + `discoverDevices` via their
|
|
611
|
+
* device-provider cap.
|
|
612
|
+
* - `device-adoption` — integration-style providers (Home Assistant
|
|
613
|
+
* and future siblings) that materialise devices via a generic
|
|
614
|
+
* adoption cap instead of a manual create-form. The picker treats
|
|
615
|
+
* them the same way; the wizard's discovery step routes through the
|
|
616
|
+
* specific cap based on the addon's declared surface.
|
|
617
|
+
*
|
|
618
|
+
* Exported so the integration-markers spec can assert the recognised set
|
|
619
|
+
* without booting the whole provider factory.
|
|
620
|
+
*/
|
|
621
|
+
export const INTEGRATION_CAP_MARKERS = new Set(['device-provider', 'device-adoption'])
|
|
622
|
+
|
|
528
623
|
export function buildIntegrationsProvider(
|
|
529
624
|
ar: AddonRegistryService,
|
|
530
625
|
eb: EventBusService,
|
|
531
626
|
loggingService: LoggingService,
|
|
627
|
+
capabilityRegistry: CapabilityRegistry | null,
|
|
532
628
|
): IIntegrationsProvider {
|
|
533
629
|
const logger = loggingService.createLogger('integrations')
|
|
534
630
|
const withProcessState = (i: Integration): IntegrationWithProcessState => ({
|
|
535
631
|
...i,
|
|
536
632
|
processState:
|
|
537
|
-
ar.listAddons().find(a => a.manifest.id === i.addonId)?.process?.state
|
|
538
|
-
?? 'unknown',
|
|
633
|
+
ar.listAddons().find((a) => a.manifest.id === i.addonId)?.process?.state ?? 'unknown',
|
|
539
634
|
})
|
|
540
635
|
|
|
541
636
|
return {
|
|
@@ -548,25 +643,27 @@ export function buildIntegrationsProvider(
|
|
|
548
643
|
if (!integration) throw new Error(`Integration "${input.id}" not found`)
|
|
549
644
|
return withProcessState(integration)
|
|
550
645
|
},
|
|
551
|
-
getByAddonId: async (input) =>
|
|
646
|
+
getByAddonId: async (input) =>
|
|
647
|
+
requireIntegrationRegistry(ar).getIntegrationByAddonId(input.addonId),
|
|
552
648
|
create: async (input) => {
|
|
553
649
|
const { skipRestart, ...payload } = input
|
|
554
650
|
const reg = requireIntegrationRegistry(ar)
|
|
555
651
|
|
|
556
|
-
logger.info('request', {
|
|
652
|
+
logger.info('request', {
|
|
653
|
+
tags: { addonId: input.addonId },
|
|
654
|
+
meta: { phase: 'create', name: input.name },
|
|
655
|
+
})
|
|
557
656
|
|
|
558
657
|
const addon = ar.listAddons().find((a) => a.manifest.id === input.addonId)
|
|
559
658
|
const instanceMode =
|
|
560
659
|
addon?.declaration?.instanceMode ?? addon?.manifest?.instanceMode ?? 'multiple'
|
|
561
660
|
if (instanceMode === 'unique') {
|
|
562
|
-
const existing = (await reg.listIntegrations()).filter(
|
|
563
|
-
(i) => i.addonId === input.addonId,
|
|
564
|
-
)
|
|
661
|
+
const existing = (await reg.listIntegrations()).filter((i) => i.addonId === input.addonId)
|
|
565
662
|
if (existing.length > 0) {
|
|
566
|
-
logger.warn(
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
)
|
|
663
|
+
logger.warn('rejected duplicate unique', {
|
|
664
|
+
tags: { addonId: input.addonId, integrationId: existing[0]!.id },
|
|
665
|
+
meta: { phase: 'create' },
|
|
666
|
+
})
|
|
570
667
|
throw new Error(
|
|
571
668
|
`Addon "${input.addonId}" is unique-instance and already has an integration (${existing[0]!.id})`,
|
|
572
669
|
)
|
|
@@ -574,15 +671,26 @@ export function buildIntegrationsProvider(
|
|
|
574
671
|
}
|
|
575
672
|
|
|
576
673
|
const integration = await reg.createIntegration(payload)
|
|
577
|
-
logger.info('persisted', {
|
|
674
|
+
logger.info('persisted', {
|
|
675
|
+
tags: { integrationId: integration.id, addonId: integration.addonId },
|
|
676
|
+
meta: { phase: 'create' },
|
|
677
|
+
})
|
|
578
678
|
|
|
579
679
|
const hasSettings = input.settings != null && Object.keys(input.settings).length > 0
|
|
580
680
|
if (!skipRestart && hasSettings) {
|
|
581
|
-
logger.info('settings present — restarting addon', {
|
|
681
|
+
logger.info('settings present — restarting addon', {
|
|
682
|
+
tags: { addonId: input.addonId },
|
|
683
|
+
meta: { phase: 'create' },
|
|
684
|
+
})
|
|
582
685
|
await ar.restartAddon(input.addonId)
|
|
583
|
-
logger.info('addon restart complete', {
|
|
686
|
+
logger.info('addon restart complete', {
|
|
687
|
+
tags: { addonId: input.addonId },
|
|
688
|
+
meta: { phase: 'create' },
|
|
689
|
+
})
|
|
584
690
|
} else {
|
|
585
|
-
logger.info('skipping restart (no settings or skipRestart=true)', {
|
|
691
|
+
logger.info('skipping restart (no settings or skipRestart=true)', {
|
|
692
|
+
meta: { phase: 'create' },
|
|
693
|
+
})
|
|
586
694
|
}
|
|
587
695
|
return integration
|
|
588
696
|
},
|
|
@@ -597,22 +705,21 @@ export function buildIntegrationsProvider(
|
|
|
597
705
|
const changedFields = Object.keys(updates).filter(
|
|
598
706
|
(k) => (updates as Record<string, unknown>)[k] !== undefined,
|
|
599
707
|
)
|
|
600
|
-
logger.info(
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
)
|
|
708
|
+
logger.info('request', {
|
|
709
|
+
tags: { integrationId: input.id, addonId: previous.addonId },
|
|
710
|
+
meta: { phase: 'update', fields: changedFields },
|
|
711
|
+
})
|
|
604
712
|
|
|
605
713
|
const result = await reg.updateIntegration(id, updates)
|
|
606
714
|
if (!result) throw new Error(`Integration "${id}" not found`)
|
|
607
715
|
|
|
608
|
-
const enabledChanged =
|
|
609
|
-
input.enabled !== undefined && input.enabled !== previous.enabled
|
|
716
|
+
const enabledChanged = input.enabled !== undefined && input.enabled !== previous.enabled
|
|
610
717
|
if (enabledChanged) {
|
|
611
718
|
const category = input.enabled ? 'integration.enabled' : 'integration.disabled'
|
|
612
|
-
logger.info(
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
)
|
|
719
|
+
logger.info('enabled state changed', {
|
|
720
|
+
tags: { integrationId: result.id, addonId: result.addonId },
|
|
721
|
+
meta: { phase: 'update', enabled: input.enabled },
|
|
722
|
+
})
|
|
616
723
|
eb.emit({
|
|
617
724
|
id: `integration-${category}-${Date.now()}`,
|
|
618
725
|
timestamp: new Date(),
|
|
@@ -626,17 +733,23 @@ export function buildIntegrationsProvider(
|
|
|
626
733
|
}
|
|
627
734
|
|
|
628
735
|
if (input.name !== undefined && input.name !== previous.name) {
|
|
629
|
-
logger.info(
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
)
|
|
736
|
+
logger.info('renamed', {
|
|
737
|
+
tags: { integrationId: result.id },
|
|
738
|
+
meta: { phase: 'update', previousName: previous.name, newName: input.name },
|
|
739
|
+
})
|
|
633
740
|
}
|
|
634
741
|
|
|
635
742
|
const infoChanged = input.info !== undefined
|
|
636
743
|
if (infoChanged) {
|
|
637
|
-
logger.info('info changed — restarting addon', {
|
|
744
|
+
logger.info('info changed — restarting addon', {
|
|
745
|
+
tags: { addonId: result.addonId },
|
|
746
|
+
meta: { phase: 'update' },
|
|
747
|
+
})
|
|
638
748
|
await ar.restartAddon(result.addonId)
|
|
639
|
-
logger.info('addon restart complete', {
|
|
749
|
+
logger.info('addon restart complete', {
|
|
750
|
+
tags: { addonId: result.addonId },
|
|
751
|
+
meta: { phase: 'update' },
|
|
752
|
+
})
|
|
640
753
|
} else {
|
|
641
754
|
logger.info('no restart needed (only enabled/name changed)', { meta: { phase: 'update' } })
|
|
642
755
|
}
|
|
@@ -651,23 +764,107 @@ export function buildIntegrationsProvider(
|
|
|
651
764
|
logger.warn('not found', { tags: { integrationId: input.id }, meta: { phase: 'delete' } })
|
|
652
765
|
throw new Error(`Integration "${input.id}" not found`)
|
|
653
766
|
}
|
|
654
|
-
logger.info(
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
)
|
|
767
|
+
logger.info('removing', {
|
|
768
|
+
tags: { integrationId: input.id, addonId: integration.addonId },
|
|
769
|
+
meta: { phase: 'delete', name: integration.name },
|
|
770
|
+
})
|
|
771
|
+
|
|
772
|
+
// Cascade-delete every live device whose integrationId matches.
|
|
773
|
+
// Best-effort: a device-removal hiccup must not abort the integration
|
|
774
|
+
// delete — log a warning and continue so the record + event always fire.
|
|
775
|
+
const dm =
|
|
776
|
+
capabilityRegistry?.getSingleton<{
|
|
777
|
+
removeByIntegration?: (input: { integrationId: string }) => Promise<{ removed: number }>
|
|
778
|
+
listAll?: (input: Record<string, never>) => Promise<
|
|
779
|
+
readonly {
|
|
780
|
+
id: number
|
|
781
|
+
addonId: string
|
|
782
|
+
parentDeviceId: number | null
|
|
783
|
+
integrationId?: string
|
|
784
|
+
}[]
|
|
785
|
+
>
|
|
786
|
+
setIntegrationId?: (input: { deviceId: number; integrationId: string }) => Promise<void>
|
|
787
|
+
}>('device-manager') ?? null
|
|
788
|
+
|
|
789
|
+
// Claim legacy un-tagged devices BEFORE the cascade. Devices created
|
|
790
|
+
// before stamping (or whose provider never stamps, e.g. `provider-rtsp`)
|
|
791
|
+
// carry no integrationId, so `removeByIntegration` (which matches on
|
|
792
|
+
// integrationId) would leave them orphaned forever once their integration
|
|
793
|
+
// is gone. While the integration record still exists, stamp the
|
|
794
|
+
// unambiguous ones (addons hosting exactly one integration) so the cascade
|
|
795
|
+
// below removes them too. Best-effort: never abort the delete.
|
|
796
|
+
if (dm?.listAll && dm?.setIntegrationId) {
|
|
797
|
+
try {
|
|
798
|
+
const [integrations, devices] = await Promise.all([
|
|
799
|
+
reg.listIntegrations(),
|
|
800
|
+
dm.listAll({}),
|
|
801
|
+
])
|
|
802
|
+
const stamps = planDeleteTimeStamps(
|
|
803
|
+
input.id,
|
|
804
|
+
integrations.map((i) => ({ id: i.id, addonId: i.addonId })),
|
|
805
|
+
devices.map((d) => ({
|
|
806
|
+
id: d.id,
|
|
807
|
+
addonId: d.addonId,
|
|
808
|
+
parentDeviceId: d.parentDeviceId,
|
|
809
|
+
...(d.integrationId !== undefined ? { integrationId: d.integrationId } : {}),
|
|
810
|
+
})),
|
|
811
|
+
)
|
|
812
|
+
for (const stamp of stamps) {
|
|
813
|
+
await dm.setIntegrationId({
|
|
814
|
+
deviceId: stamp.deviceId,
|
|
815
|
+
integrationId: stamp.integrationId,
|
|
816
|
+
})
|
|
817
|
+
}
|
|
818
|
+
if (stamps.length > 0) {
|
|
819
|
+
logger.info('claimed legacy un-tagged devices for cascade', {
|
|
820
|
+
tags: { integrationId: input.id, addonId: integration.addonId },
|
|
821
|
+
meta: { phase: 'delete', claimed: stamps.length },
|
|
822
|
+
})
|
|
823
|
+
}
|
|
824
|
+
} catch (err) {
|
|
825
|
+
logger.warn('legacy device claim failed (best-effort — continuing)', {
|
|
826
|
+
tags: { integrationId: input.id },
|
|
827
|
+
meta: { phase: 'delete', error: errMsg(err) },
|
|
828
|
+
})
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
if (dm?.removeByIntegration) {
|
|
833
|
+
try {
|
|
834
|
+
const result = await dm.removeByIntegration({ integrationId: input.id })
|
|
835
|
+
logger.info('cascade-removed devices', {
|
|
836
|
+
tags: { integrationId: input.id },
|
|
837
|
+
meta: { phase: 'delete', removed: result.removed },
|
|
838
|
+
})
|
|
839
|
+
} catch (err) {
|
|
840
|
+
logger.warn('device cascade-remove failed (best-effort — continuing)', {
|
|
841
|
+
tags: { integrationId: input.id },
|
|
842
|
+
meta: { phase: 'delete', error: errMsg(err) },
|
|
843
|
+
})
|
|
844
|
+
}
|
|
845
|
+
} else {
|
|
846
|
+
logger.warn('device-manager not available — skipping cascade device removal', {
|
|
847
|
+
tags: { integrationId: input.id },
|
|
848
|
+
meta: { phase: 'delete' },
|
|
849
|
+
})
|
|
850
|
+
}
|
|
851
|
+
|
|
658
852
|
await reg.deleteIntegration(input.id)
|
|
659
853
|
|
|
660
854
|
eb.emit({
|
|
661
855
|
id: `integration-deleted-${Date.now()}`,
|
|
662
856
|
timestamp: new Date(),
|
|
663
857
|
source: { type: 'integration', id: input.id },
|
|
664
|
-
category:
|
|
858
|
+
category: EventCategory.IntegrationDeleted,
|
|
665
859
|
data: {
|
|
666
860
|
integrationId: input.id,
|
|
667
861
|
addonId: integration.addonId,
|
|
668
862
|
},
|
|
669
863
|
})
|
|
670
|
-
logger.info('completed (no restart)', {
|
|
864
|
+
logger.info('completed (no restart)', {
|
|
865
|
+
tags: { integrationId: input.id },
|
|
866
|
+
meta: { phase: 'delete' },
|
|
867
|
+
})
|
|
671
868
|
|
|
672
869
|
return { success: true, deletedId: input.id }
|
|
673
870
|
},
|
|
@@ -681,19 +878,28 @@ export function buildIntegrationsProvider(
|
|
|
681
878
|
const reg = requireIntegrationRegistry(ar)
|
|
682
879
|
const integration = await reg.getIntegration(input.id)
|
|
683
880
|
if (!integration) {
|
|
684
|
-
logger.warn('not found', {
|
|
881
|
+
logger.warn('not found', {
|
|
882
|
+
tags: { integrationId: input.id },
|
|
883
|
+
meta: { phase: 'setSettings' },
|
|
884
|
+
})
|
|
685
885
|
throw new Error(`Integration "${input.id}" not found`)
|
|
686
886
|
}
|
|
687
887
|
const settingsKeys = Object.keys(input.settings)
|
|
688
|
-
logger.info(
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
)
|
|
888
|
+
logger.info('request', {
|
|
889
|
+
tags: { integrationId: input.id, addonId: integration.addonId },
|
|
890
|
+
meta: { phase: 'setSettings', keys: settingsKeys },
|
|
891
|
+
})
|
|
692
892
|
await reg.setIntegrationSettings(input.id, input.settings)
|
|
693
893
|
|
|
694
|
-
logger.info('persisted — restarting addon', {
|
|
894
|
+
logger.info('persisted — restarting addon', {
|
|
895
|
+
tags: { addonId: integration.addonId },
|
|
896
|
+
meta: { phase: 'setSettings' },
|
|
897
|
+
})
|
|
695
898
|
await ar.restartAddon(integration.addonId)
|
|
696
|
-
logger.info('addon restart complete', {
|
|
899
|
+
logger.info('addon restart complete', {
|
|
900
|
+
tags: { addonId: integration.addonId },
|
|
901
|
+
meta: { phase: 'setSettings' },
|
|
902
|
+
})
|
|
697
903
|
return { success: true }
|
|
698
904
|
},
|
|
699
905
|
getAvailableTypes: async () => {
|
|
@@ -704,22 +910,52 @@ export function buildIntegrationsProvider(
|
|
|
704
910
|
// integration against an addon that didn't load produces an orphaned
|
|
705
911
|
// row that `createFilteredRegistry` then filters out — silent data
|
|
706
912
|
// loss from the operator's POV. Filter at the source instead.
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
913
|
+
//
|
|
914
|
+
// Markers that flag an addon as a creatable integration type live
|
|
915
|
+
// in the module-level `INTEGRATION_CAP_MARKERS` set (exported so the
|
|
916
|
+
// integration-markers spec can assert the recognised caps).
|
|
917
|
+
const providerAddons = addons.filter(
|
|
918
|
+
(a) =>
|
|
919
|
+
a.process?.state !== 'failed' &&
|
|
920
|
+
a.manifest.capabilities?.some((c) => {
|
|
921
|
+
const name = typeof c === 'string' ? c : c.name
|
|
922
|
+
return typeof name === 'string' && INTEGRATION_CAP_MARKERS.has(name)
|
|
923
|
+
}),
|
|
712
924
|
)
|
|
713
925
|
const integrations = await reg.listIntegrations()
|
|
714
|
-
return providerAddons.map(addon => {
|
|
926
|
+
return providerAddons.map((addon) => {
|
|
715
927
|
const m = addon.manifest
|
|
716
928
|
const d = addon.declaration
|
|
717
929
|
const icon = d?.icon ?? m.icon
|
|
718
930
|
const color = d?.color ?? m.color ?? '#78716c'
|
|
719
931
|
const instanceMode = d?.instanceMode ?? m.instanceMode ?? 'multiple'
|
|
720
|
-
const existing = integrations.filter(i => i.addonId === m.id)
|
|
932
|
+
const existing = integrations.filter((i) => i.addonId === m.id)
|
|
721
933
|
const provider = getDeviceProvider(ar, m.id)
|
|
722
934
|
const discoveryMode = provider?.discoveryMode ?? 'manual'
|
|
935
|
+
|
|
936
|
+
// Branch by CAP, not by addon name. Surface which integration-marker
|
|
937
|
+
// cap the addon declared so the wizard routes `device-adoption`
|
|
938
|
+
// (Approach A: pick/create a broker, store `{ brokerId }`) vs the
|
|
939
|
+
// legacy `device-provider` config → discovery flow. A `device-adoption`
|
|
940
|
+
// marker wins when both are present (an integration-style addon may
|
|
941
|
+
// also expose a `device-provider` shim); the broker step is the
|
|
942
|
+
// intended entry point for it.
|
|
943
|
+
const capNames = (m.capabilities ?? []).map((c) => (typeof c === 'string' ? c : c.name))
|
|
944
|
+
const kind: 'device-adoption' | 'device-provider' = capNames.includes('device-adoption')
|
|
945
|
+
? 'device-adoption'
|
|
946
|
+
: 'device-provider'
|
|
947
|
+
|
|
948
|
+
// For device-adoption addons, the broker kind to create/link comes
|
|
949
|
+
// from the addon manifest (`brokerKind`). Null for device-provider
|
|
950
|
+
// addons, which carry no broker.
|
|
951
|
+
const brokerKind =
|
|
952
|
+
kind === 'device-adoption' ? (d?.brokerKind ?? m.brokerKind ?? null) : null
|
|
953
|
+
|
|
954
|
+
const supportsLocationImport =
|
|
955
|
+
kind === 'device-adoption'
|
|
956
|
+
? (d?.supportsLocationImport ?? m.supportsLocationImport ?? false)
|
|
957
|
+
: false
|
|
958
|
+
|
|
723
959
|
return {
|
|
724
960
|
addonId: m.id,
|
|
725
961
|
name: m.name ?? m.id,
|
|
@@ -728,7 +964,10 @@ export function buildIntegrationsProvider(
|
|
|
728
964
|
color,
|
|
729
965
|
instanceMode,
|
|
730
966
|
discoveryMode,
|
|
731
|
-
|
|
967
|
+
kind,
|
|
968
|
+
brokerKind,
|
|
969
|
+
supportsLocationImport,
|
|
970
|
+
existingInstances: existing.map((i) => ({
|
|
732
971
|
id: i.id,
|
|
733
972
|
name: i.name,
|
|
734
973
|
})),
|
|
@@ -737,14 +976,55 @@ export function buildIntegrationsProvider(
|
|
|
737
976
|
})
|
|
738
977
|
},
|
|
739
978
|
testConnection: async (input) => {
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
979
|
+
// Broker-backed integrations (Approach A) carry their connection
|
|
980
|
+
// identity as a `brokerId` in settings — testing is a broker
|
|
981
|
+
// concern now, so delegate to the addon's `broker` cap. The broker
|
|
982
|
+
// already owns the real semantic check (HA opens a temporary WS
|
|
983
|
+
// handshake; MQTT pings the bridge). We translate the broker's
|
|
984
|
+
// discriminated result (`{ok:true,latencyMs}|{ok:false,error}`) into
|
|
985
|
+
// the integrations `{success, error?}` output shape. Falls back to
|
|
986
|
+
// the default RTSP/ffprobe path below for legacy device-provider
|
|
987
|
+
// addons (Reolink/Frigate/ONVIF) that probe a stream URL.
|
|
988
|
+
const registry = ar.getCapabilityRegistry()
|
|
989
|
+
const brokerId = input.settings['brokerId']
|
|
990
|
+
if (typeof brokerId === 'string' && brokerId.length > 0) {
|
|
991
|
+
const brokerProvider = registry.getProviderByAddonId<IBrokerProvider>(
|
|
992
|
+
'broker',
|
|
993
|
+
input.addonId,
|
|
994
|
+
)
|
|
995
|
+
if (!brokerProvider) {
|
|
996
|
+
return {
|
|
997
|
+
success: false,
|
|
998
|
+
error: `Broker provider for addon '${input.addonId}' is not available`,
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
try {
|
|
1002
|
+
const result = await brokerProvider.testConnection({ id: brokerId })
|
|
1003
|
+
return result.ok ? { success: true } : { success: false, error: result.error }
|
|
1004
|
+
} catch (err) {
|
|
1005
|
+
return { success: false, error: errMsg(err) }
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// Default — RTSP/Frigate/ONVIF legacy path: probe the stream URL.
|
|
1010
|
+
const url = String(input.settings['main_stream_url'] ?? input.settings['url'] ?? '').trim()
|
|
743
1011
|
if (!url) return { success: false, error: 'No stream URL provided' }
|
|
744
1012
|
try {
|
|
745
1013
|
const { stdout } = await execFileAsync(
|
|
746
1014
|
'ffprobe',
|
|
747
|
-
[
|
|
1015
|
+
[
|
|
1016
|
+
'-v',
|
|
1017
|
+
'error',
|
|
1018
|
+
'-rtsp_transport',
|
|
1019
|
+
'tcp',
|
|
1020
|
+
'-timeout',
|
|
1021
|
+
'3000000',
|
|
1022
|
+
'-show_entries',
|
|
1023
|
+
'stream=codec_name,width,height',
|
|
1024
|
+
'-of',
|
|
1025
|
+
'json',
|
|
1026
|
+
url,
|
|
1027
|
+
],
|
|
748
1028
|
{ timeout: 5000 },
|
|
749
1029
|
)
|
|
750
1030
|
const parsed = asJsonObject(JSON.parse(stdout))
|
|
@@ -839,7 +1119,9 @@ export function buildAddonsProvider(
|
|
|
839
1119
|
|
|
840
1120
|
const bulkCoordinator = new BulkUpdateCoordinator({
|
|
841
1121
|
eventBus: bulkEventBus,
|
|
842
|
-
updateAddon: async (i) => {
|
|
1122
|
+
updateAddon: async (i) => {
|
|
1123
|
+
await ps.updatePackage(i.name, i.version)
|
|
1124
|
+
},
|
|
843
1125
|
updateFrameworkPackage: async (i) => {
|
|
844
1126
|
await ps.updateFrameworkPackage({
|
|
845
1127
|
packageName: i.packageName,
|
|
@@ -858,7 +1140,10 @@ export function buildAddonsProvider(
|
|
|
858
1140
|
return {
|
|
859
1141
|
list: async () => {
|
|
860
1142
|
const rollbackable = ps.getRollbackablePackages()
|
|
861
|
-
const healthByPackage = new Map<
|
|
1143
|
+
const healthByPackage = new Map<
|
|
1144
|
+
string,
|
|
1145
|
+
ReturnType<typeof ar.getAddonHealthSnapshot>[number]
|
|
1146
|
+
>()
|
|
862
1147
|
for (const h of ar.getAddonHealthSnapshot()) {
|
|
863
1148
|
healthByPackage.set(h.packageName, h)
|
|
864
1149
|
}
|
|
@@ -868,11 +1153,12 @@ export function buildAddonsProvider(
|
|
|
868
1153
|
health: healthByPackage.get(item.manifest.packageName) ?? null,
|
|
869
1154
|
}))
|
|
870
1155
|
},
|
|
871
|
-
getLogs: async (input) =>
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
1156
|
+
getLogs: async (input) =>
|
|
1157
|
+
ls.query({
|
|
1158
|
+
tags: { addonId: input.addonId },
|
|
1159
|
+
limit: input.limit,
|
|
1160
|
+
level: input.level,
|
|
1161
|
+
}),
|
|
876
1162
|
listPackages: async () => ps.listInstalled(),
|
|
877
1163
|
installPackage: async (input) => ps.installAndLoad(input.packageName, input.version),
|
|
878
1164
|
installFromWorkspace: async (input) => ps.installFromWorkspaceAndLoad(input.packageName),
|
|
@@ -890,10 +1176,11 @@ export function buildAddonsProvider(
|
|
|
890
1176
|
},
|
|
891
1177
|
listUpdates: async (input) => {
|
|
892
1178
|
const nodeId = input.nodeId
|
|
893
|
-
const updates =
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
1179
|
+
const updates =
|
|
1180
|
+
nodeId === undefined || isHubNode(nodeId)
|
|
1181
|
+
? await ps.checkUpdates()
|
|
1182
|
+
: await ps.checkUpdatesForInstalled(await fetchAgentInstalledPackages(broker, nodeId))
|
|
1183
|
+
return updates.map((u) => ({ ...u, isSystem: frameworkAllowSet.has(u.name) }))
|
|
897
1184
|
},
|
|
898
1185
|
updatePackage: async (input) => {
|
|
899
1186
|
const nodeId = input.nodeId
|
|
@@ -903,7 +1190,11 @@ export function buildAddonsProvider(
|
|
|
903
1190
|
// Agent target: the hub packs the resolved version and ships the
|
|
904
1191
|
// tarball over `$agent.deploy` — the agent has no npm runtime.
|
|
905
1192
|
const packed = await ps.packPackage(input.name, input.version)
|
|
906
|
-
await broker.call(
|
|
1193
|
+
await broker.call(
|
|
1194
|
+
'$agent.deploy',
|
|
1195
|
+
{ addonId: input.name, bundle: packed.buffer },
|
|
1196
|
+
{ nodeID: nodeId, timeout: 120_000 },
|
|
1197
|
+
)
|
|
907
1198
|
await broker.call('$agent.reload', {}, { nodeID: nodeId, timeout: 120_000 })
|
|
908
1199
|
return { success: true, name: input.name, version: packed.version, nodeId }
|
|
909
1200
|
},
|
|
@@ -929,14 +1220,12 @@ export function buildAddonsProvider(
|
|
|
929
1220
|
const caps = registry.listCapabilities()
|
|
930
1221
|
const found = caps.find((c) => c.name === input.capName)
|
|
931
1222
|
if (!found) return []
|
|
932
|
-
const mode = found.mode === 'collection' ? 'collection' as const : 'singleton' as const
|
|
1223
|
+
const mode = found.mode === 'collection' ? ('collection' as const) : ('singleton' as const)
|
|
933
1224
|
const disabled = new Set(found.disabledProviders)
|
|
934
1225
|
return found.providers.map((addonId) => ({
|
|
935
1226
|
addonId,
|
|
936
1227
|
mode,
|
|
937
|
-
isActive: mode === 'collection'
|
|
938
|
-
? !disabled.has(addonId)
|
|
939
|
-
: found.activeProvider === addonId,
|
|
1228
|
+
isActive: mode === 'collection' ? !disabled.has(addonId) : found.activeProvider === addonId,
|
|
940
1229
|
}))
|
|
941
1230
|
},
|
|
942
1231
|
setCapabilityProviderEnabled: async (input) => {
|
|
@@ -970,23 +1259,20 @@ export function buildAddonsProvider(
|
|
|
970
1259
|
// Reuses the same `capabilities.collection.<cap>` key/format the
|
|
971
1260
|
// `capabilities` core router writes — via the shared canonical writer.
|
|
972
1261
|
const updated = registry.listCapabilities().find((c) => c.name === input.capName)
|
|
973
|
-
persistCollectionDisabled(
|
|
974
|
-
configService,
|
|
975
|
-
input.capName,
|
|
976
|
-
updated?.disabledProviders ?? [],
|
|
977
|
-
)
|
|
1262
|
+
persistCollectionDisabled(configService, input.capName, updated?.disabledProviders ?? [])
|
|
978
1263
|
return { success: true as const }
|
|
979
1264
|
},
|
|
980
|
-
updateFrameworkPackage: async (input) =>
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
1265
|
+
updateFrameworkPackage: async (input) =>
|
|
1266
|
+
ps.updateFrameworkPackage({
|
|
1267
|
+
packageName: input.packageName,
|
|
1268
|
+
...(input.version !== undefined ? { version: input.version } : {}),
|
|
1269
|
+
...(ctx.user?.username !== undefined
|
|
1270
|
+
? { requestedBy: ctx.user.username }
|
|
1271
|
+
: ctx.user?.id !== undefined
|
|
1272
|
+
? { requestedBy: ctx.user.id }
|
|
1273
|
+
: {}),
|
|
1274
|
+
...(input.deferRestart !== undefined ? { deferRestart: input.deferRestart } : {}),
|
|
1275
|
+
}),
|
|
990
1276
|
getVersions: async (input) => ps.getPackageVersions(input.name),
|
|
991
1277
|
restartAddon: async (input) => ar.restartAddon(input.addonId),
|
|
992
1278
|
retryLoad: async (input) => {
|
|
@@ -994,7 +1280,8 @@ export function buildAddonsProvider(
|
|
|
994
1280
|
return { success: true as const }
|
|
995
1281
|
},
|
|
996
1282
|
getAutoUpdateSettings: async () => ps.getAutoUpdateSettings(),
|
|
997
|
-
setAutoUpdateSettings: async (input) =>
|
|
1283
|
+
setAutoUpdateSettings: async (input) =>
|
|
1284
|
+
ps.setAutoUpdateSettings(input.channel, input.intervalSeconds),
|
|
998
1285
|
getAddonAutoUpdate: async (input) => ps.getAddonAutoUpdate(input.addonId),
|
|
999
1286
|
setAddonAutoUpdate: async (input) => ps.setAddonAutoUpdate(input.addonId, input.channel),
|
|
1000
1287
|
applyAutoUpdateToAll: async (input) => {
|
|
@@ -1025,11 +1312,17 @@ export function buildAddonsProvider(
|
|
|
1025
1312
|
onAddonLogs: (input, push) => {
|
|
1026
1313
|
const unsubscribe = ls.subscribe(
|
|
1027
1314
|
{ tags: { addonId: input.addonId }, level: input.level },
|
|
1028
|
-
(entry: {
|
|
1315
|
+
(entry: {
|
|
1316
|
+
timestamp: Date | string | number
|
|
1317
|
+
level: string
|
|
1318
|
+
message: string
|
|
1319
|
+
scope?: string
|
|
1320
|
+
}) => {
|
|
1029
1321
|
push({
|
|
1030
|
-
timestamp:
|
|
1031
|
-
|
|
1032
|
-
|
|
1322
|
+
timestamp:
|
|
1323
|
+
entry.timestamp instanceof Date
|
|
1324
|
+
? entry.timestamp.toISOString()
|
|
1325
|
+
: String(entry.timestamp),
|
|
1033
1326
|
level: entry.level,
|
|
1034
1327
|
message: entry.message,
|
|
1035
1328
|
scope: entry.scope,
|