Skip to content

Commit 805322b

Browse files
feat(ui): add DevicesDropdown component to replace notifications
Co-authored-by: Luiz Henrique <luizhf42@gmail.com>
1 parent 25d6b58 commit 805322b

File tree

18 files changed

+1154
-525
lines changed

18 files changed

+1154
-525
lines changed

ui/src/components/AppBar/AppBar.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
<span>Need assistance? Click here for support.</span>
4747
</v-tooltip>
4848

49-
<NotificationsMenu data-test="notification-component" />
49+
<DevicesDropdown />
5050

5151
<v-menu>
5252
<template v-slot:activator="{ props }">
@@ -108,7 +108,7 @@ import { useRouter, useRoute, RouteLocationRaw, RouteLocation } from "vue-router
108108
import { useChatWoot } from "@productdevbook/chatwoot/vue";
109109
import handleError from "@/utils/handleError";
110110
import UserIcon from "../User/UserIcon.vue";
111-
import NotificationsMenu from "./Notifications/NotificationsMenu.vue";
111+
import DevicesDropdown from "./DevicesDropdown.vue";
112112
import PaywallChat from "../User/PaywallChat.vue";
113113
import Namespace from "@/components/Namespace/Namespace.vue";
114114
import { envVariables } from "@/envVariables";
Lines changed: 350 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,350 @@
1+
<template>
2+
<v-icon
3+
@click="toggleDrawer"
4+
color="primary"
5+
aria-label="Open devices menu"
6+
icon="mdi-developer-board"
7+
data-test="devices-icon"
8+
class="ml-3 mr-2"
9+
/>
10+
11+
<v-navigation-drawer
12+
v-model="isDrawerOpen"
13+
location="right"
14+
temporary
15+
:width="drawerWidth"
16+
class="bg-v-theme-surface"
17+
data-test="devices-drawer"
18+
>
19+
<v-card
20+
class="bg-v-theme-surface h-100"
21+
flat
22+
data-test="devices-card"
23+
>
24+
<v-card-title class="text-h6 py-3">
25+
Device Management
26+
</v-card-title>
27+
28+
<v-card-text class="pa-4 pt-0">
29+
<v-row dense class="mb-4">
30+
<v-col cols="6" sm="3">
31+
<v-card
32+
class="pa-3 text-center"
33+
variant="tonal"
34+
data-test="total-devices-card"
35+
>
36+
<div class="text-h4 font-weight-bold">
37+
{{ stats.registered_devices }}
38+
</div>
39+
<div class="text-caption text-medium-emphasis">
40+
Total
41+
</div>
42+
</v-card>
43+
</v-col>
44+
45+
<v-col cols="6" sm="3">
46+
<v-card
47+
class="pa-3 text-center"
48+
variant="tonal"
49+
data-test="online-devices-card"
50+
>
51+
<div class="text-h4 font-weight-bold">
52+
{{ stats.online_devices }}
53+
</div>
54+
<div class="text-caption text-medium-emphasis">
55+
Online
56+
</div>
57+
</v-card>
58+
</v-col>
59+
60+
<v-col cols="6" sm="3">
61+
<v-card
62+
class="pa-3 text-center"
63+
variant="tonal"
64+
data-test="pending-devices-card"
65+
>
66+
<div class="text-h4 font-weight-bold">
67+
{{ stats.pending_devices }}
68+
</div>
69+
<div class="text-caption text-medium-emphasis">
70+
Pending
71+
</div>
72+
</v-card>
73+
</v-col>
74+
75+
<v-col cols="6" sm="3">
76+
<v-card
77+
class="pa-3 text-center"
78+
variant="tonal"
79+
data-test="offline-devices-card"
80+
>
81+
<div class="text-h4 font-weight-bold">
82+
{{ offlineDevices }}
83+
</div>
84+
<div class="text-caption text-medium-emphasis">
85+
Offline
86+
</div>
87+
</v-card>
88+
</v-col>
89+
</v-row>
90+
91+
<v-btn-toggle
92+
v-model="activeTab"
93+
mandatory
94+
color="primary"
95+
variant="outlined"
96+
divided
97+
class="mb-3 w-100"
98+
data-test="tab-toggle"
99+
>
100+
<v-btn value="pending" data-test="pending-tab" class="w-50">
101+
<v-icon icon="mdi-clock-alert" :size="smAndUp ? 'small' : 'large'" class="mr-2" />
102+
<span v-if="smAndUp">Pending Approval</span>
103+
<v-chip
104+
v-if="stats.pending_devices > 0"
105+
color="warning"
106+
size="x-small"
107+
class="ml-2"
108+
>
109+
{{ stats.pending_devices }}
110+
</v-chip>
111+
</v-btn>
112+
<v-btn value="recent" data-test="recent-tab" class="w-50">
113+
<v-icon icon="mdi-history" :size="smAndUp ? 'small' : 'large'" class="mr-2" />
114+
<span v-if="smAndUp">Recent Activity</span>
115+
</v-btn>
116+
</v-btn-toggle>
117+
118+
<v-window v-model="activeTab" class="overflow-visible">
119+
<v-window-item value="pending">
120+
<v-card
121+
variant="text"
122+
class="overflow-y-auto border"
123+
>
124+
<v-list
125+
v-if="pendingDevicesList.length > 0"
126+
density="compact"
127+
class="bg-v-theme-surface pa-0"
128+
>
129+
<template
130+
v-for="(device, index) in pendingDevicesList"
131+
:key="device.uid"
132+
>
133+
<v-divider v-if="index > 0" />
134+
<v-list-item class="px-3 py-3" data-test="pending-device-item">
135+
<template #prepend>
136+
<v-icon
137+
icon="mdi-devices"
138+
color="primary"
139+
size="small"
140+
class="mr-n3 ml-1"
141+
/>
142+
</template>
143+
<v-list-item-title class="text-body-2 font-weight-medium mb-1">
144+
{{ device.name }}
145+
</v-list-item-title>
146+
<v-list-item-subtitle class="text-caption">
147+
<span class="font-mono">{{ device.identity?.mac || device.uid }}</span>
148+
<span class="mx-1">•</span>
149+
<span>{{ device.remote_addr }}</span>
150+
</v-list-item-subtitle>
151+
<template
152+
v-if="smAndUp"
153+
#append
154+
>
155+
<span class="text-caption text-medium-emphasis">
156+
{{ formatTimeAgo(device.status_updated_at) }}
157+
</span>
158+
</template>
159+
<div class="d-flex align-center ga-2 mt-1">
160+
<v-btn
161+
color="success"
162+
variant="flat"
163+
size="small"
164+
prepend-icon="mdi-check-circle"
165+
@click="handleAccept(device.uid)"
166+
:data-test="`accept-${device.uid}`"
167+
>
168+
Accept
169+
</v-btn>
170+
<v-btn
171+
color="error"
172+
variant="tonal"
173+
size="small"
174+
prepend-icon="mdi-cancel"
175+
@click="handleReject(device.uid)"
176+
:data-test="`reject-${device.uid}`"
177+
>
178+
Reject
179+
</v-btn>
180+
<v-btn
181+
icon="mdi-dots-vertical"
182+
variant="text"
183+
size="small"
184+
:active="false"
185+
:to="`/devices/${device.uid}`"
186+
/>
187+
</div>
188+
</v-list-item>
189+
</template>
190+
</v-list>
191+
192+
<div class="pa-8 text-center" v-else>
193+
<v-icon icon="mdi-check-circle" size="64" color="success" class="opacity-50 mb-3" />
194+
<p class="text-body-2 text-medium-emphasis">No pending devices</p>
195+
<p class="text-caption text-disabled mt-1">All devices have been approved</p>
196+
</div>
197+
</v-card>
198+
</v-window-item>
199+
200+
<v-window-item value="recent">
201+
<v-card
202+
variant="text"
203+
class="overflow-y-auto border"
204+
>
205+
<v-list density="compact" class="pa-0" v-if="recentDevicesList.length > 0">
206+
<template v-for="(device, index) in recentDevicesList" :key="device.uid">
207+
<v-divider v-if="index > 0" />
208+
<v-list-item class="px-3 py-2" :to="`/devices/${device.uid}`">
209+
<template v-slot:prepend>
210+
<v-badge
211+
:color="device.online ? 'success' : 'grey'"
212+
dot
213+
inline
214+
class="mr-2"
215+
/>
216+
</template>
217+
218+
<v-list-item-title class="text-body-2 font-weight-medium">
219+
{{ device.name }}
220+
</v-list-item-title>
221+
222+
<v-list-item-subtitle class="text-caption font-mono">
223+
{{ device.identity?.mac || device.uid }}
224+
</v-list-item-subtitle>
225+
226+
<template #append>
227+
<span class="text-caption text-medium-emphasis">
228+
{{ device.online ? 'Active now' : formatTimeAgo(device.last_seen) }}
229+
</span>
230+
</template>
231+
</v-list-item>
232+
</template>
233+
</v-list>
234+
235+
<div class="pa-8 text-center" v-else>
236+
<v-icon icon="mdi-history" size="64" color="primary" class="opacity-50 mb-3" />
237+
<p class="text-body-2 text-medium-emphasis">No recent activity</p>
238+
</div>
239+
</v-card>
240+
</v-window-item>
241+
</v-window>
242+
</v-card-text>
243+
244+
<v-divider />
245+
<v-card-actions class="pa-3">
246+
<v-btn
247+
to="/devices"
248+
variant="text"
249+
color="primary"
250+
block
251+
size="small"
252+
append-icon="mdi-arrow-right"
253+
data-test="view-all-devices-btn"
254+
text="View all devices"
255+
:active="false"
256+
/>
257+
</v-card-actions>
258+
</v-card>
259+
</v-navigation-drawer>
260+
</template>
261+
262+
<script setup lang="ts">
263+
import { computed, onBeforeMount, ref } from "vue";
264+
import { useDisplay } from "vuetify";
265+
import useStatsStore from "@/store/modules/stats";
266+
import useDevicesStore from "@/store/modules/devices";
267+
import handleError from "@/utils/handleError";
268+
import useSnackbar from "@/helpers/snackbar";
269+
import moment from "moment";
270+
import { IDevice } from "@/interfaces/IDevice";
271+
272+
const { smAndUp, thresholds } = useDisplay();
273+
const statsStore = useStatsStore();
274+
const devicesStore = useDevicesStore();
275+
const snackbar = useSnackbar();
276+
277+
const drawerWidth = computed(() => thresholds.value.sm);
278+
const isDrawerOpen = ref(false);
279+
const activeTab = ref<"pending" | "recent">("pending");
280+
const pendingDevicesList = ref<IDevice[]>([]);
281+
const recentDevicesList = ref<IDevice[]>([]);
282+
const stats = computed(() => statsStore.stats);
283+
const offlineDevices = computed(() => stats.value.registered_devices - stats.value.online_devices);
284+
const toggleDrawer = () => { isDrawerOpen.value = !isDrawerOpen.value };
285+
286+
const formatTimeAgo = (date: string | Date) => {
287+
if (!date) return "Unknown";
288+
return moment(date).fromNow();
289+
};
290+
291+
const handleAccept = async (uid: string) => {
292+
try {
293+
await devicesStore.acceptDevice(uid);
294+
await fetchStats();
295+
await fetchPendingDevices();
296+
snackbar.showSuccess("Device accepted successfully");
297+
} catch (error: unknown) {
298+
snackbar.showError("Failed to accept device");
299+
handleError(error);
300+
}
301+
};
302+
303+
const handleReject = async (uid: string) => {
304+
try {
305+
await devicesStore.rejectDevice(uid);
306+
await fetchStats();
307+
await fetchPendingDevices();
308+
snackbar.showSuccess("Device rejected successfully");
309+
} catch (error: unknown) {
310+
snackbar.showError("Failed to reject device");
311+
handleError(error);
312+
}
313+
};
314+
315+
const fetchStats = async () => {
316+
try {
317+
await statsStore.fetchStats();
318+
} catch (error: unknown) {
319+
snackbar.showError("Failed to load device statistics");
320+
handleError(error);
321+
}
322+
};
323+
324+
const fetchPendingDevices = async () => {
325+
try {
326+
await devicesStore.fetchDeviceList({ status: "pending", perPage: 100 });
327+
pendingDevicesList.value = [...devicesStore.devices];
328+
} catch (error: unknown) {
329+
handleError(error);
330+
}
331+
};
332+
333+
const fetchRecentDevices = async () => {
334+
try {
335+
await devicesStore.fetchDeviceList({ status: "accepted" });
336+
recentDevicesList.value = [...devicesStore.devices]
337+
.sort((a, b) => new Date(b.last_seen).getTime() - new Date(a.last_seen).getTime());
338+
} catch (error: unknown) {
339+
handleError(error);
340+
}
341+
};
342+
343+
onBeforeMount(async () => {
344+
await fetchStats();
345+
await fetchPendingDevices();
346+
await fetchRecentDevices();
347+
});
348+
349+
defineExpose({ toggleDrawer, formatTimeAgo, isDrawerOpen, handleAccept, handleReject, activeTab, pendingDevicesList, recentDevicesList, stats, offlineDevices });
350+
</script>

0 commit comments

Comments
 (0)