290 lines
11 KiB
Vue
290 lines
11 KiB
Vue
<script setup lang="ts">
|
|
import {
|
|
Card, CardContent, CardFooter, CardHeader, CardTitle,
|
|
} from '@/components/ui/card'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Label } from '@/components/ui/label'
|
|
import { Button } from '@/components/ui/button'
|
|
import {
|
|
NumberField,
|
|
NumberFieldContent,
|
|
NumberFieldDecrement,
|
|
NumberFieldIncrement,
|
|
NumberFieldInput,
|
|
} from '@/components/ui/number-field'
|
|
|
|
import { h, ref, watch, computed, type PropType } from 'vue'
|
|
import { api } from '@/lib/api'
|
|
|
|
// Table bits for tasks
|
|
import type { Task, TaskDto, TaskListResp } from '@/lib/interfaces'
|
|
import type { ColumnDef } from '@tanstack/vue-table'
|
|
import DataTableNoCheckboxScroll from './DataTableNoCheckboxScroll.vue'
|
|
|
|
const props = defineProps({
|
|
guid: { type: String as PropType<string>, required: true },
|
|
})
|
|
|
|
/** -------------------- Config (left card) -------------------- **/
|
|
type DeviceConfigDto = {
|
|
m_guid: string
|
|
m_recordingDuration: number
|
|
m_baseUrl: string
|
|
m_polling: number
|
|
m_jitter: number
|
|
}
|
|
|
|
const cfgLoading = ref(false)
|
|
const cfgError = ref<string | null>(null)
|
|
const savingCfg = ref(false)
|
|
|
|
const baseUrl = ref<string>('')
|
|
const duration = ref<number>(240)
|
|
const polling = ref<number>(60)
|
|
const jitter = ref<number>(10)
|
|
|
|
async function loadConfig() {
|
|
if (!props.guid) return
|
|
cfgLoading.value = true
|
|
cfgError.value = null
|
|
try {
|
|
const { data } = await api.get<DeviceConfigDto>(`/device/${encodeURIComponent(props.guid)}/config`)
|
|
// map DTO → UI state
|
|
baseUrl.value = data.m_baseUrl ?? ''
|
|
duration.value = Number.isFinite(data.m_recordingDuration) ? data.m_recordingDuration : 240
|
|
polling.value = Number.isFinite(data.m_polling) ? data.m_polling : 60
|
|
jitter.value = Number.isFinite(data.m_jitter) ? data.m_jitter : 10
|
|
} catch (e: any) {
|
|
console.error(e)
|
|
cfgError.value = e?.message ?? 'Failed to load config'
|
|
} finally {
|
|
cfgLoading.value = false
|
|
}
|
|
}
|
|
|
|
async function saveUpdateConfigTask() {
|
|
if (!props.guid) return
|
|
savingCfg.value = true
|
|
cfgError.value = null
|
|
|
|
// Build config DTO exactly as backend expects
|
|
const cfgBody = {
|
|
m_recordingDuration: Number(duration.value),
|
|
m_baseUrl: String(baseUrl.value || ''),
|
|
m_polling: Number(polling.value), // <-- FIXED (was duration)
|
|
m_jitter: Number(jitter.value),
|
|
}
|
|
|
|
try {
|
|
// 1) Update DB config first (send as object; Axios sets JSON header)
|
|
await api.put(`/device/${encodeURIComponent(props.guid)}/config`, cfgBody)
|
|
|
|
// 2) Push live config to device via task (matches C++ handler: duration/sleep/jitter/endpoint)
|
|
const taskPayload = {
|
|
duration: Number(duration.value),
|
|
sleep: Number(polling.value),
|
|
jitter: Number(jitter.value),
|
|
endpoint: String(baseUrl.value || ''),
|
|
}
|
|
|
|
const dto: Task = {
|
|
type: 'update_config',
|
|
payload: JSON.stringify(taskPayload),
|
|
}
|
|
|
|
await api.post(`/device/${encodeURIComponent(props.guid)}/task`, dto)
|
|
|
|
// 3) Re-load from server to reflect DB state
|
|
await loadConfig()
|
|
} catch (e: any) {
|
|
console.error(e)
|
|
cfgError.value = e?.response?.data?.message ?? 'Failed to update config'
|
|
} finally {
|
|
savingCfg.value = false
|
|
}
|
|
}
|
|
|
|
/** Optional quick actions */
|
|
const sending = ref(false)
|
|
async function startRecording() {
|
|
if (!props.guid) return
|
|
const dto: Task = { type: 'start_recording', payload: '' }
|
|
try {
|
|
sending.value = true
|
|
await api.post(`/device/${encodeURIComponent(props.guid)}/task`, dto)
|
|
} finally {
|
|
sending.value = false
|
|
}
|
|
}
|
|
async function stopRecording() {
|
|
if (!props.guid) return
|
|
const dto: Task = { type: 'stop_recording', payload: '' }
|
|
try {
|
|
sending.value = true
|
|
await api.post(`/device/${encodeURIComponent(props.guid)}/task`, dto)
|
|
} finally {
|
|
sending.value = false
|
|
}
|
|
}
|
|
|
|
/** -------------------- Tasks (right card) -------------------- **/
|
|
const tasks = ref<TaskDto[]>([])
|
|
const tasksLoading = ref(false)
|
|
const tasksError = ref<string | null>(null)
|
|
|
|
async function fetchTasks() {
|
|
if (!props.guid) return
|
|
tasksLoading.value = true
|
|
tasksError.value = null
|
|
try {
|
|
const { data } = await api.get<TaskListResp>(`/device/${encodeURIComponent(props.guid)}/tasks`)
|
|
tasks.value = Array.isArray(data?.tasks) ? data.tasks : []
|
|
} catch (e: any) {
|
|
console.error(e)
|
|
tasksError.value = e?.message ?? 'Failed to load tasks'
|
|
tasks.value = []
|
|
} finally {
|
|
tasksLoading.value = false
|
|
}
|
|
}
|
|
|
|
function fmt(ts?: string | null) {
|
|
if (!ts) return ''
|
|
try { return new Date(ts).toLocaleString() } catch { return ts ?? '' }
|
|
}
|
|
|
|
const task_columns: ColumnDef<TaskDto, any>[] = [
|
|
{ accessorKey: 'id', header: 'ID' },
|
|
{ accessorKey: 'type', header: 'Task' },
|
|
{
|
|
accessorKey: 'status',
|
|
header: 'Status',
|
|
cell: ({ row }) => {
|
|
const s = row.original.status
|
|
const cls =
|
|
s === 'finished' ? 'px-2 py-0.5 rounded text-xs text-green-700 bg-green-100'
|
|
: s === 'running' ? 'px-2 py-0.5 rounded text-xs text-blue-700 bg-blue-100'
|
|
: s === 'error' ? 'px-2 py-0.5 rounded text-xs text-red-700 bg-red-100'
|
|
: 'px-2 py-0.5 rounded text-xs text-amber-700 bg-amber-100'
|
|
return h('span', { class: cls }, s)
|
|
},
|
|
},
|
|
{ accessorKey: 'createdAt', header: 'Created', cell: ({ row }) => fmt(row.original.createdAt) },
|
|
{ accessorKey: 'startedAt', header: 'Started', cell: ({ row }) => fmt(row.original.startedAt) },
|
|
{ accessorKey: 'finishedAt', header: 'Finished', cell: ({ row }) => fmt(row.original.finishedAt) },
|
|
]
|
|
|
|
/** -------------------- lifecycle -------------------- **/
|
|
watch(() => props.guid, (g) => {
|
|
if (g) {
|
|
loadConfig()
|
|
fetchTasks()
|
|
}
|
|
}, { immediate: true })
|
|
</script>
|
|
|
|
<template>
|
|
<!-- Two columns, full available height -->
|
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 h-[calc(100vh-6rem)]">
|
|
<!-- LEFT: Config card -->
|
|
<Card class="h-full flex flex-col">
|
|
<CardHeader>
|
|
<div class="flex items-center justify-between">
|
|
<CardTitle>Device {{ props.guid }}</CardTitle>
|
|
<div class="flex gap-2">
|
|
<Button variant="outline" :disabled="cfgLoading" @click="loadConfig">
|
|
{{ cfgLoading ? 'Loading…' : 'Refresh config' }}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
|
|
<CardContent class="flex-1 grid gap-5">
|
|
<div class="flex gap-2">
|
|
<Button :disabled="sending" @click="startRecording">
|
|
{{ sending ? 'Starting…' : 'Start recording' }}
|
|
</Button>
|
|
<Button :disabled="sending" @click="stopRecording">
|
|
{{ sending ? 'Stopping…' : 'Stop recording' }}
|
|
</Button>
|
|
</div>
|
|
|
|
<div v-if="cfgError" class="text-sm text-red-600">{{ cfgError }}</div>
|
|
|
|
<!-- Base URL -->
|
|
<div class="grid grid-cols-4 items-center gap-4">
|
|
<Label for="baseurl" class="text-right">Base URL</Label>
|
|
<Input id="baseurl" class="col-span-3 w-full" placeholder="https://host" v-model="baseUrl"
|
|
:disabled="cfgLoading || savingCfg" />
|
|
</div>
|
|
|
|
<!-- Row 1: duration (full width) -->
|
|
<div class="space-y-2">
|
|
<NumberField id="duration" v-model="duration" :min="30" :step="1">
|
|
<Label for="duration">Record duration (sec)</Label>
|
|
<NumberFieldContent>
|
|
<NumberFieldDecrement />
|
|
<NumberFieldInput />
|
|
<NumberFieldIncrement />
|
|
</NumberFieldContent>
|
|
</NumberField>
|
|
</div>
|
|
|
|
<!-- Row 2: polling + jitter (two columns) -->
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div class="space-y-2">
|
|
<NumberField id="polling" v-model="polling" :min="30" :step="1">
|
|
<Label for="polling">Timeout interval (sec)</Label>
|
|
<NumberFieldContent>
|
|
<NumberFieldDecrement />
|
|
<NumberFieldInput />
|
|
<NumberFieldIncrement />
|
|
</NumberFieldContent>
|
|
</NumberField>
|
|
</div>
|
|
|
|
<div class="space-y-2">
|
|
<NumberField id="jitter" v-model="jitter" :min="5" :step="1">
|
|
<Label for="jitter">Jitter (sec)</Label>
|
|
<NumberFieldContent>
|
|
<NumberFieldDecrement />
|
|
<NumberFieldInput />
|
|
<NumberFieldIncrement />
|
|
</NumberFieldContent>
|
|
</NumberField>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
|
|
<CardFooter class="flex gap-2">
|
|
<Button :disabled="savingCfg || cfgLoading" @click="saveUpdateConfigTask">
|
|
{{ savingCfg ? 'Saving…' : 'Update config (task)' }}
|
|
</Button>
|
|
</CardFooter>
|
|
</Card>
|
|
|
|
<!-- RIGHT: Tasks card -->
|
|
<Card class="h-full flex flex-col">
|
|
<CardHeader class="pb-0">
|
|
<div class="flex items-center justify-between">
|
|
<CardTitle>Tasks</CardTitle>
|
|
<Button variant="outline" :disabled="tasksLoading || !props.guid" @click="fetchTasks">
|
|
{{ tasksLoading ? 'Loading…' : 'Refresh' }}
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
|
|
<CardContent class="flex-1 pt-4">
|
|
<div v-if="tasksError" class="text-sm text-red-600 mb-3">{{ tasksError }}</div>
|
|
|
|
<div v-if="tasksLoading" class="text-sm text-muted-foreground py-8 text-center">
|
|
Loading tasks…
|
|
</div>
|
|
<div v-else class="h-full">
|
|
<DataTableNoCheckboxScroll :columns="task_columns" :data="tasks" minTableWidth="min-w-[800px]" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</template>
|