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,1084 @@
|
|
|
1
|
+
# Jobs, Exports, Files & Downloads - EEN API Toolkit
|
|
2
|
+
|
|
3
|
+
> **Version:** 0.3.49
|
|
4
|
+
>
|
|
5
|
+
> Complete reference for async jobs, video exports, and file management.
|
|
6
|
+
> Load this document when implementing export workflows or file downloads.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Overview
|
|
11
|
+
|
|
12
|
+
| Concept | Description |
|
|
13
|
+
|---------|-------------|
|
|
14
|
+
| **Export Jobs** | Create video/timelapse exports from camera recordings |
|
|
15
|
+
| **Jobs** | Track async job progress (pending, started, success, failure) |
|
|
16
|
+
| **Files** | Access files created by completed export jobs |
|
|
17
|
+
| **Downloads** | Access downloadable content |
|
|
18
|
+
|
|
19
|
+
### Export Workflow
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────────────────┐
|
|
23
|
+
│ createExportJob │────▶│ Poll with │────▶│ downloadFile │
|
|
24
|
+
│ (camera, time) │ │ getJob() │ │ (extract fileId from URL) │
|
|
25
|
+
└─────────────────┘ └─────────────────┘ └─────────────────────────────┘
|
|
26
|
+
│
|
|
27
|
+
▼
|
|
28
|
+
Job States:
|
|
29
|
+
pending → started → success/failure
|
|
30
|
+
|
|
31
|
+
When job.state === 'success':
|
|
32
|
+
File URL at: job.result?.intervals?.[0]?.files?.[0]?.url
|
|
33
|
+
Extract fileId from URL (last path segment)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Job Types
|
|
39
|
+
|
|
40
|
+
```typescript
|
|
41
|
+
type JobState = 'pending' | 'started' | 'success' | 'failure' | 'revoked'
|
|
42
|
+
|
|
43
|
+
// Nested structures for Job response
|
|
44
|
+
interface JobResultFile {
|
|
45
|
+
name: string
|
|
46
|
+
path?: string
|
|
47
|
+
size?: number
|
|
48
|
+
startTimestamp?: string
|
|
49
|
+
endTimestamp?: string
|
|
50
|
+
url?: string // Download URL - extract fileId from last segment
|
|
51
|
+
checksum?: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface JobResultInterval {
|
|
55
|
+
startTimestamp?: string
|
|
56
|
+
endTimestamp?: string
|
|
57
|
+
state?: string
|
|
58
|
+
files?: JobResultFile[]
|
|
59
|
+
error?: string | null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface JobResult {
|
|
63
|
+
state?: string
|
|
64
|
+
error?: string | null
|
|
65
|
+
intervals?: JobResultInterval[]
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface JobOriginalRequest {
|
|
69
|
+
type?: string
|
|
70
|
+
name?: string // Job display name set at creation
|
|
71
|
+
directory?: string
|
|
72
|
+
startTimestamp?: string
|
|
73
|
+
endTimestamp?: string
|
|
74
|
+
notes?: string | null
|
|
75
|
+
tags?: string[] | null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface JobArguments {
|
|
79
|
+
deviceId?: string
|
|
80
|
+
originalRequest?: JobOriginalRequest
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface Job {
|
|
84
|
+
id: string
|
|
85
|
+
namespace?: string
|
|
86
|
+
type: string // 'video' | 'timeLapse' | 'bundle'
|
|
87
|
+
userId: string
|
|
88
|
+
state: JobState
|
|
89
|
+
detailedState?: string | null
|
|
90
|
+
progress?: number // 0-1 float (multiply by 100 for percentage)
|
|
91
|
+
error?: string | null // Set when job fails
|
|
92
|
+
arguments?: JobArguments // Contains originalRequest with name
|
|
93
|
+
result?: JobResult // Contains intervals with file URLs
|
|
94
|
+
createTimestamp: string
|
|
95
|
+
updateTimestamp?: string
|
|
96
|
+
expireTimestamp?: string
|
|
97
|
+
scheduleTimestamp?: string | null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Access nested fields:
|
|
101
|
+
// - Job name: job.arguments?.originalRequest?.name
|
|
102
|
+
// - File URL: job.result?.intervals?.[0]?.files?.[0]?.url
|
|
103
|
+
// - Request timestamps: job.arguments?.originalRequest?.startTimestamp
|
|
104
|
+
|
|
105
|
+
interface ListJobsParams {
|
|
106
|
+
pageSize?: number
|
|
107
|
+
pageToken?: string
|
|
108
|
+
state__in?: JobState[] // Filter by state
|
|
109
|
+
type__in?: string[] // Filter by job type
|
|
110
|
+
userId?: string // Filter by user
|
|
111
|
+
createTimestamp__gte?: string // Filter by creation time
|
|
112
|
+
createTimestamp__lte?: string
|
|
113
|
+
sort?: string[] // Sort fields
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
interface GetJobParams {
|
|
117
|
+
include?: string[]
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## Export Types
|
|
124
|
+
|
|
125
|
+
```typescript
|
|
126
|
+
type ExportType = 'bundle' | 'timeLapse' | 'video'
|
|
127
|
+
|
|
128
|
+
interface CreateExportParams {
|
|
129
|
+
type: ExportType // Required
|
|
130
|
+
cameraId: string // Required
|
|
131
|
+
startTimestamp: string // Required: ISO 8601 with +00:00 timezone
|
|
132
|
+
endTimestamp: string // Required: ISO 8601 with +00:00 timezone
|
|
133
|
+
name?: string // Optional display name
|
|
134
|
+
playbackMultiplier?: number // Required for timeLapse/bundle (1-48)
|
|
135
|
+
autoDelete?: boolean // Auto-delete after 2 weeks (default: false)
|
|
136
|
+
directory?: string // Archive directory (default: '/')
|
|
137
|
+
notes?: string // Optional notes
|
|
138
|
+
tags?: string[] // Optional tags
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
interface ExportJobResponse {
|
|
142
|
+
id: string
|
|
143
|
+
type: ExportType
|
|
144
|
+
name?: string
|
|
145
|
+
state: JobState
|
|
146
|
+
createTimestamp: string
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## File Types
|
|
153
|
+
|
|
154
|
+
```typescript
|
|
155
|
+
type FileType = 'video' | 'image' | 'bundle' | 'timeLapse' | 'other'
|
|
156
|
+
|
|
157
|
+
interface EenFile {
|
|
158
|
+
id: string
|
|
159
|
+
name: string
|
|
160
|
+
type?: FileType
|
|
161
|
+
sizeBytes?: number
|
|
162
|
+
contentType?: string
|
|
163
|
+
createTimestamp: string
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
interface ListFilesParams {
|
|
167
|
+
pageSize?: number
|
|
168
|
+
pageToken?: string
|
|
169
|
+
type__in?: FileType[]
|
|
170
|
+
name__contains?: string
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
interface DownloadFileResult {
|
|
174
|
+
blob: Blob // Binary file data
|
|
175
|
+
filename: string // Parsed from Content-Disposition
|
|
176
|
+
contentType: string // MIME type
|
|
177
|
+
size: number // File size in bytes
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
## Download Types
|
|
184
|
+
|
|
185
|
+
```typescript
|
|
186
|
+
type DownloadStatus = 'available' | 'expired' | 'pending' | 'error'
|
|
187
|
+
|
|
188
|
+
interface Download {
|
|
189
|
+
id: string
|
|
190
|
+
name?: string
|
|
191
|
+
status: DownloadStatus
|
|
192
|
+
sizeBytes?: number
|
|
193
|
+
contentType?: string
|
|
194
|
+
downloadUrl?: string
|
|
195
|
+
expiresAt?: string
|
|
196
|
+
createTimestamp: string
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
interface ListDownloadsParams {
|
|
200
|
+
pageSize?: number
|
|
201
|
+
pageToken?: string
|
|
202
|
+
status__in?: DownloadStatus[]
|
|
203
|
+
name__contains?: string
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
interface DownloadDownloadResult {
|
|
207
|
+
blob: Blob
|
|
208
|
+
filename: string
|
|
209
|
+
contentType: string
|
|
210
|
+
size: number
|
|
211
|
+
}
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
## Export Functions
|
|
217
|
+
|
|
218
|
+
### createExportJob(params)
|
|
219
|
+
|
|
220
|
+
Create a video export from a camera:
|
|
221
|
+
|
|
222
|
+
```typescript
|
|
223
|
+
import { createExportJob, formatTimestamp, type ExportType } from 'een-api-toolkit'
|
|
224
|
+
|
|
225
|
+
async function createExport(cameraId: string, durationMinutes: number = 15) {
|
|
226
|
+
const endTime = new Date()
|
|
227
|
+
const startTime = new Date(endTime.getTime() - durationMinutes * 60 * 1000)
|
|
228
|
+
|
|
229
|
+
const { data, error } = await createExportJob({
|
|
230
|
+
name: `Export - ${new Date().toLocaleString()}`,
|
|
231
|
+
type: 'video',
|
|
232
|
+
cameraId,
|
|
233
|
+
startTimestamp: formatTimestamp(startTime.toISOString()),
|
|
234
|
+
endTimestamp: formatTimestamp(endTime.toISOString())
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
if (error) {
|
|
238
|
+
console.error('Failed to create export:', error.message)
|
|
239
|
+
return null
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
console.log('Export job created:', data.id)
|
|
243
|
+
return data
|
|
244
|
+
}
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
---
|
|
248
|
+
|
|
249
|
+
## Job Functions
|
|
250
|
+
|
|
251
|
+
### listJobs(params?)
|
|
252
|
+
|
|
253
|
+
List jobs with optional state filtering:
|
|
254
|
+
|
|
255
|
+
```typescript
|
|
256
|
+
import { listJobs, type Job, type JobState } from 'een-api-toolkit'
|
|
257
|
+
|
|
258
|
+
const { data, error } = await listJobs({
|
|
259
|
+
pageSize: 20,
|
|
260
|
+
state__in: ['pending', 'started', 'success']
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
if (data) {
|
|
264
|
+
data.results.forEach(job => {
|
|
265
|
+
const progressPercent = Math.round((job.progress || 0) * 100)
|
|
266
|
+
console.log(`${job.name}: ${job.state} (${progressPercent}%)`)
|
|
267
|
+
})
|
|
268
|
+
}
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
### getJob(jobId, params?)
|
|
272
|
+
|
|
273
|
+
Get a single job with polling pattern:
|
|
274
|
+
|
|
275
|
+
```typescript
|
|
276
|
+
import { ref, onUnmounted } from 'vue'
|
|
277
|
+
import { getJob, type Job } from 'een-api-toolkit'
|
|
278
|
+
|
|
279
|
+
const job = ref<Job | null>(null)
|
|
280
|
+
let pollInterval: ReturnType<typeof setInterval> | null = null
|
|
281
|
+
|
|
282
|
+
async function fetchJob(jobId: string) {
|
|
283
|
+
const { data, error } = await getJob(jobId)
|
|
284
|
+
|
|
285
|
+
if (error) {
|
|
286
|
+
stopPolling()
|
|
287
|
+
return
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
job.value = data
|
|
291
|
+
|
|
292
|
+
// Auto-manage polling based on job state
|
|
293
|
+
if (['pending', 'started'].includes(data.state)) {
|
|
294
|
+
startPolling(jobId)
|
|
295
|
+
} else {
|
|
296
|
+
stopPolling()
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function startPolling(jobId: string) {
|
|
301
|
+
if (pollInterval) return
|
|
302
|
+
pollInterval = setInterval(() => fetchJob(jobId), 3000)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function stopPolling() {
|
|
306
|
+
if (pollInterval) {
|
|
307
|
+
clearInterval(pollInterval)
|
|
308
|
+
pollInterval = null
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
onUnmounted(() => stopPolling())
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
---
|
|
316
|
+
|
|
317
|
+
## File Functions
|
|
318
|
+
|
|
319
|
+
### listFiles(params?)
|
|
320
|
+
|
|
321
|
+
List available files:
|
|
322
|
+
|
|
323
|
+
```typescript
|
|
324
|
+
import { listFiles, type EenFile } from 'een-api-toolkit'
|
|
325
|
+
|
|
326
|
+
const { data, error } = await listFiles({
|
|
327
|
+
pageSize: 20
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
if (data) {
|
|
331
|
+
data.results.forEach(file => {
|
|
332
|
+
console.log(`${file.name} (${file.sizeBytes} bytes)`)
|
|
333
|
+
})
|
|
334
|
+
}
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
### downloadFile(fileId)
|
|
338
|
+
|
|
339
|
+
Download a file by ID:
|
|
340
|
+
|
|
341
|
+
```typescript
|
|
342
|
+
import { downloadFile, type EenFile } from 'een-api-toolkit'
|
|
343
|
+
|
|
344
|
+
async function handleDownload(file: EenFile) {
|
|
345
|
+
const { data, error } = await downloadFile(file.id)
|
|
346
|
+
|
|
347
|
+
if (error) {
|
|
348
|
+
console.error('Download failed:', error.message)
|
|
349
|
+
return
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Create browser download
|
|
353
|
+
const url = URL.createObjectURL(data.blob)
|
|
354
|
+
const a = document.createElement('a')
|
|
355
|
+
a.href = url
|
|
356
|
+
a.download = data.filename || file.name
|
|
357
|
+
document.body.appendChild(a)
|
|
358
|
+
a.click()
|
|
359
|
+
document.body.removeChild(a)
|
|
360
|
+
URL.revokeObjectURL(url)
|
|
361
|
+
}
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
---
|
|
365
|
+
|
|
366
|
+
## Download Functions
|
|
367
|
+
|
|
368
|
+
### listDownloads(params?)
|
|
369
|
+
|
|
370
|
+
List available downloads:
|
|
371
|
+
|
|
372
|
+
```typescript
|
|
373
|
+
import { listDownloads, type Download } from 'een-api-toolkit'
|
|
374
|
+
|
|
375
|
+
const { data, error } = await listDownloads({
|
|
376
|
+
status__in: ['available'],
|
|
377
|
+
pageSize: 20
|
|
378
|
+
})
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
### downloadDownload(downloadId)
|
|
382
|
+
|
|
383
|
+
Download from downloads endpoint:
|
|
384
|
+
|
|
385
|
+
```typescript
|
|
386
|
+
import { downloadDownload, type Download } from 'een-api-toolkit'
|
|
387
|
+
|
|
388
|
+
async function handleDownload(download: Download) {
|
|
389
|
+
const { data, error } = await downloadDownload(download.id)
|
|
390
|
+
|
|
391
|
+
if (error) {
|
|
392
|
+
console.error('Download failed:', error.message)
|
|
393
|
+
return
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const url = URL.createObjectURL(data.blob)
|
|
397
|
+
const a = document.createElement('a')
|
|
398
|
+
a.href = url
|
|
399
|
+
a.download = data.filename || download.name || 'download'
|
|
400
|
+
a.click()
|
|
401
|
+
URL.revokeObjectURL(url)
|
|
402
|
+
}
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
---
|
|
406
|
+
|
|
407
|
+
## Complete Export Workflow
|
|
408
|
+
|
|
409
|
+
```typescript
|
|
410
|
+
import { ref, onUnmounted } from 'vue'
|
|
411
|
+
import {
|
|
412
|
+
createExportJob,
|
|
413
|
+
getJob,
|
|
414
|
+
downloadFile,
|
|
415
|
+
formatTimestamp,
|
|
416
|
+
type Job
|
|
417
|
+
} from 'een-api-toolkit'
|
|
418
|
+
|
|
419
|
+
const job = ref<Job | null>(null)
|
|
420
|
+
const error = ref<string | null>(null)
|
|
421
|
+
let pollInterval: ReturnType<typeof setInterval> | null = null
|
|
422
|
+
|
|
423
|
+
async function startExport(cameraId: string, durationMinutes: number) {
|
|
424
|
+
const endTime = new Date()
|
|
425
|
+
const startTime = new Date(endTime.getTime() - durationMinutes * 60 * 1000)
|
|
426
|
+
|
|
427
|
+
const result = await createExportJob({
|
|
428
|
+
type: 'video',
|
|
429
|
+
cameraId,
|
|
430
|
+
startTimestamp: formatTimestamp(startTime.toISOString()),
|
|
431
|
+
endTimestamp: formatTimestamp(endTime.toISOString())
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
if (result.error) {
|
|
435
|
+
error.value = result.error.message
|
|
436
|
+
return
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
job.value = result.data
|
|
440
|
+
pollInterval = setInterval(() => pollJob(result.data.id), 3000)
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async function pollJob(jobId: string) {
|
|
444
|
+
const result = await getJob(jobId)
|
|
445
|
+
if (result.error) {
|
|
446
|
+
error.value = result.error.message
|
|
447
|
+
stopPolling()
|
|
448
|
+
return
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
job.value = result.data
|
|
452
|
+
|
|
453
|
+
if (!['pending', 'started'].includes(result.data.state)) {
|
|
454
|
+
stopPolling()
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function stopPolling() {
|
|
459
|
+
if (pollInterval) {
|
|
460
|
+
clearInterval(pollInterval)
|
|
461
|
+
pollInterval = null
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
async function downloadExport() {
|
|
466
|
+
// Extract file URL from job result
|
|
467
|
+
const fileUrl = job.value?.result?.intervals?.[0]?.files?.[0]?.url
|
|
468
|
+
if (!fileUrl) return
|
|
469
|
+
|
|
470
|
+
// Extract fileId from URL (last path segment)
|
|
471
|
+
const fileId = fileUrl.substring(fileUrl.lastIndexOf('/') + 1)
|
|
472
|
+
if (!fileId) return
|
|
473
|
+
|
|
474
|
+
const result = await downloadFile(fileId)
|
|
475
|
+
if (result.error) {
|
|
476
|
+
error.value = result.error.message
|
|
477
|
+
return
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const url = URL.createObjectURL(result.data.blob)
|
|
481
|
+
const a = document.createElement('a')
|
|
482
|
+
a.href = url
|
|
483
|
+
a.download = result.data.filename || `export-${job.value?.id}.mp4`
|
|
484
|
+
document.body.appendChild(a)
|
|
485
|
+
a.click()
|
|
486
|
+
document.body.removeChild(a)
|
|
487
|
+
URL.revokeObjectURL(url)
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
onUnmounted(() => stopPolling())
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
---
|
|
494
|
+
|
|
495
|
+
## Vue Component Examples
|
|
496
|
+
|
|
497
|
+
### Jobs.vue
|
|
498
|
+
|
|
499
|
+
```typescript
|
|
500
|
+
import { ref, computed, onMounted } from 'vue'
|
|
501
|
+
import { listJobs, getCurrentUser, deleteJob, type Job, type EenError, type ListJobsParams, type JobState } from 'een-api-toolkit'
|
|
502
|
+
import { useRouter } from 'vue-router'
|
|
503
|
+
|
|
504
|
+
const router = useRouter()
|
|
505
|
+
|
|
506
|
+
// Reactive state
|
|
507
|
+
const jobs = ref<Job[]>([])
|
|
508
|
+
const loading = ref(false)
|
|
509
|
+
const error = ref<EenError | null>(null)
|
|
510
|
+
const nextPageToken = ref<string | undefined>(undefined)
|
|
511
|
+
const currentUserId = ref<string | null>(null)
|
|
512
|
+
const deletingId = ref<string | null>(null)
|
|
513
|
+
|
|
514
|
+
const hasNextPage = computed(() => !!nextPageToken.value)
|
|
515
|
+
|
|
516
|
+
// Filter state
|
|
517
|
+
const selectedStates = ref<JobState[]>([])
|
|
518
|
+
const stateOptions: { value: JobState; label: string }[] = [
|
|
519
|
+
{ value: 'pending', label: 'Pending' },
|
|
520
|
+
{ value: 'started', label: 'Started' },
|
|
521
|
+
{ value: 'success', label: 'Success' },
|
|
522
|
+
{ value: 'failure', label: 'Failure' },
|
|
523
|
+
{ value: 'revoked', label: 'Revoked' }
|
|
524
|
+
]
|
|
525
|
+
|
|
526
|
+
const params = ref<ListJobsParams>({ pageSize: 20 })
|
|
527
|
+
|
|
528
|
+
async function fetchJobs(fetchParams?: ListJobsParams, append = false) {
|
|
529
|
+
loading.value = true
|
|
530
|
+
error.value = null
|
|
531
|
+
|
|
532
|
+
// Get current user ID if not already fetched
|
|
533
|
+
if (!currentUserId.value) {
|
|
534
|
+
const userResult = await getCurrentUser()
|
|
535
|
+
if (userResult.error) {
|
|
536
|
+
error.value = userResult.error
|
|
537
|
+
loading.value = false
|
|
538
|
+
return { error: userResult.error, data: null }
|
|
539
|
+
}
|
|
540
|
+
currentUserId.value = userResult.data.id
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const mergedParams: ListJobsParams = { ...params.value, ...fetchParams, userId: currentUserId.value }
|
|
544
|
+
|
|
545
|
+
// Add state filter if selected
|
|
546
|
+
if (selectedStates.value.length > 0) {
|
|
547
|
+
mergedParams.state__in = selectedStates.value
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const result = await listJobs(mergedParams)
|
|
551
|
+
|
|
552
|
+
if (result.error) {
|
|
553
|
+
error.value = result.error
|
|
554
|
+
if (!append) {
|
|
555
|
+
jobs.value = []
|
|
556
|
+
}
|
|
557
|
+
nextPageToken.value = undefined
|
|
558
|
+
} else {
|
|
559
|
+
if (append) {
|
|
560
|
+
jobs.value = [...jobs.value, ...result.data.results]
|
|
561
|
+
} else {
|
|
562
|
+
jobs.value = result.data.results
|
|
563
|
+
}
|
|
564
|
+
nextPageToken.value = result.data.nextPageToken
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
loading.value = false
|
|
568
|
+
return result
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function refresh() {
|
|
572
|
+
return fetchJobs()
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
async function fetchNextPage() {
|
|
576
|
+
if (!nextPageToken.value) return
|
|
577
|
+
return fetchJobs({ ...params.value, pageToken: nextPageToken.value }, true)
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function viewJob(jobId: string) {
|
|
581
|
+
router.push(`/jobs/${jobId}`)
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
async function handleDelete(job: Job) {
|
|
585
|
+
const jobName = getJobName(job)
|
|
586
|
+
if (!confirm(`Are you sure you want to delete job "${jobName}"?`)) {
|
|
587
|
+
return
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
deletingId.value = job.id
|
|
591
|
+
const result = await deleteJob(job.id)
|
|
592
|
+
|
|
593
|
+
if (result.error) {
|
|
594
|
+
error.value = result.error
|
|
595
|
+
} else {
|
|
596
|
+
// Remove the job from the list
|
|
597
|
+
jobs.value = jobs.value.filter(j => j.id !== job.id)
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
deletingId.value = null
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function formatDate(timestamp: string) {
|
|
604
|
+
return new Date(timestamp).toLocaleString()
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function getStateBadgeClass(state: JobState) {
|
|
608
|
+
return `badge badge-${state}`
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function getJobName(job: Job) {
|
|
612
|
+
return job.arguments?.originalRequest?.name || job.id
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
onMounted(() => {
|
|
616
|
+
fetchJobs()
|
|
617
|
+
})
|
|
618
|
+
```
|
|
619
|
+
|
|
620
|
+
### JobDetail.vue
|
|
621
|
+
|
|
622
|
+
```typescript
|
|
623
|
+
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
|
624
|
+
import { useRoute, useRouter } from 'vue-router'
|
|
625
|
+
import { getJob, downloadFile, type Job, type EenError } from 'een-api-toolkit'
|
|
626
|
+
|
|
627
|
+
const route = useRoute()
|
|
628
|
+
const router = useRouter()
|
|
629
|
+
|
|
630
|
+
const job = ref<Job | null>(null)
|
|
631
|
+
const loading = ref(false)
|
|
632
|
+
const error = ref<EenError | null>(null)
|
|
633
|
+
const downloading = ref(false)
|
|
634
|
+
const videoUrl = ref<string | null>(null)
|
|
635
|
+
const loadingVideo = ref(false)
|
|
636
|
+
|
|
637
|
+
// Polling state
|
|
638
|
+
const isPolling = ref(false)
|
|
639
|
+
let pollInterval: ReturnType<typeof setInterval> | null = null
|
|
640
|
+
|
|
641
|
+
const jobId = computed(() => route.params.id as string)
|
|
642
|
+
|
|
643
|
+
// Extract name from nested structure
|
|
644
|
+
const jobName = computed(() => {
|
|
645
|
+
return job.value?.arguments?.originalRequest?.name || '-'
|
|
646
|
+
})
|
|
647
|
+
|
|
648
|
+
// Extract file URL from result (for download)
|
|
649
|
+
const fileUrl = computed(() => {
|
|
650
|
+
return job.value?.result?.intervals?.[0]?.files?.[0]?.url
|
|
651
|
+
})
|
|
652
|
+
|
|
653
|
+
// Extract request timestamps from arguments
|
|
654
|
+
const requestStartTimestamp = computed(() => {
|
|
655
|
+
return job.value?.arguments?.originalRequest?.startTimestamp
|
|
656
|
+
})
|
|
657
|
+
|
|
658
|
+
const requestEndTimestamp = computed(() => {
|
|
659
|
+
return job.value?.arguments?.originalRequest?.endTimestamp
|
|
660
|
+
})
|
|
661
|
+
|
|
662
|
+
const canDownload = computed(() => {
|
|
663
|
+
return job.value?.state === 'success' && fileUrl.value
|
|
664
|
+
})
|
|
665
|
+
|
|
666
|
+
const canPlay = computed(() => {
|
|
667
|
+
// Can play video exports
|
|
668
|
+
return canDownload.value && (job.value?.type === 'video' || job.value?.type === 'export')
|
|
669
|
+
})
|
|
670
|
+
|
|
671
|
+
async function fetchJob() {
|
|
672
|
+
loading.value = true
|
|
673
|
+
error.value = null
|
|
674
|
+
|
|
675
|
+
const result = await getJob(jobId.value)
|
|
676
|
+
|
|
677
|
+
if (result.error) {
|
|
678
|
+
error.value = result.error
|
|
679
|
+
job.value = null
|
|
680
|
+
stopPolling()
|
|
681
|
+
} else {
|
|
682
|
+
job.value = result.data
|
|
683
|
+
|
|
684
|
+
// Auto-start polling if job is pending or started
|
|
685
|
+
if (['pending', 'started'].includes(result.data.state) && !isPolling.value) {
|
|
686
|
+
startPolling()
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// Stop polling if job is complete
|
|
690
|
+
if (['success', 'failure', 'revoked'].includes(result.data.state)) {
|
|
691
|
+
stopPolling()
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
loading.value = false
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function startPolling() {
|
|
699
|
+
if (isPolling.value) return
|
|
700
|
+
isPolling.value = true
|
|
701
|
+
pollInterval = setInterval(fetchJob, 3000) // Poll every 3 seconds
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function stopPolling() {
|
|
705
|
+
if (pollInterval) {
|
|
706
|
+
clearInterval(pollInterval)
|
|
707
|
+
pollInterval = null
|
|
708
|
+
}
|
|
709
|
+
isPolling.value = false
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
async function handleDownload() {
|
|
713
|
+
const url = fileUrl.value
|
|
714
|
+
if (!url) return
|
|
715
|
+
|
|
716
|
+
// Extract file ID from URL (last path segment)
|
|
717
|
+
const urlParts = url.split('/')
|
|
718
|
+
const fileId = urlParts[urlParts.length - 1]
|
|
719
|
+
|
|
720
|
+
downloading.value = true
|
|
721
|
+
const result = await downloadFile(fileId)
|
|
722
|
+
|
|
723
|
+
if (result.error) {
|
|
724
|
+
error.value = result.error
|
|
725
|
+
} else {
|
|
726
|
+
// Create download link
|
|
727
|
+
const blobUrl = URL.createObjectURL(result.data.blob)
|
|
728
|
+
const a = document.createElement('a')
|
|
729
|
+
a.href = blobUrl
|
|
730
|
+
a.download = result.data.filename
|
|
731
|
+
a.click()
|
|
732
|
+
URL.revokeObjectURL(blobUrl)
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
downloading.value = false
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
async function handlePlay() {
|
|
739
|
+
const url = fileUrl.value
|
|
740
|
+
if (!url) return
|
|
741
|
+
|
|
742
|
+
// Extract file ID from URL (last path segment)
|
|
743
|
+
const urlParts = url.split('/')
|
|
744
|
+
const fileId = urlParts[urlParts.length - 1]
|
|
745
|
+
|
|
746
|
+
loadingVideo.value = true
|
|
747
|
+
error.value = null
|
|
748
|
+
|
|
749
|
+
const result = await downloadFile(fileId)
|
|
750
|
+
|
|
751
|
+
if (result.error) {
|
|
752
|
+
error.value = result.error
|
|
753
|
+
} else {
|
|
754
|
+
// Revoke previous URL if exists
|
|
755
|
+
if (videoUrl.value) {
|
|
756
|
+
URL.revokeObjectURL(videoUrl.value)
|
|
757
|
+
}
|
|
758
|
+
// Create blob URL for video playback
|
|
759
|
+
videoUrl.value = URL.createObjectURL(result.data.blob)
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
loadingVideo.value = false
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function closePlayer() {
|
|
766
|
+
if (videoUrl.value) {
|
|
767
|
+
URL.revokeObjectURL(videoUrl.value)
|
|
768
|
+
videoUrl.value = null
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function goBack() {
|
|
773
|
+
router.push('/jobs')
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function formatDate(timestamp: string | undefined) {
|
|
777
|
+
if (!timestamp) return '-'
|
|
778
|
+
return new Date(timestamp).toLocaleString()
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function getStateBadgeClass(state: string) {
|
|
782
|
+
return `badge badge-${state}`
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
onMounted(() => {
|
|
786
|
+
fetchJob()
|
|
787
|
+
})
|
|
788
|
+
|
|
789
|
+
onUnmounted(() => {
|
|
790
|
+
stopPolling()
|
|
791
|
+
if (videoUrl.value) {
|
|
792
|
+
URL.revokeObjectURL(videoUrl.value)
|
|
793
|
+
}
|
|
794
|
+
})
|
|
795
|
+
```
|
|
796
|
+
|
|
797
|
+
### CreateExport.vue
|
|
798
|
+
|
|
799
|
+
```typescript
|
|
800
|
+
import { ref, onMounted, computed } from 'vue'
|
|
801
|
+
import { useRouter } from 'vue-router'
|
|
802
|
+
import { getCameras, createExportJob, formatTimestamp, type Camera, type EenError, type ExportType } from 'een-api-toolkit'
|
|
803
|
+
|
|
804
|
+
const router = useRouter()
|
|
805
|
+
|
|
806
|
+
// Form state
|
|
807
|
+
const selectedCamera = ref<string>('')
|
|
808
|
+
const exportType = ref<ExportType>('video')
|
|
809
|
+
const exportName = ref('')
|
|
810
|
+
const duration = ref(15) // minutes
|
|
811
|
+
const playbackMultiplier = ref(10) // default multiplier for timeLapse/bundle
|
|
812
|
+
|
|
813
|
+
// Check if playbackMultiplier is needed
|
|
814
|
+
const needsPlaybackMultiplier = computed(() => {
|
|
815
|
+
return exportType.value === 'timeLapse' || exportType.value === 'bundle'
|
|
816
|
+
})
|
|
817
|
+
|
|
818
|
+
// Data state
|
|
819
|
+
const cameras = ref<Camera[]>([])
|
|
820
|
+
const loading = ref(false)
|
|
821
|
+
const submitting = ref(false)
|
|
822
|
+
const error = ref<EenError | null>(null)
|
|
823
|
+
const success = ref<string | null>(null)
|
|
824
|
+
|
|
825
|
+
const exportTypes: { value: ExportType; label: string }[] = [
|
|
826
|
+
{ value: 'video', label: 'Video' },
|
|
827
|
+
{ value: 'timeLapse', label: 'Time Lapse' },
|
|
828
|
+
{ value: 'bundle', label: 'Bundle (Video + Images)' }
|
|
829
|
+
]
|
|
830
|
+
|
|
831
|
+
const durationOptions = [
|
|
832
|
+
{ value: 5, label: '5 minutes' },
|
|
833
|
+
{ value: 15, label: '15 minutes' },
|
|
834
|
+
{ value: 30, label: '30 minutes' },
|
|
835
|
+
{ value: 60, label: '1 hour' }
|
|
836
|
+
]
|
|
837
|
+
|
|
838
|
+
const playbackMultiplierOptions = [
|
|
839
|
+
{ value: 2, label: '2x (30 min → 15 min)' },
|
|
840
|
+
{ value: 5, label: '5x (30 min → 6 min)' },
|
|
841
|
+
{ value: 10, label: '10x (30 min → 3 min)' },
|
|
842
|
+
{ value: 20, label: '20x (30 min → 1.5 min)' },
|
|
843
|
+
{ value: 48, label: '48x (30 min → 37 sec)' }
|
|
844
|
+
]
|
|
845
|
+
|
|
846
|
+
const canSubmit = computed(() => {
|
|
847
|
+
if (!selectedCamera.value || !exportType.value || submitting.value) {
|
|
848
|
+
return false
|
|
849
|
+
}
|
|
850
|
+
// Validate playbackMultiplier for timeLapse/bundle
|
|
851
|
+
if (needsPlaybackMultiplier.value) {
|
|
852
|
+
return playbackMultiplier.value >= 1 && playbackMultiplier.value <= 48
|
|
853
|
+
}
|
|
854
|
+
return true
|
|
855
|
+
})
|
|
856
|
+
|
|
857
|
+
async function fetchCameras() {
|
|
858
|
+
loading.value = true
|
|
859
|
+
error.value = null
|
|
860
|
+
|
|
861
|
+
const result = await getCameras({
|
|
862
|
+
pageSize: 100,
|
|
863
|
+
status__in: ['online', 'streaming']
|
|
864
|
+
})
|
|
865
|
+
|
|
866
|
+
if (result.error) {
|
|
867
|
+
error.value = result.error
|
|
868
|
+
} else {
|
|
869
|
+
cameras.value = result.data.results
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
loading.value = false
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
async function handleSubmit() {
|
|
876
|
+
if (!canSubmit.value) return
|
|
877
|
+
|
|
878
|
+
submitting.value = true
|
|
879
|
+
error.value = null
|
|
880
|
+
success.value = null
|
|
881
|
+
|
|
882
|
+
const endTime = new Date()
|
|
883
|
+
const startTime = new Date(endTime.getTime() - duration.value * 60 * 1000)
|
|
884
|
+
|
|
885
|
+
const exportParams: Parameters<typeof createExportJob>[0] = {
|
|
886
|
+
name: exportName.value || `Export - ${new Date().toLocaleString()}`,
|
|
887
|
+
type: exportType.value,
|
|
888
|
+
cameraId: selectedCamera.value,
|
|
889
|
+
startTimestamp: formatTimestamp(startTime.toISOString()),
|
|
890
|
+
endTimestamp: formatTimestamp(endTime.toISOString())
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// Add playbackMultiplier for timeLapse and bundle exports
|
|
894
|
+
if (needsPlaybackMultiplier.value) {
|
|
895
|
+
exportParams.playbackMultiplier = playbackMultiplier.value
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
const result = await createExportJob(exportParams)
|
|
899
|
+
|
|
900
|
+
if (result.error) {
|
|
901
|
+
error.value = result.error
|
|
902
|
+
} else {
|
|
903
|
+
success.value = `Export job created successfully! Job ID: ${result.data.id}`
|
|
904
|
+
// Redirect to job detail page after a moment
|
|
905
|
+
setTimeout(() => {
|
|
906
|
+
router.push(`/jobs/${result.data.id}`)
|
|
907
|
+
}, 2000)
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
submitting.value = false
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
onMounted(() => {
|
|
914
|
+
fetchCameras()
|
|
915
|
+
})
|
|
916
|
+
```
|
|
917
|
+
|
|
918
|
+
### Files.vue
|
|
919
|
+
|
|
920
|
+
```typescript
|
|
921
|
+
import { ref, computed, onMounted } from 'vue'
|
|
922
|
+
import { listFiles, downloadFile, deleteFile, type EenFile, type EenError, type ListFilesParams, type FileIncludeField } from 'een-api-toolkit'
|
|
923
|
+
|
|
924
|
+
// Reactive state
|
|
925
|
+
const files = ref<EenFile[]>([])
|
|
926
|
+
const loading = ref(false)
|
|
927
|
+
const error = ref<EenError | null>(null)
|
|
928
|
+
const nextPageToken = ref<string | undefined>(undefined)
|
|
929
|
+
const downloadingId = ref<string | null>(null)
|
|
930
|
+
const deletingId = ref<string | null>(null)
|
|
931
|
+
const selectedFile = ref<EenFile | null>(null)
|
|
932
|
+
|
|
933
|
+
const hasNextPage = computed(() => !!nextPageToken.value)
|
|
934
|
+
|
|
935
|
+
// Request size and createTimestamp by default for better display
|
|
936
|
+
const defaultInclude: FileIncludeField[] = ['size', 'createTimestamp']
|
|
937
|
+
|
|
938
|
+
const params = ref<ListFilesParams>({
|
|
939
|
+
pageSize: 20,
|
|
940
|
+
include: defaultInclude,
|
|
941
|
+
sort: ['-createTimestamp']
|
|
942
|
+
})
|
|
943
|
+
|
|
944
|
+
async function fetchFiles(fetchParams?: ListFilesParams, append = false) {
|
|
945
|
+
loading.value = true
|
|
946
|
+
error.value = null
|
|
947
|
+
|
|
948
|
+
const mergedParams = { ...params.value, ...fetchParams }
|
|
949
|
+
const result = await listFiles(mergedParams)
|
|
950
|
+
|
|
951
|
+
if (result.error) {
|
|
952
|
+
error.value = result.error
|
|
953
|
+
if (!append) {
|
|
954
|
+
files.value = []
|
|
955
|
+
}
|
|
956
|
+
nextPageToken.value = undefined
|
|
957
|
+
} else {
|
|
958
|
+
if (append) {
|
|
959
|
+
files.value = [...files.value, ...result.data.results]
|
|
960
|
+
} else {
|
|
961
|
+
files.value = result.data.results
|
|
962
|
+
}
|
|
963
|
+
nextPageToken.value = result.data.nextPageToken
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
loading.value = false
|
|
967
|
+
return result
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function refresh() {
|
|
971
|
+
return fetchFiles()
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
async function fetchNextPage() {
|
|
975
|
+
if (!nextPageToken.value) return
|
|
976
|
+
return fetchFiles({ ...params.value, pageToken: nextPageToken.value }, true)
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
async function handleDownload(file: EenFile) {
|
|
980
|
+
downloadingId.value = file.id
|
|
981
|
+
const result = await downloadFile(file.id)
|
|
982
|
+
|
|
983
|
+
if (result.error) {
|
|
984
|
+
error.value = result.error
|
|
985
|
+
} else {
|
|
986
|
+
// Create download link
|
|
987
|
+
const url = URL.createObjectURL(result.data.blob)
|
|
988
|
+
const a = document.createElement('a')
|
|
989
|
+
a.href = url
|
|
990
|
+
a.download = result.data.filename || file.name
|
|
991
|
+
a.click()
|
|
992
|
+
URL.revokeObjectURL(url)
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
downloadingId.value = null
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
async function handleDelete(file: EenFile) {
|
|
999
|
+
if (!confirm(`Are you sure you want to delete "${file.name}"? This will move it to the recycle bin.`)) {
|
|
1000
|
+
return
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
deletingId.value = file.id
|
|
1004
|
+
const result = await deleteFile(file.id)
|
|
1005
|
+
|
|
1006
|
+
if (result.error) {
|
|
1007
|
+
error.value = result.error
|
|
1008
|
+
} else {
|
|
1009
|
+
// Remove the file from the list
|
|
1010
|
+
files.value = files.value.filter(f => f.id !== file.id)
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
deletingId.value = null
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
function formatDate(timestamp: string | undefined) {
|
|
1017
|
+
if (!timestamp) return '-'
|
|
1018
|
+
return new Date(timestamp).toLocaleString()
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
function getFileType(file: EenFile) {
|
|
1022
|
+
// Use type if available, otherwise derive from mimeType
|
|
1023
|
+
if (file.type) return file.type
|
|
1024
|
+
if (file.mimeType) {
|
|
1025
|
+
if (file.mimeType.startsWith('video/')) return 'video'
|
|
1026
|
+
if (file.mimeType.startsWith('image/')) return 'image'
|
|
1027
|
+
if (file.mimeType === 'application/directory') return 'folder'
|
|
1028
|
+
return file.mimeType.split('/')[1] || file.mimeType
|
|
1029
|
+
}
|
|
1030
|
+
return '-'
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
function formatSize(bytes: number | undefined) {
|
|
1034
|
+
if (bytes === undefined || bytes === null) return '-'
|
|
1035
|
+
if (bytes === 0) return '0 B'
|
|
1036
|
+
if (bytes < 1024) return `${bytes} B`
|
|
1037
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
1038
|
+
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
1039
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
function openFileDetails(file: EenFile) {
|
|
1043
|
+
selectedFile.value = file
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
function closeModal() {
|
|
1047
|
+
selectedFile.value = null
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
onMounted(() => {
|
|
1051
|
+
fetchFiles()
|
|
1052
|
+
})
|
|
1053
|
+
```
|
|
1054
|
+
|
|
1055
|
+
---
|
|
1056
|
+
|
|
1057
|
+
## Error Handling
|
|
1058
|
+
|
|
1059
|
+
| Error Code | HTTP Status | Meaning | Action |
|
|
1060
|
+
|------------|-------------|---------|--------|
|
|
1061
|
+
| AUTH_REQUIRED | 401 | Not authenticated | Redirect to login |
|
|
1062
|
+
| CAMERA_NOT_FOUND | 404 | Invalid camera ID | Verify camera exists |
|
|
1063
|
+
| INVALID_TIMESTAMP | 400 | Bad timestamp format | Use formatTimestamp() |
|
|
1064
|
+
| JOB_NOT_FOUND | 404 | Job ID doesn't exist | Check job ID |
|
|
1065
|
+
| FILE_NOT_FOUND | 404 | File ID doesn't exist | Job may not be complete |
|
|
1066
|
+
| DOWNLOAD_EXPIRED | 410 | Download link expired | Request new download |
|
|
1067
|
+
| EXPORT_FAILED | 500 | Export processing failed | Check job.error |
|
|
1068
|
+
|
|
1069
|
+
---
|
|
1070
|
+
|
|
1071
|
+
## Best Practices
|
|
1072
|
+
|
|
1073
|
+
1. **Always use formatTimestamp()** for timestamp parameters
|
|
1074
|
+
2. **Poll at reasonable intervals** (3-5 seconds recommended)
|
|
1075
|
+
3. **Clean up polling** on component unmount
|
|
1076
|
+
4. **Check job.state === 'success'** before attempting download
|
|
1077
|
+
5. **Extract fileId from job.result URL** when calling downloadFile()
|
|
1078
|
+
6. **Handle large downloads** appropriately (show progress)
|
|
1079
|
+
|
|
1080
|
+
---
|
|
1081
|
+
|
|
1082
|
+
## Reference Examples
|
|
1083
|
+
|
|
1084
|
+
- `examples/vue-jobs/` - Complete jobs, exports, files example
|