een-api-toolkit 0.3.15 → 0.3.20
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/CHANGELOG.md +45 -6
- package/README.md +1 -0
- package/dist/index.cjs +3 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +585 -0
- package/dist/index.js +485 -261
- package/dist/index.js.map +1 -1
- package/docs/AI-CONTEXT.md +144 -1
- package/examples/vue-alerts-metrics/e2e/auth.spec.ts +8 -1
- package/examples/vue-alerts-metrics/package-lock.json +8 -1
- package/examples/vue-alerts-metrics/package.json +4 -3
- package/examples/vue-alerts-metrics/src/components/AlertsList.vue +567 -16
- package/examples/vue-alerts-metrics/src/components/CameraSelector.vue +16 -6
- package/examples/vue-alerts-metrics/src/components/MetricsChart.vue +23 -9
- package/examples/vue-alerts-metrics/src/components/NotificationsList.vue +579 -17
- package/examples/vue-alerts-metrics/src/components/TimeRangeSelector.vue +197 -12
- package/examples/vue-alerts-metrics/src/composables/useHlsPlayer.ts +285 -0
- package/examples/vue-alerts-metrics/src/views/Dashboard.vue +31 -9
- package/examples/vue-alerts-metrics/src/views/Home.vue +56 -7
- package/examples/vue-event-subscriptions/.env.example +15 -0
- package/examples/vue-event-subscriptions/README.md +103 -0
- package/examples/vue-event-subscriptions/e2e/app.spec.ts +71 -0
- package/examples/vue-event-subscriptions/e2e/auth.spec.ts +290 -0
- package/examples/vue-event-subscriptions/index.html +13 -0
- package/examples/vue-event-subscriptions/package-lock.json +1719 -0
- package/examples/vue-event-subscriptions/package.json +28 -0
- package/examples/vue-event-subscriptions/playwright.config.ts +47 -0
- package/examples/vue-event-subscriptions/src/App.vue +233 -0
- package/examples/vue-event-subscriptions/src/main.ts +25 -0
- package/examples/vue-event-subscriptions/src/router/index.ts +68 -0
- package/examples/vue-event-subscriptions/src/views/Callback.vue +76 -0
- package/examples/vue-event-subscriptions/src/views/Home.vue +192 -0
- package/examples/vue-event-subscriptions/src/views/LiveEvents.vue +640 -0
- package/examples/vue-event-subscriptions/src/views/Login.vue +33 -0
- package/examples/vue-event-subscriptions/src/views/Logout.vue +59 -0
- package/examples/vue-event-subscriptions/src/views/Subscriptions.vue +402 -0
- package/examples/vue-event-subscriptions/src/vite-env.d.ts +12 -0
- package/examples/vue-event-subscriptions/tsconfig.json +21 -0
- package/examples/vue-event-subscriptions/tsconfig.node.json +10 -0
- package/examples/vue-event-subscriptions/vite.config.ts +12 -0
- package/package.json +1 -1
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { ref, watch } from 'vue'
|
|
3
|
-
import { listAlerts, listAlertTypes, type Camera, type Alert, type AlertType, type EenError } from 'een-api-toolkit'
|
|
2
|
+
import { ref, watch, computed, onUnmounted } from 'vue'
|
|
3
|
+
import { listAlerts, listAlertTypes, getAlert, getRecordedImage, type Camera, type Alert, type AlertType, type EenError } from 'een-api-toolkit'
|
|
4
|
+
import { useHlsPlayer } from '../composables/useHlsPlayer'
|
|
5
|
+
|
|
6
|
+
// Initialize HLS player composable
|
|
7
|
+
// Note: videoRef is not destructured - accessed as hlsPlayer.videoRef in template
|
|
8
|
+
const hlsPlayer = useHlsPlayer()
|
|
9
|
+
const { videoUrl, videoError, loadingVideo, loadVideo, resetVideo } = hlsPlayer
|
|
4
10
|
|
|
5
11
|
const props = defineProps<{
|
|
6
|
-
camera: Camera
|
|
12
|
+
camera: Camera | null
|
|
7
13
|
timeRange: string
|
|
8
14
|
}>()
|
|
9
15
|
|
|
@@ -16,6 +22,62 @@ const loadingMore = ref(false)
|
|
|
16
22
|
const error = ref<EenError | null>(null)
|
|
17
23
|
const nextPageToken = ref<string | undefined>(undefined)
|
|
18
24
|
|
|
25
|
+
// Modal state
|
|
26
|
+
const showModal = ref(false)
|
|
27
|
+
const selectedAlert = ref<Alert | null>(null)
|
|
28
|
+
const loadingDetails = ref(false)
|
|
29
|
+
const detailsError = ref<EenError | null>(null)
|
|
30
|
+
|
|
31
|
+
// Lightbox state
|
|
32
|
+
const showLightbox = ref(false)
|
|
33
|
+
const lightboxImageUrl = ref<string | null>(null)
|
|
34
|
+
const loadingImage = ref(false)
|
|
35
|
+
const imageError = ref<string | null>(null)
|
|
36
|
+
|
|
37
|
+
// Video state (showVideo controls lightbox mode, rest from composable)
|
|
38
|
+
const showVideo = ref(false)
|
|
39
|
+
|
|
40
|
+
// Check if alert has an httpsUrl in its data
|
|
41
|
+
// The data is an array, look for type "een.fullFrameImageUrl.v1"
|
|
42
|
+
const alertImageUrl = computed(() => {
|
|
43
|
+
if (!selectedAlert.value?.data) return null
|
|
44
|
+
const data = selectedAlert.value.data
|
|
45
|
+
|
|
46
|
+
// Alert data is an array
|
|
47
|
+
if (!Array.isArray(data)) return null
|
|
48
|
+
|
|
49
|
+
// Find the object with type "een.fullFrameImageUrl.v1"
|
|
50
|
+
const imageItem = data.find(
|
|
51
|
+
(item: unknown) =>
|
|
52
|
+
item &&
|
|
53
|
+
typeof item === 'object' &&
|
|
54
|
+
(item as Record<string, unknown>).type === 'een.fullFrameImageUrl.v1'
|
|
55
|
+
) as Record<string, unknown> | undefined
|
|
56
|
+
|
|
57
|
+
if (imageItem && typeof imageItem.httpsUrl === 'string') {
|
|
58
|
+
return imageItem.httpsUrl
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return null
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
// Parse the image URL to extract parameters for getRecordedImage
|
|
65
|
+
function parseImageUrlParams(url: string): { deviceId: string; type: 'preview' | 'main'; timestamp: string } | null {
|
|
66
|
+
try {
|
|
67
|
+
const urlObj = new URL(url)
|
|
68
|
+
const deviceId = urlObj.searchParams.get('deviceId')
|
|
69
|
+
const type = urlObj.searchParams.get('type') as 'preview' | 'main'
|
|
70
|
+
const timestamp = urlObj.searchParams.get('timestamp__gte')
|
|
71
|
+
|
|
72
|
+
if (deviceId && type && timestamp) {
|
|
73
|
+
return { deviceId, type, timestamp }
|
|
74
|
+
}
|
|
75
|
+
return null
|
|
76
|
+
} catch {
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
19
81
|
function getTimeRangeMs(range: string): number {
|
|
20
82
|
switch (range) {
|
|
21
83
|
case '1h': return 60 * 60 * 1000
|
|
@@ -57,8 +119,6 @@ async function fetchAlertTypes() {
|
|
|
57
119
|
}
|
|
58
120
|
|
|
59
121
|
async function fetchAlerts(append = false) {
|
|
60
|
-
if (!props.camera?.id) return
|
|
61
|
-
|
|
62
122
|
if (append) {
|
|
63
123
|
loadingMore.value = true
|
|
64
124
|
} else {
|
|
@@ -68,20 +128,27 @@ async function fetchAlerts(append = false) {
|
|
|
68
128
|
}
|
|
69
129
|
error.value = null
|
|
70
130
|
|
|
71
|
-
const now = new Date()
|
|
72
|
-
const rangeMs = getTimeRangeMs(props.timeRange)
|
|
73
|
-
const startTime = new Date(now.getTime() - rangeMs)
|
|
74
|
-
|
|
75
131
|
const params: Parameters<typeof listAlerts>[0] = {
|
|
76
|
-
actorId__in: [props.camera.id],
|
|
77
|
-
timestamp__gte: startTime.toISOString(),
|
|
78
|
-
timestamp__lte: now.toISOString(),
|
|
79
132
|
pageSize: 20,
|
|
80
133
|
pageToken: append ? nextPageToken.value : undefined,
|
|
81
134
|
include: ['description'],
|
|
82
135
|
sort: ['-timestamp']
|
|
83
136
|
}
|
|
84
137
|
|
|
138
|
+
// Only apply time filter if a specific time range is selected (not 'none')
|
|
139
|
+
if (props.timeRange !== 'none') {
|
|
140
|
+
const now = new Date()
|
|
141
|
+
const rangeMs = getTimeRangeMs(props.timeRange)
|
|
142
|
+
const startTime = new Date(now.getTime() - rangeMs)
|
|
143
|
+
params.timestamp__gte = startTime.toISOString()
|
|
144
|
+
params.timestamp__lte = now.toISOString()
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Only filter by camera if a specific camera is selected
|
|
148
|
+
if (props.camera?.id) {
|
|
149
|
+
params.actorId__in = [props.camera.id]
|
|
150
|
+
}
|
|
151
|
+
|
|
85
152
|
// Add alert type filter if selected
|
|
86
153
|
if (selectedAlertType.value) {
|
|
87
154
|
params.alertType__in = [selectedAlertType.value]
|
|
@@ -126,17 +193,131 @@ function getPriorityClass(priority?: number): string {
|
|
|
126
193
|
return 'priority-low'
|
|
127
194
|
}
|
|
128
195
|
|
|
196
|
+
async function handleAlertClick(alert: Alert) {
|
|
197
|
+
showModal.value = true
|
|
198
|
+
loadingDetails.value = true
|
|
199
|
+
detailsError.value = null
|
|
200
|
+
selectedAlert.value = null
|
|
201
|
+
|
|
202
|
+
const result = await getAlert(alert.id, {
|
|
203
|
+
include: ['data', 'actions', 'description', 'dataSchemas']
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
if (result.error) {
|
|
207
|
+
detailsError.value = result.error
|
|
208
|
+
} else {
|
|
209
|
+
selectedAlert.value = result.data
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
loadingDetails.value = false
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function closeModal() {
|
|
216
|
+
showModal.value = false
|
|
217
|
+
selectedAlert.value = null
|
|
218
|
+
detailsError.value = null
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function handleImageClick(quality: 'preview' | 'main' = 'preview') {
|
|
222
|
+
if (!alertImageUrl.value) return
|
|
223
|
+
|
|
224
|
+
loadingImage.value = true
|
|
225
|
+
imageError.value = null
|
|
226
|
+
lightboxImageUrl.value = null
|
|
227
|
+
showLightbox.value = true
|
|
228
|
+
|
|
229
|
+
// Parse the URL to extract parameters
|
|
230
|
+
const params = parseImageUrlParams(alertImageUrl.value)
|
|
231
|
+
if (!params) {
|
|
232
|
+
imageError.value = 'Invalid image URL format'
|
|
233
|
+
loadingImage.value = false
|
|
234
|
+
return
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Use the toolkit's getRecordedImage function
|
|
238
|
+
const result = await getRecordedImage({
|
|
239
|
+
deviceId: params.deviceId,
|
|
240
|
+
type: quality,
|
|
241
|
+
timestamp__gte: params.timestamp
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
if (result.error) {
|
|
245
|
+
imageError.value = result.error.message
|
|
246
|
+
} else if (result.data?.imageData) {
|
|
247
|
+
lightboxImageUrl.value = result.data.imageData
|
|
248
|
+
} else {
|
|
249
|
+
imageError.value = 'No image data returned'
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
loadingImage.value = false
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function closeLightbox() {
|
|
256
|
+
showLightbox.value = false
|
|
257
|
+
lightboxImageUrl.value = null
|
|
258
|
+
imageError.value = null
|
|
259
|
+
// Also cleanup video if it was playing
|
|
260
|
+
resetVideo()
|
|
261
|
+
showVideo.value = false
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function handleVideoClick() {
|
|
265
|
+
if (!alertImageUrl.value) return
|
|
266
|
+
|
|
267
|
+
// Parse the URL to extract parameters
|
|
268
|
+
const params = parseImageUrlParams(alertImageUrl.value)
|
|
269
|
+
if (!params) {
|
|
270
|
+
return
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
showVideo.value = true
|
|
274
|
+
showLightbox.value = true
|
|
275
|
+
|
|
276
|
+
// Use the composable to load and play video
|
|
277
|
+
await loadVideo(params.deviceId, params.timestamp)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Debounce delay for filter changes (ms)
|
|
281
|
+
const DEBOUNCE_DELAY = 300
|
|
282
|
+
|
|
283
|
+
// Debounce timer reference - component-scoped to avoid race conditions
|
|
284
|
+
const debounceTimer = ref<ReturnType<typeof setTimeout> | null>(null)
|
|
285
|
+
|
|
286
|
+
// Debounced fetch to avoid rapid API calls on quick filter changes
|
|
287
|
+
function debouncedFetchAlerts() {
|
|
288
|
+
if (debounceTimer.value) {
|
|
289
|
+
clearTimeout(debounceTimer.value)
|
|
290
|
+
}
|
|
291
|
+
debounceTimer.value = setTimeout(() => {
|
|
292
|
+
fetchAlerts()
|
|
293
|
+
debounceTimer.value = null
|
|
294
|
+
}, DEBOUNCE_DELAY)
|
|
295
|
+
}
|
|
296
|
+
|
|
129
297
|
// Fetch alert types once on mount
|
|
130
298
|
fetchAlertTypes()
|
|
131
299
|
|
|
132
|
-
// Fetch alerts when camera or time range changes
|
|
300
|
+
// Fetch alerts when camera or time range changes (debounced)
|
|
133
301
|
watch(
|
|
134
302
|
() => [props.camera?.id, props.timeRange],
|
|
135
|
-
() => {
|
|
136
|
-
|
|
303
|
+
(_newVal, oldVal) => {
|
|
304
|
+
// Immediate fetch on first load (when oldVal is undefined)
|
|
305
|
+
if (oldVal === undefined) {
|
|
306
|
+
fetchAlerts()
|
|
307
|
+
} else {
|
|
308
|
+
debouncedFetchAlerts()
|
|
309
|
+
}
|
|
137
310
|
},
|
|
138
311
|
{ immediate: true }
|
|
139
312
|
)
|
|
313
|
+
|
|
314
|
+
// Cleanup debounce timer on unmount to prevent memory leaks
|
|
315
|
+
onUnmounted(() => {
|
|
316
|
+
if (debounceTimer.value) {
|
|
317
|
+
clearTimeout(debounceTimer.value)
|
|
318
|
+
debounceTimer.value = null
|
|
319
|
+
}
|
|
320
|
+
})
|
|
140
321
|
</script>
|
|
141
322
|
|
|
142
323
|
<template>
|
|
@@ -177,8 +358,9 @@ watch(
|
|
|
177
358
|
<div
|
|
178
359
|
v-for="alert in alerts"
|
|
179
360
|
:key="alert.id"
|
|
180
|
-
class="alert-item"
|
|
361
|
+
class="alert-item clickable"
|
|
181
362
|
data-testid="alert-item"
|
|
363
|
+
@click="handleAlertClick(alert)"
|
|
182
364
|
>
|
|
183
365
|
<div class="alert-header">
|
|
184
366
|
<span class="alert-type">{{ alert.alertType?.split('.')[1] || alert.alertType }}</span>
|
|
@@ -206,6 +388,154 @@ watch(
|
|
|
206
388
|
{{ loadingMore ? 'Loading...' : 'Load More' }}
|
|
207
389
|
</button>
|
|
208
390
|
</div>
|
|
391
|
+
|
|
392
|
+
<!-- Alert Details Modal -->
|
|
393
|
+
<div v-if="showModal" class="modal-overlay" @click.self="closeModal" data-testid="alert-modal-overlay">
|
|
394
|
+
<div class="modal-content" data-testid="alert-modal">
|
|
395
|
+
<div class="modal-header">
|
|
396
|
+
<h3>Alert Details</h3>
|
|
397
|
+
<div class="modal-header-buttons">
|
|
398
|
+
<button
|
|
399
|
+
v-if="alertImageUrl"
|
|
400
|
+
class="image-button"
|
|
401
|
+
@click="handleImageClick('preview')"
|
|
402
|
+
data-testid="alert-image-button"
|
|
403
|
+
>
|
|
404
|
+
Preview
|
|
405
|
+
</button>
|
|
406
|
+
<button
|
|
407
|
+
v-if="alertImageUrl"
|
|
408
|
+
class="image-button image-button-hd"
|
|
409
|
+
@click="handleImageClick('main')"
|
|
410
|
+
data-testid="alert-image-hd-button"
|
|
411
|
+
>
|
|
412
|
+
HD Image
|
|
413
|
+
</button>
|
|
414
|
+
<button
|
|
415
|
+
v-if="alertImageUrl"
|
|
416
|
+
class="image-button image-button-video"
|
|
417
|
+
@click="handleVideoClick"
|
|
418
|
+
data-testid="alert-video-button"
|
|
419
|
+
>
|
|
420
|
+
Video
|
|
421
|
+
</button>
|
|
422
|
+
<button class="close-button" @click="closeModal" data-testid="alert-modal-close">×</button>
|
|
423
|
+
</div>
|
|
424
|
+
</div>
|
|
425
|
+
<div class="modal-body">
|
|
426
|
+
<div v-if="loadingDetails" class="loading">Loading alert details...</div>
|
|
427
|
+
<div v-else-if="detailsError" class="error">{{ detailsError.message }}</div>
|
|
428
|
+
<div v-else-if="selectedAlert" class="alert-details">
|
|
429
|
+
<div class="detail-row">
|
|
430
|
+
<span class="detail-label">ID:</span>
|
|
431
|
+
<span class="detail-value monospace">{{ selectedAlert.id }}</span>
|
|
432
|
+
</div>
|
|
433
|
+
<div class="detail-row">
|
|
434
|
+
<span class="detail-label">Type:</span>
|
|
435
|
+
<span class="detail-value">{{ formatAlertType(selectedAlert.alertType) }}</span>
|
|
436
|
+
</div>
|
|
437
|
+
<div v-if="selectedAlert.alertName" class="detail-row">
|
|
438
|
+
<span class="detail-label">Name:</span>
|
|
439
|
+
<span class="detail-value">{{ selectedAlert.alertName }}</span>
|
|
440
|
+
</div>
|
|
441
|
+
<div class="detail-row">
|
|
442
|
+
<span class="detail-label">Timestamp:</span>
|
|
443
|
+
<span class="detail-value">{{ formatTime(selectedAlert.timestamp) }}</span>
|
|
444
|
+
</div>
|
|
445
|
+
<div class="detail-row">
|
|
446
|
+
<span class="detail-label">Created:</span>
|
|
447
|
+
<span class="detail-value">{{ formatTime(selectedAlert.createTimestamp) }}</span>
|
|
448
|
+
</div>
|
|
449
|
+
<div v-if="selectedAlert.priority !== undefined" class="detail-row">
|
|
450
|
+
<span class="detail-label">Priority:</span>
|
|
451
|
+
<span class="detail-value">
|
|
452
|
+
<span class="priority-badge" :class="getPriorityClass(selectedAlert.priority)">
|
|
453
|
+
P{{ selectedAlert.priority }}
|
|
454
|
+
</span>
|
|
455
|
+
</span>
|
|
456
|
+
</div>
|
|
457
|
+
<div v-if="selectedAlert.category" class="detail-row">
|
|
458
|
+
<span class="detail-label">Category:</span>
|
|
459
|
+
<span class="detail-value">{{ selectedAlert.category }}</span>
|
|
460
|
+
</div>
|
|
461
|
+
<div v-if="selectedAlert.description" class="detail-row">
|
|
462
|
+
<span class="detail-label">Description:</span>
|
|
463
|
+
<span class="detail-value">{{ selectedAlert.description }}</span>
|
|
464
|
+
</div>
|
|
465
|
+
<div class="detail-row">
|
|
466
|
+
<span class="detail-label">Actor ID:</span>
|
|
467
|
+
<span class="detail-value monospace">{{ selectedAlert.actorId }}</span>
|
|
468
|
+
</div>
|
|
469
|
+
<div v-if="selectedAlert.actorName" class="detail-row">
|
|
470
|
+
<span class="detail-label">Actor Name:</span>
|
|
471
|
+
<span class="detail-value">{{ selectedAlert.actorName }}</span>
|
|
472
|
+
</div>
|
|
473
|
+
<div class="detail-row">
|
|
474
|
+
<span class="detail-label">Actor Type:</span>
|
|
475
|
+
<span class="detail-value">{{ selectedAlert.actorType }}</span>
|
|
476
|
+
</div>
|
|
477
|
+
<div v-if="selectedAlert.locationName" class="detail-row">
|
|
478
|
+
<span class="detail-label">Location:</span>
|
|
479
|
+
<span class="detail-value">{{ selectedAlert.locationName }}</span>
|
|
480
|
+
</div>
|
|
481
|
+
<div v-if="selectedAlert.eventId" class="detail-row">
|
|
482
|
+
<span class="detail-label">Event ID:</span>
|
|
483
|
+
<span class="detail-value monospace">{{ selectedAlert.eventId }}</span>
|
|
484
|
+
</div>
|
|
485
|
+
<div v-if="selectedAlert.ruleId" class="detail-row">
|
|
486
|
+
<span class="detail-label">Rule ID:</span>
|
|
487
|
+
<span class="detail-value monospace">{{ selectedAlert.ruleId }}</span>
|
|
488
|
+
</div>
|
|
489
|
+
<div v-if="selectedAlert.dataSchemas && selectedAlert.dataSchemas.length > 0" class="detail-row">
|
|
490
|
+
<span class="detail-label">Data Schemas:</span>
|
|
491
|
+
<span class="detail-value">{{ selectedAlert.dataSchemas.join(', ') }}</span>
|
|
492
|
+
</div>
|
|
493
|
+
<div v-if="selectedAlert.data && Object.keys(selectedAlert.data).length > 0" class="detail-section">
|
|
494
|
+
<span class="detail-label">Data:</span>
|
|
495
|
+
<pre class="detail-json">{{ JSON.stringify(selectedAlert.data, null, 2) }}</pre>
|
|
496
|
+
</div>
|
|
497
|
+
<div v-if="selectedAlert.actions && Object.keys(selectedAlert.actions).length > 0" class="detail-section">
|
|
498
|
+
<span class="detail-label">Actions:</span>
|
|
499
|
+
<pre class="detail-json">{{ JSON.stringify(selectedAlert.actions, null, 2) }}</pre>
|
|
500
|
+
</div>
|
|
501
|
+
</div>
|
|
502
|
+
</div>
|
|
503
|
+
</div>
|
|
504
|
+
</div>
|
|
505
|
+
|
|
506
|
+
<!-- Image/Video Lightbox -->
|
|
507
|
+
<div v-if="showLightbox" class="lightbox-overlay" @click.self="closeLightbox" data-testid="lightbox-overlay">
|
|
508
|
+
<div class="lightbox-content" data-testid="lightbox">
|
|
509
|
+
<button class="lightbox-close" @click="closeLightbox" data-testid="lightbox-close">×</button>
|
|
510
|
+
<!-- Video mode -->
|
|
511
|
+
<template v-if="showVideo">
|
|
512
|
+
<div v-if="loadingVideo" class="lightbox-loading">Loading video...</div>
|
|
513
|
+
<div v-else-if="videoError" class="lightbox-error">{{ videoError }}</div>
|
|
514
|
+
<video
|
|
515
|
+
v-else-if="videoUrl"
|
|
516
|
+
:ref="(el) => hlsPlayer.videoRef.value = el as HTMLVideoElement | null"
|
|
517
|
+
class="lightbox-video"
|
|
518
|
+
controls
|
|
519
|
+
autoplay
|
|
520
|
+
muted
|
|
521
|
+
playsinline
|
|
522
|
+
data-testid="lightbox-video"
|
|
523
|
+
/>
|
|
524
|
+
</template>
|
|
525
|
+
<!-- Image mode -->
|
|
526
|
+
<template v-else>
|
|
527
|
+
<div v-if="loadingImage" class="lightbox-loading">Loading image...</div>
|
|
528
|
+
<div v-else-if="imageError" class="lightbox-error">{{ imageError }}</div>
|
|
529
|
+
<img
|
|
530
|
+
v-else-if="lightboxImageUrl"
|
|
531
|
+
:src="lightboxImageUrl"
|
|
532
|
+
alt="Alert image"
|
|
533
|
+
class="lightbox-image"
|
|
534
|
+
data-testid="lightbox-image"
|
|
535
|
+
/>
|
|
536
|
+
</template>
|
|
537
|
+
</div>
|
|
538
|
+
</div>
|
|
209
539
|
</div>
|
|
210
540
|
</template>
|
|
211
541
|
|
|
@@ -327,4 +657,225 @@ watch(
|
|
|
327
657
|
background: #f5f5f5;
|
|
328
658
|
color: #999;
|
|
329
659
|
}
|
|
660
|
+
|
|
661
|
+
.alert-item.clickable {
|
|
662
|
+
cursor: pointer;
|
|
663
|
+
transition: background-color 0.15s ease, border-color 0.15s ease;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
.alert-item.clickable:hover {
|
|
667
|
+
background: #f0f0f0;
|
|
668
|
+
border-color: #ccc;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/* Modal styles */
|
|
672
|
+
.modal-overlay {
|
|
673
|
+
position: fixed;
|
|
674
|
+
top: 0;
|
|
675
|
+
left: 0;
|
|
676
|
+
right: 0;
|
|
677
|
+
bottom: 0;
|
|
678
|
+
background: rgba(0, 0, 0, 0.5);
|
|
679
|
+
display: flex;
|
|
680
|
+
align-items: center;
|
|
681
|
+
justify-content: center;
|
|
682
|
+
z-index: 1000;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
.modal-content {
|
|
686
|
+
background: white;
|
|
687
|
+
border-radius: 8px;
|
|
688
|
+
width: 80%;
|
|
689
|
+
max-height: 80vh;
|
|
690
|
+
overflow: hidden;
|
|
691
|
+
display: flex;
|
|
692
|
+
flex-direction: column;
|
|
693
|
+
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
.modal-header {
|
|
697
|
+
display: flex;
|
|
698
|
+
justify-content: space-between;
|
|
699
|
+
align-items: center;
|
|
700
|
+
padding: 16px 20px;
|
|
701
|
+
border-bottom: 1px solid #eee;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
.modal-header h3 {
|
|
705
|
+
margin: 0;
|
|
706
|
+
font-size: 1.1rem;
|
|
707
|
+
color: #333;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
.close-button {
|
|
711
|
+
background: none;
|
|
712
|
+
border: none;
|
|
713
|
+
font-size: 1.5rem;
|
|
714
|
+
cursor: pointer;
|
|
715
|
+
color: #666;
|
|
716
|
+
padding: 0;
|
|
717
|
+
line-height: 1;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
.close-button:hover {
|
|
721
|
+
color: #333;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
.modal-body {
|
|
725
|
+
padding: 20px;
|
|
726
|
+
overflow-y: auto;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
.alert-details {
|
|
730
|
+
display: flex;
|
|
731
|
+
flex-direction: column;
|
|
732
|
+
gap: 12px;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
.detail-row {
|
|
736
|
+
display: flex;
|
|
737
|
+
gap: 10px;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
.detail-label {
|
|
741
|
+
font-weight: 600;
|
|
742
|
+
color: #555;
|
|
743
|
+
min-width: 110px;
|
|
744
|
+
flex-shrink: 0;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
.detail-value {
|
|
748
|
+
color: #333;
|
|
749
|
+
word-break: break-word;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
.detail-value.monospace {
|
|
753
|
+
font-family: monospace;
|
|
754
|
+
font-size: 0.85rem;
|
|
755
|
+
background: #f5f5f5;
|
|
756
|
+
padding: 2px 6px;
|
|
757
|
+
border-radius: 3px;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
.detail-section {
|
|
761
|
+
display: flex;
|
|
762
|
+
flex-direction: column;
|
|
763
|
+
gap: 8px;
|
|
764
|
+
margin-top: 8px;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
.detail-json {
|
|
768
|
+
background: #f8f8f8;
|
|
769
|
+
border: 1px solid #eee;
|
|
770
|
+
border-radius: 4px;
|
|
771
|
+
padding: 12px;
|
|
772
|
+
font-size: 0.8rem;
|
|
773
|
+
overflow-x: auto;
|
|
774
|
+
margin: 0;
|
|
775
|
+
white-space: pre-wrap;
|
|
776
|
+
word-break: break-word;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
.modal-header-buttons {
|
|
780
|
+
display: flex;
|
|
781
|
+
align-items: center;
|
|
782
|
+
gap: 10px;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
.image-button {
|
|
786
|
+
padding: 6px 14px;
|
|
787
|
+
background: #42b883;
|
|
788
|
+
color: white;
|
|
789
|
+
border: none;
|
|
790
|
+
border-radius: 4px;
|
|
791
|
+
cursor: pointer;
|
|
792
|
+
font-size: 0.85rem;
|
|
793
|
+
font-weight: 500;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
.image-button:hover {
|
|
797
|
+
background: #3aa876;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
.image-button-hd {
|
|
801
|
+
background: #3b82f6;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
.image-button-hd:hover {
|
|
805
|
+
background: #2563eb;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
.image-button-video {
|
|
809
|
+
background: #9b59b6;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
.image-button-video:hover {
|
|
813
|
+
background: #8e44ad;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/* Lightbox styles */
|
|
817
|
+
.lightbox-overlay {
|
|
818
|
+
position: fixed;
|
|
819
|
+
top: 0;
|
|
820
|
+
left: 0;
|
|
821
|
+
right: 0;
|
|
822
|
+
bottom: 0;
|
|
823
|
+
background: rgba(0, 0, 0, 0.9);
|
|
824
|
+
display: flex;
|
|
825
|
+
align-items: center;
|
|
826
|
+
justify-content: center;
|
|
827
|
+
z-index: 1100;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
.lightbox-content {
|
|
831
|
+
position: relative;
|
|
832
|
+
max-width: 90vw;
|
|
833
|
+
max-height: 90vh;
|
|
834
|
+
display: flex;
|
|
835
|
+
align-items: center;
|
|
836
|
+
justify-content: center;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
.lightbox-close {
|
|
840
|
+
position: absolute;
|
|
841
|
+
top: -40px;
|
|
842
|
+
right: 0;
|
|
843
|
+
background: none;
|
|
844
|
+
border: none;
|
|
845
|
+
color: white;
|
|
846
|
+
font-size: 2rem;
|
|
847
|
+
cursor: pointer;
|
|
848
|
+
padding: 5px 10px;
|
|
849
|
+
line-height: 1;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
.lightbox-close:hover {
|
|
853
|
+
color: #ccc;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
.lightbox-image {
|
|
857
|
+
max-width: 90vw;
|
|
858
|
+
max-height: 90vh;
|
|
859
|
+
object-fit: contain;
|
|
860
|
+
border-radius: 4px;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
.lightbox-video {
|
|
864
|
+
max-width: 90vw;
|
|
865
|
+
max-height: 90vh;
|
|
866
|
+
width: 100%;
|
|
867
|
+
background: #000;
|
|
868
|
+
border-radius: 4px;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
.lightbox-loading,
|
|
872
|
+
.lightbox-error {
|
|
873
|
+
color: white;
|
|
874
|
+
font-size: 1.1rem;
|
|
875
|
+
padding: 40px;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
.lightbox-error {
|
|
879
|
+
color: #ff6b6b;
|
|
880
|
+
}
|
|
330
881
|
</style>
|
|
@@ -2,14 +2,16 @@
|
|
|
2
2
|
import { ref, onMounted } from 'vue'
|
|
3
3
|
import { getCameras, type Camera, type EenError } from 'een-api-toolkit'
|
|
4
4
|
|
|
5
|
+
const ALL_CAMERAS_VALUE = '__all__'
|
|
6
|
+
|
|
5
7
|
const emit = defineEmits<{
|
|
6
|
-
select: [camera: Camera]
|
|
8
|
+
select: [camera: Camera | null]
|
|
7
9
|
}>()
|
|
8
10
|
|
|
9
11
|
const cameras = ref<Camera[]>([])
|
|
10
12
|
const loading = ref(false)
|
|
11
13
|
const error = ref<EenError | null>(null)
|
|
12
|
-
const selectedCameraId = ref<string>(
|
|
14
|
+
const selectedCameraId = ref<string>(ALL_CAMERAS_VALUE)
|
|
13
15
|
|
|
14
16
|
async function fetchCameras() {
|
|
15
17
|
loading.value = true
|
|
@@ -22,15 +24,22 @@ async function fetchCameras() {
|
|
|
22
24
|
cameras.value = []
|
|
23
25
|
} else {
|
|
24
26
|
cameras.value = result.data?.results ?? []
|
|
27
|
+
// Default to "All Cameras" and emit null
|
|
28
|
+
selectedCameraId.value = ALL_CAMERAS_VALUE
|
|
29
|
+
emit('select', null)
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
loading.value = false
|
|
28
33
|
}
|
|
29
34
|
|
|
30
35
|
function handleChange() {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
36
|
+
if (selectedCameraId.value === ALL_CAMERAS_VALUE) {
|
|
37
|
+
emit('select', null)
|
|
38
|
+
} else {
|
|
39
|
+
const camera = cameras.value.find(c => c.id === selectedCameraId.value)
|
|
40
|
+
if (camera) {
|
|
41
|
+
emit('select', camera)
|
|
42
|
+
}
|
|
34
43
|
}
|
|
35
44
|
}
|
|
36
45
|
|
|
@@ -49,7 +58,8 @@ onMounted(() => {
|
|
|
49
58
|
:disabled="loading"
|
|
50
59
|
data-testid="camera-select"
|
|
51
60
|
>
|
|
52
|
-
<option value="" disabled>
|
|
61
|
+
<option v-if="loading" value="" disabled>Loading cameras...</option>
|
|
62
|
+
<option :value="ALL_CAMERAS_VALUE" data-testid="camera-option-all">All Cameras</option>
|
|
53
63
|
<option
|
|
54
64
|
v-for="camera in cameras"
|
|
55
65
|
:key="camera.id"
|