een-api-toolkit 0.3.47 → 0.3.49
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/.claude/agents/een-jobs-agent.md +676 -0
- package/CHANGELOG.md +7 -8
- package/dist/index.cjs +3 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1172 -28
- package/dist/index.js +796 -333
- package/dist/index.js.map +1 -1
- package/docs/AI-CONTEXT.md +22 -1
- package/docs/ai-reference/AI-AUTH.md +1 -1
- package/docs/ai-reference/AI-AUTOMATIONS.md +1 -1
- package/docs/ai-reference/AI-DEVICES.md +1 -1
- package/docs/ai-reference/AI-EVENTS.md +1 -1
- package/docs/ai-reference/AI-GROUPING.md +1 -1
- package/docs/ai-reference/AI-JOBS.md +1084 -0
- package/docs/ai-reference/AI-MEDIA.md +1 -1
- package/docs/ai-reference/AI-SETUP.md +1 -1
- package/docs/ai-reference/AI-USERS.md +1 -1
- package/examples/vue-jobs/.env.example +11 -0
- package/examples/vue-jobs/README.md +245 -0
- package/examples/vue-jobs/e2e/app.spec.ts +79 -0
- package/examples/vue-jobs/e2e/auth.spec.ts +382 -0
- package/examples/vue-jobs/e2e/delete-features.spec.ts +564 -0
- package/examples/vue-jobs/e2e/timelapse.spec.ts +361 -0
- package/examples/vue-jobs/index.html +13 -0
- package/examples/vue-jobs/package-lock.json +1722 -0
- package/examples/vue-jobs/package.json +28 -0
- package/examples/vue-jobs/playwright.config.ts +47 -0
- package/examples/vue-jobs/src/App.vue +154 -0
- package/examples/vue-jobs/src/main.ts +25 -0
- package/examples/vue-jobs/src/router/index.ts +82 -0
- package/examples/vue-jobs/src/views/Callback.vue +76 -0
- package/examples/vue-jobs/src/views/CreateExport.vue +284 -0
- package/examples/vue-jobs/src/views/Files.vue +424 -0
- package/examples/vue-jobs/src/views/Home.vue +195 -0
- package/examples/vue-jobs/src/views/JobDetail.vue +392 -0
- package/examples/vue-jobs/src/views/Jobs.vue +297 -0
- package/examples/vue-jobs/src/views/Login.vue +33 -0
- package/examples/vue-jobs/src/views/Logout.vue +59 -0
- package/examples/vue-jobs/src/vite-env.d.ts +1 -0
- package/examples/vue-jobs/tsconfig.json +25 -0
- package/examples/vue-jobs/vite.config.ts +12 -0
- package/package.json +1 -1
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
|
3
|
+
import { useRoute, useRouter } from 'vue-router'
|
|
4
|
+
import { getJob, downloadFile, type Job, type EenError } from 'een-api-toolkit'
|
|
5
|
+
|
|
6
|
+
const route = useRoute()
|
|
7
|
+
const router = useRouter()
|
|
8
|
+
|
|
9
|
+
const job = ref<Job | null>(null)
|
|
10
|
+
const loading = ref(false)
|
|
11
|
+
const error = ref<EenError | null>(null)
|
|
12
|
+
const downloading = ref(false)
|
|
13
|
+
const videoUrl = ref<string | null>(null)
|
|
14
|
+
const loadingVideo = ref(false)
|
|
15
|
+
|
|
16
|
+
// Polling state
|
|
17
|
+
const isPolling = ref(false)
|
|
18
|
+
let pollInterval: ReturnType<typeof setInterval> | null = null
|
|
19
|
+
|
|
20
|
+
const jobId = computed(() => route.params.id as string)
|
|
21
|
+
|
|
22
|
+
// Extract name from nested structure
|
|
23
|
+
const jobName = computed(() => {
|
|
24
|
+
return job.value?.arguments?.originalRequest?.name || '-'
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
// Extract file URL from result (for download)
|
|
28
|
+
const fileUrl = computed(() => {
|
|
29
|
+
return job.value?.result?.intervals?.[0]?.files?.[0]?.url
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
// Extract request timestamps from arguments
|
|
33
|
+
const requestStartTimestamp = computed(() => {
|
|
34
|
+
return job.value?.arguments?.originalRequest?.startTimestamp
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const requestEndTimestamp = computed(() => {
|
|
38
|
+
return job.value?.arguments?.originalRequest?.endTimestamp
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const canDownload = computed(() => {
|
|
42
|
+
return job.value?.state === 'success' && fileUrl.value
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
const canPlay = computed(() => {
|
|
46
|
+
// Can play video exports
|
|
47
|
+
return canDownload.value && (job.value?.type === 'video' || job.value?.type === 'export')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
async function fetchJob() {
|
|
51
|
+
loading.value = true
|
|
52
|
+
error.value = null
|
|
53
|
+
|
|
54
|
+
const result = await getJob(jobId.value)
|
|
55
|
+
|
|
56
|
+
if (result.error) {
|
|
57
|
+
error.value = result.error
|
|
58
|
+
job.value = null
|
|
59
|
+
stopPolling()
|
|
60
|
+
} else {
|
|
61
|
+
job.value = result.data
|
|
62
|
+
|
|
63
|
+
// Auto-start polling if job is pending or started
|
|
64
|
+
if (['pending', 'started'].includes(result.data.state) && !isPolling.value) {
|
|
65
|
+
startPolling()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Stop polling if job is complete
|
|
69
|
+
if (['success', 'failure', 'revoked'].includes(result.data.state)) {
|
|
70
|
+
stopPolling()
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
loading.value = false
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function startPolling() {
|
|
78
|
+
if (isPolling.value) return
|
|
79
|
+
isPolling.value = true
|
|
80
|
+
pollInterval = setInterval(fetchJob, 3000) // Poll every 3 seconds
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function stopPolling() {
|
|
84
|
+
if (pollInterval) {
|
|
85
|
+
clearInterval(pollInterval)
|
|
86
|
+
pollInterval = null
|
|
87
|
+
}
|
|
88
|
+
isPolling.value = false
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function handleDownload() {
|
|
92
|
+
const url = fileUrl.value
|
|
93
|
+
if (!url) return
|
|
94
|
+
|
|
95
|
+
// Extract file ID from URL (last path segment)
|
|
96
|
+
const urlParts = url.split('/')
|
|
97
|
+
const fileId = urlParts[urlParts.length - 1]
|
|
98
|
+
|
|
99
|
+
downloading.value = true
|
|
100
|
+
const result = await downloadFile(fileId)
|
|
101
|
+
|
|
102
|
+
if (result.error) {
|
|
103
|
+
error.value = result.error
|
|
104
|
+
} else {
|
|
105
|
+
// Create download link
|
|
106
|
+
const blobUrl = URL.createObjectURL(result.data.blob)
|
|
107
|
+
const a = document.createElement('a')
|
|
108
|
+
a.href = blobUrl
|
|
109
|
+
a.download = result.data.filename
|
|
110
|
+
a.click()
|
|
111
|
+
URL.revokeObjectURL(blobUrl)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
downloading.value = false
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function handlePlay() {
|
|
118
|
+
const url = fileUrl.value
|
|
119
|
+
if (!url) return
|
|
120
|
+
|
|
121
|
+
// Extract file ID from URL (last path segment)
|
|
122
|
+
const urlParts = url.split('/')
|
|
123
|
+
const fileId = urlParts[urlParts.length - 1]
|
|
124
|
+
|
|
125
|
+
loadingVideo.value = true
|
|
126
|
+
error.value = null
|
|
127
|
+
|
|
128
|
+
const result = await downloadFile(fileId)
|
|
129
|
+
|
|
130
|
+
if (result.error) {
|
|
131
|
+
error.value = result.error
|
|
132
|
+
} else {
|
|
133
|
+
// Revoke previous URL if exists
|
|
134
|
+
if (videoUrl.value) {
|
|
135
|
+
URL.revokeObjectURL(videoUrl.value)
|
|
136
|
+
}
|
|
137
|
+
// Create blob URL for video playback
|
|
138
|
+
videoUrl.value = URL.createObjectURL(result.data.blob)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
loadingVideo.value = false
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function closePlayer() {
|
|
145
|
+
if (videoUrl.value) {
|
|
146
|
+
URL.revokeObjectURL(videoUrl.value)
|
|
147
|
+
videoUrl.value = null
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function goBack() {
|
|
152
|
+
router.push('/jobs')
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function formatDate(timestamp: string | undefined) {
|
|
156
|
+
if (!timestamp) return '-'
|
|
157
|
+
return new Date(timestamp).toLocaleString()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function getStateBadgeClass(state: string) {
|
|
161
|
+
return `badge badge-${state}`
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
onMounted(() => {
|
|
165
|
+
fetchJob()
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
onUnmounted(() => {
|
|
169
|
+
stopPolling()
|
|
170
|
+
if (videoUrl.value) {
|
|
171
|
+
URL.revokeObjectURL(videoUrl.value)
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
</script>
|
|
175
|
+
|
|
176
|
+
<template>
|
|
177
|
+
<div class="job-detail">
|
|
178
|
+
<div class="header">
|
|
179
|
+
<h2>Job Details</h2>
|
|
180
|
+
<button @click="goBack">Back to Jobs</button>
|
|
181
|
+
</div>
|
|
182
|
+
|
|
183
|
+
<div v-if="loading && !job" class="loading">
|
|
184
|
+
Loading job...
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
<div v-else-if="error" class="error">
|
|
188
|
+
Error: {{ error.message }}
|
|
189
|
+
</div>
|
|
190
|
+
|
|
191
|
+
<div v-else-if="job" class="job-info">
|
|
192
|
+
<div class="info-card">
|
|
193
|
+
<div class="info-row">
|
|
194
|
+
<span class="label">ID:</span>
|
|
195
|
+
<span class="value">{{ job.id }}</span>
|
|
196
|
+
</div>
|
|
197
|
+
<div class="info-row">
|
|
198
|
+
<span class="label">Name:</span>
|
|
199
|
+
<span class="value">{{ jobName }}</span>
|
|
200
|
+
</div>
|
|
201
|
+
<div class="info-row">
|
|
202
|
+
<span class="label">Type:</span>
|
|
203
|
+
<span class="value">{{ job.type }}</span>
|
|
204
|
+
</div>
|
|
205
|
+
<div class="info-row">
|
|
206
|
+
<span class="label">State:</span>
|
|
207
|
+
<span :class="getStateBadgeClass(job.state)">{{ job.state }}</span>
|
|
208
|
+
</div>
|
|
209
|
+
<div v-if="job.state === 'started'" class="info-row">
|
|
210
|
+
<span class="label">Progress:</span>
|
|
211
|
+
<span class="value">
|
|
212
|
+
<div class="progress-bar">
|
|
213
|
+
<div class="progress-fill" :style="{ width: `${(job.progress || 0) * 100}%` }"></div>
|
|
214
|
+
</div>
|
|
215
|
+
<span class="progress-text">{{ Math.round((job.progress || 0) * 100) }}%</span>
|
|
216
|
+
</span>
|
|
217
|
+
</div>
|
|
218
|
+
<div v-if="job.state === 'failure' && job.error" class="info-row">
|
|
219
|
+
<span class="label">Error:</span>
|
|
220
|
+
<span class="value error-text">{{ job.error }}</span>
|
|
221
|
+
</div>
|
|
222
|
+
<div v-if="fileUrl" class="info-row">
|
|
223
|
+
<span class="label">File:</span>
|
|
224
|
+
<span class="value file-url">{{ job.result?.intervals?.[0]?.files?.[0]?.name || 'Available' }}</span>
|
|
225
|
+
</div>
|
|
226
|
+
<div class="info-row">
|
|
227
|
+
<span class="label">Created:</span>
|
|
228
|
+
<span class="value">{{ formatDate(job.createTimestamp) }}</span>
|
|
229
|
+
</div>
|
|
230
|
+
<div v-if="requestStartTimestamp" class="info-row">
|
|
231
|
+
<span class="label">Period Start:</span>
|
|
232
|
+
<span class="value">{{ formatDate(requestStartTimestamp) }}</span>
|
|
233
|
+
</div>
|
|
234
|
+
<div v-if="requestEndTimestamp" class="info-row">
|
|
235
|
+
<span class="label">Period End:</span>
|
|
236
|
+
<span class="value">{{ formatDate(requestEndTimestamp) }}</span>
|
|
237
|
+
</div>
|
|
238
|
+
<div v-if="job.updateTimestamp" class="info-row">
|
|
239
|
+
<span class="label">Last Updated:</span>
|
|
240
|
+
<span class="value">{{ formatDate(job.updateTimestamp) }}</span>
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
243
|
+
|
|
244
|
+
<div class="actions">
|
|
245
|
+
<button v-if="canPlay" @click="handlePlay" :disabled="loadingVideo || downloading">
|
|
246
|
+
{{ loadingVideo ? 'Loading...' : 'Play Video' }}
|
|
247
|
+
</button>
|
|
248
|
+
<button v-if="canDownload" @click="handleDownload" :disabled="downloading || loadingVideo">
|
|
249
|
+
{{ downloading ? 'Downloading...' : 'Download File' }}
|
|
250
|
+
</button>
|
|
251
|
+
<button @click="fetchJob" :disabled="loading">
|
|
252
|
+
{{ loading ? 'Refreshing...' : 'Refresh' }}
|
|
253
|
+
</button>
|
|
254
|
+
</div>
|
|
255
|
+
|
|
256
|
+
<!-- Video Player -->
|
|
257
|
+
<div v-if="videoUrl" class="video-player">
|
|
258
|
+
<div class="video-header">
|
|
259
|
+
<h3>Video Player</h3>
|
|
260
|
+
<button class="close-player" @click="closePlayer">×</button>
|
|
261
|
+
</div>
|
|
262
|
+
<video controls autoplay :src="videoUrl" class="video-element">
|
|
263
|
+
Your browser does not support the video tag.
|
|
264
|
+
</video>
|
|
265
|
+
</div>
|
|
266
|
+
|
|
267
|
+
<div v-if="isPolling" class="polling-notice">
|
|
268
|
+
Auto-refreshing every 3 seconds...
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
</div>
|
|
272
|
+
</template>
|
|
273
|
+
|
|
274
|
+
<style scoped>
|
|
275
|
+
.job-detail {
|
|
276
|
+
width: 80vw;
|
|
277
|
+
min-width: 600px;
|
|
278
|
+
margin: 0 auto;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
.header {
|
|
282
|
+
display: flex;
|
|
283
|
+
justify-content: space-between;
|
|
284
|
+
align-items: center;
|
|
285
|
+
margin-bottom: 20px;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
.info-card {
|
|
289
|
+
background: #f5f5f5;
|
|
290
|
+
border-radius: 8px;
|
|
291
|
+
padding: 20px;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
.info-row {
|
|
295
|
+
display: flex;
|
|
296
|
+
margin-bottom: 12px;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
.info-row:last-child {
|
|
300
|
+
margin-bottom: 0;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
.label {
|
|
304
|
+
font-weight: 600;
|
|
305
|
+
width: 30%;
|
|
306
|
+
flex-shrink: 0;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
.value {
|
|
310
|
+
color: #666;
|
|
311
|
+
display: flex;
|
|
312
|
+
align-items: center;
|
|
313
|
+
gap: 10px;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
.progress-bar {
|
|
317
|
+
width: 150px;
|
|
318
|
+
height: 10px;
|
|
319
|
+
background: #e0e0e0;
|
|
320
|
+
border-radius: 5px;
|
|
321
|
+
overflow: hidden;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
.progress-fill {
|
|
325
|
+
height: 100%;
|
|
326
|
+
background: #42b883;
|
|
327
|
+
transition: width 0.3s ease;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
.progress-text {
|
|
331
|
+
font-weight: 500;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
.error-text {
|
|
335
|
+
color: #e74c3c;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
.actions {
|
|
339
|
+
margin-top: 20px;
|
|
340
|
+
display: flex;
|
|
341
|
+
gap: 10px;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
.polling-notice {
|
|
345
|
+
margin-top: 15px;
|
|
346
|
+
padding: 10px;
|
|
347
|
+
background: #e3f2fd;
|
|
348
|
+
border-radius: 4px;
|
|
349
|
+
color: #1976d2;
|
|
350
|
+
font-size: 0.9rem;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
.video-player {
|
|
354
|
+
margin-top: 20px;
|
|
355
|
+
background: #000;
|
|
356
|
+
border-radius: 8px;
|
|
357
|
+
overflow: hidden;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
.video-header {
|
|
361
|
+
display: flex;
|
|
362
|
+
justify-content: space-between;
|
|
363
|
+
align-items: center;
|
|
364
|
+
padding: 10px 15px;
|
|
365
|
+
background: #222;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
.video-header h3 {
|
|
369
|
+
margin: 0;
|
|
370
|
+
color: white;
|
|
371
|
+
font-size: 1rem;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
.close-player {
|
|
375
|
+
background: none;
|
|
376
|
+
border: none;
|
|
377
|
+
color: white;
|
|
378
|
+
font-size: 24px;
|
|
379
|
+
cursor: pointer;
|
|
380
|
+
padding: 0;
|
|
381
|
+
line-height: 1;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
.close-player:hover {
|
|
385
|
+
color: #e74c3c;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
.video-element {
|
|
389
|
+
width: 100%;
|
|
390
|
+
display: block;
|
|
391
|
+
}
|
|
392
|
+
</style>
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, computed, onMounted } from 'vue'
|
|
3
|
+
import { listJobs, getCurrentUser, deleteJob, type Job, type EenError, type ListJobsParams, type JobState } from 'een-api-toolkit'
|
|
4
|
+
import { useRouter } from 'vue-router'
|
|
5
|
+
|
|
6
|
+
const router = useRouter()
|
|
7
|
+
|
|
8
|
+
// Reactive state
|
|
9
|
+
const jobs = ref<Job[]>([])
|
|
10
|
+
const loading = ref(false)
|
|
11
|
+
const error = ref<EenError | null>(null)
|
|
12
|
+
const nextPageToken = ref<string | undefined>(undefined)
|
|
13
|
+
const currentUserId = ref<string | null>(null)
|
|
14
|
+
const deletingId = ref<string | null>(null)
|
|
15
|
+
|
|
16
|
+
const hasNextPage = computed(() => !!nextPageToken.value)
|
|
17
|
+
|
|
18
|
+
// Filter state
|
|
19
|
+
const selectedStates = ref<JobState[]>([])
|
|
20
|
+
const stateOptions: { value: JobState; label: string }[] = [
|
|
21
|
+
{ value: 'pending', label: 'Pending' },
|
|
22
|
+
{ value: 'started', label: 'Started' },
|
|
23
|
+
{ value: 'success', label: 'Success' },
|
|
24
|
+
{ value: 'failure', label: 'Failure' },
|
|
25
|
+
{ value: 'revoked', label: 'Revoked' }
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
const params = ref<ListJobsParams>({ pageSize: 20 })
|
|
29
|
+
|
|
30
|
+
async function fetchJobs(fetchParams?: ListJobsParams, append = false) {
|
|
31
|
+
loading.value = true
|
|
32
|
+
error.value = null
|
|
33
|
+
|
|
34
|
+
// Get current user ID if not already fetched
|
|
35
|
+
if (!currentUserId.value) {
|
|
36
|
+
const userResult = await getCurrentUser()
|
|
37
|
+
if (userResult.error) {
|
|
38
|
+
error.value = userResult.error
|
|
39
|
+
loading.value = false
|
|
40
|
+
return { error: userResult.error, data: null }
|
|
41
|
+
}
|
|
42
|
+
currentUserId.value = userResult.data.id
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const mergedParams: ListJobsParams = { ...params.value, ...fetchParams, userId: currentUserId.value }
|
|
46
|
+
|
|
47
|
+
// Add state filter if selected
|
|
48
|
+
if (selectedStates.value.length > 0) {
|
|
49
|
+
mergedParams.state__in = selectedStates.value
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const result = await listJobs(mergedParams)
|
|
53
|
+
|
|
54
|
+
if (result.error) {
|
|
55
|
+
error.value = result.error
|
|
56
|
+
if (!append) {
|
|
57
|
+
jobs.value = []
|
|
58
|
+
}
|
|
59
|
+
nextPageToken.value = undefined
|
|
60
|
+
} else {
|
|
61
|
+
if (append) {
|
|
62
|
+
jobs.value = [...jobs.value, ...result.data.results]
|
|
63
|
+
} else {
|
|
64
|
+
jobs.value = result.data.results
|
|
65
|
+
}
|
|
66
|
+
nextPageToken.value = result.data.nextPageToken
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
loading.value = false
|
|
70
|
+
return result
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function refresh() {
|
|
74
|
+
return fetchJobs()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function fetchNextPage() {
|
|
78
|
+
if (!nextPageToken.value) return
|
|
79
|
+
return fetchJobs({ ...params.value, pageToken: nextPageToken.value }, true)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function viewJob(jobId: string) {
|
|
83
|
+
router.push(`/jobs/${jobId}`)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function handleDelete(job: Job) {
|
|
87
|
+
const jobName = getJobName(job)
|
|
88
|
+
if (!confirm(`Are you sure you want to delete job "${jobName}"?`)) {
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
deletingId.value = job.id
|
|
93
|
+
const result = await deleteJob(job.id)
|
|
94
|
+
|
|
95
|
+
if (result.error) {
|
|
96
|
+
error.value = result.error
|
|
97
|
+
} else {
|
|
98
|
+
// Remove the job from the list
|
|
99
|
+
jobs.value = jobs.value.filter(j => j.id !== job.id)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
deletingId.value = null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function formatDate(timestamp: string) {
|
|
106
|
+
return new Date(timestamp).toLocaleString()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function getStateBadgeClass(state: JobState) {
|
|
110
|
+
return `badge badge-${state}`
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function getJobName(job: Job) {
|
|
114
|
+
return job.arguments?.originalRequest?.name || job.id
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
onMounted(() => {
|
|
118
|
+
fetchJobs()
|
|
119
|
+
})
|
|
120
|
+
</script>
|
|
121
|
+
|
|
122
|
+
<template>
|
|
123
|
+
<div class="jobs">
|
|
124
|
+
<div class="header">
|
|
125
|
+
<h2>Jobs</h2>
|
|
126
|
+
<button @click="refresh" :disabled="loading">
|
|
127
|
+
{{ loading ? 'Loading...' : 'Refresh' }}
|
|
128
|
+
</button>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
<div class="filters">
|
|
132
|
+
<label>Filter by state:</label>
|
|
133
|
+
<div class="state-checkboxes">
|
|
134
|
+
<label v-for="option in stateOptions" :key="option.value" class="checkbox-label">
|
|
135
|
+
<input
|
|
136
|
+
type="checkbox"
|
|
137
|
+
:value="option.value"
|
|
138
|
+
v-model="selectedStates"
|
|
139
|
+
@change="refresh"
|
|
140
|
+
/>
|
|
141
|
+
{{ option.label }}
|
|
142
|
+
</label>
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
<div v-if="loading && jobs.length === 0" class="loading">
|
|
147
|
+
Loading jobs...
|
|
148
|
+
</div>
|
|
149
|
+
|
|
150
|
+
<div v-else-if="error" class="error">
|
|
151
|
+
Error: {{ error.message }}
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
<div v-else>
|
|
155
|
+
<table v-if="jobs.length > 0">
|
|
156
|
+
<thead>
|
|
157
|
+
<tr>
|
|
158
|
+
<th>Name</th>
|
|
159
|
+
<th>Type</th>
|
|
160
|
+
<th>State</th>
|
|
161
|
+
<th>Progress</th>
|
|
162
|
+
<th>Created</th>
|
|
163
|
+
<th>Actions</th>
|
|
164
|
+
</tr>
|
|
165
|
+
</thead>
|
|
166
|
+
<tbody>
|
|
167
|
+
<tr v-for="job in jobs" :key="job.id">
|
|
168
|
+
<td>{{ getJobName(job) }}</td>
|
|
169
|
+
<td>{{ job.type }}</td>
|
|
170
|
+
<td>
|
|
171
|
+
<span :class="getStateBadgeClass(job.state)">
|
|
172
|
+
{{ job.state }}
|
|
173
|
+
</span>
|
|
174
|
+
</td>
|
|
175
|
+
<td>
|
|
176
|
+
<span v-if="job.state === 'started'">{{ Math.round((job.progress || 0) * 100) }}%</span>
|
|
177
|
+
<span v-else-if="job.state === 'success'">Complete</span>
|
|
178
|
+
<span v-else-if="job.state === 'failure'" class="error-text">Failed</span>
|
|
179
|
+
<span v-else>-</span>
|
|
180
|
+
</td>
|
|
181
|
+
<td>{{ formatDate(job.createTimestamp) }}</td>
|
|
182
|
+
<td class="actions">
|
|
183
|
+
<button class="btn-small" @click="viewJob(job.id)" :disabled="deletingId === job.id">View</button>
|
|
184
|
+
<button
|
|
185
|
+
class="btn-small btn-danger"
|
|
186
|
+
@click="handleDelete(job)"
|
|
187
|
+
:disabled="deletingId === job.id"
|
|
188
|
+
>
|
|
189
|
+
{{ deletingId === job.id ? 'Deleting...' : 'Delete' }}
|
|
190
|
+
</button>
|
|
191
|
+
</td>
|
|
192
|
+
</tr>
|
|
193
|
+
</tbody>
|
|
194
|
+
</table>
|
|
195
|
+
|
|
196
|
+
<p v-else>No jobs found.</p>
|
|
197
|
+
|
|
198
|
+
<div v-if="hasNextPage" class="pagination">
|
|
199
|
+
<button @click="fetchNextPage" :disabled="loading">
|
|
200
|
+
{{ loading ? 'Loading...' : 'Load More' }}
|
|
201
|
+
</button>
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
</template>
|
|
206
|
+
|
|
207
|
+
<style scoped>
|
|
208
|
+
.jobs {
|
|
209
|
+
width: 80vw;
|
|
210
|
+
min-width: 900px;
|
|
211
|
+
margin: 0 auto;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
.header {
|
|
215
|
+
display: flex;
|
|
216
|
+
justify-content: space-between;
|
|
217
|
+
align-items: center;
|
|
218
|
+
margin-bottom: 20px;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
.filters {
|
|
222
|
+
margin-bottom: 20px;
|
|
223
|
+
padding: 15px;
|
|
224
|
+
background: #f5f5f5;
|
|
225
|
+
border-radius: 8px;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
.filters label {
|
|
229
|
+
font-weight: 500;
|
|
230
|
+
margin-right: 10px;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
.state-checkboxes {
|
|
234
|
+
display: flex;
|
|
235
|
+
flex-wrap: wrap;
|
|
236
|
+
gap: 15px;
|
|
237
|
+
margin-top: 10px;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
.checkbox-label {
|
|
241
|
+
display: flex;
|
|
242
|
+
align-items: center;
|
|
243
|
+
gap: 5px;
|
|
244
|
+
font-weight: normal;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
table {
|
|
248
|
+
width: 100%;
|
|
249
|
+
border-collapse: collapse;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
th,
|
|
253
|
+
td {
|
|
254
|
+
padding: 12px;
|
|
255
|
+
text-align: left;
|
|
256
|
+
border-bottom: 1px solid #eee;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
th {
|
|
260
|
+
background: #f5f5f5;
|
|
261
|
+
font-weight: 600;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
.actions {
|
|
265
|
+
display: flex;
|
|
266
|
+
gap: 8px;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
.btn-small {
|
|
270
|
+
padding: 5px 10px;
|
|
271
|
+
font-size: 0.85rem;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
.btn-danger {
|
|
275
|
+
background-color: #e74c3c;
|
|
276
|
+
color: white;
|
|
277
|
+
border: none;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
.btn-danger:hover:not(:disabled) {
|
|
281
|
+
background-color: #c0392b;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
.btn-danger:disabled {
|
|
285
|
+
background-color: #f5b7b1;
|
|
286
|
+
cursor: not-allowed;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
.error-text {
|
|
290
|
+
color: #e74c3c;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
.pagination {
|
|
294
|
+
margin-top: 20px;
|
|
295
|
+
text-align: center;
|
|
296
|
+
}
|
|
297
|
+
</style>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { getAuthUrl } from 'een-api-toolkit'
|
|
3
|
+
|
|
4
|
+
function login() {
|
|
5
|
+
// Redirect to EEN OAuth login
|
|
6
|
+
window.location.href = getAuthUrl()
|
|
7
|
+
}
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<template>
|
|
11
|
+
<div class="login">
|
|
12
|
+
<h2 data-testid="login-title">Login</h2>
|
|
13
|
+
<p>Click the button below to login with your Eagle Eye Networks account.</p>
|
|
14
|
+
<button data-testid="login-button" @click="login">Login with Eagle Eye Networks</button>
|
|
15
|
+
</div>
|
|
16
|
+
</template>
|
|
17
|
+
|
|
18
|
+
<style scoped>
|
|
19
|
+
.login {
|
|
20
|
+
text-align: center;
|
|
21
|
+
max-width: 400px;
|
|
22
|
+
margin: 0 auto;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
h2 {
|
|
26
|
+
margin-bottom: 20px;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
p {
|
|
30
|
+
margin-bottom: 20px;
|
|
31
|
+
color: #666;
|
|
32
|
+
}
|
|
33
|
+
</style>
|