created prototype of tasks for device and dialog, that shows all tasks for admin per each device
This commit is contained in:
@@ -74,15 +74,6 @@ function onSelect(ev: CustomEvent) {
|
||||
// When parent switches between external/internal data at runtime, refetch if needed.
|
||||
watch(() => props.allUsers, () => { if (!usingExternal.value) loadUsers() })
|
||||
|
||||
// Debug: whenever modelValue changes (from dialog)
|
||||
watch(() => props.modelValue, (v) => {
|
||||
console.log('[AssignDevice:props.modelValue]', v)
|
||||
}, { immediate: true })
|
||||
|
||||
// Debug: when effective users list is ready
|
||||
watch(() => effectiveUsers.value, (v) => {
|
||||
console.log('[AssignDevice:effectiveUsers]', v?.length, 'items')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
124
management-ui/src/customcompometns/DataTableNoCheckboxScroll.vue
Normal file
124
management-ui/src/customcompometns/DataTableNoCheckboxScroll.vue
Normal file
@@ -0,0 +1,124 @@
|
||||
<script setup lang="ts" generic="TData, TValue">
|
||||
import { defineProps } from 'vue'
|
||||
import type { DefineComponent } from 'vue'
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
import {
|
||||
useVueTable,
|
||||
getCoreRowModel,
|
||||
FlexRender,
|
||||
} from '@tanstack/vue-table'
|
||||
|
||||
import {
|
||||
Table, TableHeader, TableRow, TableHead, TableBody, TableCell,
|
||||
} from '@/components/ui/table'
|
||||
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area' // <-- add ScrollBar
|
||||
|
||||
const props = defineProps<{
|
||||
columns: ColumnDef<TData, TValue>[]
|
||||
data: TData[]
|
||||
dropdownComponent?: DefineComponent<{ row: TData }, any, any>
|
||||
dropdownProps?: Record<string, any>
|
||||
/** Optional tailwind class to control min table width for horizontal scrolling */
|
||||
minTableWidth?: string
|
||||
}>()
|
||||
|
||||
const table = useVueTable({
|
||||
get data() { return props.data },
|
||||
get columns() { return props.columns },
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'row-updated', row: TData, payload: any): void
|
||||
(e: 'row-deleted', row: TData): void
|
||||
}>()
|
||||
|
||||
const minWidthClass = props.minTableWidth ?? 'min-w-[1100px]' // tweak as needed
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full h-full border rounded-md flex flex-col">
|
||||
<!-- Both-direction scroll area -->
|
||||
<ScrollArea class="flex-1 w-full">
|
||||
<!-- The min-width container enables horizontal scroll on small displays -->
|
||||
<div :class="['w-full', minWidthClass]">
|
||||
<Table class="w-full">
|
||||
<!-- header -->
|
||||
<TableHeader>
|
||||
<TableRow
|
||||
v-for="headerGroup in table.getHeaderGroups()"
|
||||
:key="headerGroup.id"
|
||||
>
|
||||
<TableHead
|
||||
v-for="header in headerGroup.headers"
|
||||
:key="header.id"
|
||||
class="sticky top-0 bg-background z-10 whitespace-nowrap"
|
||||
>
|
||||
<FlexRender
|
||||
v-if="!header.isPlaceholder"
|
||||
:render="header.column.columnDef.header"
|
||||
:props="header.getContext()"
|
||||
/>
|
||||
</TableHead>
|
||||
|
||||
<!-- extra empty head for dropdown column -->
|
||||
<TableHead
|
||||
v-if="props.dropdownComponent"
|
||||
class="sticky top-0 bg-background z-10 w-12"
|
||||
/>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<!-- body -->
|
||||
<TableBody>
|
||||
<template v-if="table.getRowModel().rows.length">
|
||||
<TableRow
|
||||
v-for="row in table.getRowModel().rows"
|
||||
:key="row.id"
|
||||
class="whitespace-nowrap"
|
||||
>
|
||||
<!-- data cells -->
|
||||
<TableCell
|
||||
v-for="cell in row.getVisibleCells()"
|
||||
:key="cell.id"
|
||||
>
|
||||
<FlexRender
|
||||
:render="cell.column.columnDef.cell"
|
||||
:props="cell.getContext()"
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
<!-- dropdown cell -->
|
||||
<TableCell v-if="props.dropdownComponent" class="text-right">
|
||||
<component
|
||||
:is="props.dropdownComponent"
|
||||
:row="row.original"
|
||||
v-bind="props.dropdownProps"
|
||||
@updated="(payload: any) => emit('row-updated', row.original, payload)"
|
||||
@deleted="() => emit('row-deleted', row.original)"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
|
||||
<!-- no-data row -->
|
||||
<template v-else>
|
||||
<TableRow>
|
||||
<TableCell
|
||||
:colspan="props.columns.length + (props.dropdownComponent ? 1 : 0)"
|
||||
class="h-24 text-center"
|
||||
>
|
||||
No data.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<!-- Scrollbars -->
|
||||
<ScrollBar orientation="horizontal" />
|
||||
<ScrollBar orientation="vertical" />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</template>
|
||||
@@ -3,12 +3,50 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import DeviceDashboard from './DeviceDashboard.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import DeviceRecordings from './DeviceRecordings.vue';
|
||||
import { type PropType } from 'vue';
|
||||
import { ref, type PropType } from 'vue';
|
||||
import type { Task } from '@/lib/interfaces';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
const props = defineProps({
|
||||
guid: { type: String as PropType<string>, required: true },
|
||||
})
|
||||
const sending = ref(false)
|
||||
|
||||
async function startStreaming() {
|
||||
const dto: Task = {
|
||||
type: 'start_stream',
|
||||
payload: '' // empty as requested
|
||||
}
|
||||
// debug
|
||||
console.log('CreateTaskDto →', dto)
|
||||
|
||||
try {
|
||||
sending.value = true
|
||||
await api.post(`/device/${encodeURIComponent(props.guid)}/task`, dto)
|
||||
} catch (e) {
|
||||
console.error('Failed to create task:', e)
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function stopStreaming() {
|
||||
const dto: Task = {
|
||||
type: 'stop_stream',
|
||||
payload: '' // empty as requested
|
||||
}
|
||||
// debug
|
||||
console.log('CreateTaskDto →', dto)
|
||||
|
||||
try {
|
||||
sending.value = true
|
||||
await api.post(`/device/${encodeURIComponent(props.guid)}/task`, dto)
|
||||
} catch (e) {
|
||||
console.error('Failed to create task:', e)
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<Tabs default-value="records">
|
||||
@@ -24,18 +62,26 @@ const props = defineProps({
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="dashboard">
|
||||
<DeviceDashboard/>
|
||||
<DeviceDashboard :guid="guid" />
|
||||
</TabsContent>
|
||||
<TabsContent value="records">
|
||||
<DeviceRecordings :guid="guid"/>
|
||||
<DeviceRecordings :guid="guid" />
|
||||
</TabsContent>
|
||||
<TabsContent value="livestream">
|
||||
<Button>
|
||||
<!-- <Button>
|
||||
Start streaming
|
||||
</Button>
|
||||
<Button>
|
||||
Stop streaming
|
||||
</Button> -->
|
||||
<div class="flex space-x-4 pt-2 gap-4">
|
||||
<Button :disabled="sending" @click="startStreaming">
|
||||
{{ sending ? 'Starting…' : 'Start streaming' }}
|
||||
</Button>
|
||||
<Button :disabled="sending" @click="stopStreaming">
|
||||
{{ sending ? 'Stopping…' : 'Stop streaming' }}
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</template>
|
||||
@@ -18,11 +18,54 @@ import {
|
||||
NumberFieldInput,
|
||||
} from '@/components/ui/number-field'
|
||||
import AssignDevice from "./AssignDevice.vue";
|
||||
import { ref } from "vue";
|
||||
import { ref, type PropType } from "vue";
|
||||
import Separator from "@/components/ui/separator/Separator.vue";
|
||||
import DataRangePicker from "./DataRangePicker.vue";
|
||||
import { api } from "@/lib/api";
|
||||
import type { Task } from "@/lib/interfaces";
|
||||
const selectedUserIds = ref<string[]>([])
|
||||
const usrIDs = selectedUserIds.value
|
||||
|
||||
const props = defineProps({
|
||||
guid: { type: String as PropType<string>, required: true },
|
||||
})
|
||||
|
||||
const sending = ref(false)
|
||||
async function startRecording() {
|
||||
const dto: Task = {
|
||||
type: 'start_recording',
|
||||
payload: '' // empty as requested
|
||||
}
|
||||
// debug
|
||||
console.log('CreateTaskDto →', dto)
|
||||
|
||||
try {
|
||||
sending.value = true
|
||||
await api.post(`/device/${encodeURIComponent(props.guid)}/task`, dto)
|
||||
} catch (e) {
|
||||
console.error('Failed to create task:', e)
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function stopRecording() {
|
||||
const dto: Task = {
|
||||
type: 'stop_recording',
|
||||
payload: '' // empty as requested
|
||||
}
|
||||
// debug
|
||||
console.log('CreateTaskDto →', dto)
|
||||
|
||||
try {
|
||||
sending.value = true
|
||||
await api.post(`/device/${encodeURIComponent(props.guid)}/task`, dto)
|
||||
} catch (e) {
|
||||
console.error('Failed to create task:', e)
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -41,8 +84,12 @@ const usrIDs = selectedUserIds.value
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-4 pt-2 gap-4">
|
||||
<Button>Start recording</Button>
|
||||
<Button>Stop recording</Button>
|
||||
<Button :disabled="sending" @click="startRecording">
|
||||
{{ sending ? 'Starting…' : 'Start recording' }}
|
||||
</Button>
|
||||
<Button :disabled="sending" @click="stopRecording">
|
||||
{{ sending ? 'Stopping…' : 'Stop recording' }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="space-y-2 gap-4">
|
||||
<NumberField id="duration" :default-value="120" :min="30">
|
||||
|
||||
@@ -1,47 +1,129 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { Device } from '@/lib/interfaces';
|
||||
import type { PropType } from 'vue';
|
||||
import type { Device, TaskDto, TaskListResp } from '@/lib/interfaces'
|
||||
import type { PropType } from 'vue'
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
import DataTableNoCheckbox from './DataTableNoCheckbox.vue'
|
||||
import DataTableNoCheckboxScroll from './DataTableNoCheckboxScroll.vue'
|
||||
import { ref, watch, h } from 'vue' // <-- import h
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
required: true,
|
||||
},
|
||||
modelValue: { type: Boolean as PropType<boolean>, required: true },
|
||||
device: { type: Object as PropType<Device>, required: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: boolean): void
|
||||
(e: 'confirm'): void
|
||||
}>()
|
||||
|
||||
function onSave() {
|
||||
const tasks = ref<TaskDto[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function fetchTasks() {
|
||||
if (!props.device?.guid) return
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const { data } = await api.get<TaskListResp>(`/device/${encodeURIComponent(props.device.guid)}/tasks`)
|
||||
// server returns { limit, offset, total, tasks: [...] }
|
||||
tasks.value = Array.isArray(data?.tasks) ? data.tasks : []
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
error.value = e?.message ?? 'Failed to load tasks'
|
||||
tasks.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (open) => { if (open) fetchTasks() })
|
||||
watch(() => props.device?.guid, (n, o) => {
|
||||
if (props.modelValue && n && n !== o) fetchTasks()
|
||||
})
|
||||
|
||||
function onClose() {
|
||||
emit('confirm')
|
||||
// close the dialog
|
||||
emit('update:modelValue', 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: 'deviceGuid', header: 'GUID' },
|
||||
{ accessorKey: 'type', header: 'Task' },
|
||||
{
|
||||
accessorKey: 'payload',
|
||||
header: 'Command',
|
||||
cell: ({ row }) => {
|
||||
const p = row.original.payload ?? ''
|
||||
return p.length > 80 ? `${p.slice(0, 80)}…` : p
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: 'Status',
|
||||
// Return a VNode, not an HTML string
|
||||
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: 'error', header: 'Error', cell: ({ row }) => row.original.error ?? '' },
|
||||
{
|
||||
accessorKey: 'result',
|
||||
header: 'Result',
|
||||
cell: ({ row }) => {
|
||||
const r = row.original.result ?? ''
|
||||
return r.length > 80 ? `${r.slice(0, 80)}…` : r
|
||||
},
|
||||
},
|
||||
{ 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) },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="props.modelValue" @update:open="(v: boolean) => emit('update:modelValue', v)">
|
||||
<DialogContent class="sm:min-w-[800px]">
|
||||
<DialogHeader>
|
||||
<DialogContent class="sm:min-w-[1000px]">
|
||||
<DialogHeader class="flex flex-row items-center justify-between gap-4">
|
||||
<div>
|
||||
<DialogTitle>Tasks</DialogTitle>
|
||||
<DialogDescription>
|
||||
{{ props.device?.guid }}
|
||||
</DialogDescription>
|
||||
<DialogDescription>{{ props.device?.guid }}</DialogDescription>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" :disabled="loading || !props.device?.guid" @click="fetchTasks">
|
||||
{{ loading ? 'Loading…' : 'Refresh' }}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div v-if="error" class="text-sm text-red-600 mb-3">{{ error }}</div>
|
||||
|
||||
<div v-if="loading" class="text-sm text-muted-foreground py-8 text-center">
|
||||
Loading tasks…
|
||||
</div>
|
||||
<div v-else>
|
||||
<DataTableNoCheckboxScroll :columns="task_columns" :data="tasks" minTableWidth="min-w-[800px]"/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button @click="onSave">Close</Button>
|
||||
<Button @click="onClose">Close</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -9,3 +9,39 @@ export interface Users {
|
||||
username: string,
|
||||
role: string
|
||||
}
|
||||
|
||||
export type DeviceTaskType =
|
||||
| 'start_stream'
|
||||
| 'stop_stream'
|
||||
| 'start_recording'
|
||||
| 'stop_recording'
|
||||
| 'update_config'
|
||||
| 'set_deep_sleep'
|
||||
|
||||
export interface Task {
|
||||
type: DeviceTaskType
|
||||
/** Raw JSON string (e.g. '{"sleepTimeout":5}') */
|
||||
payload: string
|
||||
}
|
||||
|
||||
export type TaskStatus = 'pending' | 'running' | 'finished' | 'error'
|
||||
|
||||
export interface TaskDto {
|
||||
id: number
|
||||
deviceGuid: string
|
||||
type: DeviceTaskType
|
||||
payload: string // raw JSON string
|
||||
status: TaskStatus
|
||||
error?: string // from `ErrorMsg` -> `error`
|
||||
result?: string
|
||||
createdAt: string // ISO string from API
|
||||
startedAt?: string | null // ISO string or null
|
||||
finishedAt?: string | null
|
||||
}
|
||||
|
||||
export interface TaskListResp {
|
||||
limit: number
|
||||
offset: number
|
||||
total: number
|
||||
tasks: TaskDto[]
|
||||
}
|
||||
@@ -38,7 +38,7 @@ type CreateTaskDto struct {
|
||||
Type models.DeviceTaskType `json:"type" binding:"required,oneof=start_stream stop_stream start_recording stop_recording update_config set_deep_sleep"`
|
||||
// Pass raw JSON string as payload (e.g. {"sleepTimeout":5,"jitterMs":50,"recordingDurationSec":60})
|
||||
// Keep it string to let device/server evolve freely.
|
||||
Payload string `json:"payload" binding:"required"`
|
||||
Payload string `json:"payload"`
|
||||
}
|
||||
|
||||
// Device polls: single next task
|
||||
|
||||
Reference in New Issue
Block a user