created tracker api endpoint and created UI interface for trackers
This commit is contained in:
@@ -117,13 +117,71 @@ async function loadDevices() {
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Trackers
|
||||
type ApiTracker = { guid: string; name: string; users?: ApiDeviceUser[] }
|
||||
type TrackersResponse = { trackers: ApiTracker[]; offset: number; limit: number; total: number }
|
||||
|
||||
function isTrackersResponse(t: unknown): t is TrackersResponse {
|
||||
const x = t as any
|
||||
return !!x && Array.isArray(x.trackers) &&
|
||||
x.trackers.every((dev: any) =>
|
||||
dev &&
|
||||
typeof dev.guid === 'string' &&
|
||||
typeof dev.name === 'string' &&
|
||||
(
|
||||
dev.users === undefined ||
|
||||
(
|
||||
Array.isArray(dev.users) &&
|
||||
dev.users.every((u: any) => u && typeof u.username === 'string')
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const trackers_data = ref<Device[]>([])
|
||||
const trackersLoading = ref(false)
|
||||
const trackersError = ref<string | null>(null)
|
||||
|
||||
const tracker_columns = [
|
||||
{ accessorKey: 'guid', header: 'GUID' },
|
||||
{ accessorKey: 'devicename', header: 'Tracker' },
|
||||
{ accessorKey: 'assigned_users', header: 'Users' },
|
||||
]
|
||||
|
||||
let treckerCtrl: AbortController | null = null
|
||||
async function loadTrackers() {
|
||||
trackersError.value = null
|
||||
trackersLoading.value = true
|
||||
try {
|
||||
treckerCtrl?.abort()
|
||||
treckerCtrl = new AbortController()
|
||||
const { data } = await api.get('/trackers', { signal: treckerCtrl.signal })
|
||||
|
||||
if (!isTrackersResponse(data)) throw new Error('Unexpected trackers response')
|
||||
|
||||
// Transform API -> table shape
|
||||
trackers_data.value = data.trackers.map((d) => ({
|
||||
guid: d.guid,
|
||||
devicename: d.name,
|
||||
assigned_users: Array.isArray(d.users) ? d.users.map(u => u.username).join(', ') : '',
|
||||
}))
|
||||
} catch (e: any) {
|
||||
if (e?.name === 'CanceledError' || e?.message === 'canceled') return
|
||||
trackersError.value = 'Failed to load trackers.'
|
||||
} finally {
|
||||
trackersLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadUsers()
|
||||
loadDevices()
|
||||
loadTrackers()
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
ctrl?.abort()
|
||||
devicesCtrl?.abort()
|
||||
treckerCtrl?.abort()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -150,5 +208,9 @@ onBeforeUnmount(() => {
|
||||
<DataTableNoCheckbox :columns="device_columns" :data="device_data"
|
||||
:dropdownComponent="AdminDeviceDropdown" />
|
||||
</TabsContent>
|
||||
<TabsContent value="trackers">
|
||||
<DataTableNoCheckbox :columns="tracker_columns" :data="trackers_data"
|
||||
:dropdownComponent="AdminDeviceDropdown" />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</template>
|
||||
@@ -44,6 +44,13 @@ function navLinkClass(prefix: string) {
|
||||
>
|
||||
Devices
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/trackers"
|
||||
:class="navLinkClass('/trackers')"
|
||||
:aria-current="isActive('/trackers') ? 'page' : undefined"
|
||||
>
|
||||
Trackers
|
||||
</RouterLink>
|
||||
</nav>
|
||||
|
||||
<DropdownMenu>
|
||||
|
||||
103
management-ui/src/customcompometns/TrackerGrid.vue
Normal file
103
management-ui/src/customcompometns/TrackerGrid.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<script setup lang="ts">
|
||||
import { defineProps, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
|
||||
import type { Device } from '@/lib/interfaces'
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
const props = defineProps<{
|
||||
}>()
|
||||
// ----- API response models (same as before) -----
|
||||
type ApiDeviceUser = { id: number; username: string; role: string }
|
||||
type ApiTracker = { guid: string; name: string; users?: ApiDeviceUser[] }
|
||||
type TrackersResponse = { trackers: ApiTracker[]; offset: number; limit: number; total: number }
|
||||
|
||||
function isTrackersResponse(t: unknown): t is TrackersResponse {
|
||||
const x = t as any
|
||||
return !!x && Array.isArray(x.trackers) &&
|
||||
x.trackers.every((dev: any) =>
|
||||
dev &&
|
||||
typeof dev.guid === 'string' &&
|
||||
typeof dev.name === 'string' &&
|
||||
(
|
||||
dev.users === undefined ||
|
||||
(
|
||||
Array.isArray(dev.users) &&
|
||||
dev.users.every((u: any) => u && typeof u.username === 'string')
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const trackers_data = ref<Device[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const tracker_columns = [
|
||||
{ accessorKey: 'guid', header: 'GUID' },
|
||||
{ accessorKey: 'devicename', header: 'Tracker' },
|
||||
{ accessorKey: 'assigned_users', header: 'Users' },
|
||||
]
|
||||
|
||||
let treckerCtrl: AbortController | null = null
|
||||
async function loadTrackers() {
|
||||
error.value = null
|
||||
loading.value = true
|
||||
try {
|
||||
treckerCtrl?.abort()
|
||||
treckerCtrl = new AbortController()
|
||||
const { data } = await api.get('/trackers', { signal: treckerCtrl.signal })
|
||||
|
||||
if (!isTrackersResponse(data)) throw new Error('Unexpected trackers response')
|
||||
|
||||
// Transform API -> table shape
|
||||
trackers_data.value = data.trackers.map((d) => ({
|
||||
guid: d.guid,
|
||||
devicename: d.name,
|
||||
assigned_users: Array.isArray(d.users) ? d.users.map(u => u.username).join(', ') : '',
|
||||
}))
|
||||
} catch (e: any) {
|
||||
if (e?.name === 'CanceledError' || e?.message === 'canceled') return
|
||||
error.value = 'Failed to load trackers.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
onMounted(loadTrackers)
|
||||
onBeforeUnmount(() => treckerCtrl?.abort())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div v-if="loading">Loading devices…</div>
|
||||
<div v-else-if="error" class="text-red-600">{{ error }}</div>
|
||||
<div v-else class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
|
||||
<router-link v-for="device in trackers_data" :key="device.guid"
|
||||
:to="{ name: 'DeviceView', params: { guid: device.guid } }" class="block group hover:no-underline">
|
||||
<Card class="h-full transition-shadow group-hover:shadow-lg">
|
||||
<CardHeader class="bg-primary/5">
|
||||
<CardTitle class="text-lg">
|
||||
{{ device.devicename }}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p class="text-sm text-muted-foreground mb-2">
|
||||
<span class="font-medium">GUID:</span> {{ device.guid }}
|
||||
</p>
|
||||
<p class="text-sm">
|
||||
<span class="font-medium">Assigned Users:</span><br />
|
||||
{{ device.assigned_users || '—' }}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ensure cards stretch to fill the anchor height */
|
||||
a>.Card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
@@ -8,4 +8,4 @@ export interface Users {
|
||||
id: number,
|
||||
username: string,
|
||||
role: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,11 @@ type CreateUserPayload = {
|
||||
role: string // 'admin' | 'user'
|
||||
}
|
||||
|
||||
type CreateTrackerPayload = {
|
||||
guid: string
|
||||
name: string
|
||||
userIds: number[]
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -49,6 +54,11 @@ const user_form = reactive({
|
||||
isAdmin: false,
|
||||
})
|
||||
|
||||
const tracker_form = reactive({
|
||||
guid: uuidv4(),
|
||||
name: '',
|
||||
})
|
||||
|
||||
// userIds from AssignDevice (expects v-model of string[] ids)
|
||||
const selectedUserIds = ref<string[]>([])
|
||||
|
||||
@@ -65,6 +75,8 @@ watch(
|
||||
userError.value = null
|
||||
userSubmitting.value = false
|
||||
userSuccess.value = null
|
||||
// tracker_form.guid = uuidv4()
|
||||
// tracker_form.name = ''
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -151,6 +163,35 @@ async function submitUser() {
|
||||
userSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitTracker() {
|
||||
errorMsg.value = null
|
||||
if (!canSubmit.value) {
|
||||
errorMsg.value = 'Please provide a valid GUID and name.'
|
||||
return
|
||||
}
|
||||
|
||||
const userIds: number[] = selectedUserIds.value
|
||||
.map((s) => Number(s))
|
||||
.filter((n) => Number.isFinite(n) && n >= 0)
|
||||
|
||||
const payload: CreateDevicePayload = {
|
||||
guid: device_form.guid,
|
||||
name: device_form.name.trim(),
|
||||
userIds,
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await api.post('/trackers/create', payload)
|
||||
router.replace('/admin')
|
||||
} catch (e: any) {
|
||||
// keep client error generic
|
||||
errorMsg.value = e?.response?.status === 403 ? 'Access denied.' : 'Failed to create device.'
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -239,6 +280,43 @@ async function submitUser() {
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<Card class="h-full flex flex-col">
|
||||
<CardHeader>
|
||||
<CardTitle>Create tracker</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="flex-1">
|
||||
<!-- add vertical spacing between rows -->
|
||||
<div class="grid gap-5">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="guid" class="text-right">GUID</Label>
|
||||
<Input id="guid" class="col-span-3 w-full" v-model="device_form.guid" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="name" class="text-right">Name</Label>
|
||||
<Input id="name" class="col-span-3 w-full" v-model="device_form.name" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="users" class="text-right">Allowed users</Label>
|
||||
<!-- make the component span and fill -->
|
||||
<AssignDevice id="users" class="col-span-3 w-full" v-model="selectedUserIds" />
|
||||
</div>
|
||||
<p v-if="errorMsg" class="text-sm text-red-600" aria-live="assertive">
|
||||
{{ errorMsg }}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter>
|
||||
<Button :disabled="!canSubmit" @click="submitTracker">
|
||||
{{ submitting ? 'Saving…' : 'Save' }}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
14
management-ui/src/pages/Trackers.vue
Normal file
14
management-ui/src/pages/Trackers.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { useColorMode } from '@vueuse/core'
|
||||
import Navbar from '@/customcompometns/Navbar.vue';
|
||||
import DevicesGrid from '@/customcompometns/DevicesGrid.vue';
|
||||
import TrackerGrid from '@/customcompometns/TrackerGrid.vue';
|
||||
const mode = useColorMode()
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Navbar>
|
||||
<TrackerGrid />
|
||||
</Navbar>
|
||||
</template>
|
||||
@@ -7,6 +7,7 @@ import Devices from '@/pages/Devices.vue';
|
||||
import DeviceView from './pages/DeviceView.vue';
|
||||
import Forbidden from './pages/Forbidden.vue';
|
||||
import Create from './pages/Create.vue';
|
||||
import Trackers from '@/pages/Trackers.vue';
|
||||
import { auth } from './lib/auth';
|
||||
|
||||
|
||||
@@ -64,7 +65,13 @@ const routes = [
|
||||
name: 'Create',
|
||||
component: Create,
|
||||
meta: { requiresAuth: true, roles: ['admin'] }
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/trackers',
|
||||
name: 'Trackers',
|
||||
component: Trackers,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
|
||||
Reference in New Issue
Block a user