een-api-toolkit 0.3.20 → 0.3.22
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/.claude/agents/docs-accuracy-reviewer.md +146 -0
- package/.claude/agents/een-auth-agent.md +168 -0
- package/.claude/agents/een-devices-agent.md +294 -0
- package/.claude/agents/een-events-agent.md +375 -0
- package/.claude/agents/een-media-agent.md +256 -0
- package/.claude/agents/een-setup-agent.md +126 -0
- package/.claude/agents/een-users-agent.md +239 -0
- package/.claude/agents/test-runner.md +144 -0
- package/CHANGELOG.md +138 -30
- package/docs/AI-CONTEXT.md +169 -1700
- package/docs/ai-reference/AI-AUTH.md +288 -0
- package/docs/ai-reference/AI-DEVICES.md +569 -0
- package/docs/ai-reference/AI-EVENTS.md +1745 -0
- package/docs/ai-reference/AI-MEDIA.md +974 -0
- package/docs/ai-reference/AI-SETUP.md +267 -0
- package/docs/ai-reference/AI-USERS.md +255 -0
- package/examples/vue-event-subscriptions/package-lock.json +8 -1
- package/examples/vue-event-subscriptions/package.json +1 -0
- package/examples/vue-event-subscriptions/src/App.vue +1 -41
- package/examples/vue-event-subscriptions/src/composables/useHlsPlayer.ts +272 -0
- package/examples/vue-event-subscriptions/src/main.ts +3 -3
- package/examples/vue-event-subscriptions/src/stores/connection.ts +101 -0
- package/examples/vue-event-subscriptions/src/stores/mediaSession.ts +79 -0
- package/examples/vue-event-subscriptions/src/views/LiveEvents.vue +349 -88
- package/examples/vue-event-subscriptions/src/views/Logout.vue +6 -0
- package/examples/vue-event-subscriptions/src/views/Subscriptions.vue +0 -13
- package/examples/vue-events/package-lock.json +8 -1
- package/examples/vue-events/package.json +1 -0
- package/examples/vue-events/src/components/EventsModal.vue +269 -47
- package/examples/vue-events/src/composables/useHlsPlayer.ts +272 -0
- package/examples/vue-events/src/stores/mediaSession.ts +79 -0
- package/package.json +10 -2
- package/scripts/setup-agents.ts +116 -0
|
@@ -0,0 +1,1745 @@
|
|
|
1
|
+
# Events, Alerts & Real-Time Streaming - EEN API Toolkit
|
|
2
|
+
|
|
3
|
+
> **Version:** 0.3.22
|
|
4
|
+
>
|
|
5
|
+
> Complete reference for events, alerts, metrics, and SSE subscriptions.
|
|
6
|
+
> Load this document when implementing event-driven features.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Overview
|
|
11
|
+
|
|
12
|
+
| Concept | Description |
|
|
13
|
+
|---------|-------------|
|
|
14
|
+
| **Events** | Camera-generated occurrences (motion, analytics) |
|
|
15
|
+
| **Alerts** | Rule-triggered notifications |
|
|
16
|
+
| **Metrics** | Aggregated event counts over time |
|
|
17
|
+
| **Notifications** | User-facing alert messages |
|
|
18
|
+
| **Subscriptions** | Real-time SSE streams for events |
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Event Types
|
|
23
|
+
|
|
24
|
+
```typescript
|
|
25
|
+
type ActorType =
|
|
26
|
+
| 'bridge' | 'camera' | 'speaker' | 'account' | 'user'
|
|
27
|
+
| 'layout' | 'job' | 'measurement' | 'sensor' | 'gateway'
|
|
28
|
+
|
|
29
|
+
interface Event {
|
|
30
|
+
id: string
|
|
31
|
+
startTimestamp: string // ISO 8601
|
|
32
|
+
endTimestamp?: string | null
|
|
33
|
+
span: boolean
|
|
34
|
+
accountId: string
|
|
35
|
+
actorId: string
|
|
36
|
+
actorAccountId: string
|
|
37
|
+
actorType: ActorType
|
|
38
|
+
creatorId: string
|
|
39
|
+
type: string // e.g., 'een.motionDetectionEvent.v1'
|
|
40
|
+
dataSchemas: string[]
|
|
41
|
+
data: EventData[]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface ListEventsParams {
|
|
45
|
+
actor: string // Required: 'camera:{id}' format
|
|
46
|
+
type__in: string[] // Required: event types to fetch
|
|
47
|
+
startTimestamp__gte: string // Required: ISO 8601 timestamp
|
|
48
|
+
startTimestamp__lte?: string
|
|
49
|
+
endTimestamp__gte?: string
|
|
50
|
+
endTimestamp__lte?: string
|
|
51
|
+
pageSize?: number
|
|
52
|
+
pageToken?: string
|
|
53
|
+
sort?: '+startTimestamp' | '-startTimestamp'
|
|
54
|
+
include?: string[] // e.g., ['data.een.fullFrameImageUrl.v1']
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## Event Metrics Types
|
|
61
|
+
|
|
62
|
+
```typescript
|
|
63
|
+
type MetricDataPoint = [number, number] // [timestamp_ms, count]
|
|
64
|
+
|
|
65
|
+
interface EventMetric {
|
|
66
|
+
eventType: string
|
|
67
|
+
actorId: string
|
|
68
|
+
actorType: string
|
|
69
|
+
dataPoints: MetricDataPoint[]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface GetEventMetricsParams {
|
|
73
|
+
actor: string // Required: 'camera:{id}'
|
|
74
|
+
eventType: string // Required: e.g., 'een.motionDetectionEvent.v1'
|
|
75
|
+
timestamp__gte?: string // Defaults to 7 days ago
|
|
76
|
+
timestamp__lte?: string // Defaults to now
|
|
77
|
+
aggregateByMinutes?: number // Default 60, minimum 60
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## Alert Types
|
|
84
|
+
|
|
85
|
+
```typescript
|
|
86
|
+
interface Alert {
|
|
87
|
+
id: string
|
|
88
|
+
timestamp: string
|
|
89
|
+
alertType: string
|
|
90
|
+
alertName?: string
|
|
91
|
+
actorId: string
|
|
92
|
+
actorType: string
|
|
93
|
+
actorName?: string
|
|
94
|
+
priority?: number // 0-20
|
|
95
|
+
actions?: Record<string, AlertAction>
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface ListAlertsParams {
|
|
99
|
+
timestamp__gte?: string
|
|
100
|
+
timestamp__lte?: string
|
|
101
|
+
alertType__in?: string[]
|
|
102
|
+
actorId__in?: string[]
|
|
103
|
+
priority__gte?: number
|
|
104
|
+
include?: ('data' | 'actions')[]
|
|
105
|
+
sort?: ('+timestamp' | '-timestamp')[]
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## Event Subscription Types
|
|
112
|
+
|
|
113
|
+
```typescript
|
|
114
|
+
type EventSubscriptionLifecycle = 'temporary' | 'persistent'
|
|
115
|
+
type EventSubscriptionDeliveryType = 'serverSentEvents.v1' | 'webhook.v1'
|
|
116
|
+
|
|
117
|
+
interface EventSubscription {
|
|
118
|
+
id: string
|
|
119
|
+
subscriptionConfig?: {
|
|
120
|
+
lifeCycle: EventSubscriptionLifecycle
|
|
121
|
+
timeToLiveSeconds?: number
|
|
122
|
+
}
|
|
123
|
+
deliveryConfig: {
|
|
124
|
+
type: EventSubscriptionDeliveryType
|
|
125
|
+
sseUrl?: string // SSE endpoint URL (for SSE type)
|
|
126
|
+
secret?: string // Webhook signature secret (for webhook type)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
interface EventTypeFilter {
|
|
131
|
+
id: string // e.g., 'een.motionDetectionEvent.v1'
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
interface FilterCreate {
|
|
135
|
+
actors: string[] // e.g., ['camera:{id}']
|
|
136
|
+
types: EventTypeFilter[] // Event types to subscribe to
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
interface CreateEventSubscriptionParams {
|
|
140
|
+
deliveryConfig: {
|
|
141
|
+
type: 'serverSentEvents.v1'
|
|
142
|
+
} | {
|
|
143
|
+
type: 'webhook.v1'
|
|
144
|
+
webhookUrl: string
|
|
145
|
+
technicalContactEmail: string
|
|
146
|
+
technicalContactName: string
|
|
147
|
+
}
|
|
148
|
+
filters: FilterCreate[]
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
type SSEConnectionStatus = 'connecting' | 'connected' | 'disconnected' | 'error'
|
|
152
|
+
|
|
153
|
+
interface SSEConnection {
|
|
154
|
+
close: () => void
|
|
155
|
+
status: SSEConnectionStatus
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
interface SSEEvent {
|
|
159
|
+
id: string
|
|
160
|
+
type: string
|
|
161
|
+
actorId: string
|
|
162
|
+
actorType?: string
|
|
163
|
+
startTimestamp: string
|
|
164
|
+
endTimestamp?: string | null
|
|
165
|
+
span?: boolean
|
|
166
|
+
data?: Array<{ type: string; [key: string]: unknown }>
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## Event Functions
|
|
173
|
+
|
|
174
|
+
### listEvents(params)
|
|
175
|
+
|
|
176
|
+
```typescript
|
|
177
|
+
import { listEvents, formatTimestamp } from 'een-api-toolkit'
|
|
178
|
+
|
|
179
|
+
const { data, error } = await listEvents({
|
|
180
|
+
actor: `camera:${cameraId}`,
|
|
181
|
+
type__in: ['een.motionDetectionEvent.v1'],
|
|
182
|
+
startTimestamp__gte: formatTimestamp(startDate.toISOString()),
|
|
183
|
+
include: ['data.een.fullFrameImageUrl.v1']
|
|
184
|
+
})
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### getEventMetrics(params)
|
|
188
|
+
|
|
189
|
+
```typescript
|
|
190
|
+
import { getEventMetrics, formatTimestamp } from 'een-api-toolkit'
|
|
191
|
+
|
|
192
|
+
const { data, error } = await getEventMetrics({
|
|
193
|
+
actor: `camera:${cameraId}`,
|
|
194
|
+
eventType: 'een.motionDetectionEvent.v1',
|
|
195
|
+
timestamp__gte: formatTimestamp(sevenDaysAgo.toISOString()),
|
|
196
|
+
aggregateByMinutes: 60
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
// Convert to Chart.js format
|
|
200
|
+
const chartData = data.map(metric => ({
|
|
201
|
+
x: new Date(metric.dataPoints[0][0]),
|
|
202
|
+
y: metric.dataPoints[0][1]
|
|
203
|
+
}))
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
## Alert Functions
|
|
209
|
+
|
|
210
|
+
### listAlerts(params?)
|
|
211
|
+
|
|
212
|
+
```typescript
|
|
213
|
+
import { listAlerts, formatTimestamp } from 'een-api-toolkit'
|
|
214
|
+
|
|
215
|
+
const { data, error } = await listAlerts({
|
|
216
|
+
timestamp__gte: formatTimestamp(startDate.toISOString()),
|
|
217
|
+
actorId__in: [cameraId],
|
|
218
|
+
include: ['actions'],
|
|
219
|
+
sort: ['-timestamp']
|
|
220
|
+
})
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
## Notification Functions
|
|
226
|
+
|
|
227
|
+
### listNotifications(params?)
|
|
228
|
+
|
|
229
|
+
```typescript
|
|
230
|
+
import { listNotifications } from 'een-api-toolkit'
|
|
231
|
+
|
|
232
|
+
const { data, error } = await listNotifications({
|
|
233
|
+
read: false,
|
|
234
|
+
sort: ['-timestamp']
|
|
235
|
+
})
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
---
|
|
239
|
+
|
|
240
|
+
## Event Subscription Functions
|
|
241
|
+
|
|
242
|
+
### SSE Lifecycle
|
|
243
|
+
|
|
244
|
+
```typescript
|
|
245
|
+
import {
|
|
246
|
+
createEventSubscription,
|
|
247
|
+
connectToEventSubscription,
|
|
248
|
+
deleteEventSubscription
|
|
249
|
+
} from 'een-api-toolkit'
|
|
250
|
+
|
|
251
|
+
// 1. Create subscription
|
|
252
|
+
const { data: subscription } = await createEventSubscription({
|
|
253
|
+
deliveryConfig: { type: 'serverSentEvents.v1' },
|
|
254
|
+
filters: [{
|
|
255
|
+
actors: [`camera:${cameraId}`],
|
|
256
|
+
types: [{ id: 'een.motionDetectionEvent.v1' }]
|
|
257
|
+
}]
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
// 2. Connect to SSE stream
|
|
261
|
+
const { data: connection } = connectToEventSubscription(
|
|
262
|
+
subscription.deliveryConfig.sseUrl,
|
|
263
|
+
{
|
|
264
|
+
onEvent: (event) => {
|
|
265
|
+
console.log('Event received:', event)
|
|
266
|
+
},
|
|
267
|
+
onError: (error) => {
|
|
268
|
+
console.error('SSE error:', error)
|
|
269
|
+
},
|
|
270
|
+
onStatusChange: (status) => {
|
|
271
|
+
console.log('Connection status:', status)
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
// 3. Cleanup when done
|
|
277
|
+
connection.close()
|
|
278
|
+
await deleteEventSubscription(subscription.id)
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
---
|
|
282
|
+
|
|
283
|
+
## Vue Components
|
|
284
|
+
|
|
285
|
+
### EventsModal.vue
|
|
286
|
+
|
|
287
|
+
```vue
|
|
288
|
+
<script setup lang="ts">
|
|
289
|
+
import { ref, watch, computed, onMounted, onUnmounted } from 'vue'
|
|
290
|
+
import {
|
|
291
|
+
listEvents,
|
|
292
|
+
listEventFieldValues,
|
|
293
|
+
listEventTypes,
|
|
294
|
+
getRecordedImage,
|
|
295
|
+
type Camera,
|
|
296
|
+
type Event,
|
|
297
|
+
type EventType,
|
|
298
|
+
type EenError
|
|
299
|
+
} from 'een-api-toolkit'
|
|
300
|
+
import { useHlsPlayer } from '../composables/useHlsPlayer'
|
|
301
|
+
|
|
302
|
+
// Initialize HLS player composable
|
|
303
|
+
const hlsPlayer = useHlsPlayer()
|
|
304
|
+
const { videoUrl, videoError, loadingVideo, loadVideo, resetVideo } = hlsPlayer
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Bounding box from object detection data.
|
|
308
|
+
* Coordinates are normalized (0-1) relative to image dimensions.
|
|
309
|
+
*/
|
|
310
|
+
interface BoundingBox {
|
|
311
|
+
x: number
|
|
312
|
+
y: number
|
|
313
|
+
width: number
|
|
314
|
+
height: number
|
|
315
|
+
label?: string
|
|
316
|
+
confidence?: number
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Constant for converting normalized coordinates (0-1) to SVG viewBox percentage */
|
|
320
|
+
const NORMALIZED_TO_PERCENT = 100
|
|
321
|
+
|
|
322
|
+
/** Maximum number of images to cache to prevent memory issues */
|
|
323
|
+
const MAX_IMAGE_CACHE_SIZE = 50
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Type guard to check if a value is a non-empty string.
|
|
327
|
+
*/
|
|
328
|
+
function isNonEmptyString(value: unknown): value is string {
|
|
329
|
+
return typeof value === 'string' && value.length > 0
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Type guard to validate a bounding box array.
|
|
334
|
+
* Must be an array of exactly 4 numbers.
|
|
335
|
+
*/
|
|
336
|
+
function isValidBoundingBoxArray(value: unknown): value is [number, number, number, number] {
|
|
337
|
+
return (
|
|
338
|
+
Array.isArray(value) &&
|
|
339
|
+
value.length === 4 &&
|
|
340
|
+
value.every(v => typeof v === 'number' && !Number.isNaN(v))
|
|
341
|
+
)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const props = defineProps<{
|
|
345
|
+
camera: Camera
|
|
346
|
+
isOpen: boolean
|
|
347
|
+
}>()
|
|
348
|
+
|
|
349
|
+
const emit = defineEmits<{
|
|
350
|
+
close: []
|
|
351
|
+
}>()
|
|
352
|
+
|
|
353
|
+
// Time range options
|
|
354
|
+
type TimeRange = '1h' | '6h' | '24h'
|
|
355
|
+
const timeRangeOptions: { value: TimeRange; label: string }[] = [
|
|
356
|
+
{ value: '1h', label: 'Last 1 hour' },
|
|
357
|
+
{ value: '6h', label: 'Last 6 hours' },
|
|
358
|
+
{ value: '24h', label: 'Last 24 hours' }
|
|
359
|
+
]
|
|
360
|
+
|
|
361
|
+
// State
|
|
362
|
+
const loading = ref(false)
|
|
363
|
+
const loadingFieldValues = ref(false)
|
|
364
|
+
const error = ref<EenError | null>(null)
|
|
365
|
+
const fieldValuesError = ref<EenError | null>(null)
|
|
366
|
+
const events = ref<Event[]>([])
|
|
367
|
+
const nextPageToken = ref<string | undefined>(undefined)
|
|
368
|
+
const availableEventTypes = ref<string[]>([])
|
|
369
|
+
const selectedEventTypes = ref<string[]>([])
|
|
370
|
+
const timeRange = ref<TimeRange>('1h')
|
|
371
|
+
const eventTypeNames = ref<Map<string, string>>(new Map())
|
|
372
|
+
const eventImages = ref<Map<string, string>>(new Map())
|
|
373
|
+
const imageLoadingIds = ref<Set<string>>(new Set()) // Track which images are currently loading
|
|
374
|
+
const boundingBoxCache = ref<Map<string, BoundingBox[]>>(new Map()) // Cache bounding boxes per event
|
|
375
|
+
const enlargedEventId = ref<string | null>(null)
|
|
376
|
+
|
|
377
|
+
// Lightbox media state
|
|
378
|
+
const showVideo = ref(false)
|
|
379
|
+
const hdImageUrl = ref<string | null>(null)
|
|
380
|
+
const loadingHdImage = ref(false)
|
|
381
|
+
const hdImageError = ref<string | null>(null)
|
|
382
|
+
const currentMediaType = ref<'preview' | 'hd' | 'video'>('preview')
|
|
383
|
+
|
|
384
|
+
// Computed
|
|
385
|
+
const hasNextPage = computed(() => !!nextPageToken.value)
|
|
386
|
+
const hasNoEvents = computed(() => !loading.value && events.value.length === 0 && !error.value)
|
|
387
|
+
const enlargedEvent = computed(() => {
|
|
388
|
+
if (!enlargedEventId.value) return null
|
|
389
|
+
return events.value.find(e => e.id === enlargedEventId.value) || null
|
|
390
|
+
})
|
|
391
|
+
const enlargedImage = computed(() => {
|
|
392
|
+
if (!enlargedEventId.value) return null
|
|
393
|
+
return eventImages.value.get(enlargedEventId.value) || null
|
|
394
|
+
})
|
|
395
|
+
const enlargedBoundingBoxes = computed(() => {
|
|
396
|
+
if (!enlargedEvent.value) return []
|
|
397
|
+
return getBoundingBoxes(enlargedEvent.value)
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
// Get start timestamp based on time range
|
|
401
|
+
function getStartTimestamp(range: TimeRange): string {
|
|
402
|
+
const now = Date.now()
|
|
403
|
+
const hoursMap: Record<TimeRange, number> = {
|
|
404
|
+
'1h': 1,
|
|
405
|
+
'6h': 6,
|
|
406
|
+
'24h': 24
|
|
407
|
+
}
|
|
408
|
+
return new Date(now - hoursMap[range] * 60 * 60 * 1000).toISOString()
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Get fallback label for detected object based on event type.
|
|
413
|
+
*/
|
|
414
|
+
function getFallbackLabel(eventType: string): string {
|
|
415
|
+
if (eventType.includes('person')) return 'Person'
|
|
416
|
+
if (eventType.includes('vehicle')) return 'Vehicle'
|
|
417
|
+
if (eventType.includes('licensePlate') || eventType.includes('lpr')) return 'License Plate'
|
|
418
|
+
return 'Object'
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Extract bounding boxes from event data.
|
|
423
|
+
* Looks for object detection data schemas and extracts bounding box info.
|
|
424
|
+
* The EEN API returns boundingBox as [x1, y1, x2, y2] normalized coordinates (0-1).
|
|
425
|
+
* Labels are obtained from een.objectClassification.v1 data when available.
|
|
426
|
+
* Results are cached per event ID to avoid redundant calculations.
|
|
427
|
+
*/
|
|
428
|
+
function getBoundingBoxes(event: Event): BoundingBox[] {
|
|
429
|
+
// Check cache first
|
|
430
|
+
const cached = boundingBoxCache.value.get(event.id)
|
|
431
|
+
if (cached) {
|
|
432
|
+
return cached
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const boxes: BoundingBox[] = []
|
|
436
|
+
const fallbackLabel = getFallbackLabel(event.type)
|
|
437
|
+
|
|
438
|
+
// Build a map of objectId -> classification label from objectClassification data
|
|
439
|
+
const classificationMap = new Map<string, string>()
|
|
440
|
+
for (const dataItem of event.data) {
|
|
441
|
+
if (dataItem.type === 'een.objectClassification.v1') {
|
|
442
|
+
// Use type guards for proper runtime validation
|
|
443
|
+
const objectId = dataItem.objectId
|
|
444
|
+
const label = dataItem.label
|
|
445
|
+
if (isNonEmptyString(objectId) && isNonEmptyString(label)) {
|
|
446
|
+
// Capitalize first letter of label
|
|
447
|
+
const formattedLabel = label.charAt(0).toUpperCase() + label.slice(1).toLowerCase()
|
|
448
|
+
classificationMap.set(objectId, formattedLabel)
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Extract bounding boxes from objectDetection data
|
|
454
|
+
for (const dataItem of event.data) {
|
|
455
|
+
if (dataItem.type === 'een.objectDetection.v1') {
|
|
456
|
+
const boundingBox = dataItem.boundingBox
|
|
457
|
+
const objectId = dataItem.objectId
|
|
458
|
+
|
|
459
|
+
// Use type guard for proper runtime validation of bounding box array
|
|
460
|
+
if (isValidBoundingBoxArray(boundingBox)) {
|
|
461
|
+
const [x1, y1, x2, y2] = boundingBox
|
|
462
|
+
// Use classification label if available, otherwise use fallback
|
|
463
|
+
const label = (isNonEmptyString(objectId) && classificationMap.get(objectId)) || fallbackLabel
|
|
464
|
+
boxes.push({
|
|
465
|
+
x: x1,
|
|
466
|
+
y: y1,
|
|
467
|
+
width: x2 - x1,
|
|
468
|
+
height: y2 - y1,
|
|
469
|
+
label
|
|
470
|
+
})
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Cache the result
|
|
476
|
+
boundingBoxCache.value.set(event.id, boxes)
|
|
477
|
+
return boxes
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Get the count of bounding boxes for an event.
|
|
482
|
+
*/
|
|
483
|
+
function getBoundingBoxCount(event: Event): number {
|
|
484
|
+
return getBoundingBoxes(event).length
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Fetch available event types for this camera
|
|
488
|
+
async function fetchAvailableEventTypes() {
|
|
489
|
+
loadingFieldValues.value = true
|
|
490
|
+
fieldValuesError.value = null
|
|
491
|
+
|
|
492
|
+
const result = await listEventFieldValues({
|
|
493
|
+
actor: `camera:${props.camera.id}`
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
if (result.error) {
|
|
497
|
+
fieldValuesError.value = result.error
|
|
498
|
+
availableEventTypes.value = []
|
|
499
|
+
} else {
|
|
500
|
+
availableEventTypes.value = result.data.type || []
|
|
501
|
+
// Select all by default
|
|
502
|
+
selectedEventTypes.value = [...availableEventTypes.value]
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
loadingFieldValues.value = false
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Fetch event type names for display
|
|
509
|
+
async function fetchEventTypeNames() {
|
|
510
|
+
const result = await listEventTypes({ pageSize: 100 })
|
|
511
|
+
if (!result.error && result.data) {
|
|
512
|
+
const nameMap = new Map<string, string>()
|
|
513
|
+
for (const et of result.data.results as EventType[]) {
|
|
514
|
+
nameMap.set(et.type, et.name)
|
|
515
|
+
}
|
|
516
|
+
eventTypeNames.value = nameMap
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Get human-readable event type name
|
|
521
|
+
function getEventTypeName(type: string): string {
|
|
522
|
+
return eventTypeNames.value.get(type) || formatEventType(type)
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Format event type for display (fallback)
|
|
526
|
+
function formatEventType(type: string): string {
|
|
527
|
+
// e.g., "een.motionDetectionEvent.v1" -> "Motion Detection"
|
|
528
|
+
const match = type.match(/een\.(\w+)Event\.v\d+/)
|
|
529
|
+
if (match) {
|
|
530
|
+
return match[1]
|
|
531
|
+
.replace(/([A-Z])/g, ' $1')
|
|
532
|
+
.replace(/^./, str => str.toUpperCase())
|
|
533
|
+
.trim()
|
|
534
|
+
}
|
|
535
|
+
return type
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// Fetch events
|
|
539
|
+
async function fetchEvents(append = false) {
|
|
540
|
+
if (selectedEventTypes.value.length === 0) {
|
|
541
|
+
events.value = []
|
|
542
|
+
nextPageToken.value = undefined
|
|
543
|
+
return
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
loading.value = true
|
|
547
|
+
if (!append) {
|
|
548
|
+
error.value = null
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const result = await listEvents({
|
|
552
|
+
actor: `camera:${props.camera.id}`,
|
|
553
|
+
type__in: selectedEventTypes.value,
|
|
554
|
+
startTimestamp__gte: getStartTimestamp(timeRange.value),
|
|
555
|
+
endTimestamp__lte: new Date().toISOString(),
|
|
556
|
+
pageSize: 20,
|
|
557
|
+
pageToken: append ? nextPageToken.value : undefined,
|
|
558
|
+
sort: '-startTimestamp',
|
|
559
|
+
include: ['data.een.fullFrameImageUrl.v1', 'data.een.croppedFrameImageUrl.v1', 'data.een.objectDetection.v1', 'data.een.objectClassification.v1']
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
if (result.error) {
|
|
563
|
+
error.value = result.error
|
|
564
|
+
if (!append) {
|
|
565
|
+
events.value = []
|
|
566
|
+
}
|
|
567
|
+
nextPageToken.value = undefined
|
|
568
|
+
} else {
|
|
569
|
+
const newEvents = result.data.results
|
|
570
|
+
if (append) {
|
|
571
|
+
events.value = [...events.value, ...newEvents]
|
|
572
|
+
} else {
|
|
573
|
+
events.value = newEvents
|
|
574
|
+
eventImages.value.clear()
|
|
575
|
+
}
|
|
576
|
+
nextPageToken.value = result.data.nextPageToken
|
|
577
|
+
|
|
578
|
+
// Load images for the new events (don't block UI)
|
|
579
|
+
loadEventImages(newEvents)
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
loading.value = false
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// Load more events
|
|
586
|
+
async function loadMore() {
|
|
587
|
+
if (!nextPageToken.value) return
|
|
588
|
+
await fetchEvents(true)
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Evict oldest images from cache if it exceeds the maximum size.
|
|
593
|
+
* Removes images that are not currently being displayed.
|
|
594
|
+
*/
|
|
595
|
+
function evictOldestImages() {
|
|
596
|
+
if (eventImages.value.size <= MAX_IMAGE_CACHE_SIZE) return
|
|
597
|
+
|
|
598
|
+
// Get IDs of images to keep (currently visible events)
|
|
599
|
+
const visibleEventIds = new Set(events.value.map(e => e.id))
|
|
600
|
+
|
|
601
|
+
// Find images to evict (not currently visible)
|
|
602
|
+
const idsToEvict: string[] = []
|
|
603
|
+
for (const id of eventImages.value.keys()) {
|
|
604
|
+
if (!visibleEventIds.has(id)) {
|
|
605
|
+
idsToEvict.push(id)
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Evict oldest first (Map maintains insertion order)
|
|
610
|
+
const numToEvict = eventImages.value.size - MAX_IMAGE_CACHE_SIZE
|
|
611
|
+
for (let i = 0; i < Math.min(numToEvict, idsToEvict.length); i++) {
|
|
612
|
+
eventImages.value.delete(idsToEvict[i])
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Load images for events using getRecordedImage API
|
|
617
|
+
async function loadEventImages(eventsToLoad: Event[]) {
|
|
618
|
+
// Load images in parallel for all camera events
|
|
619
|
+
const loadPromises = eventsToLoad
|
|
620
|
+
.filter(event => event.actorType === 'camera')
|
|
621
|
+
.map(async (event) => {
|
|
622
|
+
// Skip if already loaded or currently loading (prevents race condition)
|
|
623
|
+
if (eventImages.value.has(event.id) || imageLoadingIds.value.has(event.id)) {
|
|
624
|
+
return
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Mark as loading to prevent duplicate requests
|
|
628
|
+
imageLoadingIds.value.add(event.id)
|
|
629
|
+
|
|
630
|
+
try {
|
|
631
|
+
const result = await getRecordedImage({
|
|
632
|
+
deviceId: event.actorId,
|
|
633
|
+
type: 'preview',
|
|
634
|
+
timestamp__gte: event.startTimestamp
|
|
635
|
+
})
|
|
636
|
+
|
|
637
|
+
if (!result.error && result.data) {
|
|
638
|
+
eventImages.value.set(event.id, result.data.imageData)
|
|
639
|
+
// Evict old images if cache is too large
|
|
640
|
+
evictOldestImages()
|
|
641
|
+
}
|
|
642
|
+
} finally {
|
|
643
|
+
// Remove from loading set regardless of success/failure
|
|
644
|
+
imageLoadingIds.value.delete(event.id)
|
|
645
|
+
}
|
|
646
|
+
})
|
|
647
|
+
|
|
648
|
+
await Promise.all(loadPromises)
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Get image for an event (from loaded images map)
|
|
652
|
+
function getEventImage(event: Event): string | null {
|
|
653
|
+
return eventImages.value.get(event.id) || null
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Format timestamp for display
|
|
657
|
+
function formatTimestamp(timestamp: string): string {
|
|
658
|
+
const date = new Date(timestamp)
|
|
659
|
+
return date.toLocaleString()
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// Handle event type checkbox toggle
|
|
663
|
+
function toggleEventType(type: string) {
|
|
664
|
+
const index = selectedEventTypes.value.indexOf(type)
|
|
665
|
+
if (index === -1) {
|
|
666
|
+
selectedEventTypes.value.push(type)
|
|
667
|
+
} else {
|
|
668
|
+
selectedEventTypes.value.splice(index, 1)
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// Select/deselect all event types
|
|
673
|
+
function toggleAllEventTypes() {
|
|
674
|
+
if (selectedEventTypes.value.length === availableEventTypes.value.length) {
|
|
675
|
+
selectedEventTypes.value = []
|
|
676
|
+
} else {
|
|
677
|
+
selectedEventTypes.value = [...availableEventTypes.value]
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Open enlarged image view
|
|
682
|
+
function openEnlargedImage(eventId: string) {
|
|
683
|
+
enlargedEventId.value = eventId
|
|
684
|
+
currentMediaType.value = 'preview'
|
|
685
|
+
showVideo.value = false
|
|
686
|
+
hdImageUrl.value = null
|
|
687
|
+
hdImageError.value = null
|
|
688
|
+
resetVideo()
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// Close enlarged image view
|
|
692
|
+
function closeEnlargedImage() {
|
|
693
|
+
enlargedEventId.value = null
|
|
694
|
+
showVideo.value = false
|
|
695
|
+
hdImageUrl.value = null
|
|
696
|
+
hdImageError.value = null
|
|
697
|
+
currentMediaType.value = 'preview'
|
|
698
|
+
resetVideo()
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// Switch to preview mode
|
|
702
|
+
function showPreview() {
|
|
703
|
+
currentMediaType.value = 'preview'
|
|
704
|
+
showVideo.value = false
|
|
705
|
+
resetVideo()
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// Load and show HD image
|
|
709
|
+
async function showHdImage() {
|
|
710
|
+
if (!enlargedEvent.value) return
|
|
711
|
+
|
|
712
|
+
currentMediaType.value = 'hd'
|
|
713
|
+
showVideo.value = false
|
|
714
|
+
loadingHdImage.value = true
|
|
715
|
+
hdImageError.value = null
|
|
716
|
+
hdImageUrl.value = null
|
|
717
|
+
resetVideo()
|
|
718
|
+
|
|
719
|
+
const result = await getRecordedImage({
|
|
720
|
+
deviceId: enlargedEvent.value.actorId,
|
|
721
|
+
type: 'main',
|
|
722
|
+
timestamp__gte: enlargedEvent.value.startTimestamp
|
|
723
|
+
})
|
|
724
|
+
|
|
725
|
+
if (result.error) {
|
|
726
|
+
hdImageError.value = result.error.message
|
|
727
|
+
} else if (result.data?.imageData) {
|
|
728
|
+
hdImageUrl.value = result.data.imageData
|
|
729
|
+
} else {
|
|
730
|
+
hdImageError.value = 'No image data returned'
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
loadingHdImage.value = false
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// Load and show video
|
|
737
|
+
async function showVideoPlayer() {
|
|
738
|
+
if (!enlargedEvent.value) return
|
|
739
|
+
|
|
740
|
+
currentMediaType.value = 'video'
|
|
741
|
+
showVideo.value = true
|
|
742
|
+
hdImageUrl.value = null
|
|
743
|
+
hdImageError.value = null
|
|
744
|
+
|
|
745
|
+
await loadVideo(enlargedEvent.value.actorId, enlargedEvent.value.startTimestamp)
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// Handle keyboard events for accessibility
|
|
749
|
+
function handleKeydown(event: KeyboardEvent) {
|
|
750
|
+
if (event.key === 'Escape' && enlargedEventId.value) {
|
|
751
|
+
closeEnlargedImage()
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// Set up keyboard event listener for ESC key
|
|
756
|
+
onMounted(() => {
|
|
757
|
+
window.addEventListener('keydown', handleKeydown)
|
|
758
|
+
})
|
|
759
|
+
|
|
760
|
+
onUnmounted(() => {
|
|
761
|
+
window.removeEventListener('keydown', handleKeydown)
|
|
762
|
+
})
|
|
763
|
+
|
|
764
|
+
// Watch for modal open/close
|
|
765
|
+
watch(() => props.isOpen, async (isOpen) => {
|
|
766
|
+
if (isOpen) {
|
|
767
|
+
events.value = []
|
|
768
|
+
nextPageToken.value = undefined
|
|
769
|
+
error.value = null
|
|
770
|
+
eventImages.value.clear()
|
|
771
|
+
imageLoadingIds.value.clear()
|
|
772
|
+
boundingBoxCache.value.clear()
|
|
773
|
+
|
|
774
|
+
await fetchEventTypeNames()
|
|
775
|
+
await fetchAvailableEventTypes()
|
|
776
|
+
|
|
777
|
+
if (availableEventTypes.value.length > 0) {
|
|
778
|
+
await fetchEvents()
|
|
779
|
+
}
|
|
780
|
+
} else {
|
|
781
|
+
// Clean up on modal close to free memory (base64 images can be large)
|
|
782
|
+
eventImages.value.clear()
|
|
783
|
+
imageLoadingIds.value.clear()
|
|
784
|
+
boundingBoxCache.value.clear()
|
|
785
|
+
events.value = []
|
|
786
|
+
enlargedEventId.value = null
|
|
787
|
+
}
|
|
788
|
+
}, { immediate: true })
|
|
789
|
+
|
|
790
|
+
// Watch for filter changes
|
|
791
|
+
watch([timeRange, selectedEventTypes], () => {
|
|
792
|
+
if (props.isOpen) {
|
|
793
|
+
fetchEvents()
|
|
794
|
+
}
|
|
795
|
+
}, { deep: true })
|
|
796
|
+
</script>
|
|
797
|
+
|
|
798
|
+
<template>
|
|
799
|
+
<div v-if="isOpen" class="modal-overlay" @click.self="emit('close')">
|
|
800
|
+
<div class="modal">
|
|
801
|
+
<div class="modal-header">
|
|
802
|
+
<div class="header-info">
|
|
803
|
+
<h2>Events: {{ camera.name }}</h2>
|
|
804
|
+
<div class="camera-id">Camera ID: {{ camera.id }}</div>
|
|
805
|
+
</div>
|
|
806
|
+
<button class="close-button" @click="emit('close')">×</button>
|
|
807
|
+
</div>
|
|
808
|
+
|
|
809
|
+
<div class="modal-filters" data-testid="modal-filters">
|
|
810
|
+
<div class="filter-row">
|
|
811
|
+
<label>Time Range:</label>
|
|
812
|
+
<select v-model="timeRange" data-testid="time-range-select">
|
|
813
|
+
<option v-for="option in timeRangeOptions" :key="option.value" :value="option.value">
|
|
814
|
+
{{ option.label }}
|
|
815
|
+
</option>
|
|
816
|
+
</select>
|
|
817
|
+
</div>
|
|
818
|
+
|
|
819
|
+
<div class="filter-row event-types">
|
|
820
|
+
<label>Event Types:</label>
|
|
821
|
+
<div v-if="loadingFieldValues" class="loading-types">
|
|
822
|
+
Loading event types...
|
|
823
|
+
</div>
|
|
824
|
+
<div v-else-if="fieldValuesError" class="error-types" data-testid="field-values-error">
|
|
825
|
+
Error loading event types: {{ fieldValuesError.message }}
|
|
826
|
+
</div>
|
|
827
|
+
<div v-else-if="availableEventTypes.length === 0" class="no-types">
|
|
828
|
+
No event types available for this camera
|
|
829
|
+
</div>
|
|
830
|
+
<div v-else class="event-type-list">
|
|
831
|
+
<label class="event-type-checkbox select-all">
|
|
832
|
+
<input
|
|
833
|
+
type="checkbox"
|
|
834
|
+
:checked="selectedEventTypes.length === availableEventTypes.length"
|
|
835
|
+
:indeterminate="selectedEventTypes.length > 0 && selectedEventTypes.length < availableEventTypes.length"
|
|
836
|
+
@change="toggleAllEventTypes"
|
|
837
|
+
/>
|
|
838
|
+
<span>Select All</span>
|
|
839
|
+
</label>
|
|
840
|
+
<label
|
|
841
|
+
v-for="eventType in availableEventTypes"
|
|
842
|
+
:key="eventType"
|
|
843
|
+
class="event-type-checkbox"
|
|
844
|
+
>
|
|
845
|
+
<input
|
|
846
|
+
type="checkbox"
|
|
847
|
+
:checked="selectedEventTypes.includes(eventType)"
|
|
848
|
+
@change="toggleEventType(eventType)"
|
|
849
|
+
/>
|
|
850
|
+
<span>{{ getEventTypeName(eventType) }}</span>
|
|
851
|
+
</label>
|
|
852
|
+
</div>
|
|
853
|
+
</div>
|
|
854
|
+
</div>
|
|
855
|
+
|
|
856
|
+
<div class="modal-body">
|
|
857
|
+
<div v-if="loading && events.length === 0" class="loading">
|
|
858
|
+
Loading events...
|
|
859
|
+
</div>
|
|
860
|
+
|
|
861
|
+
<div v-else-if="error" class="error">
|
|
862
|
+
Error: {{ error.message }}
|
|
863
|
+
</div>
|
|
864
|
+
|
|
865
|
+
<div v-else-if="hasNoEvents" class="no-events" data-testid="no-events">
|
|
866
|
+
No events found for the selected filters.
|
|
867
|
+
</div>
|
|
868
|
+
|
|
869
|
+
<div v-else class="events-list" data-testid="events-list">
|
|
870
|
+
<div v-for="event in events" :key="event.id" class="event-item" data-testid="event-item">
|
|
871
|
+
<div
|
|
872
|
+
class="event-thumbnail"
|
|
873
|
+
:class="{ clickable: getEventImage(event) }"
|
|
874
|
+
@click="getEventImage(event) && openEnlargedImage(event.id)"
|
|
875
|
+
>
|
|
876
|
+
<img
|
|
877
|
+
v-if="getEventImage(event)"
|
|
878
|
+
:src="getEventImage(event) || ''"
|
|
879
|
+
:alt="event.type"
|
|
880
|
+
/>
|
|
881
|
+
<div v-else class="no-thumbnail loading-image">
|
|
882
|
+
Loading...
|
|
883
|
+
</div>
|
|
884
|
+
</div>
|
|
885
|
+
<div class="event-info">
|
|
886
|
+
<div class="event-type">{{ getEventTypeName(event.type) }}</div>
|
|
887
|
+
<div class="event-time">{{ formatTimestamp(event.startTimestamp) }}</div>
|
|
888
|
+
<div v-if="getBoundingBoxCount(event) > 0" class="event-detections" data-testid="event-detections">
|
|
889
|
+
{{ getBoundingBoxCount(event) }} detection{{ getBoundingBoxCount(event) !== 1 ? 's' : '' }}
|
|
890
|
+
</div>
|
|
891
|
+
<div class="event-id">ID: {{ event.id }}</div>
|
|
892
|
+
</div>
|
|
893
|
+
</div>
|
|
894
|
+
</div>
|
|
895
|
+
|
|
896
|
+
<div v-if="hasNextPage" class="load-more">
|
|
897
|
+
<button @click="loadMore" :disabled="loading">
|
|
898
|
+
{{ loading ? 'Loading...' : 'Load More' }}
|
|
899
|
+
</button>
|
|
900
|
+
</div>
|
|
901
|
+
</div>
|
|
902
|
+
|
|
903
|
+
<!-- Enlarged image lightbox -->
|
|
904
|
+
<div
|
|
905
|
+
v-if="enlargedEventId && (enlargedImage || showVideo)"
|
|
906
|
+
class="lightbox-overlay"
|
|
907
|
+
@click.self="closeEnlargedImage"
|
|
908
|
+
data-testid="lightbox-overlay"
|
|
909
|
+
>
|
|
910
|
+
<div class="lightbox-content">
|
|
911
|
+
<!-- Header with buttons -->
|
|
912
|
+
<div class="lightbox-header">
|
|
913
|
+
<div class="lightbox-buttons">
|
|
914
|
+
<button
|
|
915
|
+
class="media-button"
|
|
916
|
+
:class="{ active: currentMediaType === 'preview' }"
|
|
917
|
+
@click="showPreview"
|
|
918
|
+
>
|
|
919
|
+
Preview
|
|
920
|
+
</button>
|
|
921
|
+
<button
|
|
922
|
+
class="media-button media-button-hd"
|
|
923
|
+
:class="{ active: currentMediaType === 'hd' }"
|
|
924
|
+
@click="showHdImage"
|
|
925
|
+
>
|
|
926
|
+
HD Image
|
|
927
|
+
</button>
|
|
928
|
+
<button
|
|
929
|
+
class="media-button media-button-video"
|
|
930
|
+
:class="{ active: currentMediaType === 'video' }"
|
|
931
|
+
@click="showVideoPlayer"
|
|
932
|
+
>
|
|
933
|
+
Video
|
|
934
|
+
</button>
|
|
935
|
+
</div>
|
|
936
|
+
<button
|
|
937
|
+
class="lightbox-close"
|
|
938
|
+
@click="closeEnlargedImage"
|
|
939
|
+
aria-label="Close enlarged image"
|
|
940
|
+
data-testid="lightbox-close"
|
|
941
|
+
>×</button>
|
|
942
|
+
</div>
|
|
943
|
+
|
|
944
|
+
<!-- Video mode -->
|
|
945
|
+
<template v-if="showVideo">
|
|
946
|
+
<div v-if="loadingVideo" class="lightbox-loading">Loading video...</div>
|
|
947
|
+
<div v-else-if="videoError" class="lightbox-error">{{ videoError }}</div>
|
|
948
|
+
<video
|
|
949
|
+
v-else-if="videoUrl"
|
|
950
|
+
:ref="(el) => hlsPlayer.videoRef.value = el as HTMLVideoElement | null"
|
|
951
|
+
class="lightbox-video"
|
|
952
|
+
controls
|
|
953
|
+
autoplay
|
|
954
|
+
muted
|
|
955
|
+
playsinline
|
|
956
|
+
/>
|
|
957
|
+
</template>
|
|
958
|
+
```
|
|
959
|
+
|
|
960
|
+
### MetricsChart.vue
|
|
961
|
+
|
|
962
|
+
```vue
|
|
963
|
+
<script setup lang="ts">
|
|
964
|
+
import { ref, watch, computed } from 'vue'
|
|
965
|
+
import { Line } from 'vue-chartjs'
|
|
966
|
+
import {
|
|
967
|
+
Chart as ChartJS,
|
|
968
|
+
CategoryScale,
|
|
969
|
+
LinearScale,
|
|
970
|
+
PointElement,
|
|
971
|
+
LineElement,
|
|
972
|
+
Title,
|
|
973
|
+
Tooltip,
|
|
974
|
+
Legend,
|
|
975
|
+
TimeScale
|
|
976
|
+
} from 'chart.js'
|
|
977
|
+
import { getEventMetrics, listEventFieldValues, type Camera, type EventMetric, type EenError } from 'een-api-toolkit'
|
|
978
|
+
|
|
979
|
+
ChartJS.register(
|
|
980
|
+
CategoryScale,
|
|
981
|
+
LinearScale,
|
|
982
|
+
PointElement,
|
|
983
|
+
LineElement,
|
|
984
|
+
Title,
|
|
985
|
+
Tooltip,
|
|
986
|
+
Legend,
|
|
987
|
+
TimeScale
|
|
988
|
+
)
|
|
989
|
+
|
|
990
|
+
const props = defineProps<{
|
|
991
|
+
camera: Camera
|
|
992
|
+
timeRange: string
|
|
993
|
+
aggregateMinutes?: number
|
|
994
|
+
}>()
|
|
995
|
+
|
|
996
|
+
interface DataPoint {
|
|
997
|
+
timestamp: number
|
|
998
|
+
count: number
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
const dataPoints = ref<DataPoint[]>([])
|
|
1002
|
+
const eventTypes = ref<string[]>([])
|
|
1003
|
+
const selectedEventType = ref<string>('')
|
|
1004
|
+
const loadingEventTypes = ref(false)
|
|
1005
|
+
const loadingMetrics = ref(false)
|
|
1006
|
+
const error = ref<EenError | null>(null)
|
|
1007
|
+
|
|
1008
|
+
function getTimeRangeMs(range: string): number {
|
|
1009
|
+
switch (range) {
|
|
1010
|
+
case '1h': return 60 * 60 * 1000
|
|
1011
|
+
case '6h': return 6 * 60 * 60 * 1000
|
|
1012
|
+
case '24h': return 24 * 60 * 60 * 1000
|
|
1013
|
+
case '7d': return 7 * 24 * 60 * 60 * 1000
|
|
1014
|
+
case 'none': return 7 * 24 * 60 * 60 * 1000 // Default to 7 days when "none"
|
|
1015
|
+
default: return 7 * 24 * 60 * 60 * 1000
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
function getAggregationMinutes(range: string): number {
|
|
1020
|
+
// Note: EEN API requires minimum 60 minute aggregation
|
|
1021
|
+
switch (range) {
|
|
1022
|
+
case '1h': return 60 // 1 hour bucket (1 data point)
|
|
1023
|
+
case '6h': return 60 // 1 hour buckets (6 data points)
|
|
1024
|
+
case '24h': return 60 // 1 hour buckets (24 data points)
|
|
1025
|
+
case '7d': return 360 // 6 hour buckets (28 data points)
|
|
1026
|
+
case 'none': return 360 // 6 hour buckets for 7 days default
|
|
1027
|
+
default: return 360
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
function formatEventType(eventType: string): string {
|
|
1032
|
+
// Convert "een.motionDetectionEvent.v1" to "Motion Detection"
|
|
1033
|
+
const parts = eventType.split('.')
|
|
1034
|
+
if (parts.length >= 2) {
|
|
1035
|
+
const name = parts[1]
|
|
1036
|
+
.replace(/Event$/, '')
|
|
1037
|
+
.replace(/([A-Z])/g, ' $1')
|
|
1038
|
+
.trim()
|
|
1039
|
+
return name.charAt(0).toUpperCase() + name.slice(1)
|
|
1040
|
+
}
|
|
1041
|
+
return eventType
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
async function fetchEventTypes() {
|
|
1045
|
+
if (!props.camera?.id) return
|
|
1046
|
+
|
|
1047
|
+
loadingEventTypes.value = true
|
|
1048
|
+
error.value = null
|
|
1049
|
+
eventTypes.value = []
|
|
1050
|
+
selectedEventType.value = ''
|
|
1051
|
+
dataPoints.value = []
|
|
1052
|
+
|
|
1053
|
+
const result = await listEventFieldValues({
|
|
1054
|
+
actor: `camera:${props.camera.id}`
|
|
1055
|
+
})
|
|
1056
|
+
|
|
1057
|
+
if (result.error) {
|
|
1058
|
+
error.value = result.error
|
|
1059
|
+
loadingEventTypes.value = false
|
|
1060
|
+
return
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
eventTypes.value = result.data?.type ?? []
|
|
1064
|
+
// Auto-select first event type if available
|
|
1065
|
+
if (eventTypes.value.length > 0) {
|
|
1066
|
+
selectedEventType.value = eventTypes.value[0]
|
|
1067
|
+
fetchMetrics()
|
|
1068
|
+
}
|
|
1069
|
+
loadingEventTypes.value = false
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
async function fetchMetrics() {
|
|
1073
|
+
if (!props.camera?.id || !selectedEventType.value) return
|
|
1074
|
+
|
|
1075
|
+
loadingMetrics.value = true
|
|
1076
|
+
error.value = null
|
|
1077
|
+
dataPoints.value = []
|
|
1078
|
+
|
|
1079
|
+
const now = new Date()
|
|
1080
|
+
const rangeMs = getTimeRangeMs(props.timeRange)
|
|
1081
|
+
const startTime = new Date(now.getTime() - rangeMs)
|
|
1082
|
+
// Use provided aggregateMinutes prop, or fall back to auto-calculated value
|
|
1083
|
+
const aggregateByMinutes = props.aggregateMinutes ?? getAggregationMinutes(props.timeRange)
|
|
1084
|
+
|
|
1085
|
+
const result = await getEventMetrics({
|
|
1086
|
+
actor: `camera:${props.camera.id}`,
|
|
1087
|
+
eventType: selectedEventType.value,
|
|
1088
|
+
timestamp__gte: startTime.toISOString(),
|
|
1089
|
+
timestamp__lte: now.toISOString(),
|
|
1090
|
+
aggregateByMinutes
|
|
1091
|
+
})
|
|
1092
|
+
|
|
1093
|
+
if (result.error) {
|
|
1094
|
+
error.value = result.error
|
|
1095
|
+
loadingMetrics.value = false
|
|
1096
|
+
return
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// Transform API data to chart data points
|
|
1100
|
+
const metrics = result.data ?? []
|
|
1101
|
+
const nowMs = now.getTime()
|
|
1102
|
+
if (metrics.length > 0) {
|
|
1103
|
+
// Use the first metric's data points (for count target)
|
|
1104
|
+
const metric = metrics.find((m: EventMetric) => m.target === 'count') ?? metrics[0]
|
|
1105
|
+
// Defensive check for dataPoints array
|
|
1106
|
+
if (metric.dataPoints && Array.isArray(metric.dataPoints)) {
|
|
1107
|
+
// Filter out future data points
|
|
1108
|
+
dataPoints.value = metric.dataPoints
|
|
1109
|
+
.filter(([timestamp]) => timestamp <= nowMs)
|
|
1110
|
+
.map(([timestamp, count]) => ({
|
|
1111
|
+
timestamp,
|
|
1112
|
+
count
|
|
1113
|
+
}))
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
loadingMetrics.value = false
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
function handleEventTypeChange() {
|
|
1121
|
+
if (selectedEventType.value) {
|
|
1122
|
+
fetchMetrics()
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
const chartData = computed(() => {
|
|
1127
|
+
if (dataPoints.value.length === 0) {
|
|
1128
|
+
return {
|
|
1129
|
+
labels: [],
|
|
1130
|
+
datasets: []
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
return {
|
|
1135
|
+
labels: dataPoints.value.map(({ timestamp }) => {
|
|
1136
|
+
const date = new Date(timestamp)
|
|
1137
|
+
// Use time-only format for short ranges, date+time for longer ranges (including 'none' which defaults to 7 days)
|
|
1138
|
+
if (props.timeRange === '1h' || props.timeRange === '6h') {
|
|
1139
|
+
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
1140
|
+
}
|
|
1141
|
+
return date.toLocaleDateString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
|
1142
|
+
}),
|
|
1143
|
+
datasets: [
|
|
1144
|
+
{
|
|
1145
|
+
label: formatEventType(selectedEventType.value),
|
|
1146
|
+
data: dataPoints.value.map(({ count }) => count),
|
|
1147
|
+
borderColor: '#42b883',
|
|
1148
|
+
backgroundColor: 'rgba(66, 184, 131, 0.1)',
|
|
1149
|
+
tension: 0.3,
|
|
1150
|
+
fill: true
|
|
1151
|
+
}
|
|
1152
|
+
]
|
|
1153
|
+
}
|
|
1154
|
+
})
|
|
1155
|
+
|
|
1156
|
+
const chartOptions = {
|
|
1157
|
+
responsive: true,
|
|
1158
|
+
maintainAspectRatio: false,
|
|
1159
|
+
plugins: {
|
|
1160
|
+
legend: {
|
|
1161
|
+
display: true,
|
|
1162
|
+
position: 'top' as const
|
|
1163
|
+
},
|
|
1164
|
+
tooltip: {
|
|
1165
|
+
mode: 'index' as const,
|
|
1166
|
+
intersect: false
|
|
1167
|
+
}
|
|
1168
|
+
},
|
|
1169
|
+
scales: {
|
|
1170
|
+
y: {
|
|
1171
|
+
beginAtZero: true,
|
|
1172
|
+
title: {
|
|
1173
|
+
display: true,
|
|
1174
|
+
text: 'Event Count'
|
|
1175
|
+
}
|
|
1176
|
+
},
|
|
1177
|
+
x: {
|
|
1178
|
+
title: {
|
|
1179
|
+
display: true,
|
|
1180
|
+
text: 'Time'
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// Fetch event types when camera changes
|
|
1187
|
+
watch(
|
|
1188
|
+
() => props.camera?.id,
|
|
1189
|
+
() => {
|
|
1190
|
+
fetchEventTypes()
|
|
1191
|
+
},
|
|
1192
|
+
{ immediate: true }
|
|
1193
|
+
)
|
|
1194
|
+
|
|
1195
|
+
// Fetch metrics when time range or aggregate changes (if event type is selected)
|
|
1196
|
+
watch(
|
|
1197
|
+
() => [props.timeRange, props.aggregateMinutes],
|
|
1198
|
+
() => {
|
|
1199
|
+
if (selectedEventType.value) {
|
|
1200
|
+
fetchMetrics()
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
)
|
|
1204
|
+
</script>
|
|
1205
|
+
|
|
1206
|
+
<template>
|
|
1207
|
+
<div class="metrics-chart" data-testid="metrics-chart">
|
|
1208
|
+
<div class="event-type-selector" data-testid="event-type-selector">
|
|
1209
|
+
<label for="event-type-select">Event Type:</label>
|
|
1210
|
+
<select
|
|
1211
|
+
id="event-type-select"
|
|
1212
|
+
v-model="selectedEventType"
|
|
1213
|
+
@change="handleEventTypeChange"
|
|
1214
|
+
:disabled="loadingEventTypes || eventTypes.length === 0"
|
|
1215
|
+
data-testid="event-type-select"
|
|
1216
|
+
>
|
|
1217
|
+
<option value="" disabled>
|
|
1218
|
+
{{ loadingEventTypes ? 'Loading event types...' : (eventTypes.length === 0 ? 'No event types available' : 'Select an event type') }}
|
|
1219
|
+
</option>
|
|
1220
|
+
<option
|
|
1221
|
+
v-for="et in eventTypes"
|
|
1222
|
+
:key="et"
|
|
1223
|
+
:value="et"
|
|
1224
|
+
data-testid="event-type-option"
|
|
1225
|
+
>
|
|
1226
|
+
{{ formatEventType(et) }} ({{ et }})
|
|
1227
|
+
</option>
|
|
1228
|
+
</select>
|
|
1229
|
+
</div>
|
|
1230
|
+
|
|
1231
|
+
<div v-if="loadingMetrics" class="loading" data-testid="metrics-loading">
|
|
1232
|
+
Loading metrics...
|
|
1233
|
+
</div>
|
|
1234
|
+
<div v-else-if="error" class="error" data-testid="metrics-error">
|
|
1235
|
+
{{ error.message }}
|
|
1236
|
+
</div>
|
|
1237
|
+
<div v-else-if="!selectedEventType" class="no-data" data-testid="metrics-no-selection">
|
|
1238
|
+
Please select an event type to view metrics.
|
|
1239
|
+
</div>
|
|
1240
|
+
<div v-else-if="chartData.datasets.length === 0" class="no-data" data-testid="metrics-no-data">
|
|
1241
|
+
No event data available for this time range.
|
|
1242
|
+
</div>
|
|
1243
|
+
<div v-else class="chart-container">
|
|
1244
|
+
<Line :data="chartData" :options="chartOptions" />
|
|
1245
|
+
</div>
|
|
1246
|
+
</div>
|
|
1247
|
+
</template>
|
|
1248
|
+
```
|
|
1249
|
+
|
|
1250
|
+
### LiveEvents.vue
|
|
1251
|
+
|
|
1252
|
+
```vue
|
|
1253
|
+
<script setup lang="ts">
|
|
1254
|
+
import { ref, computed, onMounted, watch } from 'vue'
|
|
1255
|
+
import { useRoute } from 'vue-router'
|
|
1256
|
+
import {
|
|
1257
|
+
listEventSubscriptions,
|
|
1258
|
+
getRecordedImage,
|
|
1259
|
+
type EventSubscription,
|
|
1260
|
+
type SSEEvent
|
|
1261
|
+
} from 'een-api-toolkit'
|
|
1262
|
+
import { useConnectionStore } from '../stores/connection'
|
|
1263
|
+
import { useHlsPlayer } from '../composables/useHlsPlayer'
|
|
1264
|
+
|
|
1265
|
+
const route = useRoute()
|
|
1266
|
+
const connectionStore = useConnectionStore()
|
|
1267
|
+
|
|
1268
|
+
// Initialize HLS player composable
|
|
1269
|
+
const hlsPlayer = useHlsPlayer()
|
|
1270
|
+
const { videoUrl, videoError, loadingVideo, loadVideo, resetVideo } = hlsPlayer
|
|
1271
|
+
|
|
1272
|
+
// Subscriptions state
|
|
1273
|
+
const subscriptions = ref<EventSubscription[]>([])
|
|
1274
|
+
const selectedSubscriptionId = ref<string | null>(null)
|
|
1275
|
+
const loadingSubscriptions = ref(false)
|
|
1276
|
+
|
|
1277
|
+
// Modal state
|
|
1278
|
+
const selectedEvent = ref<SSEEvent | null>(null)
|
|
1279
|
+
const showModal = ref(false)
|
|
1280
|
+
|
|
1281
|
+
// Lightbox state
|
|
1282
|
+
const showLightbox = ref(false)
|
|
1283
|
+
const lightboxImageUrl = ref<string | null>(null)
|
|
1284
|
+
const loadingImage = ref(false)
|
|
1285
|
+
const imageError = ref<string | null>(null)
|
|
1286
|
+
const showVideo = ref(false)
|
|
1287
|
+
|
|
1288
|
+
const selectedSubscription = computed(() => {
|
|
1289
|
+
return subscriptions.value.find(s => s.id === selectedSubscriptionId.value)
|
|
1290
|
+
})
|
|
1291
|
+
|
|
1292
|
+
const canConnect = computed(() => {
|
|
1293
|
+
if (!selectedSubscription.value) return false
|
|
1294
|
+
if (selectedSubscription.value.deliveryConfig.type !== 'serverSentEvents.v1') return false
|
|
1295
|
+
return !!selectedSubscription.value.deliveryConfig.sseUrl
|
|
1296
|
+
})
|
|
1297
|
+
|
|
1298
|
+
// Use store computed values
|
|
1299
|
+
const isConnected = computed(() => connectionStore.isConnected)
|
|
1300
|
+
const isConnecting = computed(() => connectionStore.isConnecting)
|
|
1301
|
+
const connectionStatus = computed(() => connectionStore.connectionStatus)
|
|
1302
|
+
const connectionError = computed(() => connectionStore.connectionError)
|
|
1303
|
+
const events = computed(() => connectionStore.events)
|
|
1304
|
+
const maxEvents = connectionStore.maxEvents
|
|
1305
|
+
|
|
1306
|
+
async function loadSubscriptions() {
|
|
1307
|
+
loadingSubscriptions.value = true
|
|
1308
|
+
const result = await listEventSubscriptions({ pageSize: 100 })
|
|
1309
|
+
|
|
1310
|
+
if (!result.error) {
|
|
1311
|
+
// Filter to only SSE subscriptions
|
|
1312
|
+
subscriptions.value = result.data.results.filter(
|
|
1313
|
+
s => s.deliveryConfig.type === 'serverSentEvents.v1'
|
|
1314
|
+
)
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
loadingSubscriptions.value = false
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
async function connect() {
|
|
1321
|
+
if (!canConnect.value || !selectedSubscription.value) return
|
|
1322
|
+
|
|
1323
|
+
const subscriptionId = selectedSubscription.value.id
|
|
1324
|
+
|
|
1325
|
+
// Reload all subscriptions to get fresh SSE URLs
|
|
1326
|
+
// SSE URLs become invalid after disconnecting
|
|
1327
|
+
await loadSubscriptions()
|
|
1328
|
+
|
|
1329
|
+
// Find the subscription in the refreshed list
|
|
1330
|
+
const freshSubscription = subscriptions.value.find(s => s.id === subscriptionId)
|
|
1331
|
+
if (!freshSubscription) {
|
|
1332
|
+
connectionStore.setConnectionError({ code: 'NOT_FOUND', message: 'Subscription no longer exists' })
|
|
1333
|
+
return
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
if (freshSubscription.deliveryConfig.type !== 'serverSentEvents.v1') {
|
|
1337
|
+
connectionStore.setConnectionError({ code: 'VALIDATION_ERROR', message: 'Subscription is not an SSE type' })
|
|
1338
|
+
return
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
const sseUrl = freshSubscription.deliveryConfig.sseUrl
|
|
1342
|
+
if (!sseUrl) {
|
|
1343
|
+
connectionStore.setConnectionError({ code: 'VALIDATION_ERROR', message: 'No SSE URL available' })
|
|
1344
|
+
return
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
connectionStore.connect(subscriptionId, sseUrl)
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
function disconnect() {
|
|
1351
|
+
// Get the subscription ID before disconnecting
|
|
1352
|
+
const disconnectedId = connectionStore.connectedSubscriptionId
|
|
1353
|
+
|
|
1354
|
+
connectionStore.disconnect()
|
|
1355
|
+
|
|
1356
|
+
// Remove the subscription from the list since SSE URLs are single-use
|
|
1357
|
+
// and cannot be reconnected
|
|
1358
|
+
if (disconnectedId) {
|
|
1359
|
+
subscriptions.value = subscriptions.value.filter(s => s.id !== disconnectedId)
|
|
1360
|
+
selectedSubscriptionId.value = null
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
function clearEvents() {
|
|
1365
|
+
connectionStore.clearEvents()
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
function formatTimestamp(timestamp: string): string {
|
|
1369
|
+
return new Date(timestamp).toLocaleString()
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
function formatEventType(type: string): string {
|
|
1373
|
+
// Extract the short name from the full event type ID
|
|
1374
|
+
// e.g., "een.motionDetectionEvent.v1" -> "Motion Detection"
|
|
1375
|
+
const parts = type.split('.')
|
|
1376
|
+
if (parts.length >= 2) {
|
|
1377
|
+
const name = parts[1]
|
|
1378
|
+
.replace('Event', '')
|
|
1379
|
+
.replace(/([A-Z])/g, ' $1')
|
|
1380
|
+
.trim()
|
|
1381
|
+
return name.charAt(0).toUpperCase() + name.slice(1)
|
|
1382
|
+
}
|
|
1383
|
+
return type
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
function openEventModal(event: SSEEvent) {
|
|
1387
|
+
selectedEvent.value = event
|
|
1388
|
+
showModal.value = true
|
|
1389
|
+
// Add keyboard listener for Escape key
|
|
1390
|
+
document.addEventListener('keydown', handleKeyDown)
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
function closeModal() {
|
|
1394
|
+
showModal.value = false
|
|
1395
|
+
selectedEvent.value = null
|
|
1396
|
+
// Remove keyboard listener
|
|
1397
|
+
document.removeEventListener('keydown', handleKeyDown)
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
function handleKeyDown(e: KeyboardEvent) {
|
|
1401
|
+
if (e.key === 'Escape') {
|
|
1402
|
+
if (showLightbox.value) {
|
|
1403
|
+
closeLightbox()
|
|
1404
|
+
} else if (showModal.value) {
|
|
1405
|
+
closeModal()
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
function handleModalBackdropClick(e: MouseEvent) {
|
|
1411
|
+
if ((e.target as HTMLElement).classList.contains('modal-backdrop')) {
|
|
1412
|
+
closeModal()
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
// Extract the image URL from event data
|
|
1417
|
+
function getEventImageUrl(event: SSEEvent): string | null {
|
|
1418
|
+
if (!event.data) return null
|
|
1419
|
+
const fullFrameData = event.data.find(d => d.type === 'een.fullFrameImageUrl.v1')
|
|
1420
|
+
if (fullFrameData && typeof fullFrameData.httpsUrl === 'string') {
|
|
1421
|
+
return fullFrameData.httpsUrl
|
|
1422
|
+
}
|
|
1423
|
+
return null
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
// Check if event has media URLs
|
|
1427
|
+
function hasMediaUrls(event: SSEEvent): boolean {
|
|
1428
|
+
return getEventImageUrl(event) !== null
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
// Handle image click (preview or HD)
|
|
1432
|
+
// Uses event.actorId and event.startTimestamp directly instead of parsing URL
|
|
1433
|
+
async function handleImageClick(quality: 'preview' | 'main' = 'preview') {
|
|
1434
|
+
if (!selectedEvent.value) return
|
|
1435
|
+
|
|
1436
|
+
// Use event properties directly for reliability
|
|
1437
|
+
const deviceId = selectedEvent.value.actorId
|
|
1438
|
+
const timestamp = selectedEvent.value.startTimestamp
|
|
1439
|
+
|
|
1440
|
+
if (!deviceId || !timestamp) {
|
|
1441
|
+
return
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
loadingImage.value = true
|
|
1445
|
+
imageError.value = null
|
|
1446
|
+
lightboxImageUrl.value = null
|
|
1447
|
+
showLightbox.value = true
|
|
1448
|
+
showVideo.value = false
|
|
1449
|
+
|
|
1450
|
+
// Use the toolkit's getRecordedImage function
|
|
1451
|
+
const result = await getRecordedImage({
|
|
1452
|
+
deviceId,
|
|
1453
|
+
type: quality,
|
|
1454
|
+
timestamp__gte: timestamp
|
|
1455
|
+
})
|
|
1456
|
+
|
|
1457
|
+
if (result.error) {
|
|
1458
|
+
imageError.value = result.error.message
|
|
1459
|
+
} else if (result.data?.imageData) {
|
|
1460
|
+
lightboxImageUrl.value = result.data.imageData
|
|
1461
|
+
} else {
|
|
1462
|
+
imageError.value = 'No image data returned'
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
loadingImage.value = false
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
// Handle video click
|
|
1469
|
+
// Uses event.actorId and event.startTimestamp directly instead of parsing URL
|
|
1470
|
+
async function handleVideoClick() {
|
|
1471
|
+
if (!selectedEvent.value) return
|
|
1472
|
+
|
|
1473
|
+
// Use event properties directly for reliability
|
|
1474
|
+
const deviceId = selectedEvent.value.actorId
|
|
1475
|
+
const timestamp = selectedEvent.value.startTimestamp
|
|
1476
|
+
|
|
1477
|
+
if (!deviceId || !timestamp) {
|
|
1478
|
+
return
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
showVideo.value = true
|
|
1482
|
+
showLightbox.value = true
|
|
1483
|
+
|
|
1484
|
+
// Use the composable to load and play video
|
|
1485
|
+
await loadVideo(deviceId, timestamp)
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
// Close lightbox
|
|
1489
|
+
function closeLightbox() {
|
|
1490
|
+
showLightbox.value = false
|
|
1491
|
+
lightboxImageUrl.value = null
|
|
1492
|
+
imageError.value = null
|
|
1493
|
+
// Cleanup video if it was playing
|
|
1494
|
+
resetVideo()
|
|
1495
|
+
showVideo.value = false
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
// Load subscriptions on mount
|
|
1499
|
+
onMounted(async () => {
|
|
1500
|
+
await loadSubscriptions()
|
|
1501
|
+
|
|
1502
|
+
// If already connected, set the selected subscription to match
|
|
1503
|
+
if (connectionStore.connectedSubscriptionId) {
|
|
1504
|
+
selectedSubscriptionId.value = connectionStore.connectedSubscriptionId
|
|
1505
|
+
} else {
|
|
1506
|
+
// Check for subscriptionId in query params
|
|
1507
|
+
const queryId = route.query.subscriptionId as string | undefined
|
|
1508
|
+
if (queryId) {
|
|
1509
|
+
selectedSubscriptionId.value = queryId
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
})
|
|
1513
|
+
|
|
1514
|
+
// Auto-disconnect when changing subscription (but not if selecting the already connected one)
|
|
1515
|
+
watch(selectedSubscriptionId, (newId) => {
|
|
1516
|
+
if (newId && newId !== connectionStore.connectedSubscriptionId && connectionStore.isConnected) {
|
|
1517
|
+
disconnect()
|
|
1518
|
+
}
|
|
1519
|
+
})
|
|
1520
|
+
</script>
|
|
1521
|
+
|
|
1522
|
+
<template>
|
|
1523
|
+
<div class="live-events">
|
|
1524
|
+
<h2>Live Events</h2>
|
|
1525
|
+
|
|
1526
|
+
<!-- Subscription Selector -->
|
|
1527
|
+
<div class="selector-section">
|
|
1528
|
+
<label>Select Subscription:</label>
|
|
1529
|
+
<select
|
|
1530
|
+
v-model="selectedSubscriptionId"
|
|
1531
|
+
:disabled="loadingSubscriptions || isConnected"
|
|
1532
|
+
data-testid="subscription-select"
|
|
1533
|
+
>
|
|
1534
|
+
<option :value="null">-- Select a subscription --</option>
|
|
1535
|
+
<option v-for="sub in subscriptions" :key="sub.id" :value="sub.id">
|
|
1536
|
+
{{ sub.id.slice(0, 16) }}... ({{ sub.subscriptionConfig?.lifeCycle || 'unknown' }})
|
|
1537
|
+
</option>
|
|
1538
|
+
</select>
|
|
1539
|
+
|
|
1540
|
+
<div class="connection-controls">
|
|
1541
|
+
<button
|
|
1542
|
+
v-if="!isConnected"
|
|
1543
|
+
@click="connect"
|
|
1544
|
+
:disabled="!canConnect || isConnecting"
|
|
1545
|
+
data-testid="connect-button"
|
|
1546
|
+
>
|
|
1547
|
+
{{ isConnecting ? 'Connecting...' : 'Connect' }}
|
|
1548
|
+
</button>
|
|
1549
|
+
<button
|
|
1550
|
+
v-else
|
|
1551
|
+
@click="disconnect"
|
|
1552
|
+
class="danger"
|
|
1553
|
+
data-testid="disconnect-button"
|
|
1554
|
+
>
|
|
1555
|
+
Disconnect
|
|
1556
|
+
</button>
|
|
1557
|
+
<button
|
|
1558
|
+
@click="clearEvents"
|
|
1559
|
+
class="secondary"
|
|
1560
|
+
:disabled="events.length === 0"
|
|
1561
|
+
>
|
|
1562
|
+
Clear Events
|
|
1563
|
+
</button>
|
|
1564
|
+
</div>
|
|
1565
|
+
</div>
|
|
1566
|
+
|
|
1567
|
+
<!-- Connection Status -->
|
|
1568
|
+
<div class="status-section">
|
|
1569
|
+
<span class="status-label">Status:</span>
|
|
1570
|
+
<span
|
|
1571
|
+
class="status-indicator"
|
|
1572
|
+
:class="{
|
|
1573
|
+
'connected': isConnected,
|
|
1574
|
+
'connecting': isConnecting,
|
|
1575
|
+
'disconnected': connectionStatus === 'disconnected',
|
|
1576
|
+
'error': connectionStatus === 'error'
|
|
1577
|
+
}"
|
|
1578
|
+
data-testid="connection-status"
|
|
1579
|
+
>
|
|
1580
|
+
{{ connectionStatus }}
|
|
1581
|
+
</span>
|
|
1582
|
+
</div>
|
|
1583
|
+
|
|
1584
|
+
<div v-if="connectionError" class="error">
|
|
1585
|
+
Connection Error: {{ connectionError.message }}
|
|
1586
|
+
</div>
|
|
1587
|
+
|
|
1588
|
+
<!-- Events List -->
|
|
1589
|
+
<div class="events-section">
|
|
1590
|
+
<h3>Events ({{ events.length }})</h3>
|
|
1591
|
+
|
|
1592
|
+
<div v-if="events.length === 0" class="no-events">
|
|
1593
|
+
<p v-if="isConnected">Waiting for events...</p>
|
|
1594
|
+
<p v-else>Connect to a subscription to start receiving events.</p>
|
|
1595
|
+
</div>
|
|
1596
|
+
|
|
1597
|
+
<div v-else class="events-list" data-testid="events-list">
|
|
1598
|
+
<div
|
|
1599
|
+
v-for="(event, index) in events"
|
|
1600
|
+
:key="`${event.id}-${index}`"
|
|
1601
|
+
class="event-card clickable"
|
|
1602
|
+
@click="openEventModal(event)"
|
|
1603
|
+
>
|
|
1604
|
+
<div class="event-header">
|
|
1605
|
+
<span class="event-type">{{ formatEventType(event.type) }}</span>
|
|
1606
|
+
<span class="event-time">{{ formatTimestamp(event.startTimestamp) }}</span>
|
|
1607
|
+
</div>
|
|
1608
|
+
<div class="event-details">
|
|
1609
|
+
<span class="detail">
|
|
1610
|
+
<strong>Actor:</strong> {{ event.actorId }}
|
|
1611
|
+
</span>
|
|
1612
|
+
<span class="detail">
|
|
1613
|
+
<strong>Type:</strong> {{ event.type }}
|
|
1614
|
+
</span>
|
|
1615
|
+
<span v-if="event.span !== undefined" class="detail">
|
|
1616
|
+
<strong>Span:</strong> {{ event.span ? 'Yes' : 'No' }}
|
|
1617
|
+
</span>
|
|
1618
|
+
</div>
|
|
1619
|
+
</div>
|
|
1620
|
+
</div>
|
|
1621
|
+
</div>
|
|
1622
|
+
|
|
1623
|
+
<div class="help-section">
|
|
1624
|
+
<h4>Tips</h4>
|
|
1625
|
+
<ul>
|
|
1626
|
+
<li>Select a subscription from the dropdown to connect</li>
|
|
1627
|
+
<li>Events will appear in real-time as they occur</li>
|
|
1628
|
+
<li>Maximum {{ maxEvents }} events are displayed (oldest removed first)</li>
|
|
1629
|
+
<li>Click on an event card to view detailed information</li>
|
|
1630
|
+
</ul>
|
|
1631
|
+
<h4>Important</h4>
|
|
1632
|
+
<ul class="warning-list">
|
|
1633
|
+
<li>SSE URLs are single-use. Once disconnected, the subscription cannot be reconnected.</li>
|
|
1634
|
+
<li>To receive events again after disconnecting, create a new subscription.</li>
|
|
1635
|
+
<li>Subscriptions have a 15-minute TTL and expire if not connected.</li>
|
|
1636
|
+
</ul>
|
|
1637
|
+
</div>
|
|
1638
|
+
|
|
1639
|
+
<!-- Event Details Modal -->
|
|
1640
|
+
<div
|
|
1641
|
+
v-if="showModal && selectedEvent"
|
|
1642
|
+
class="modal-backdrop"
|
|
1643
|
+
role="dialog"
|
|
1644
|
+
aria-modal="true"
|
|
1645
|
+
aria-labelledby="modal-title"
|
|
1646
|
+
@click="handleModalBackdropClick"
|
|
1647
|
+
>
|
|
1648
|
+
<div class="modal-content" role="document">
|
|
1649
|
+
<div class="modal-header">
|
|
1650
|
+
<h3 id="modal-title">{{ formatEventType(selectedEvent.type) }}</h3>
|
|
1651
|
+
<div class="modal-header-buttons">
|
|
1652
|
+
<button
|
|
1653
|
+
v-if="hasMediaUrls(selectedEvent)"
|
|
1654
|
+
class="image-button"
|
|
1655
|
+
@click="handleImageClick('preview')"
|
|
1656
|
+
>
|
|
1657
|
+
Preview
|
|
1658
|
+
</button>
|
|
1659
|
+
<button
|
|
1660
|
+
v-if="hasMediaUrls(selectedEvent)"
|
|
1661
|
+
class="image-button image-button-hd"
|
|
1662
|
+
@click="handleImageClick('main')"
|
|
1663
|
+
>
|
|
1664
|
+
HD Image
|
|
1665
|
+
</button>
|
|
1666
|
+
<button
|
|
1667
|
+
v-if="hasMediaUrls(selectedEvent)"
|
|
1668
|
+
class="image-button image-button-video"
|
|
1669
|
+
@click="handleVideoClick"
|
|
1670
|
+
>
|
|
1671
|
+
Video
|
|
1672
|
+
</button>
|
|
1673
|
+
<button class="close-button" @click="closeModal" aria-label="Close modal">×</button>
|
|
1674
|
+
</div>
|
|
1675
|
+
</div>
|
|
1676
|
+
<div class="modal-body">
|
|
1677
|
+
<div class="modal-section">
|
|
1678
|
+
<h4>Event Information</h4>
|
|
1679
|
+
<div class="modal-field">
|
|
1680
|
+
<strong>Event ID:</strong>
|
|
1681
|
+
<span class="mono">{{ selectedEvent.id }}</span>
|
|
1682
|
+
</div>
|
|
1683
|
+
<div class="modal-field">
|
|
1684
|
+
<strong>Type:</strong>
|
|
1685
|
+
<span class="mono">{{ selectedEvent.type }}</span>
|
|
1686
|
+
</div>
|
|
1687
|
+
<div class="modal-field">
|
|
1688
|
+
<strong>Actor:</strong>
|
|
1689
|
+
<span class="mono">{{ selectedEvent.actorId }}</span>
|
|
1690
|
+
</div>
|
|
1691
|
+
<div class="modal-field">
|
|
1692
|
+
<strong>Start Time:</strong>
|
|
1693
|
+
<span>{{ formatTimestamp(selectedEvent.startTimestamp) }}</span>
|
|
1694
|
+
</div>
|
|
1695
|
+
<div v-if="selectedEvent.endTimestamp" class="modal-field">
|
|
1696
|
+
<strong>End Time:</strong>
|
|
1697
|
+
<span>{{ formatTimestamp(selectedEvent.endTimestamp) }}</span>
|
|
1698
|
+
</div>
|
|
1699
|
+
<div v-if="selectedEvent.span !== undefined" class="modal-field">
|
|
1700
|
+
<strong>Span Event:</strong>
|
|
1701
|
+
<span>{{ selectedEvent.span ? 'Yes' : 'No' }}</span>
|
|
1702
|
+
</div>
|
|
1703
|
+
</div>
|
|
1704
|
+
|
|
1705
|
+
<div v-if="selectedEvent.data && selectedEvent.data.length > 0" class="modal-section">
|
|
1706
|
+
<h4>Event Data ({{ selectedEvent.data.length }} items)</h4>
|
|
1707
|
+
<pre class="modal-json">{{ JSON.stringify(selectedEvent.data, null, 2) }}</pre>
|
|
1708
|
+
</div>
|
|
1709
|
+
|
|
1710
|
+
<div v-else class="modal-section">
|
|
1711
|
+
<h4>Event Data</h4>
|
|
1712
|
+
<p class="no-data-text">No additional data available for this event.</p>
|
|
1713
|
+
</div>
|
|
1714
|
+
</div>
|
|
1715
|
+
</div>
|
|
1716
|
+
|
|
1717
|
+
</div>
|
|
1718
|
+
|
|
1719
|
+
<!-- Image/Video Lightbox -->
|
|
1720
|
+
<div v-if="showLightbox" class="lightbox-overlay" @click.self="closeLightbox">
|
|
1721
|
+
<div class="lightbox-content">
|
|
1722
|
+
<button class="lightbox-close" @click="closeLightbox">×</button>
|
|
1723
|
+
<!-- Video mode -->
|
|
1724
|
+
<template v-if="showVideo">
|
|
1725
|
+
<div v-if="loadingVideo" class="lightbox-loading">Loading video...</div>
|
|
1726
|
+
<div v-else-if="videoError" class="lightbox-error">{{ videoError }}</div>
|
|
1727
|
+
<video
|
|
1728
|
+
v-else-if="videoUrl"
|
|
1729
|
+
:ref="(el) => hlsPlayer.videoRef.value = el as HTMLVideoElement | null"
|
|
1730
|
+
class="lightbox-video"
|
|
1731
|
+
controls
|
|
1732
|
+
autoplay
|
|
1733
|
+
muted
|
|
1734
|
+
playsinline
|
|
1735
|
+
/>
|
|
1736
|
+
</template>
|
|
1737
|
+
```
|
|
1738
|
+
|
|
1739
|
+
---
|
|
1740
|
+
|
|
1741
|
+
## Reference Examples
|
|
1742
|
+
|
|
1743
|
+
- `examples/vue-events/` - Event listing with bounding boxes
|
|
1744
|
+
- `examples/vue-alerts-metrics/` - Metrics, alerts, notifications
|
|
1745
|
+
- `examples/vue-event-subscriptions/` - Real-time SSE streaming
|