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.
Files changed (41) hide show
  1. package/CHANGELOG.md +45 -6
  2. package/README.md +1 -0
  3. package/dist/index.cjs +3 -1
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.d.ts +585 -0
  6. package/dist/index.js +485 -261
  7. package/dist/index.js.map +1 -1
  8. package/docs/AI-CONTEXT.md +144 -1
  9. package/examples/vue-alerts-metrics/e2e/auth.spec.ts +8 -1
  10. package/examples/vue-alerts-metrics/package-lock.json +8 -1
  11. package/examples/vue-alerts-metrics/package.json +4 -3
  12. package/examples/vue-alerts-metrics/src/components/AlertsList.vue +567 -16
  13. package/examples/vue-alerts-metrics/src/components/CameraSelector.vue +16 -6
  14. package/examples/vue-alerts-metrics/src/components/MetricsChart.vue +23 -9
  15. package/examples/vue-alerts-metrics/src/components/NotificationsList.vue +579 -17
  16. package/examples/vue-alerts-metrics/src/components/TimeRangeSelector.vue +197 -12
  17. package/examples/vue-alerts-metrics/src/composables/useHlsPlayer.ts +285 -0
  18. package/examples/vue-alerts-metrics/src/views/Dashboard.vue +31 -9
  19. package/examples/vue-alerts-metrics/src/views/Home.vue +56 -7
  20. package/examples/vue-event-subscriptions/.env.example +15 -0
  21. package/examples/vue-event-subscriptions/README.md +103 -0
  22. package/examples/vue-event-subscriptions/e2e/app.spec.ts +71 -0
  23. package/examples/vue-event-subscriptions/e2e/auth.spec.ts +290 -0
  24. package/examples/vue-event-subscriptions/index.html +13 -0
  25. package/examples/vue-event-subscriptions/package-lock.json +1719 -0
  26. package/examples/vue-event-subscriptions/package.json +28 -0
  27. package/examples/vue-event-subscriptions/playwright.config.ts +47 -0
  28. package/examples/vue-event-subscriptions/src/App.vue +233 -0
  29. package/examples/vue-event-subscriptions/src/main.ts +25 -0
  30. package/examples/vue-event-subscriptions/src/router/index.ts +68 -0
  31. package/examples/vue-event-subscriptions/src/views/Callback.vue +76 -0
  32. package/examples/vue-event-subscriptions/src/views/Home.vue +192 -0
  33. package/examples/vue-event-subscriptions/src/views/LiveEvents.vue +640 -0
  34. package/examples/vue-event-subscriptions/src/views/Login.vue +33 -0
  35. package/examples/vue-event-subscriptions/src/views/Logout.vue +59 -0
  36. package/examples/vue-event-subscriptions/src/views/Subscriptions.vue +402 -0
  37. package/examples/vue-event-subscriptions/src/vite-env.d.ts +12 -0
  38. package/examples/vue-event-subscriptions/tsconfig.json +21 -0
  39. package/examples/vue-event-subscriptions/tsconfig.node.json +10 -0
  40. package/examples/vue-event-subscriptions/vite.config.ts +12 -0
  41. package/package.json +1 -1
@@ -1,36 +1,157 @@
1
1
  <script setup lang="ts">
2
- defineProps<{
2
+ import { ref, computed } from 'vue'
3
+ import { formatTimestamp } from 'een-api-toolkit'
4
+
5
+ const props = defineProps<{
3
6
  selected: string
4
7
  }>()
5
8
 
6
9
  const emit = defineEmits<{
7
10
  change: [range: string]
11
+ 'update:aggregateMinutes': [minutes: number | undefined]
8
12
  }>()
9
13
 
10
14
  const ranges = [
15
+ { value: 'none', label: 'None' },
11
16
  { value: '1h', label: '1 Hour' },
12
17
  { value: '6h', label: '6 Hours' },
13
18
  { value: '24h', label: '24 Hours' },
14
19
  { value: '7d', label: '7 Days' }
15
20
  ]
16
21
 
22
+ const aggregateOptions = [
23
+ { value: '', label: 'None', minutes: undefined },
24
+ { value: '60', label: '60 min', minutes: 60 },
25
+ { value: '360', label: '6h', minutes: 360 },
26
+ { value: '1440', label: '24h', minutes: 1440 }
27
+ ]
28
+
29
+ const selectedAggregate = ref('')
30
+ const showApiFormat = ref(false)
31
+ const startCopied = ref(false)
32
+ const endCopied = ref(false)
33
+
34
+ function getTimeRangeMs(range: string): number {
35
+ switch (range) {
36
+ case '1h': return 60 * 60 * 1000
37
+ case '6h': return 6 * 60 * 60 * 1000
38
+ case '24h': return 24 * 60 * 60 * 1000
39
+ case '7d': return 7 * 24 * 60 * 60 * 1000
40
+ default: return 24 * 60 * 60 * 1000
41
+ }
42
+ }
43
+
44
+ const timeRange = computed(() => {
45
+ const now = new Date()
46
+ const rangeMs = getTimeRangeMs(props.selected)
47
+ const startTime = new Date(now.getTime() - rangeMs)
48
+ return { start: startTime, end: now }
49
+ })
50
+
51
+ // Always compute API format timestamps for copying
52
+ const apiStartTimestamp = computed(() => {
53
+ return formatTimestamp(timeRange.value.start.toISOString())
54
+ })
55
+
56
+ const apiEndTimestamp = computed(() => {
57
+ return formatTimestamp(timeRange.value.end.toISOString())
58
+ })
59
+
60
+ const formattedStart = computed(() => {
61
+ if (showApiFormat.value) {
62
+ return apiStartTimestamp.value
63
+ }
64
+ return timeRange.value.start.toLocaleString()
65
+ })
66
+
67
+ const formattedEnd = computed(() => {
68
+ if (showApiFormat.value) {
69
+ return apiEndTimestamp.value
70
+ }
71
+ return timeRange.value.end.toLocaleString()
72
+ })
73
+
17
74
  function handleClick(range: string) {
18
75
  emit('change', range)
19
76
  }
77
+
78
+ function toggleFormat() {
79
+ showApiFormat.value = !showApiFormat.value
80
+ }
81
+
82
+ function handleAggregateChange() {
83
+ const option = aggregateOptions.find(o => o.value === selectedAggregate.value)
84
+ emit('update:aggregateMinutes', option?.minutes)
85
+ }
86
+
87
+ async function copyStartTimestamp() {
88
+ await navigator.clipboard.writeText(apiStartTimestamp.value)
89
+ startCopied.value = true
90
+ setTimeout(() => {
91
+ startCopied.value = false
92
+ }, 500)
93
+ }
94
+
95
+ async function copyEndTimestamp() {
96
+ await navigator.clipboard.writeText(apiEndTimestamp.value)
97
+ endCopied.value = true
98
+ setTimeout(() => {
99
+ endCopied.value = false
100
+ }, 500)
101
+ }
20
102
  </script>
21
103
 
22
104
  <template>
23
105
  <div class="time-range-selector">
24
- <span class="label">Time Range:</span>
25
- <div class="buttons">
106
+ <div class="selector-row">
107
+ <span class="label">Time Range:</span>
108
+ <div class="buttons">
109
+ <button
110
+ v-for="range in ranges"
111
+ :key="range.value"
112
+ :class="{ active: selected === range.value }"
113
+ @click="handleClick(range.value)"
114
+ :data-testid="`time-range-${range.value}`"
115
+ >
116
+ {{ range.label }}
117
+ </button>
118
+ </div>
119
+ <span class="label aggregate-label">Aggregate:</span>
120
+ <div class="buttons">
121
+ <button
122
+ v-for="option in aggregateOptions"
123
+ :key="option.value"
124
+ :class="{ active: selectedAggregate === option.value }"
125
+ @click="selectedAggregate = option.value; handleAggregateChange()"
126
+ :data-testid="`aggregate-${option.value || 'none'}`"
127
+ >
128
+ {{ option.label }}
129
+ </button>
130
+ </div>
131
+ </div>
132
+ <div v-if="selected !== 'none'" class="time-display">
133
+ <div class="timestamps">
134
+ <span class="time-label">From:</span>
135
+ <code
136
+ :class="{ copied: startCopied }"
137
+ @click="copyStartTimestamp"
138
+ data-testid="time-start"
139
+ title="Click to copy API format timestamp"
140
+ >{{ formattedStart }}</code>
141
+ <span class="time-label">To:</span>
142
+ <code
143
+ :class="{ copied: endCopied }"
144
+ @click="copyEndTimestamp"
145
+ data-testid="time-end"
146
+ title="Click to copy API format timestamp"
147
+ >{{ formattedEnd }}</code>
148
+ </div>
26
149
  <button
27
- v-for="range in ranges"
28
- :key="range.value"
29
- :class="{ active: selected === range.value }"
30
- @click="handleClick(range.value)"
31
- :data-testid="`time-range-${range.value}`"
150
+ class="format-toggle"
151
+ @click="toggleFormat"
152
+ data-testid="format-toggle"
32
153
  >
33
- {{ range.label }}
154
+ {{ showApiFormat ? 'Local Time' : 'API Format' }}
34
155
  </button>
35
156
  </div>
36
157
  </div>
@@ -38,6 +159,12 @@ function handleClick(range: string) {
38
159
 
39
160
  <style scoped>
40
161
  .time-range-selector {
162
+ display: flex;
163
+ flex-direction: column;
164
+ gap: 8px;
165
+ }
166
+
167
+ .selector-row {
41
168
  display: flex;
42
169
  align-items: center;
43
170
  gap: 10px;
@@ -47,12 +174,16 @@ function handleClick(range: string) {
47
174
  font-weight: 500;
48
175
  }
49
176
 
177
+ .aggregate-label {
178
+ margin-left: 20px;
179
+ }
180
+
50
181
  .buttons {
51
182
  display: flex;
52
183
  gap: 5px;
53
184
  }
54
185
 
55
- button {
186
+ .buttons button {
56
187
  padding: 8px 16px;
57
188
  border: 1px solid #ddd;
58
189
  background: #fff;
@@ -62,13 +193,67 @@ button {
62
193
  color: #333;
63
194
  }
64
195
 
65
- button:hover {
196
+ .buttons button:hover {
66
197
  background: #f5f5f5;
67
198
  }
68
199
 
69
- button.active {
200
+ .buttons button.active {
70
201
  background: #42b883;
71
202
  color: white;
72
203
  border-color: #42b883;
73
204
  }
205
+
206
+ .time-display {
207
+ display: flex;
208
+ align-items: center;
209
+ gap: 10px;
210
+ flex-wrap: wrap;
211
+ }
212
+
213
+ .timestamps {
214
+ display: flex;
215
+ align-items: center;
216
+ gap: 8px;
217
+ font-size: 0.85rem;
218
+ color: #666;
219
+ }
220
+
221
+ .time-label {
222
+ font-weight: 500;
223
+ }
224
+
225
+ .timestamps code {
226
+ background: #f5f5f5;
227
+ padding: 2px 6px;
228
+ border-radius: 3px;
229
+ font-family: monospace;
230
+ font-size: 0.8rem;
231
+ color: #333;
232
+ cursor: pointer;
233
+ transition: background-color 0.15s ease;
234
+ }
235
+
236
+ .timestamps code:hover {
237
+ background: #e0e0e0;
238
+ }
239
+
240
+ .timestamps code.copied {
241
+ background: #42b883;
242
+ color: white;
243
+ }
244
+
245
+ .format-toggle {
246
+ padding: 4px 10px;
247
+ border: 1px solid #ccc;
248
+ background: #fff;
249
+ border-radius: 4px;
250
+ cursor: pointer;
251
+ font-size: 0.8rem;
252
+ color: #666;
253
+ }
254
+
255
+ .format-toggle:hover {
256
+ background: #f0f0f0;
257
+ border-color: #999;
258
+ }
74
259
  </style>
@@ -0,0 +1,285 @@
1
+ import { ref, nextTick, onUnmounted, type Ref } from 'vue'
2
+ import { listMedia, initMediaSession, formatTimestamp, useAuthStore } from 'een-api-toolkit'
3
+ import Hls from 'hls.js'
4
+
5
+ // Constants
6
+ const SEARCH_WINDOW_MS = 60 * 60 * 1000 // 1 hour before/after target timestamp
7
+ const MAX_MEDIA_PAGE_SIZE = 100 // Limit results for performance
8
+ const MAX_NETWORK_RETRIES = 3 // Maximum retry attempts for network errors
9
+
10
+ // Debug utility - logs only when VITE_DEBUG=true
11
+ const isDebug = import.meta.env?.VITE_DEBUG === 'true'
12
+ function debugError(...args: unknown[]): void {
13
+ if (isDebug) {
14
+ console.error('[useHlsPlayer]', ...args)
15
+ }
16
+ }
17
+
18
+ // Cache media session to avoid redundant initialization calls
19
+ let mediaSessionInitialized = false
20
+ let mediaSessionPromise: Promise<boolean> | null = null
21
+
22
+ /** Return type for the useHlsPlayer composable */
23
+ export interface HlsPlayerReturn {
24
+ videoUrl: Ref<string | null>
25
+ videoError: Ref<string | null>
26
+ loadingVideo: Ref<boolean>
27
+ videoRef: Ref<HTMLVideoElement | null>
28
+ loadVideo: (deviceId: string, timestamp: string) => Promise<void>
29
+ resetVideo: () => void
30
+ destroyHls: () => void
31
+ }
32
+
33
+ /**
34
+ * Composable for HLS video playback from EEN recordings.
35
+ * Handles media session initialization, interval search, and HLS.js setup.
36
+ */
37
+ export function useHlsPlayer(): HlsPlayerReturn {
38
+ const authStore = useAuthStore()
39
+
40
+ // State
41
+ const videoUrl = ref<string | null>(null)
42
+ const videoError = ref<string | null>(null)
43
+ const loadingVideo = ref(false)
44
+ const videoRef = ref<HTMLVideoElement | null>(null)
45
+
46
+ let hlsInstance: Hls | null = null
47
+ let networkRetryCount = 0
48
+
49
+ /**
50
+ * Initialize media session with caching.
51
+ * Only calls the API once per session, subsequent calls return cached result.
52
+ */
53
+ async function ensureMediaSession(): Promise<boolean> {
54
+ // Return cached result if already initialized
55
+ if (mediaSessionInitialized) {
56
+ return true
57
+ }
58
+
59
+ // If initialization is in progress, wait for it
60
+ if (mediaSessionPromise) {
61
+ return mediaSessionPromise
62
+ }
63
+
64
+ // Start new initialization
65
+ mediaSessionPromise = (async () => {
66
+ const result = await initMediaSession()
67
+ if (result.error) {
68
+ videoError.value = `Media session error: ${result.error.message}`
69
+ mediaSessionPromise = null
70
+ return false
71
+ }
72
+ mediaSessionInitialized = true
73
+ return true
74
+ })()
75
+
76
+ return mediaSessionPromise
77
+ }
78
+
79
+ /**
80
+ * Destroy the HLS instance and clean up resources.
81
+ */
82
+ function destroyHls() {
83
+ if (hlsInstance) {
84
+ hlsInstance.destroy()
85
+ hlsInstance = null
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Initialize HLS.js with proper authentication and error handling.
91
+ */
92
+ function initHls() {
93
+ if (!videoUrl.value || !videoRef.value) return
94
+
95
+ destroyHls()
96
+
97
+ // Always use hls.js even on Safari - native HLS cannot send Authorization headers
98
+ if (!Hls.isSupported()) {
99
+ videoError.value = 'HLS is not supported in this browser. Please use a modern browser like Chrome, Firefox, or Edge.'
100
+ return
101
+ }
102
+
103
+ // Configure hls.js to send Authorization header for authentication
104
+ hlsInstance = new Hls({
105
+ xhrSetup: function(xhr) {
106
+ xhr.setRequestHeader('Authorization', `Bearer ${authStore.token}`)
107
+ }
108
+ })
109
+
110
+ hlsInstance.loadSource(videoUrl.value)
111
+ hlsInstance.attachMedia(videoRef.value)
112
+
113
+ hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => {
114
+ videoRef.value?.play().catch(() => {
115
+ // Autoplay may be blocked, user can manually play
116
+ })
117
+ })
118
+
119
+ // Reset retry counter on successful load
120
+ networkRetryCount = 0
121
+
122
+ // Enhanced error handling for different error types
123
+ hlsInstance.on(Hls.Events.ERROR, (_, data) => {
124
+ debugError('HLS error:', data)
125
+
126
+ if (data.fatal) {
127
+ switch (data.type) {
128
+ case Hls.ErrorTypes.NETWORK_ERROR:
129
+ // Network error - could be auth issue or connectivity
130
+ if (data.response?.code === 401) {
131
+ videoError.value = 'Authentication expired. Please refresh the page and try again.'
132
+ // Don't retry on auth errors - requires user action
133
+ destroyHls()
134
+ } else if (data.response?.code === 403) {
135
+ videoError.value = 'Access denied to video stream.'
136
+ // Don't retry on permission errors
137
+ destroyHls()
138
+ } else {
139
+ // Retry other network errors with limit
140
+ networkRetryCount++
141
+ if (networkRetryCount <= MAX_NETWORK_RETRIES) {
142
+ videoError.value = `Network error loading video: ${data.details}. Retry ${networkRetryCount}/${MAX_NETWORK_RETRIES}...`
143
+ hlsInstance?.startLoad()
144
+ } else {
145
+ videoError.value = `Network error loading video: ${data.details}. Max retries (${MAX_NETWORK_RETRIES}) exceeded.`
146
+ destroyHls()
147
+ }
148
+ }
149
+ break
150
+
151
+ case Hls.ErrorTypes.MEDIA_ERROR:
152
+ // Media error - try to recover
153
+ videoError.value = `Media error: ${data.details}. Attempting recovery...`
154
+ hlsInstance?.recoverMediaError()
155
+ break
156
+
157
+ default:
158
+ // Other fatal errors
159
+ videoError.value = `HLS error: ${data.type} - ${data.details}`
160
+ destroyHls()
161
+ }
162
+ }
163
+ })
164
+ }
165
+
166
+ /**
167
+ * Load and play HLS video for a given device and timestamp.
168
+ *
169
+ * @param deviceId - The camera device ID
170
+ * @param timestamp - ISO timestamp string (the target time to find video for)
171
+ * @returns Promise that resolves when video is ready or error occurs
172
+ */
173
+ async function loadVideo(deviceId: string, timestamp: string): Promise<void> {
174
+ loadingVideo.value = true
175
+ videoError.value = null
176
+ videoUrl.value = null
177
+
178
+ // Initialize media session (cached after first call)
179
+ const sessionOk = await ensureMediaSession()
180
+ if (!sessionOk) {
181
+ loadingVideo.value = false
182
+ return
183
+ }
184
+
185
+ // Search for recordings around the target timestamp
186
+ const targetTime = new Date(timestamp)
187
+ const searchStartTime = new Date(targetTime.getTime() - SEARCH_WINDOW_MS)
188
+ const searchEndTime = new Date(targetTime.getTime() + SEARCH_WINDOW_MS)
189
+
190
+ // Use 'main' type for video - HLS is typically only available for main feeds
191
+ const result = await listMedia({
192
+ deviceId: deviceId,
193
+ type: 'main',
194
+ mediaType: 'video',
195
+ startTimestamp: formatTimestamp(searchStartTime.toISOString()),
196
+ endTimestamp: formatTimestamp(searchEndTime.toISOString()),
197
+ include: ['hlsUrl'],
198
+ pageSize: MAX_MEDIA_PAGE_SIZE
199
+ })
200
+
201
+ if (result.error) {
202
+ videoError.value = result.error.message
203
+ loadingVideo.value = false
204
+ return
205
+ }
206
+
207
+ const intervals = result.data?.results ?? []
208
+
209
+ // Validate target timestamp
210
+ const targetTimeMs = targetTime.getTime()
211
+ if (isNaN(targetTimeMs)) {
212
+ videoError.value = `Invalid timestamp format: ${timestamp}`
213
+ loadingVideo.value = false
214
+ return
215
+ }
216
+
217
+ // Find an interval that contains the target timestamp and has an HLS URL
218
+ const interval = intervals.find(i => {
219
+ if (!i.hlsUrl) return false
220
+ const intervalStart = new Date(i.startTimestamp).getTime()
221
+ const intervalEnd = new Date(i.endTimestamp).getTime()
222
+ // Skip intervals with invalid timestamps
223
+ if (isNaN(intervalStart) || isNaN(intervalEnd)) return false
224
+ return targetTimeMs >= intervalStart && targetTimeMs <= intervalEnd
225
+ })
226
+
227
+ if (!interval?.hlsUrl) {
228
+ // Provide detailed error message
229
+ if (intervals.length === 0) {
230
+ videoError.value = 'No recordings found for this time range'
231
+ } else if (!intervals.some(i => i.hlsUrl)) {
232
+ videoError.value = 'Recordings found but HLS not available'
233
+ } else {
234
+ videoError.value = `No recording contains timestamp ${timestamp}`
235
+ }
236
+ loadingVideo.value = false
237
+ return
238
+ }
239
+
240
+ // Set the HLS URL
241
+ videoUrl.value = interval.hlsUrl
242
+ loadingVideo.value = false
243
+
244
+ // Initialize HLS.js after the DOM has been updated
245
+ await nextTick()
246
+ initHls()
247
+ }
248
+
249
+ /**
250
+ * Reset all video state.
251
+ */
252
+ function resetVideo() {
253
+ destroyHls()
254
+ videoUrl.value = null
255
+ videoError.value = null
256
+ loadingVideo.value = false
257
+ }
258
+
259
+ // Cleanup on unmount
260
+ onUnmounted(() => {
261
+ destroyHls()
262
+ })
263
+
264
+ return {
265
+ // State
266
+ videoUrl,
267
+ videoError,
268
+ loadingVideo,
269
+ videoRef,
270
+
271
+ // Methods
272
+ loadVideo,
273
+ resetVideo,
274
+ destroyHls
275
+ }
276
+ }
277
+
278
+ /**
279
+ * Reset the media session cache.
280
+ * Call this when the user logs out or the session expires.
281
+ */
282
+ export function resetMediaSessionCache() {
283
+ mediaSessionInitialized = false
284
+ mediaSessionPromise = null
285
+ }
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { ref, watch } from 'vue'
2
+ import { ref, watch, computed } from 'vue'
3
3
  import type { Camera } from 'een-api-toolkit'
4
4
  import CameraSelector from '../components/CameraSelector.vue'
5
5
  import TimeRangeSelector from '../components/TimeRangeSelector.vue'
@@ -8,16 +8,26 @@ import AlertsList from '../components/AlertsList.vue'
8
8
  import NotificationsList from '../components/NotificationsList.vue'
9
9
 
10
10
  const selectedCamera = ref<Camera | null>(null)
11
- const selectedTimeRange = ref('24h')
11
+ const selectedTimeRange = ref('none')
12
+ const selectedAggregateMinutes = ref<number | undefined>(undefined)
13
+ const cameraListLoaded = ref(false)
12
14
 
13
- function handleCameraSelect(camera: Camera) {
15
+ // Track if a specific camera is selected (not "All Cameras")
16
+ const hasSpecificCamera = computed(() => selectedCamera.value !== null)
17
+
18
+ function handleCameraSelect(camera: Camera | null) {
14
19
  selectedCamera.value = camera
20
+ cameraListLoaded.value = true
15
21
  }
16
22
 
17
23
  function handleTimeRangeChange(range: string) {
18
24
  selectedTimeRange.value = range
19
25
  }
20
26
 
27
+ function handleAggregateChange(minutes: number | undefined) {
28
+ selectedAggregateMinutes.value = minutes
29
+ }
30
+
21
31
  // Reset lists when camera changes
22
32
  watch(selectedCamera, () => {
23
33
  // Lists will reset automatically through their props
@@ -36,20 +46,26 @@ watch(selectedCamera, () => {
36
46
  <TimeRangeSelector
37
47
  :selected="selectedTimeRange"
38
48
  @change="handleTimeRangeChange"
49
+ @update:aggregate-minutes="handleAggregateChange"
39
50
  data-testid="time-range-selector"
40
51
  />
41
52
  </div>
42
53
 
43
- <div v-if="!selectedCamera" class="no-camera-selected">
44
- <p>Select a camera to view its metrics, alerts, and notifications.</p>
54
+ <div v-if="!cameraListLoaded" class="no-camera-selected">
55
+ <p>Loading cameras...</p>
45
56
  </div>
46
57
 
47
58
  <div v-else class="dashboard-content">
48
59
  <section class="metrics-section">
49
60
  <h3>Event Metrics</h3>
61
+ <div v-if="!hasSpecificCamera" class="select-camera-message">
62
+ <p>Select a specific camera to view event metrics.</p>
63
+ </div>
50
64
  <MetricsChart
51
- :camera="selectedCamera"
65
+ v-else
66
+ :camera="selectedCamera!"
52
67
  :time-range="selectedTimeRange"
68
+ :aggregate-minutes="selectedAggregateMinutes"
53
69
  />
54
70
  </section>
55
71
 
@@ -86,10 +102,9 @@ h2 {
86
102
 
87
103
  .controls {
88
104
  display: flex;
89
- gap: 20px;
105
+ flex-direction: column;
106
+ gap: 15px;
90
107
  margin-bottom: 20px;
91
- align-items: flex-start;
92
- flex-wrap: wrap;
93
108
  }
94
109
 
95
110
  .no-camera-selected {
@@ -118,6 +133,13 @@ h2 {
118
133
  color: #333;
119
134
  }
120
135
 
136
+ .select-camera-message {
137
+ text-align: center;
138
+ padding: 40px;
139
+ color: #666;
140
+ font-style: italic;
141
+ }
142
+
121
143
  .lists-container {
122
144
  display: grid;
123
145
  grid-template-columns: 1fr 1fr;