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.
- package/CHANGELOG.md +5 -35
- package/README.md +2 -0
- package/dist/index.cjs +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +801 -0
- package/dist/index.js +486 -252
- package/dist/index.js.map +1 -1
- package/docs/AI-CONTEXT.md +195 -2
- package/examples/vue-alerts-metrics/README.md +136 -0
- package/examples/vue-alerts-metrics/e2e/app.spec.ts +74 -0
- package/examples/vue-alerts-metrics/e2e/auth.spec.ts +554 -0
- package/examples/vue-alerts-metrics/index.html +13 -0
- package/examples/vue-alerts-metrics/package-lock.json +1749 -0
- package/examples/vue-alerts-metrics/package.json +30 -0
- package/examples/vue-alerts-metrics/playwright.config.ts +46 -0
- package/examples/vue-alerts-metrics/src/App.vue +108 -0
- package/examples/vue-alerts-metrics/src/components/AlertsList.vue +330 -0
- package/examples/vue-alerts-metrics/src/components/CameraSelector.vue +96 -0
- package/examples/vue-alerts-metrics/src/components/MetricsChart.vue +322 -0
- package/examples/vue-alerts-metrics/src/components/NotificationsList.vue +263 -0
- package/examples/vue-alerts-metrics/src/components/TimeRangeSelector.vue +74 -0
- package/examples/vue-alerts-metrics/src/main.ts +23 -0
- package/examples/vue-alerts-metrics/src/router/index.ts +61 -0
- package/examples/vue-alerts-metrics/src/views/Callback.vue +76 -0
- package/examples/vue-alerts-metrics/src/views/Dashboard.vue +152 -0
- package/examples/vue-alerts-metrics/src/views/Home.vue +167 -0
- package/examples/vue-alerts-metrics/src/views/Login.vue +33 -0
- package/examples/vue-alerts-metrics/src/views/Logout.vue +66 -0
- package/examples/vue-alerts-metrics/src/vite-env.d.ts +12 -0
- package/examples/vue-alerts-metrics/tsconfig.json +21 -0
- package/examples/vue-alerts-metrics/tsconfig.node.json +10 -0
- package/examples/vue-alerts-metrics/vite.config.ts +12 -0
- package/examples/vue-events/README.md +68 -0
- package/examples/vue-events/e2e/auth.spec.ts +105 -0
- package/examples/vue-events/src/components/EventsModal.vue +452 -14
- package/examples/vue-events/src/views/Home.vue +1 -0
- 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')
|