een-api-toolkit 0.3.13 → 0.3.15

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 (37) hide show
  1. package/CHANGELOG.md +5 -35
  2. package/README.md +2 -0
  3. package/dist/index.cjs +1 -1
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.d.ts +801 -0
  6. package/dist/index.js +486 -252
  7. package/dist/index.js.map +1 -1
  8. package/docs/AI-CONTEXT.md +195 -2
  9. package/examples/vue-alerts-metrics/README.md +136 -0
  10. package/examples/vue-alerts-metrics/e2e/app.spec.ts +74 -0
  11. package/examples/vue-alerts-metrics/e2e/auth.spec.ts +554 -0
  12. package/examples/vue-alerts-metrics/index.html +13 -0
  13. package/examples/vue-alerts-metrics/package-lock.json +1749 -0
  14. package/examples/vue-alerts-metrics/package.json +30 -0
  15. package/examples/vue-alerts-metrics/playwright.config.ts +46 -0
  16. package/examples/vue-alerts-metrics/src/App.vue +108 -0
  17. package/examples/vue-alerts-metrics/src/components/AlertsList.vue +330 -0
  18. package/examples/vue-alerts-metrics/src/components/CameraSelector.vue +96 -0
  19. package/examples/vue-alerts-metrics/src/components/MetricsChart.vue +322 -0
  20. package/examples/vue-alerts-metrics/src/components/NotificationsList.vue +263 -0
  21. package/examples/vue-alerts-metrics/src/components/TimeRangeSelector.vue +74 -0
  22. package/examples/vue-alerts-metrics/src/main.ts +23 -0
  23. package/examples/vue-alerts-metrics/src/router/index.ts +61 -0
  24. package/examples/vue-alerts-metrics/src/views/Callback.vue +76 -0
  25. package/examples/vue-alerts-metrics/src/views/Dashboard.vue +152 -0
  26. package/examples/vue-alerts-metrics/src/views/Home.vue +167 -0
  27. package/examples/vue-alerts-metrics/src/views/Login.vue +33 -0
  28. package/examples/vue-alerts-metrics/src/views/Logout.vue +66 -0
  29. package/examples/vue-alerts-metrics/src/vite-env.d.ts +12 -0
  30. package/examples/vue-alerts-metrics/tsconfig.json +21 -0
  31. package/examples/vue-alerts-metrics/tsconfig.node.json +10 -0
  32. package/examples/vue-alerts-metrics/vite.config.ts +12 -0
  33. package/examples/vue-events/README.md +68 -0
  34. package/examples/vue-events/e2e/auth.spec.ts +105 -0
  35. package/examples/vue-events/src/components/EventsModal.vue +452 -14
  36. package/examples/vue-events/src/views/Home.vue +1 -0
  37. package/package.json +1 -1
@@ -0,0 +1,322 @@
1
+ <script setup lang="ts">
2
+ import { ref, watch, computed } from 'vue'
3
+ import { Line } from 'vue-chartjs'
4
+ import {
5
+ Chart as ChartJS,
6
+ CategoryScale,
7
+ LinearScale,
8
+ PointElement,
9
+ LineElement,
10
+ Title,
11
+ Tooltip,
12
+ Legend,
13
+ TimeScale
14
+ } from 'chart.js'
15
+ import { getEventMetrics, listEventFieldValues, type Camera, type EventMetric, type EenError } from 'een-api-toolkit'
16
+
17
+ ChartJS.register(
18
+ CategoryScale,
19
+ LinearScale,
20
+ PointElement,
21
+ LineElement,
22
+ Title,
23
+ Tooltip,
24
+ Legend,
25
+ TimeScale
26
+ )
27
+
28
+ const props = defineProps<{
29
+ camera: Camera
30
+ timeRange: string
31
+ }>()
32
+
33
+ interface DataPoint {
34
+ timestamp: number
35
+ count: number
36
+ }
37
+
38
+ const dataPoints = ref<DataPoint[]>([])
39
+ const eventTypes = ref<string[]>([])
40
+ const selectedEventType = ref<string>('')
41
+ const loadingEventTypes = ref(false)
42
+ const loadingMetrics = ref(false)
43
+ const error = ref<EenError | null>(null)
44
+
45
+ function getTimeRangeMs(range: string): number {
46
+ switch (range) {
47
+ case '1h': return 60 * 60 * 1000
48
+ case '6h': return 6 * 60 * 60 * 1000
49
+ case '24h': return 24 * 60 * 60 * 1000
50
+ case '7d': return 7 * 24 * 60 * 60 * 1000
51
+ default: return 24 * 60 * 60 * 1000
52
+ }
53
+ }
54
+
55
+ function getAggregationMinutes(range: string): number {
56
+ // Note: EEN API requires minimum 60 minute aggregation
57
+ switch (range) {
58
+ case '1h': return 60 // 1 hour bucket (1 data point)
59
+ case '6h': return 60 // 1 hour buckets (6 data points)
60
+ case '24h': return 60 // 1 hour buckets (24 data points)
61
+ case '7d': return 360 // 6 hour buckets (28 data points)
62
+ default: return 60
63
+ }
64
+ }
65
+
66
+ function formatEventType(eventType: string): string {
67
+ // Convert "een.motionDetectionEvent.v1" to "Motion Detection"
68
+ const parts = eventType.split('.')
69
+ if (parts.length >= 2) {
70
+ const name = parts[1]
71
+ .replace(/Event$/, '')
72
+ .replace(/([A-Z])/g, ' $1')
73
+ .trim()
74
+ return name.charAt(0).toUpperCase() + name.slice(1)
75
+ }
76
+ return eventType
77
+ }
78
+
79
+ async function fetchEventTypes() {
80
+ if (!props.camera?.id) return
81
+
82
+ loadingEventTypes.value = true
83
+ error.value = null
84
+ eventTypes.value = []
85
+ selectedEventType.value = ''
86
+ dataPoints.value = []
87
+
88
+ const result = await listEventFieldValues({
89
+ actor: `camera:${props.camera.id}`
90
+ })
91
+
92
+ if (result.error) {
93
+ error.value = result.error
94
+ loadingEventTypes.value = false
95
+ return
96
+ }
97
+
98
+ eventTypes.value = result.data?.type ?? []
99
+ loadingEventTypes.value = false
100
+ }
101
+
102
+ async function fetchMetrics() {
103
+ if (!props.camera?.id || !selectedEventType.value) return
104
+
105
+ loadingMetrics.value = true
106
+ error.value = null
107
+ dataPoints.value = []
108
+
109
+ const now = new Date()
110
+ const rangeMs = getTimeRangeMs(props.timeRange)
111
+ const startTime = new Date(now.getTime() - rangeMs)
112
+ const aggregateByMinutes = getAggregationMinutes(props.timeRange)
113
+
114
+ const result = await getEventMetrics({
115
+ actor: `camera:${props.camera.id}`,
116
+ eventType: selectedEventType.value,
117
+ timestamp__gte: startTime.toISOString(),
118
+ timestamp__lte: now.toISOString(),
119
+ aggregateByMinutes
120
+ })
121
+
122
+ if (result.error) {
123
+ error.value = result.error
124
+ loadingMetrics.value = false
125
+ return
126
+ }
127
+
128
+ // Transform API data to chart data points
129
+ const metrics = result.data ?? []
130
+ if (metrics.length > 0) {
131
+ // Use the first metric's data points (for count target)
132
+ const metric = metrics.find((m: EventMetric) => m.target === 'count') ?? metrics[0]
133
+ // Defensive check for dataPoints array
134
+ if (metric.dataPoints && Array.isArray(metric.dataPoints)) {
135
+ dataPoints.value = metric.dataPoints.map(([timestamp, count]) => ({
136
+ timestamp,
137
+ count
138
+ }))
139
+ }
140
+ }
141
+
142
+ loadingMetrics.value = false
143
+ }
144
+
145
+ function handleEventTypeChange() {
146
+ if (selectedEventType.value) {
147
+ fetchMetrics()
148
+ }
149
+ }
150
+
151
+ const chartData = computed(() => {
152
+ if (dataPoints.value.length === 0) {
153
+ return {
154
+ labels: [],
155
+ datasets: []
156
+ }
157
+ }
158
+
159
+ return {
160
+ labels: dataPoints.value.map(({ timestamp }) => {
161
+ const date = new Date(timestamp)
162
+ if (props.timeRange === '1h' || props.timeRange === '6h') {
163
+ return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
164
+ }
165
+ return date.toLocaleDateString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
166
+ }),
167
+ datasets: [
168
+ {
169
+ label: formatEventType(selectedEventType.value),
170
+ data: dataPoints.value.map(({ count }) => count),
171
+ borderColor: '#42b883',
172
+ backgroundColor: 'rgba(66, 184, 131, 0.1)',
173
+ tension: 0.3,
174
+ fill: true
175
+ }
176
+ ]
177
+ }
178
+ })
179
+
180
+ const chartOptions = {
181
+ responsive: true,
182
+ maintainAspectRatio: false,
183
+ plugins: {
184
+ legend: {
185
+ display: true,
186
+ position: 'top' as const
187
+ },
188
+ tooltip: {
189
+ mode: 'index' as const,
190
+ intersect: false
191
+ }
192
+ },
193
+ scales: {
194
+ y: {
195
+ beginAtZero: true,
196
+ title: {
197
+ display: true,
198
+ text: 'Event Count'
199
+ }
200
+ },
201
+ x: {
202
+ title: {
203
+ display: true,
204
+ text: 'Time'
205
+ }
206
+ }
207
+ }
208
+ }
209
+
210
+ // Fetch event types when camera changes
211
+ watch(
212
+ () => props.camera?.id,
213
+ () => {
214
+ fetchEventTypes()
215
+ },
216
+ { immediate: true }
217
+ )
218
+
219
+ // Fetch metrics when time range changes (if event type is selected)
220
+ watch(
221
+ () => props.timeRange,
222
+ () => {
223
+ if (selectedEventType.value) {
224
+ fetchMetrics()
225
+ }
226
+ }
227
+ )
228
+ </script>
229
+
230
+ <template>
231
+ <div class="metrics-chart" data-testid="metrics-chart">
232
+ <div class="event-type-selector" data-testid="event-type-selector">
233
+ <label for="event-type-select">Event Type:</label>
234
+ <select
235
+ id="event-type-select"
236
+ v-model="selectedEventType"
237
+ @change="handleEventTypeChange"
238
+ :disabled="loadingEventTypes || eventTypes.length === 0"
239
+ data-testid="event-type-select"
240
+ >
241
+ <option value="" disabled>
242
+ {{ loadingEventTypes ? 'Loading event types...' : (eventTypes.length === 0 ? 'No event types available' : 'Select an event type') }}
243
+ </option>
244
+ <option
245
+ v-for="et in eventTypes"
246
+ :key="et"
247
+ :value="et"
248
+ data-testid="event-type-option"
249
+ >
250
+ {{ formatEventType(et) }} ({{ et }})
251
+ </option>
252
+ </select>
253
+ </div>
254
+
255
+ <div v-if="loadingMetrics" class="loading" data-testid="metrics-loading">
256
+ Loading metrics...
257
+ </div>
258
+ <div v-else-if="error" class="error" data-testid="metrics-error">
259
+ {{ error.message }}
260
+ </div>
261
+ <div v-else-if="!selectedEventType" class="no-data" data-testid="metrics-no-selection">
262
+ Please select an event type to view metrics.
263
+ </div>
264
+ <div v-else-if="chartData.datasets.length === 0" class="no-data" data-testid="metrics-no-data">
265
+ No event data available for this time range.
266
+ </div>
267
+ <div v-else class="chart-container">
268
+ <Line :data="chartData" :options="chartOptions" />
269
+ </div>
270
+ </div>
271
+ </template>
272
+
273
+ <style scoped>
274
+ .metrics-chart {
275
+ min-height: 300px;
276
+ }
277
+
278
+ .event-type-selector {
279
+ display: flex;
280
+ align-items: center;
281
+ gap: 10px;
282
+ margin-bottom: 15px;
283
+ }
284
+
285
+ .event-type-selector label {
286
+ font-weight: 500;
287
+ }
288
+
289
+ .event-type-selector select {
290
+ padding: 8px 12px;
291
+ border: 1px solid #ddd;
292
+ border-radius: 4px;
293
+ font-size: 0.9rem;
294
+ min-width: 350px;
295
+ }
296
+
297
+ .event-type-selector select:disabled {
298
+ background: #f5f5f5;
299
+ cursor: not-allowed;
300
+ }
301
+
302
+ .chart-container {
303
+ height: 300px;
304
+ }
305
+
306
+ .loading,
307
+ .no-data {
308
+ display: flex;
309
+ align-items: center;
310
+ justify-content: center;
311
+ height: 200px;
312
+ color: #666;
313
+ font-style: italic;
314
+ }
315
+
316
+ .error {
317
+ color: #e74c3c;
318
+ padding: 10px;
319
+ background: #fdf2f2;
320
+ border-radius: 4px;
321
+ }
322
+ </style>
@@ -0,0 +1,263 @@
1
+ <script setup lang="ts">
2
+ import { ref, watch } from 'vue'
3
+ import { listNotifications, type Camera, type Notification, type EenError } from 'een-api-toolkit'
4
+
5
+ const props = defineProps<{
6
+ camera: Camera
7
+ timeRange: string
8
+ }>()
9
+
10
+ const notifications = ref<Notification[]>([])
11
+ const loading = ref(false)
12
+ const loadingMore = ref(false)
13
+ const error = ref<EenError | null>(null)
14
+ const nextPageToken = ref<string | undefined>(undefined)
15
+
16
+ function getTimeRangeMs(range: string): number {
17
+ switch (range) {
18
+ case '1h': return 60 * 60 * 1000
19
+ case '6h': return 6 * 60 * 60 * 1000
20
+ case '24h': return 24 * 60 * 60 * 1000
21
+ case '7d': return 7 * 24 * 60 * 60 * 1000
22
+ default: return 24 * 60 * 60 * 1000
23
+ }
24
+ }
25
+
26
+ async function fetchNotifications(append = false) {
27
+ if (!props.camera?.id) return
28
+
29
+ if (append) {
30
+ loadingMore.value = true
31
+ } else {
32
+ loading.value = true
33
+ notifications.value = []
34
+ nextPageToken.value = undefined
35
+ }
36
+ error.value = null
37
+
38
+ const now = new Date()
39
+ const rangeMs = getTimeRangeMs(props.timeRange)
40
+ const startTime = new Date(now.getTime() - rangeMs)
41
+
42
+ const result = await listNotifications({
43
+ actorId: props.camera.id,
44
+ timestamp__gte: startTime.toISOString(),
45
+ timestamp__lte: now.toISOString(),
46
+ pageSize: 20,
47
+ pageToken: append ? nextPageToken.value : undefined,
48
+ sort: ['-timestamp']
49
+ })
50
+
51
+ if (result.error) {
52
+ error.value = result.error
53
+ } else {
54
+ const newNotifications = result.data?.results ?? []
55
+ if (append) {
56
+ notifications.value = [...notifications.value, ...newNotifications]
57
+ } else {
58
+ notifications.value = newNotifications
59
+ }
60
+ nextPageToken.value = result.data?.nextPageToken
61
+ }
62
+
63
+ loading.value = false
64
+ loadingMore.value = false
65
+ }
66
+
67
+ function loadMore() {
68
+ if (nextPageToken.value) {
69
+ fetchNotifications(true)
70
+ }
71
+ }
72
+
73
+ function formatTime(timestamp: string): string {
74
+ return new Date(timestamp).toLocaleString()
75
+ }
76
+
77
+ function getCategoryClass(category: string): string {
78
+ switch (category) {
79
+ case 'health': return 'category-health'
80
+ case 'video': return 'category-video'
81
+ case 'security': return 'category-security'
82
+ case 'operational': return 'category-operational'
83
+ default: return 'category-default'
84
+ }
85
+ }
86
+
87
+ watch(
88
+ () => [props.camera?.id, props.timeRange],
89
+ () => {
90
+ fetchNotifications()
91
+ },
92
+ { immediate: true }
93
+ )
94
+ </script>
95
+
96
+ <template>
97
+ <div class="notifications-list" data-testid="notifications-list">
98
+ <div v-if="loading" class="loading" data-testid="notifications-loading">
99
+ Loading notifications...
100
+ </div>
101
+ <div v-else-if="error" class="error" data-testid="notifications-error">
102
+ {{ error.message }}
103
+ </div>
104
+ <div v-else-if="notifications.length === 0" class="no-data" data-testid="notifications-no-data">
105
+ No notifications found for this time range.
106
+ </div>
107
+ <div v-else>
108
+ <div
109
+ v-for="notification in notifications"
110
+ :key="notification.id"
111
+ class="notification-item"
112
+ :class="{ unread: !notification.read }"
113
+ data-testid="notification-item"
114
+ >
115
+ <div class="notification-header">
116
+ <span
117
+ class="category-badge"
118
+ :class="getCategoryClass(notification.category)"
119
+ >
120
+ {{ notification.category }}
121
+ </span>
122
+ <span v-if="!notification.read" class="unread-badge">New</span>
123
+ </div>
124
+ <div class="notification-time">{{ formatTime(notification.timestamp) }}</div>
125
+ <div v-if="notification.description" class="notification-description">
126
+ {{ notification.description }}
127
+ </div>
128
+ <div class="notification-status">
129
+ Status: {{ notification.status }}
130
+ </div>
131
+ </div>
132
+
133
+ <button
134
+ v-if="nextPageToken"
135
+ @click="loadMore"
136
+ :disabled="loadingMore"
137
+ class="load-more-button"
138
+ data-testid="notifications-load-more"
139
+ >
140
+ {{ loadingMore ? 'Loading...' : 'Load More' }}
141
+ </button>
142
+ </div>
143
+ </div>
144
+ </template>
145
+
146
+ <style scoped>
147
+ .notifications-list {
148
+ min-height: 200px;
149
+ }
150
+
151
+ .loading,
152
+ .no-data {
153
+ display: flex;
154
+ align-items: center;
155
+ justify-content: center;
156
+ height: 100px;
157
+ color: #666;
158
+ font-style: italic;
159
+ }
160
+
161
+ .error {
162
+ color: #e74c3c;
163
+ padding: 10px;
164
+ background: #fdf2f2;
165
+ border-radius: 4px;
166
+ }
167
+
168
+ .notification-item {
169
+ padding: 12px;
170
+ border: 1px solid #eee;
171
+ border-radius: 6px;
172
+ margin-bottom: 10px;
173
+ background: #fafafa;
174
+ }
175
+
176
+ .notification-item.unread {
177
+ background: #f0f9ff;
178
+ border-color: #bae6fd;
179
+ }
180
+
181
+ .notification-header {
182
+ display: flex;
183
+ justify-content: space-between;
184
+ align-items: center;
185
+ margin-bottom: 5px;
186
+ }
187
+
188
+ .category-badge {
189
+ padding: 2px 8px;
190
+ border-radius: 12px;
191
+ font-size: 0.75rem;
192
+ font-weight: 500;
193
+ }
194
+
195
+ .category-health {
196
+ background: #fef3c7;
197
+ color: #92400e;
198
+ }
199
+
200
+ .category-video {
201
+ background: #dbeafe;
202
+ color: #1e40af;
203
+ }
204
+
205
+ .category-security {
206
+ background: #fde8e8;
207
+ color: #c53030;
208
+ }
209
+
210
+ .category-operational {
211
+ background: #e0e7ff;
212
+ color: #3730a3;
213
+ }
214
+
215
+ .category-default {
216
+ background: #f3f4f6;
217
+ color: #374151;
218
+ }
219
+
220
+ .unread-badge {
221
+ background: #3b82f6;
222
+ color: white;
223
+ padding: 2px 8px;
224
+ border-radius: 12px;
225
+ font-size: 0.7rem;
226
+ font-weight: 600;
227
+ }
228
+
229
+ .notification-time {
230
+ font-size: 0.8rem;
231
+ color: #666;
232
+ margin-bottom: 5px;
233
+ }
234
+
235
+ .notification-description {
236
+ font-size: 0.85rem;
237
+ color: #555;
238
+ margin-bottom: 5px;
239
+ }
240
+
241
+ .notification-status {
242
+ font-size: 0.75rem;
243
+ color: #888;
244
+ }
245
+
246
+ .load-more-button {
247
+ width: 100%;
248
+ margin-top: 10px;
249
+ padding: 10px;
250
+ background: #f5f5f5;
251
+ border: 1px solid #ddd;
252
+ color: #333;
253
+ }
254
+
255
+ .load-more-button:hover {
256
+ background: #eee;
257
+ }
258
+
259
+ .load-more-button:disabled {
260
+ background: #f5f5f5;
261
+ color: #999;
262
+ }
263
+ </style>
@@ -0,0 +1,74 @@
1
+ <script setup lang="ts">
2
+ defineProps<{
3
+ selected: string
4
+ }>()
5
+
6
+ const emit = defineEmits<{
7
+ change: [range: string]
8
+ }>()
9
+
10
+ const ranges = [
11
+ { value: '1h', label: '1 Hour' },
12
+ { value: '6h', label: '6 Hours' },
13
+ { value: '24h', label: '24 Hours' },
14
+ { value: '7d', label: '7 Days' }
15
+ ]
16
+
17
+ function handleClick(range: string) {
18
+ emit('change', range)
19
+ }
20
+ </script>
21
+
22
+ <template>
23
+ <div class="time-range-selector">
24
+ <span class="label">Time Range:</span>
25
+ <div class="buttons">
26
+ <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}`"
32
+ >
33
+ {{ range.label }}
34
+ </button>
35
+ </div>
36
+ </div>
37
+ </template>
38
+
39
+ <style scoped>
40
+ .time-range-selector {
41
+ display: flex;
42
+ align-items: center;
43
+ gap: 10px;
44
+ }
45
+
46
+ .label {
47
+ font-weight: 500;
48
+ }
49
+
50
+ .buttons {
51
+ display: flex;
52
+ gap: 5px;
53
+ }
54
+
55
+ button {
56
+ padding: 8px 16px;
57
+ border: 1px solid #ddd;
58
+ background: #fff;
59
+ border-radius: 4px;
60
+ cursor: pointer;
61
+ font-size: 0.9rem;
62
+ color: #333;
63
+ }
64
+
65
+ button:hover {
66
+ background: #f5f5f5;
67
+ }
68
+
69
+ button.active {
70
+ background: #42b883;
71
+ color: white;
72
+ border-color: #42b883;
73
+ }
74
+ </style>
@@ -0,0 +1,23 @@
1
+ import { createApp } from 'vue'
2
+ import { createPinia } from 'pinia'
3
+ import { initEenToolkit } from 'een-api-toolkit'
4
+ import App from './App.vue'
5
+ import router from './router'
6
+
7
+ const app = createApp(App)
8
+
9
+ // Install Pinia (required before initEenToolkit)
10
+ app.use(createPinia())
11
+
12
+ // Initialize EEN API Toolkit
13
+ initEenToolkit({
14
+ proxyUrl: import.meta.env.VITE_PROXY_URL,
15
+ clientId: import.meta.env.VITE_EEN_CLIENT_ID,
16
+ redirectUri: import.meta.env.VITE_REDIRECT_URI,
17
+ debug: import.meta.env.VITE_DEBUG === 'true'
18
+ })
19
+
20
+ // Install router
21
+ app.use(router)
22
+
23
+ app.mount('#app')