@camstack/server 0.1.3
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/.env.example +17 -0
- package/package.json +55 -0
- package/src/__tests__/addon-install-e2e.test.ts +75 -0
- package/src/__tests__/addon-pages-e2e.test.ts +178 -0
- package/src/__tests__/addon-route-session.test.ts +17 -0
- package/src/__tests__/addon-settings-router.spec.ts +62 -0
- package/src/__tests__/addon-upload.spec.ts +355 -0
- package/src/__tests__/agent-registry.spec.ts +162 -0
- package/src/__tests__/agent-status-page.spec.ts +84 -0
- package/src/__tests__/auth-session-cookie.test.ts +21 -0
- package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +23 -0
- package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +64 -0
- package/src/__tests__/cap-routers/_meta.spec.ts +200 -0
- package/src/__tests__/cap-routers/addon-settings.router.spec.ts +106 -0
- package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +142 -0
- package/src/__tests__/cap-routers/harness.ts +159 -0
- package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +119 -0
- package/src/__tests__/cap-routers/null-provider-guard.spec.ts +66 -0
- package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +135 -0
- package/src/__tests__/cap-routers/settings-store.router.spec.ts +247 -0
- package/src/__tests__/capability-e2e.test.ts +386 -0
- package/src/__tests__/cli-e2e.test.ts +129 -0
- package/src/__tests__/core-cap-bridge.spec.ts +89 -0
- package/src/__tests__/embedded-deps-e2e.test.ts +109 -0
- package/src/__tests__/event-bus-proxy-router.spec.ts +72 -0
- package/src/__tests__/fixtures/mock-analysis-addon-a.ts +37 -0
- package/src/__tests__/fixtures/mock-analysis-addon-b.ts +37 -0
- package/src/__tests__/fixtures/mock-log-addon.ts +37 -0
- package/src/__tests__/fixtures/mock-storage-addon.ts +40 -0
- package/src/__tests__/framework-allowlist.spec.ts +95 -0
- package/src/__tests__/https-e2e.test.ts +118 -0
- package/src/__tests__/lifecycle-e2e.test.ts +140 -0
- package/src/__tests__/live-events-subscription.spec.ts +150 -0
- package/src/__tests__/moleculer-register-node-idempotency.spec.ts +229 -0
- package/src/__tests__/oauth2-account-linking.spec.ts +736 -0
- package/src/__tests__/post-boot-restart.spec.ts +161 -0
- package/src/__tests__/singleton-contention.test.ts +487 -0
- package/src/__tests__/streaming-diagnostic.test.ts +512 -0
- package/src/__tests__/streaming-scale.test.ts +280 -0
- package/src/agent-status-page.ts +121 -0
- package/src/api/__tests__/addons-custom.spec.ts +134 -0
- package/src/api/__tests__/capabilities.router.test.ts +47 -0
- package/src/api/addon-upload.ts +472 -0
- package/src/api/addons-custom.router.ts +100 -0
- package/src/api/auth-whoami.ts +99 -0
- package/src/api/bridge-addons.router.ts +120 -0
- package/src/api/capabilities.router.ts +226 -0
- package/src/api/core/__tests__/auth-router-totp.spec.ts +256 -0
- package/src/api/core/addon-settings.router.ts +124 -0
- package/src/api/core/agents.router.ts +87 -0
- package/src/api/core/auth.router.ts +303 -0
- package/src/api/core/cap-providers.ts +993 -0
- package/src/api/core/capabilities.router.ts +119 -0
- package/src/api/core/collection-preference.ts +40 -0
- package/src/api/core/event-bus-proxy.router.ts +45 -0
- package/src/api/core/hwaccel.router.ts +81 -0
- package/src/api/core/live-events.router.ts +60 -0
- package/src/api/core/logs.router.ts +162 -0
- package/src/api/core/notifications.router.ts +65 -0
- package/src/api/core/repl.router.ts +41 -0
- package/src/api/core/settings-backend.router.ts +142 -0
- package/src/api/core/stream-probe.router.ts +57 -0
- package/src/api/core/system-events.router.ts +116 -0
- package/src/api/health/health.routes.ts +123 -0
- package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +52 -0
- package/src/api/oauth2/consent-page.ts +42 -0
- package/src/api/oauth2/oauth2-routes.ts +248 -0
- package/src/api/trpc/__tests__/scope-access-device.spec.ts +223 -0
- package/src/api/trpc/__tests__/scope-access.spec.ts +107 -0
- package/src/api/trpc/cap-mount-helpers.ts +225 -0
- package/src/api/trpc/core-cap-bridge.ts +152 -0
- package/src/api/trpc/generated-cap-mounts.ts +707 -0
- package/src/api/trpc/generated-cap-routers.ts +6340 -0
- package/src/api/trpc/scope-access.ts +110 -0
- package/src/api/trpc/trpc.context.ts +255 -0
- package/src/api/trpc/trpc.middleware.ts +140 -0
- package/src/api/trpc/trpc.router.ts +275 -0
- package/src/auth/session-cookie.ts +44 -0
- package/src/boot/boot-config.ts +278 -0
- package/src/boot/post-boot.service.ts +103 -0
- package/src/core/addon/__tests__/addon-registry-capability.test.ts +53 -0
- package/src/core/addon/addon-package.service.ts +1684 -0
- package/src/core/addon/addon-registry.service.ts +2926 -0
- package/src/core/addon/addon-search.service.ts +90 -0
- package/src/core/addon/addon-settings-provider.ts +276 -0
- package/src/core/addon/addon.tokens.ts +2 -0
- package/src/core/addon-bridge/addon-bridge.service.ts +125 -0
- package/src/core/addon-pages/addon-pages.service.spec.ts +117 -0
- package/src/core/addon-pages/addon-pages.service.ts +80 -0
- package/src/core/addon-widgets/addon-widgets.service.ts +92 -0
- package/src/core/agent/agent-registry.service.ts +507 -0
- package/src/core/auth/auth.service.spec.ts +88 -0
- package/src/core/auth/auth.service.ts +8 -0
- package/src/core/capability/capability.service.ts +57 -0
- package/src/core/config/config.schema.ts +3 -0
- package/src/core/config/config.service.spec.ts +175 -0
- package/src/core/config/config.service.ts +7 -0
- package/src/core/events/event-bus.service.spec.ts +212 -0
- package/src/core/events/event-bus.service.ts +85 -0
- package/src/core/feature/feature.service.spec.ts +96 -0
- package/src/core/feature/feature.service.ts +8 -0
- package/src/core/lifecycle/lifecycle-state-machine.spec.ts +168 -0
- package/src/core/lifecycle/lifecycle-state-machine.ts +3 -0
- package/src/core/logging/log-ring-buffer.ts +3 -0
- package/src/core/logging/logging.service.spec.ts +247 -0
- package/src/core/logging/logging.service.ts +129 -0
- package/src/core/logging/scoped-logger.ts +3 -0
- package/src/core/moleculer/moleculer.service.ts +612 -0
- package/src/core/network/network-quality.service.spec.ts +47 -0
- package/src/core/network/network-quality.service.ts +5 -0
- package/src/core/notification/notification-wrapper.service.ts +36 -0
- package/src/core/notification/toast-wrapper.service.ts +31 -0
- package/src/core/provider/provider.tokens.ts +1 -0
- package/src/core/repl/repl-engine.service.spec.ts +417 -0
- package/src/core/repl/repl-engine.service.ts +156 -0
- package/src/core/storage/fs-storage-backend.spec.ts +70 -0
- package/src/core/storage/fs-storage-backend.ts +3 -0
- package/src/core/storage/settings-store.spec.ts +213 -0
- package/src/core/storage/settings-store.ts +2 -0
- package/src/core/storage/sql-schema.spec.ts +140 -0
- package/src/core/storage/sql-schema.ts +3 -0
- package/src/core/storage/storage-location-manager.spec.ts +121 -0
- package/src/core/storage/storage-location-manager.ts +3 -0
- package/src/core/storage/storage.service.spec.ts +73 -0
- package/src/core/storage/storage.service.ts +3 -0
- package/src/core/streaming/stream-probe.service.ts +212 -0
- package/src/core/topology/topology-emitter.service.ts +101 -0
- package/src/launcher.ts +309 -0
- package/src/main.ts +1049 -0
- package/src/manual-boot.ts +322 -0
- package/tsconfig.build.json +8 -0
- package/tsconfig.json +21 -0
- package/vitest.config.ts +26 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-unsafe-argument -- test file, mock typing */
|
|
2
|
+
/**
|
|
3
|
+
* Scale test — how many sub-streams can we decode for motion analysis?
|
|
4
|
+
* Run: FRIGATE_HOST=192.168.1.128 npx vitest run server/backend/src/__tests__/streaming-scale.test.ts --reporter verbose
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect, afterAll } from 'vitest'
|
|
7
|
+
import http from 'node:http'
|
|
8
|
+
import { StreamBrokerManager, FfmpegDecoderProvider } from '@camstack/addon-pipeline/stream-broker'
|
|
9
|
+
import type { FrameHandle, IStreamBroker } from '@camstack/types'
|
|
10
|
+
import type { IScopedLogger } from '@camstack/types'
|
|
11
|
+
|
|
12
|
+
const FRIGATE = process.env.FRIGATE_HOST ?? ''
|
|
13
|
+
|
|
14
|
+
const mockLogger: IScopedLogger = {
|
|
15
|
+
debug: () => {}, info: () => {}, warn: console.warn, error: console.error,
|
|
16
|
+
child: () => mockLogger,
|
|
17
|
+
}
|
|
18
|
+
const mockLoggingService = { createLogger: () => mockLogger }
|
|
19
|
+
|
|
20
|
+
function wait(ms: number) { return new Promise(r => setTimeout(r, ms)) }
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Poll-based replacement for the removed `IStreamBroker.onDecodedFrame`
|
|
24
|
+
* callback (Phase 5 / D9 — decoded frames now travel as shm `FrameHandle`s).
|
|
25
|
+
* Opens a frame-handle subscription, drains `pullFrameHandles` on a 50ms
|
|
26
|
+
* timer, and invokes `onHandle` once per decoded frame. The returned
|
|
27
|
+
* function tears the subscription down. `byteLength` stands in for the old
|
|
28
|
+
* `DecodedFrame.data.length`.
|
|
29
|
+
*/
|
|
30
|
+
async function subscribeDecodedFrames(
|
|
31
|
+
broker: IStreamBroker,
|
|
32
|
+
maxFps: number,
|
|
33
|
+
onHandle: (handle: FrameHandle) => void,
|
|
34
|
+
): Promise<() => Promise<void>> {
|
|
35
|
+
const { subscriptionId } = await broker.subscribeFrameHandles({ format: 'rgb', maxFps })
|
|
36
|
+
const iv = setInterval(() => {
|
|
37
|
+
for (const handle of broker.pullFrameHandles(subscriptionId, 8)) {
|
|
38
|
+
onHandle(handle)
|
|
39
|
+
}
|
|
40
|
+
}, 50)
|
|
41
|
+
return async () => {
|
|
42
|
+
clearInterval(iv)
|
|
43
|
+
await broker.unsubscribeFrameHandles(subscriptionId)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Fetch detect sub-stream URLs from Frigate config */
|
|
48
|
+
async function discoverSubStreams(): Promise<Array<{ name: string; url: string }>> {
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
const req = http.get(`http://${FRIGATE}:5000/api/config`, (res) => {
|
|
51
|
+
let data = ''
|
|
52
|
+
res.on('data', (chunk: Buffer) => { data += chunk })
|
|
53
|
+
res.on('end', () => {
|
|
54
|
+
try {
|
|
55
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment --
|
|
56
|
+
const config = JSON.parse(data)
|
|
57
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access --
|
|
58
|
+
const cameras = config.cameras || {}
|
|
59
|
+
const streams: Array<{ name: string; url: string }> = []
|
|
60
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any --
|
|
61
|
+
for (const [name, cam] of Object.entries(cameras) as [string, any][]) {
|
|
62
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access --
|
|
63
|
+
if (!cam.enabled && cam.enabled !== undefined) continue
|
|
64
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access --
|
|
65
|
+
const inputs = cam?.ffmpeg?.inputs || []
|
|
66
|
+
for (const inp of inputs) {
|
|
67
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access --
|
|
68
|
+
if (inp.roles?.includes('detect') && inp.path) {
|
|
69
|
+
let streamName: string
|
|
70
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access --
|
|
71
|
+
if (inp.path.includes('8554/')) {
|
|
72
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access --
|
|
73
|
+
streamName = inp.path.split('8554/').pop()!
|
|
74
|
+
} else {
|
|
75
|
+
streamName = `${name}_sub`
|
|
76
|
+
}
|
|
77
|
+
streams.push({ name, url: `rtsp://${FRIGATE}:8554/${streamName}` })
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
resolve(streams)
|
|
82
|
+
} catch (e) { reject(e) }
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
req.on('error', reject)
|
|
86
|
+
req.setTimeout(5000, () => { req.destroy(); reject(new Error('Timeout')) })
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
describe.skipIf(!FRIGATE)('Streaming Scale Test (requires Frigate)', () => {
|
|
91
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call -- @camstack/addon-stream-broker types resolve as any in this test context
|
|
92
|
+
const manager = new StreamBrokerManager([new FfmpegDecoderProvider()], mockLogger)
|
|
93
|
+
|
|
94
|
+
afterAll(async () => {
|
|
95
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access --
|
|
96
|
+
await manager.destroyAll()
|
|
97
|
+
// Wait for ffmpeg processes to exit
|
|
98
|
+
await wait(2000)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('progressive scale: 5 → 10 → 20 → 30 sub-streams @ 2fps for motion', async () => {
|
|
102
|
+
// Discover real streams, then duplicate to simulate more cameras
|
|
103
|
+
const realStreams = await discoverSubStreams()
|
|
104
|
+
|
|
105
|
+
// Test which streams actually respond (filter out 404s)
|
|
106
|
+
const workingStreams: typeof realStreams = []
|
|
107
|
+
for (const s of realStreams.slice(0, 5)) {
|
|
108
|
+
try {
|
|
109
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access --
|
|
110
|
+
const broker = await manager.createBroker(`probe-${s.name}`, {
|
|
111
|
+
type: 'rtsp', url: s.url, videoCodec: 'h264',
|
|
112
|
+
})
|
|
113
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access --
|
|
114
|
+
await (broker as any).start({ type: 'rtsp', url: s.url, videoCodec: 'h264' })
|
|
115
|
+
const ok = await new Promise<boolean>((resolve) => {
|
|
116
|
+
const timeout = setTimeout(() => resolve(false), 5000)
|
|
117
|
+
let unsub: (() => Promise<void>) | undefined
|
|
118
|
+
subscribeDecodedFrames(broker, 2, () => {
|
|
119
|
+
clearTimeout(timeout)
|
|
120
|
+
void unsub?.()
|
|
121
|
+
resolve(true)
|
|
122
|
+
}).then((fn) => { unsub = fn }).catch(() => resolve(false))
|
|
123
|
+
})
|
|
124
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access --
|
|
125
|
+
await broker.stop()
|
|
126
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access --
|
|
127
|
+
await manager.destroyBroker(`probe-${s.name}`)
|
|
128
|
+
if (ok) workingStreams.push(s)
|
|
129
|
+
} catch { /* skip */ }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
console.log(`\nDiscovered ${realStreams.length} sub-streams, ${workingStreams.length} responding`)
|
|
133
|
+
if (workingStreams.length === 0) {
|
|
134
|
+
console.log(' No working sub-streams, skipping')
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Generate virtual streams by duplicating working ones (same RTSP URL, different broker ID)
|
|
139
|
+
function generateStreams(count: number) {
|
|
140
|
+
const streams: Array<{ name: string; url: string }> = []
|
|
141
|
+
for (let i = 0; i < count; i++) {
|
|
142
|
+
const real = workingStreams[i % workingStreams.length]!
|
|
143
|
+
streams.push({ name: `cam-${String(i).padStart(2, '0')}-${real.name}`, url: real.url })
|
|
144
|
+
}
|
|
145
|
+
return streams
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const scaleLevels = [5, 10, 20, 30]
|
|
149
|
+
|
|
150
|
+
for (const targetCount of scaleLevels) {
|
|
151
|
+
|
|
152
|
+
console.log(`\n=== Scale: ${targetCount} sub-streams @ 2fps ===`)
|
|
153
|
+
|
|
154
|
+
const memBefore = process.memoryUsage()
|
|
155
|
+
const cpuBefore = process.cpuUsage()
|
|
156
|
+
const brokerIds: string[] = []
|
|
157
|
+
const unsubs: Array<() => Promise<void>> = []
|
|
158
|
+
const perCamera: Record<string, { frames: number; totalBytes: number }> = {}
|
|
159
|
+
let connectFailures = 0
|
|
160
|
+
|
|
161
|
+
// Start N brokers IN PARALLEL (using duplicated working streams)
|
|
162
|
+
const streams = generateStreams(targetCount)
|
|
163
|
+
const startTime = Date.now()
|
|
164
|
+
|
|
165
|
+
// Launch all brokers concurrently — don't wait for keyframe sequentially
|
|
166
|
+
const connectPromises = streams.map(async (s) => {
|
|
167
|
+
const brokerId = `scale-${s.name}`
|
|
168
|
+
brokerIds.push(brokerId)
|
|
169
|
+
perCamera[s.name] = { frames: 0, totalBytes: 0 }
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access --
|
|
173
|
+
const broker = await manager.createBroker(brokerId, {
|
|
174
|
+
type: 'rtsp', url: s.url, videoCodec: 'h264',
|
|
175
|
+
})
|
|
176
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access --
|
|
177
|
+
await (broker as any).start({ type: 'rtsp', url: s.url, videoCodec: 'h264' })
|
|
178
|
+
|
|
179
|
+
// Wait for first frame with tight timeout
|
|
180
|
+
await new Promise<void>((resolve) => {
|
|
181
|
+
const timeout = setTimeout(() => {
|
|
182
|
+
connectFailures++
|
|
183
|
+
resolve()
|
|
184
|
+
}, 10000)
|
|
185
|
+
let unsub: (() => Promise<void>) | undefined
|
|
186
|
+
subscribeDecodedFrames(broker, 2, () => {
|
|
187
|
+
clearTimeout(timeout)
|
|
188
|
+
void unsub?.()
|
|
189
|
+
resolve()
|
|
190
|
+
}).then((fn) => { unsub = fn }).catch(() => resolve())
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
// Subscribe for motion analysis
|
|
194
|
+
const unsub = await subscribeDecodedFrames(broker, 2, (handle: FrameHandle) => {
|
|
195
|
+
perCamera[s.name]!.frames++
|
|
196
|
+
perCamera[s.name]!.totalBytes += handle.byteLength
|
|
197
|
+
})
|
|
198
|
+
unsubs.push(unsub)
|
|
199
|
+
} catch (err) {
|
|
200
|
+
connectFailures++
|
|
201
|
+
}
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
await Promise.all(connectPromises)
|
|
205
|
+
|
|
206
|
+
const connectTime = Date.now() - startTime
|
|
207
|
+
console.log(` Connected ${targetCount - connectFailures}/${targetCount} in ${(connectTime / 1000).toFixed(1)}s`)
|
|
208
|
+
|
|
209
|
+
// Run for 5 seconds of actual streaming
|
|
210
|
+
const measureStart = Date.now()
|
|
211
|
+
const cpuMeasureStart = process.cpuUsage()
|
|
212
|
+
await wait(5000)
|
|
213
|
+
const cpuMeasure = process.cpuUsage(cpuMeasureStart)
|
|
214
|
+
const measureDuration = (Date.now() - measureStart) / 1000
|
|
215
|
+
|
|
216
|
+
// Unsubscribe
|
|
217
|
+
for (const unsub of unsubs) await unsub()
|
|
218
|
+
|
|
219
|
+
// Calculate metrics
|
|
220
|
+
const cpuAfter = process.cpuUsage(cpuBefore)
|
|
221
|
+
const memAfter = process.memoryUsage()
|
|
222
|
+
|
|
223
|
+
let totalFrames = 0
|
|
224
|
+
let totalBytes = 0
|
|
225
|
+
let activeCameras = 0
|
|
226
|
+
let starvingCameras = 0
|
|
227
|
+
|
|
228
|
+
for (const [name, data] of Object.entries(perCamera)) {
|
|
229
|
+
totalFrames += data.frames
|
|
230
|
+
totalBytes += data.totalBytes
|
|
231
|
+
if (data.frames > 0) activeCameras++
|
|
232
|
+
if (data.frames < 3) starvingCameras++ // less than 3 frames in 5s = starving
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const cpuPct = ((cpuMeasure.user + cpuMeasure.system) / (measureDuration * 1_000_000) * 100).toFixed(1)
|
|
236
|
+
const rssMB = (memAfter.rss / 1024 / 1024).toFixed(0)
|
|
237
|
+
const heapMB = (memAfter.heapUsed / 1024 / 1024).toFixed(0)
|
|
238
|
+
const combinedFps = (totalFrames / measureDuration).toFixed(1)
|
|
239
|
+
const avgFrameKB = totalFrames > 0 ? (totalBytes / totalFrames / 1024).toFixed(0) : '0'
|
|
240
|
+
const bwMBs = (totalBytes / 1024 / 1024 / measureDuration).toFixed(1)
|
|
241
|
+
|
|
242
|
+
console.log(` Active: ${activeCameras}/${targetCount} cameras producing frames`)
|
|
243
|
+
console.log(` Starving: ${starvingCameras} cameras (<3 frames in 5s)`)
|
|
244
|
+
console.log(` Total frames: ${totalFrames} (${combinedFps} fps combined)`)
|
|
245
|
+
console.log(` Avg frame: ${avgFrameKB} KB`)
|
|
246
|
+
console.log(` Decode BW: ${bwMBs} MB/s`)
|
|
247
|
+
console.log(` CPU: ${cpuPct}%`)
|
|
248
|
+
console.log(` RSS: ${rssMB} MB | Heap: ${heapMB} MB`)
|
|
249
|
+
|
|
250
|
+
// Per-camera breakdown
|
|
251
|
+
console.log(` ---`)
|
|
252
|
+
const sorted = Object.entries(perCamera).sort((a, b) => b[1].frames - a[1].frames)
|
|
253
|
+
for (const [name, data] of sorted) {
|
|
254
|
+
const fps = (data.frames / measureDuration).toFixed(1)
|
|
255
|
+
const avgKB = data.frames > 0 ? (data.totalBytes / data.frames / 1024).toFixed(0) : '-'
|
|
256
|
+
const status = data.frames >= 3 ? '✓' : data.frames > 0 ? '⚠' : '✗'
|
|
257
|
+
console.log(` ${status} ${name.padEnd(35)} ${fps.padStart(4)} fps ${avgKB.padStart(5)} KB/f (${data.frames} frames)`)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Cleanup for next scale level
|
|
261
|
+
for (const id of brokerIds) {
|
|
262
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access --
|
|
263
|
+
const b = manager.getBroker(id)
|
|
264
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access --
|
|
265
|
+
if (b) await b.stop()
|
|
266
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access --
|
|
267
|
+
await manager.destroyBroker(id)
|
|
268
|
+
}
|
|
269
|
+
brokerIds.length = 0
|
|
270
|
+
unsubs.length = 0
|
|
271
|
+
Object.keys(perCamera).forEach(k => delete perCamera[k])
|
|
272
|
+
|
|
273
|
+
// Expect at least 80% of cameras producing
|
|
274
|
+
expect(activeCameras).toBeGreaterThanOrEqual(Math.floor(targetCount * 0.8))
|
|
275
|
+
|
|
276
|
+
// Pause between levels for cleanup
|
|
277
|
+
await wait(2000)
|
|
278
|
+
}
|
|
279
|
+
}, 300000) // 5 min timeout for the full progressive test
|
|
280
|
+
})
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
export interface AgentStatusData {
|
|
2
|
+
readonly agentId: string
|
|
3
|
+
readonly agentName: string
|
|
4
|
+
readonly hubUrl: string
|
|
5
|
+
readonly connected: boolean
|
|
6
|
+
readonly activeTaskCount: number
|
|
7
|
+
readonly taskTypes: readonly string[]
|
|
8
|
+
readonly platform: string
|
|
9
|
+
readonly arch: string
|
|
10
|
+
readonly cpuCores: number
|
|
11
|
+
readonly memoryTotalMB: number
|
|
12
|
+
readonly memoryFreeMB: number
|
|
13
|
+
readonly uptime: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function formatUptime(seconds: number): string {
|
|
17
|
+
const days = Math.floor(seconds / 86400)
|
|
18
|
+
const hours = Math.floor((seconds % 86400) / 3600)
|
|
19
|
+
const mins = Math.floor((seconds % 3600) / 60)
|
|
20
|
+
const parts: string[] = []
|
|
21
|
+
if (days > 0) parts.push(`${days}d`)
|
|
22
|
+
if (hours > 0) parts.push(`${hours}h`)
|
|
23
|
+
parts.push(`${mins}m`)
|
|
24
|
+
return parts.join(' ')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function escapeHtml(str: string): string {
|
|
28
|
+
return str
|
|
29
|
+
.replace(/&/g, '&')
|
|
30
|
+
.replace(/</g, '<')
|
|
31
|
+
.replace(/>/g, '>')
|
|
32
|
+
.replace(/"/g, '"')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Render a minimal server-rendered HTML status page for the agent.
|
|
37
|
+
* No React, no build system. Pure HTML + inline CSS.
|
|
38
|
+
* Designed for local debugging only.
|
|
39
|
+
*/
|
|
40
|
+
export function renderAgentStatusPage(data: AgentStatusData): string {
|
|
41
|
+
const statusColor = data.connected ? '#22c55e' : '#ef4444'
|
|
42
|
+
const statusText = data.connected ? 'Connected' : 'Disconnected'
|
|
43
|
+
const memUsedMB = data.memoryTotalMB - data.memoryFreeMB
|
|
44
|
+
const memPercent = Math.round((memUsedMB / data.memoryTotalMB) * 100)
|
|
45
|
+
|
|
46
|
+
const taskTypesList = data.taskTypes.length > 0
|
|
47
|
+
? data.taskTypes.map((t) => `<li>${escapeHtml(t)}</li>`).join('')
|
|
48
|
+
: '<li class="muted">No task handlers registered</li>'
|
|
49
|
+
|
|
50
|
+
return `<!DOCTYPE html>
|
|
51
|
+
<html lang="en">
|
|
52
|
+
<head>
|
|
53
|
+
<meta charset="utf-8">
|
|
54
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
55
|
+
<meta http-equiv="refresh" content="5">
|
|
56
|
+
<title>CamStack Agent: ${escapeHtml(data.agentName)}</title>
|
|
57
|
+
<style>
|
|
58
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
59
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, monospace; background: #0f172a; color: #e2e8f0; padding: 2rem; }
|
|
60
|
+
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
|
|
61
|
+
.subtitle { color: #94a3b8; font-size: 0.875rem; margin-bottom: 2rem; }
|
|
62
|
+
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1.5rem; }
|
|
63
|
+
.card { background: #1e293b; border-radius: 0.75rem; padding: 1.25rem; }
|
|
64
|
+
.card h2 { font-size: 0.875rem; text-transform: uppercase; letter-spacing: 0.05em; color: #94a3b8; margin-bottom: 0.75rem; }
|
|
65
|
+
.stat { font-size: 1.25rem; font-weight: 600; margin-bottom: 0.25rem; }
|
|
66
|
+
.stat-label { font-size: 0.75rem; color: #64748b; }
|
|
67
|
+
.status-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 0.5rem; }
|
|
68
|
+
dl { display: grid; grid-template-columns: auto 1fr; gap: 0.25rem 1rem; }
|
|
69
|
+
dt { color: #94a3b8; font-size: 0.8125rem; }
|
|
70
|
+
dd { font-size: 0.8125rem; }
|
|
71
|
+
ul { list-style: none; padding: 0; }
|
|
72
|
+
li { font-size: 0.8125rem; padding: 0.25rem 0; font-family: monospace; }
|
|
73
|
+
.muted { color: #64748b; font-style: italic; }
|
|
74
|
+
.bar { height: 6px; background: #334155; border-radius: 3px; overflow: hidden; margin-top: 0.5rem; }
|
|
75
|
+
.bar-fill { height: 100%; border-radius: 3px; }
|
|
76
|
+
</style>
|
|
77
|
+
</head>
|
|
78
|
+
<body>
|
|
79
|
+
<h1>CamStack Agent</h1>
|
|
80
|
+
<p class="subtitle">${escapeHtml(data.agentId)} — auto-refreshes every 5s</p>
|
|
81
|
+
|
|
82
|
+
<div class="grid">
|
|
83
|
+
<div class="card">
|
|
84
|
+
<h2>Connection</h2>
|
|
85
|
+
<div class="stat">
|
|
86
|
+
<span class="status-dot" style="background:${statusColor}"></span>
|
|
87
|
+
${statusText}
|
|
88
|
+
</div>
|
|
89
|
+
<dl>
|
|
90
|
+
<dt>Name</dt><dd>${escapeHtml(data.agentName)}</dd>
|
|
91
|
+
<dt>Hub URL</dt><dd>${escapeHtml(data.hubUrl)}</dd>
|
|
92
|
+
</dl>
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<div class="card">
|
|
96
|
+
<h2>Hardware</h2>
|
|
97
|
+
<dl>
|
|
98
|
+
<dt>Platform</dt><dd>${escapeHtml(data.platform)} / ${escapeHtml(data.arch)}</dd>
|
|
99
|
+
<dt>CPU Cores</dt><dd>${data.cpuCores}</dd>
|
|
100
|
+
<dt>Uptime</dt><dd>${formatUptime(data.uptime)}</dd>
|
|
101
|
+
</dl>
|
|
102
|
+
<div style="margin-top:0.75rem">
|
|
103
|
+
<div class="stat-label">Memory: ${memUsedMB} / ${data.memoryTotalMB} MB (${memPercent}%)</div>
|
|
104
|
+
<div class="bar"><div class="bar-fill" style="width:${memPercent}%;background:${memPercent > 90 ? '#ef4444' : memPercent > 70 ? '#f59e0b' : '#22c55e'}"></div></div>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
<div class="card">
|
|
109
|
+
<h2>Tasks</h2>
|
|
110
|
+
<div class="stat">${data.activeTaskCount}</div>
|
|
111
|
+
<div class="stat-label">Active tasks</div>
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
<div class="card">
|
|
115
|
+
<h2>Registered Task Handlers</h2>
|
|
116
|
+
<ul>${taskTypesList}</ul>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
</body>
|
|
120
|
+
</html>`
|
|
121
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `api.addons.custom` — the generic addon custom-action dispatcher
|
|
3
|
+
* built in Task 7.2 of the device-proxy redesign.
|
|
4
|
+
*
|
|
5
|
+
* Verifies:
|
|
6
|
+
* - dispatch to a registered action returns the addon's value
|
|
7
|
+
* - input is round-tripped through the action's Zod schema
|
|
8
|
+
* - unknown (addonId, action) → NOT_FOUND
|
|
9
|
+
* - unauthenticated caller → UNAUTHORIZED
|
|
10
|
+
* - per-action `auth: 'admin'` is enforced (viewer rejected, admin allowed)
|
|
11
|
+
* - per-action `auth: 'public'` allows anonymous callers
|
|
12
|
+
* - addon output that violates its own schema → BAD_REQUEST (Zod parse)
|
|
13
|
+
*/
|
|
14
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
15
|
+
import { z } from 'zod'
|
|
16
|
+
import { TRPCError } from '@trpc/server'
|
|
17
|
+
import { customAction, defineCustomActions } from '@camstack/types'
|
|
18
|
+
import { CustomActionRegistry } from '@camstack/kernel'
|
|
19
|
+
import { trpcRouter } from '../trpc/trpc.middleware.js'
|
|
20
|
+
import { createAddonsCustomProcedures } from '../addons-custom.router.js'
|
|
21
|
+
import { makeCtx } from '../../__tests__/cap-routers/harness.js'
|
|
22
|
+
|
|
23
|
+
const catalog = defineCustomActions({
|
|
24
|
+
ping: customAction(z.void(), z.literal('pong')),
|
|
25
|
+
echo: customAction(z.object({ msg: z.string() }), z.object({ msg: z.string() })),
|
|
26
|
+
adminOnly: customAction(z.void(), z.literal('ok'), { kind: 'mutation', auth: 'admin' }),
|
|
27
|
+
open: customAction(z.void(), z.literal('open'), { auth: 'public' }),
|
|
28
|
+
badOutput: customAction(z.void(), z.literal('expected')),
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
function buildRouter(registry: CustomActionRegistry) {
|
|
32
|
+
return trpcRouter({
|
|
33
|
+
...createAddonsCustomProcedures({ getCustomActionRegistry: () => registry }),
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('api.addons.custom', () => {
|
|
38
|
+
let registry: CustomActionRegistry
|
|
39
|
+
let router: ReturnType<typeof buildRouter>
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
registry = new CustomActionRegistry()
|
|
43
|
+
registry.registerAddon('benchmark', catalog, async (action, input) => {
|
|
44
|
+
switch (action) {
|
|
45
|
+
case 'ping': return 'pong'
|
|
46
|
+
case 'echo': return { msg: (input as { msg: string }).msg }
|
|
47
|
+
case 'adminOnly': return 'ok'
|
|
48
|
+
case 'open': return 'open'
|
|
49
|
+
case 'badOutput': return 'WRONG' // will fail output validation
|
|
50
|
+
default: throw new Error(`unknown action ${action}`)
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
router = buildRouter(registry)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('dispatches a void/literal action and returns the addon value', async () => {
|
|
57
|
+
const caller = router.createCaller(makeCtx('admin'))
|
|
58
|
+
const result = await caller.custom({ addonId: 'benchmark', action: 'ping', input: undefined })
|
|
59
|
+
expect(result).toBe('pong')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('round-trips structured input through the action schema', async () => {
|
|
63
|
+
const caller = router.createCaller(makeCtx('admin'))
|
|
64
|
+
const result = await caller.custom({ addonId: 'benchmark', action: 'echo', input: { msg: 'hi' } })
|
|
65
|
+
expect(result).toEqual({ msg: 'hi' })
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('rejects unknown (addonId, action) with NOT_FOUND', async () => {
|
|
69
|
+
const caller = router.createCaller(makeCtx('admin'))
|
|
70
|
+
await expect(
|
|
71
|
+
caller.custom({ addonId: 'benchmark', action: 'missing', input: {} }),
|
|
72
|
+
).rejects.toMatchObject({ code: 'NOT_FOUND' })
|
|
73
|
+
|
|
74
|
+
await expect(
|
|
75
|
+
caller.custom({ addonId: 'unknown-addon', action: 'ping', input: undefined }),
|
|
76
|
+
).rejects.toMatchObject({ code: 'NOT_FOUND' })
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('rejects unauthenticated callers with UNAUTHORIZED', async () => {
|
|
80
|
+
const caller = router.createCaller(makeCtx('anonymous'))
|
|
81
|
+
await expect(
|
|
82
|
+
caller.custom({ addonId: 'benchmark', action: 'ping', input: undefined }),
|
|
83
|
+
).rejects.toMatchObject({ code: 'UNAUTHORIZED' })
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('enforces per-action auth: admin-only action rejects viewer', async () => {
|
|
87
|
+
const caller = router.createCaller(makeCtx('viewer'))
|
|
88
|
+
await expect(
|
|
89
|
+
caller.custom({ addonId: 'benchmark', action: 'adminOnly', input: undefined }),
|
|
90
|
+
).rejects.toMatchObject({ code: 'FORBIDDEN' })
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('enforces per-action auth: admin-only action allows admin', async () => {
|
|
94
|
+
const caller = router.createCaller(makeCtx('admin'))
|
|
95
|
+
const result = await caller.custom({ addonId: 'benchmark', action: 'adminOnly', input: undefined })
|
|
96
|
+
expect(result).toBe('ok')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('rejects bad input against the action schema', async () => {
|
|
100
|
+
const caller = router.createCaller(makeCtx('admin'))
|
|
101
|
+
await expect(
|
|
102
|
+
caller.custom({ addonId: 'benchmark', action: 'echo', input: { msg: 123 } }),
|
|
103
|
+
).rejects.toBeInstanceOf(Error)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('rejects bad addon output against the action schema (crash-early)', async () => {
|
|
107
|
+
const caller = router.createCaller(makeCtx('admin'))
|
|
108
|
+
await expect(
|
|
109
|
+
caller.custom({ addonId: 'benchmark', action: 'badOutput', input: undefined }),
|
|
110
|
+
).rejects.toBeInstanceOf(Error)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
// The outer `protectedProcedure` runs before the per-action auth check, so
|
|
114
|
+
// even a `public` action requires authentication today. This documents the
|
|
115
|
+
// intentional behavior: addons cannot expose anonymous endpoints through
|
|
116
|
+
// the generic dispatcher.
|
|
117
|
+
it('the outer protectedProcedure short-circuits even for public-auth actions', async () => {
|
|
118
|
+
const caller = router.createCaller(makeCtx('anonymous'))
|
|
119
|
+
await expect(
|
|
120
|
+
caller.custom({ addonId: 'benchmark', action: 'open', input: undefined }),
|
|
121
|
+
).rejects.toMatchObject({ code: 'UNAUTHORIZED' })
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('TRPCError carries a descriptive message for unknown actions', async () => {
|
|
125
|
+
const caller = router.createCaller(makeCtx('admin'))
|
|
126
|
+
try {
|
|
127
|
+
await caller.custom({ addonId: 'benchmark', action: 'missing', input: {} })
|
|
128
|
+
expect.fail('should have thrown')
|
|
129
|
+
} catch (err) {
|
|
130
|
+
expect(err).toBeInstanceOf(TRPCError)
|
|
131
|
+
expect((err as TRPCError).message).toMatch(/no custom action 'missing'/)
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
})
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
|
|
2
|
+
// server/backend/src/api/__tests__/capabilities.router.test.ts
|
|
3
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
4
|
+
import { CapabilityRegistry } from '@camstack/kernel'
|
|
5
|
+
import type { IScopedLogger } from '@camstack/types'
|
|
6
|
+
|
|
7
|
+
function createMockLogger(): IScopedLogger {
|
|
8
|
+
return {
|
|
9
|
+
error: vi.fn(),
|
|
10
|
+
warn: vi.fn(),
|
|
11
|
+
info: vi.fn(),
|
|
12
|
+
debug: vi.fn(),
|
|
13
|
+
child: vi.fn().mockReturnThis(),
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('capabilities tRPC router logic', () => {
|
|
18
|
+
let registry: CapabilityRegistry
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
registry = new CapabilityRegistry(createMockLogger())
|
|
22
|
+
registry.ready()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('listCapabilities returns all declared capabilities', () => {
|
|
26
|
+
registry.declareCapability({ name: 'storage', scope: 'system', mode: 'singleton', methods: {} })
|
|
27
|
+
registry.declareCapability({ name: 'log-destination', scope: 'system', mode: 'collection', methods: {} })
|
|
28
|
+
|
|
29
|
+
const list = registry.listCapabilities()
|
|
30
|
+
expect(list).toHaveLength(2)
|
|
31
|
+
expect(list.map((c) => c.name)).toContain('storage')
|
|
32
|
+
expect(list.map((c) => c.name)).toContain('log-destination')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('setActiveSingleton throws for unknown capability', async () => {
|
|
36
|
+
await expect(
|
|
37
|
+
registry.setActiveSingleton('nonexistent', 'some-addon', true),
|
|
38
|
+
).rejects.toThrow(/[Uu]nknown/)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('setActiveSingleton throws for collection capability', async () => {
|
|
42
|
+
registry.declareCapability({ name: 'log-destination', scope: 'system', mode: 'collection', methods: {} })
|
|
43
|
+
await expect(
|
|
44
|
+
registry.setActiveSingleton('log-destination', 'winston', true),
|
|
45
|
+
).rejects.toThrow(/singleton/)
|
|
46
|
+
})
|
|
47
|
+
})
|