created prototype of tasks for device and dialog, that shows all tasks for admin per each device

This commit is contained in:
tdv
2025-10-08 18:25:23 +03:00
parent 481966fcba
commit ee210e847e
7 changed files with 376 additions and 50 deletions

View File

@@ -74,15 +74,6 @@ function onSelect(ev: CustomEvent) {
// When parent switches between external/internal data at runtime, refetch if needed. // When parent switches between external/internal data at runtime, refetch if needed.
watch(() => props.allUsers, () => { if (!usingExternal.value) loadUsers() }) 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> </script>
<template> <template>

View 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>

View File

@@ -3,12 +3,50 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import DeviceDashboard from './DeviceDashboard.vue'; import DeviceDashboard from './DeviceDashboard.vue';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import DeviceRecordings from './DeviceRecordings.vue'; 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({ const props = defineProps({
guid: { type: String as PropType<string>, required: true }, 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> </script>
<template> <template>
<Tabs default-value="records"> <Tabs default-value="records">
@@ -24,18 +62,26 @@ const props = defineProps({
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="dashboard"> <TabsContent value="dashboard">
<DeviceDashboard/> <DeviceDashboard :guid="guid" />
</TabsContent> </TabsContent>
<TabsContent value="records"> <TabsContent value="records">
<DeviceRecordings :guid="guid"/> <DeviceRecordings :guid="guid" />
</TabsContent> </TabsContent>
<TabsContent value="livestream"> <TabsContent value="livestream">
<Button> <!-- <Button>
Start streaming Start streaming
</Button> </Button>
<Button> <Button>
Stop streaming Stop streaming
</Button> </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> </TabsContent>
</Tabs> </Tabs>
</template> </template>

View File

@@ -18,11 +18,54 @@ import {
NumberFieldInput, NumberFieldInput,
} from '@/components/ui/number-field' } from '@/components/ui/number-field'
import AssignDevice from "./AssignDevice.vue"; import AssignDevice from "./AssignDevice.vue";
import { ref } from "vue"; import { ref, type PropType } from "vue";
import Separator from "@/components/ui/separator/Separator.vue"; import Separator from "@/components/ui/separator/Separator.vue";
import DataRangePicker from "./DataRangePicker.vue"; import DataRangePicker from "./DataRangePicker.vue";
import { api } from "@/lib/api";
import type { Task } from "@/lib/interfaces";
const selectedUserIds = ref<string[]>([]) const selectedUserIds = ref<string[]>([])
const usrIDs = selectedUserIds.value 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> </script>
<template> <template>
@@ -41,8 +84,12 @@ const usrIDs = selectedUserIds.value
</div> </div>
<div class="flex space-x-4 pt-2 gap-4"> <div class="flex space-x-4 pt-2 gap-4">
<Button>Start recording</Button> <Button :disabled="sending" @click="startRecording">
<Button>Stop recording</Button> {{ sending ? 'Starting' : 'Start recording' }}
</Button>
<Button :disabled="sending" @click="stopRecording">
{{ sending ? 'Stopping' : 'Stop recording' }}
</Button>
</div> </div>
<div class="space-y-2 gap-4"> <div class="space-y-2 gap-4">
<NumberField id="duration" :default-value="120" :min="30"> <NumberField id="duration" :default-value="120" :min="30">

View File

@@ -1,48 +1,130 @@
<script setup lang="ts"> <script setup lang="ts">
import { import {
Dialog, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import type { Device } from '@/lib/interfaces'; import type { Device, TaskDto, TaskListResp } from '@/lib/interfaces'
import type { PropType } from 'vue'; 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({ const props = defineProps({
modelValue: { modelValue: { type: Boolean as PropType<boolean>, required: true },
type: Boolean as PropType<boolean>, device: { type: Object as PropType<Device>, required: false },
required: true,
},
device: { type: Object as PropType<Device>, required: false },
}) })
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void (e: 'update:modelValue', v: boolean): void
(e: 'confirm'): 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') emit('confirm')
// close the dialog
emit('update:modelValue', false) 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> </script>
<template> <template>
<Dialog :open="props.modelValue" @update:open="(v: boolean) => emit('update:modelValue', v)"> <Dialog :open="props.modelValue" @update:open="(v: boolean) => emit('update:modelValue', v)">
<DialogContent class="sm:min-w-[800px]"> <DialogContent class="sm:min-w-[1000px]">
<DialogHeader> <DialogHeader class="flex flex-row items-center justify-between gap-4">
<DialogTitle>Tasks</DialogTitle> <div>
<DialogDescription> <DialogTitle>Tasks</DialogTitle>
{{ props.device?.guid }} <DialogDescription>{{ props.device?.guid }}</DialogDescription>
</DialogDescription> </div>
</DialogHeader> <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>
<DialogFooter> <div v-if="loading" class="text-sm text-muted-foreground py-8 text-center">
<Button @click="onSave">Close</Button> Loading tasks
</DialogFooter> </div>
</DialogContent> <div v-else>
</Dialog> <DataTableNoCheckboxScroll :columns="task_columns" :data="tasks" minTableWidth="min-w-[800px]"/>
</div>
<DialogFooter>
<Button @click="onClose">Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template> </template>

View File

@@ -9,3 +9,39 @@ export interface Users {
username: string, username: string,
role: 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[]
}

View File

@@ -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"` 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}) // Pass raw JSON string as payload (e.g. {"sleepTimeout":5,"jitterMs":50,"recordingDurationSec":60})
// Keep it string to let device/server evolve freely. // Keep it string to let device/server evolve freely.
Payload string `json:"payload" binding:"required"` Payload string `json:"payload"`
} }
// Device polls: single next task // Device polls: single next task