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,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "een-api-toolkit-alerts-metrics-example",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"stop": "lsof -ti :3333 2>/dev/null | xargs -r kill -9 || echo 'Port 3333 is free'",
|
|
8
|
+
"dev": "npm run stop && vite",
|
|
9
|
+
"build": "vue-tsc && vite build",
|
|
10
|
+
"preview": "vite preview",
|
|
11
|
+
"test:e2e": "playwright test",
|
|
12
|
+
"test:e2e:ui": "playwright test --ui"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"een-api-toolkit": "file:../..",
|
|
16
|
+
"pinia": "^3.0.4",
|
|
17
|
+
"vue": "^3.4.0",
|
|
18
|
+
"vue-router": "^4.2.0",
|
|
19
|
+
"chart.js": "^4.4.0",
|
|
20
|
+
"vue-chartjs": "^5.3.0"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@playwright/test": "^1.57.0",
|
|
24
|
+
"@vitejs/plugin-vue": "^6.0.0",
|
|
25
|
+
"dotenv": "^17.2.3",
|
|
26
|
+
"typescript": "~5.8.0",
|
|
27
|
+
"vite": "^7.3.0",
|
|
28
|
+
"vue-tsc": "^3.2.1"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { defineConfig, devices } from '@playwright/test'
|
|
2
|
+
import dotenv from 'dotenv'
|
|
3
|
+
import path from 'path'
|
|
4
|
+
import { fileURLToPath } from 'url'
|
|
5
|
+
|
|
6
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
7
|
+
|
|
8
|
+
// Load .env files: parent first, then local with override to replace any conflicts
|
|
9
|
+
dotenv.config({ path: path.resolve(__dirname, '../../.env') })
|
|
10
|
+
dotenv.config({ path: path.resolve(__dirname, '.env'), override: true })
|
|
11
|
+
|
|
12
|
+
const redirectUri = process.env.VITE_REDIRECT_URI || 'http://127.0.0.1:3333'
|
|
13
|
+
if (!redirectUri.startsWith('http://127.0.0.1:') && !redirectUri.startsWith('http://localhost:')) {
|
|
14
|
+
throw new Error('VITE_REDIRECT_URI must use localhost or 127.0.0.1 for security')
|
|
15
|
+
}
|
|
16
|
+
const baseURL = redirectUri
|
|
17
|
+
|
|
18
|
+
export default defineConfig({
|
|
19
|
+
testDir: './e2e',
|
|
20
|
+
testMatch: '**/*.spec.ts',
|
|
21
|
+
fullyParallel: false,
|
|
22
|
+
forbidOnly: !!process.env.CI,
|
|
23
|
+
retries: 0,
|
|
24
|
+
maxFailures: 1,
|
|
25
|
+
workers: 1,
|
|
26
|
+
reporter: [['html', { open: 'never' }]],
|
|
27
|
+
timeout: 30000,
|
|
28
|
+
use: {
|
|
29
|
+
baseURL,
|
|
30
|
+
trace: 'on-first-retry',
|
|
31
|
+
video: 'retain-on-failure'
|
|
32
|
+
},
|
|
33
|
+
outputDir: './e2e-results/',
|
|
34
|
+
projects: [
|
|
35
|
+
{
|
|
36
|
+
name: 'chromium',
|
|
37
|
+
use: { ...devices['Desktop Chrome'] }
|
|
38
|
+
}
|
|
39
|
+
],
|
|
40
|
+
webServer: {
|
|
41
|
+
command: 'npm run dev',
|
|
42
|
+
url: baseURL,
|
|
43
|
+
reuseExistingServer: !process.env.CI,
|
|
44
|
+
timeout: 30000
|
|
45
|
+
}
|
|
46
|
+
})
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useAuthStore } from 'een-api-toolkit'
|
|
3
|
+
import { computed } from 'vue'
|
|
4
|
+
|
|
5
|
+
const authStore = useAuthStore()
|
|
6
|
+
const isAuthenticated = computed(() => authStore.isAuthenticated)
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<template>
|
|
10
|
+
<div class="app">
|
|
11
|
+
<header>
|
|
12
|
+
<h1>EEN Alerts & Metrics Example</h1>
|
|
13
|
+
<nav>
|
|
14
|
+
<router-link to="/" data-testid="nav-home">Home</router-link>
|
|
15
|
+
<router-link v-if="isAuthenticated" to="/dashboard" data-testid="nav-dashboard">Dashboard</router-link>
|
|
16
|
+
<router-link v-if="!isAuthenticated" to="/login" data-testid="nav-login">Login</router-link>
|
|
17
|
+
<router-link v-if="isAuthenticated" to="/logout" data-testid="nav-logout">Logout</router-link>
|
|
18
|
+
</nav>
|
|
19
|
+
</header>
|
|
20
|
+
<main>
|
|
21
|
+
<router-view />
|
|
22
|
+
</main>
|
|
23
|
+
</div>
|
|
24
|
+
</template>
|
|
25
|
+
|
|
26
|
+
<style>
|
|
27
|
+
* {
|
|
28
|
+
box-sizing: border-box;
|
|
29
|
+
margin: 0;
|
|
30
|
+
padding: 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
body {
|
|
34
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
|
|
35
|
+
Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
|
36
|
+
line-height: 1.6;
|
|
37
|
+
color: #333;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.app {
|
|
41
|
+
max-width: 1400px;
|
|
42
|
+
margin: 0 auto;
|
|
43
|
+
padding: 20px;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
header {
|
|
47
|
+
display: flex;
|
|
48
|
+
justify-content: space-between;
|
|
49
|
+
align-items: center;
|
|
50
|
+
margin-bottom: 30px;
|
|
51
|
+
padding-bottom: 20px;
|
|
52
|
+
border-bottom: 1px solid #eee;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
header h1 {
|
|
56
|
+
font-size: 1.5rem;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
nav {
|
|
60
|
+
display: flex;
|
|
61
|
+
gap: 20px;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
nav a {
|
|
65
|
+
color: #42b883;
|
|
66
|
+
text-decoration: none;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
nav a:hover {
|
|
70
|
+
text-decoration: underline;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
nav a.router-link-active {
|
|
74
|
+
font-weight: bold;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
button {
|
|
78
|
+
background: #42b883;
|
|
79
|
+
color: white;
|
|
80
|
+
border: none;
|
|
81
|
+
padding: 10px 20px;
|
|
82
|
+
border-radius: 4px;
|
|
83
|
+
cursor: pointer;
|
|
84
|
+
font-size: 1rem;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
button:hover {
|
|
88
|
+
background: #3aa876;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
button:disabled {
|
|
92
|
+
background: #ccc;
|
|
93
|
+
cursor: not-allowed;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.error {
|
|
97
|
+
color: #e74c3c;
|
|
98
|
+
padding: 10px;
|
|
99
|
+
background: #fdf2f2;
|
|
100
|
+
border-radius: 4px;
|
|
101
|
+
margin: 10px 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.loading {
|
|
105
|
+
color: #666;
|
|
106
|
+
font-style: italic;
|
|
107
|
+
}
|
|
108
|
+
</style>
|
|
@@ -0,0 +1,330 @@
|
|
|
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'
|
|
4
|
+
|
|
5
|
+
const props = defineProps<{
|
|
6
|
+
camera: Camera
|
|
7
|
+
timeRange: string
|
|
8
|
+
}>()
|
|
9
|
+
|
|
10
|
+
const alerts = ref<Alert[]>([])
|
|
11
|
+
const alertTypes = ref<AlertType[]>([])
|
|
12
|
+
const selectedAlertType = ref<string>('')
|
|
13
|
+
const loadingAlertTypes = ref(false)
|
|
14
|
+
const loading = ref(false)
|
|
15
|
+
const loadingMore = ref(false)
|
|
16
|
+
const error = ref<EenError | null>(null)
|
|
17
|
+
const nextPageToken = ref<string | undefined>(undefined)
|
|
18
|
+
|
|
19
|
+
function getTimeRangeMs(range: string): number {
|
|
20
|
+
switch (range) {
|
|
21
|
+
case '1h': return 60 * 60 * 1000
|
|
22
|
+
case '6h': return 6 * 60 * 60 * 1000
|
|
23
|
+
case '24h': return 24 * 60 * 60 * 1000
|
|
24
|
+
case '7d': return 7 * 24 * 60 * 60 * 1000
|
|
25
|
+
default: return 24 * 60 * 60 * 1000
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function formatAlertType(type: string): string {
|
|
30
|
+
// Convert "een.motionDetectionAlert.v1" to "Motion Detection Alert"
|
|
31
|
+
const parts = type.split('.')
|
|
32
|
+
if (parts.length >= 2) {
|
|
33
|
+
const name = parts[1]
|
|
34
|
+
.replace(/([A-Z])/g, ' $1')
|
|
35
|
+
.trim()
|
|
36
|
+
return name.charAt(0).toUpperCase() + name.slice(1)
|
|
37
|
+
}
|
|
38
|
+
return type
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function fetchAlertTypes() {
|
|
42
|
+
loadingAlertTypes.value = true
|
|
43
|
+
error.value = null
|
|
44
|
+
alertTypes.value = []
|
|
45
|
+
selectedAlertType.value = ''
|
|
46
|
+
|
|
47
|
+
const result = await listAlertTypes({ pageSize: 100 })
|
|
48
|
+
|
|
49
|
+
if (result.error) {
|
|
50
|
+
error.value = result.error
|
|
51
|
+
loadingAlertTypes.value = false
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
alertTypes.value = result.data?.results ?? []
|
|
56
|
+
loadingAlertTypes.value = false
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function fetchAlerts(append = false) {
|
|
60
|
+
if (!props.camera?.id) return
|
|
61
|
+
|
|
62
|
+
if (append) {
|
|
63
|
+
loadingMore.value = true
|
|
64
|
+
} else {
|
|
65
|
+
loading.value = true
|
|
66
|
+
alerts.value = []
|
|
67
|
+
nextPageToken.value = undefined
|
|
68
|
+
}
|
|
69
|
+
error.value = null
|
|
70
|
+
|
|
71
|
+
const now = new Date()
|
|
72
|
+
const rangeMs = getTimeRangeMs(props.timeRange)
|
|
73
|
+
const startTime = new Date(now.getTime() - rangeMs)
|
|
74
|
+
|
|
75
|
+
const params: Parameters<typeof listAlerts>[0] = {
|
|
76
|
+
actorId__in: [props.camera.id],
|
|
77
|
+
timestamp__gte: startTime.toISOString(),
|
|
78
|
+
timestamp__lte: now.toISOString(),
|
|
79
|
+
pageSize: 20,
|
|
80
|
+
pageToken: append ? nextPageToken.value : undefined,
|
|
81
|
+
include: ['description'],
|
|
82
|
+
sort: ['-timestamp']
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Add alert type filter if selected
|
|
86
|
+
if (selectedAlertType.value) {
|
|
87
|
+
params.alertType__in = [selectedAlertType.value]
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const result = await listAlerts(params)
|
|
91
|
+
|
|
92
|
+
if (result.error) {
|
|
93
|
+
error.value = result.error
|
|
94
|
+
} else {
|
|
95
|
+
const newAlerts = result.data?.results ?? []
|
|
96
|
+
if (append) {
|
|
97
|
+
alerts.value = [...alerts.value, ...newAlerts]
|
|
98
|
+
} else {
|
|
99
|
+
alerts.value = newAlerts
|
|
100
|
+
}
|
|
101
|
+
nextPageToken.value = result.data?.nextPageToken
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
loading.value = false
|
|
105
|
+
loadingMore.value = false
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function handleAlertTypeChange() {
|
|
109
|
+
fetchAlerts()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function loadMore() {
|
|
113
|
+
if (nextPageToken.value) {
|
|
114
|
+
fetchAlerts(true)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function formatTime(timestamp: string): string {
|
|
119
|
+
return new Date(timestamp).toLocaleString()
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function getPriorityClass(priority?: number): string {
|
|
123
|
+
if (priority === undefined) return ''
|
|
124
|
+
if (priority >= 15) return 'priority-high'
|
|
125
|
+
if (priority >= 10) return 'priority-medium'
|
|
126
|
+
return 'priority-low'
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Fetch alert types once on mount
|
|
130
|
+
fetchAlertTypes()
|
|
131
|
+
|
|
132
|
+
// Fetch alerts when camera or time range changes
|
|
133
|
+
watch(
|
|
134
|
+
() => [props.camera?.id, props.timeRange],
|
|
135
|
+
() => {
|
|
136
|
+
fetchAlerts()
|
|
137
|
+
},
|
|
138
|
+
{ immediate: true }
|
|
139
|
+
)
|
|
140
|
+
</script>
|
|
141
|
+
|
|
142
|
+
<template>
|
|
143
|
+
<div class="alerts-list" data-testid="alerts-list">
|
|
144
|
+
<div class="alert-type-selector" data-testid="alert-type-selector">
|
|
145
|
+
<label for="alert-type-select">Alert Type:</label>
|
|
146
|
+
<select
|
|
147
|
+
id="alert-type-select"
|
|
148
|
+
v-model="selectedAlertType"
|
|
149
|
+
@change="handleAlertTypeChange"
|
|
150
|
+
:disabled="loadingAlertTypes"
|
|
151
|
+
data-testid="alert-type-select"
|
|
152
|
+
>
|
|
153
|
+
<option value="">
|
|
154
|
+
{{ loadingAlertTypes ? 'Loading...' : 'All Alert Types' }}
|
|
155
|
+
</option>
|
|
156
|
+
<option
|
|
157
|
+
v-for="at in alertTypes"
|
|
158
|
+
:key="at.type"
|
|
159
|
+
:value="at.type"
|
|
160
|
+
data-testid="alert-type-option"
|
|
161
|
+
>
|
|
162
|
+
{{ formatAlertType(at.type) }}
|
|
163
|
+
</option>
|
|
164
|
+
</select>
|
|
165
|
+
</div>
|
|
166
|
+
|
|
167
|
+
<div v-if="loading" class="loading" data-testid="alerts-loading">
|
|
168
|
+
Loading alerts...
|
|
169
|
+
</div>
|
|
170
|
+
<div v-else-if="error" class="error" data-testid="alerts-error">
|
|
171
|
+
{{ error.message }}
|
|
172
|
+
</div>
|
|
173
|
+
<div v-else-if="alerts.length === 0" class="no-data" data-testid="alerts-no-data">
|
|
174
|
+
No alerts found for this time range.
|
|
175
|
+
</div>
|
|
176
|
+
<div v-else>
|
|
177
|
+
<div
|
|
178
|
+
v-for="alert in alerts"
|
|
179
|
+
:key="alert.id"
|
|
180
|
+
class="alert-item"
|
|
181
|
+
data-testid="alert-item"
|
|
182
|
+
>
|
|
183
|
+
<div class="alert-header">
|
|
184
|
+
<span class="alert-type">{{ alert.alertType?.split('.')[1] || alert.alertType }}</span>
|
|
185
|
+
<span
|
|
186
|
+
v-if="alert.priority !== undefined"
|
|
187
|
+
class="priority-badge"
|
|
188
|
+
:class="getPriorityClass(alert.priority)"
|
|
189
|
+
>
|
|
190
|
+
P{{ alert.priority }}
|
|
191
|
+
</span>
|
|
192
|
+
</div>
|
|
193
|
+
<div class="alert-time">{{ formatTime(alert.timestamp) }}</div>
|
|
194
|
+
<div v-if="alert.description" class="alert-description">
|
|
195
|
+
{{ alert.description }}
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
|
|
199
|
+
<button
|
|
200
|
+
v-if="nextPageToken"
|
|
201
|
+
@click="loadMore"
|
|
202
|
+
:disabled="loadingMore"
|
|
203
|
+
class="load-more-button"
|
|
204
|
+
data-testid="alerts-load-more"
|
|
205
|
+
>
|
|
206
|
+
{{ loadingMore ? 'Loading...' : 'Load More' }}
|
|
207
|
+
</button>
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
</template>
|
|
211
|
+
|
|
212
|
+
<style scoped>
|
|
213
|
+
.alerts-list {
|
|
214
|
+
min-height: 200px;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
.alert-type-selector {
|
|
218
|
+
display: flex;
|
|
219
|
+
align-items: center;
|
|
220
|
+
gap: 10px;
|
|
221
|
+
margin-bottom: 15px;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
.alert-type-selector label {
|
|
225
|
+
font-weight: 500;
|
|
226
|
+
font-size: 0.9rem;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
.alert-type-selector select {
|
|
230
|
+
padding: 6px 10px;
|
|
231
|
+
border: 1px solid #ddd;
|
|
232
|
+
border-radius: 4px;
|
|
233
|
+
font-size: 0.85rem;
|
|
234
|
+
min-width: 200px;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
.alert-type-selector select:disabled {
|
|
238
|
+
background: #f5f5f5;
|
|
239
|
+
cursor: not-allowed;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
.loading,
|
|
243
|
+
.no-data {
|
|
244
|
+
display: flex;
|
|
245
|
+
align-items: center;
|
|
246
|
+
justify-content: center;
|
|
247
|
+
height: 100px;
|
|
248
|
+
color: #666;
|
|
249
|
+
font-style: italic;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
.error {
|
|
253
|
+
color: #e74c3c;
|
|
254
|
+
padding: 10px;
|
|
255
|
+
background: #fdf2f2;
|
|
256
|
+
border-radius: 4px;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
.alert-item {
|
|
260
|
+
padding: 12px;
|
|
261
|
+
border: 1px solid #eee;
|
|
262
|
+
border-radius: 6px;
|
|
263
|
+
margin-bottom: 10px;
|
|
264
|
+
background: #fafafa;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
.alert-header {
|
|
268
|
+
display: flex;
|
|
269
|
+
justify-content: space-between;
|
|
270
|
+
align-items: center;
|
|
271
|
+
margin-bottom: 5px;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
.alert-type {
|
|
275
|
+
font-weight: 600;
|
|
276
|
+
color: #333;
|
|
277
|
+
font-size: 0.9rem;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
.priority-badge {
|
|
281
|
+
padding: 2px 8px;
|
|
282
|
+
border-radius: 12px;
|
|
283
|
+
font-size: 0.75rem;
|
|
284
|
+
font-weight: 500;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
.priority-high {
|
|
288
|
+
background: #fde8e8;
|
|
289
|
+
color: #c53030;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
.priority-medium {
|
|
293
|
+
background: #fef3c7;
|
|
294
|
+
color: #92400e;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
.priority-low {
|
|
298
|
+
background: #def7ec;
|
|
299
|
+
color: #03543f;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
.alert-time {
|
|
303
|
+
font-size: 0.8rem;
|
|
304
|
+
color: #666;
|
|
305
|
+
margin-bottom: 5px;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
.alert-description {
|
|
309
|
+
font-size: 0.85rem;
|
|
310
|
+
color: #555;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
.load-more-button {
|
|
314
|
+
width: 100%;
|
|
315
|
+
margin-top: 10px;
|
|
316
|
+
padding: 10px;
|
|
317
|
+
background: #f5f5f5;
|
|
318
|
+
border: 1px solid #ddd;
|
|
319
|
+
color: #333;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
.load-more-button:hover {
|
|
323
|
+
background: #eee;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
.load-more-button:disabled {
|
|
327
|
+
background: #f5f5f5;
|
|
328
|
+
color: #999;
|
|
329
|
+
}
|
|
330
|
+
</style>
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, onMounted } from 'vue'
|
|
3
|
+
import { getCameras, type Camera, type EenError } from 'een-api-toolkit'
|
|
4
|
+
|
|
5
|
+
const emit = defineEmits<{
|
|
6
|
+
select: [camera: Camera]
|
|
7
|
+
}>()
|
|
8
|
+
|
|
9
|
+
const cameras = ref<Camera[]>([])
|
|
10
|
+
const loading = ref(false)
|
|
11
|
+
const error = ref<EenError | null>(null)
|
|
12
|
+
const selectedCameraId = ref<string>('')
|
|
13
|
+
|
|
14
|
+
async function fetchCameras() {
|
|
15
|
+
loading.value = true
|
|
16
|
+
error.value = null
|
|
17
|
+
|
|
18
|
+
const result = await getCameras({ pageSize: 100 })
|
|
19
|
+
|
|
20
|
+
if (result.error) {
|
|
21
|
+
error.value = result.error
|
|
22
|
+
cameras.value = []
|
|
23
|
+
} else {
|
|
24
|
+
cameras.value = result.data?.results ?? []
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
loading.value = false
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function handleChange() {
|
|
31
|
+
const camera = cameras.value.find(c => c.id === selectedCameraId.value)
|
|
32
|
+
if (camera) {
|
|
33
|
+
emit('select', camera)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
onMounted(() => {
|
|
38
|
+
fetchCameras()
|
|
39
|
+
})
|
|
40
|
+
</script>
|
|
41
|
+
|
|
42
|
+
<template>
|
|
43
|
+
<div class="camera-selector">
|
|
44
|
+
<label for="camera-select">Camera:</label>
|
|
45
|
+
<select
|
|
46
|
+
id="camera-select"
|
|
47
|
+
v-model="selectedCameraId"
|
|
48
|
+
@change="handleChange"
|
|
49
|
+
:disabled="loading"
|
|
50
|
+
data-testid="camera-select"
|
|
51
|
+
>
|
|
52
|
+
<option value="" disabled>{{ loading ? 'Loading cameras...' : 'Select a camera' }}</option>
|
|
53
|
+
<option
|
|
54
|
+
v-for="camera in cameras"
|
|
55
|
+
:key="camera.id"
|
|
56
|
+
:value="camera.id"
|
|
57
|
+
data-testid="camera-option"
|
|
58
|
+
>
|
|
59
|
+
{{ camera.name ? `${camera.name} (${camera.id})` : camera.id }}
|
|
60
|
+
</option>
|
|
61
|
+
</select>
|
|
62
|
+
<div v-if="error" class="error" data-testid="camera-selector-error">
|
|
63
|
+
{{ error.message }}
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
</template>
|
|
67
|
+
|
|
68
|
+
<style scoped>
|
|
69
|
+
.camera-selector {
|
|
70
|
+
display: flex;
|
|
71
|
+
align-items: center;
|
|
72
|
+
gap: 10px;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
label {
|
|
76
|
+
font-weight: 500;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
select {
|
|
80
|
+
padding: 8px 12px;
|
|
81
|
+
border: 1px solid #ddd;
|
|
82
|
+
border-radius: 4px;
|
|
83
|
+
font-size: 1rem;
|
|
84
|
+
min-width: 350px;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
select:disabled {
|
|
88
|
+
background: #f5f5f5;
|
|
89
|
+
cursor: not-allowed;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.error {
|
|
93
|
+
color: #e74c3c;
|
|
94
|
+
font-size: 0.9rem;
|
|
95
|
+
}
|
|
96
|
+
</style>
|