created tracker api endpoint and created UI interface for trackers

This commit is contained in:
dtv
2025-10-04 20:18:59 +03:00
parent 269b098f0d
commit 2b863776ae
16 changed files with 597 additions and 8 deletions

View File

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

View File

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

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

View File

@@ -8,4 +8,4 @@ export interface Users {
id: number,
username: string,
role: string
}
}

View File

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

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

View File

@@ -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({