add devices list table
This commit is contained in:
parent
cf735f31bf
commit
00be81e18b
32
package-lock.json
generated
32
package-lock.json
generated
|
@ -16,6 +16,7 @@
|
||||||
"@tanstack/react-query": "^5.83.0",
|
"@tanstack/react-query": "^5.83.0",
|
||||||
"@tanstack/react-router": "^1.121.2",
|
"@tanstack/react-router": "^1.121.2",
|
||||||
"@tanstack/react-router-devtools": "^1.121.2",
|
"@tanstack/react-router-devtools": "^1.121.2",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@tanstack/router-plugin": "^1.121.2",
|
"@tanstack/router-plugin": "^1.121.2",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
@ -2496,6 +2497,25 @@
|
||||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tanstack/react-table": {
|
||||||
|
"version": "8.21.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
|
||||||
|
"integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/table-core": "8.21.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8",
|
||||||
|
"react-dom": ">=16.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tanstack/router-core": {
|
"node_modules/@tanstack/router-core": {
|
||||||
"version": "1.129.8",
|
"version": "1.129.8",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.129.8.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.129.8.tgz",
|
||||||
|
@ -2648,6 +2668,18 @@
|
||||||
"url": "https://github.com/sponsors/tannerlinsley"
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tanstack/table-core": {
|
||||||
|
"version": "8.21.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
|
||||||
|
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tanstack/virtual-file-routes": {
|
"node_modules/@tanstack/virtual-file-routes": {
|
||||||
"version": "1.129.7",
|
"version": "1.129.7",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-file-routes/-/virtual-file-routes-1.129.7.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/virtual-file-routes/-/virtual-file-routes-1.129.7.tgz",
|
||||||
|
|
|
@ -20,6 +20,7 @@
|
||||||
"@tanstack/react-query": "^5.83.0",
|
"@tanstack/react-query": "^5.83.0",
|
||||||
"@tanstack/react-router": "^1.121.2",
|
"@tanstack/react-router": "^1.121.2",
|
||||||
"@tanstack/react-router-devtools": "^1.121.2",
|
"@tanstack/react-router-devtools": "^1.121.2",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@tanstack/router-plugin": "^1.121.2",
|
"@tanstack/router-plugin": "^1.121.2",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
|
|
@ -28,4 +28,8 @@ export const API_ENDPOINTS = {
|
||||||
GET_ROOM_LIST: `/DeviceComm/rooms`,
|
GET_ROOM_LIST: `/DeviceComm/rooms`,
|
||||||
GET_DEVICE_FROM_ROOM: (roomName: string) => `/DeviceComm/room/${roomName}`,
|
GET_DEVICE_FROM_ROOM: (roomName: string) => `/DeviceComm/room/${roomName}`,
|
||||||
},
|
},
|
||||||
|
SSE_EVENTS: {
|
||||||
|
DEVICE_ONLINE: `/Sse/events/onlineDevices`,
|
||||||
|
DEVICE_OFFLINE: `/Sse/events/offlineDevices`,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
55
src/hooks/useDeviceEvents.ts
Normal file
55
src/hooks/useDeviceEvents.ts
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { BASE_URL, API_ENDPOINTS } from "@/config/api";
|
||||||
|
interface DeviceEventData {
|
||||||
|
Message: string;
|
||||||
|
DeviceId: string;
|
||||||
|
Room: string;
|
||||||
|
Timestamp?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseDeviceEventsOptions {
|
||||||
|
onRoomDeviceOnline?: (room: string, deviceId: string) => void;
|
||||||
|
onRoomDeviceOffline?: (room: string, deviceId: string) => void;
|
||||||
|
onDeviceOnlineInRoom?: (deviceId: string, room: string) => void;
|
||||||
|
onDeviceOfflineInRoom?: (deviceId: string, room: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeviceEvents(options: UseDeviceEventsOptions) {
|
||||||
|
useEffect(() => {
|
||||||
|
const onlineES = new EventSource(`${BASE_URL}${API_ENDPOINTS.SSE_EVENTS.DEVICE_ONLINE}`);
|
||||||
|
const offlineES = new EventSource(`${BASE_URL}${API_ENDPOINTS.SSE_EVENTS.DEVICE_OFFLINE}`);
|
||||||
|
|
||||||
|
onlineES.addEventListener("online", (event) => {
|
||||||
|
try {
|
||||||
|
const data: DeviceEventData = JSON.parse(event.data);
|
||||||
|
options.onRoomDeviceOnline?.(data.Room, data.DeviceId);
|
||||||
|
options.onDeviceOnlineInRoom?.(data.DeviceId, data.Room);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error parsing online event:", err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
offlineES.addEventListener("offline", (event) => {
|
||||||
|
try {
|
||||||
|
const data: DeviceEventData = JSON.parse(event.data);
|
||||||
|
options.onRoomDeviceOffline?.(data.Room, data.DeviceId);
|
||||||
|
options.onDeviceOfflineInRoom?.(data.DeviceId, data.Room);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error parsing offline event:", err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onlineES.onerror = (err) => {
|
||||||
|
console.error("Online SSE connection error:", err);
|
||||||
|
};
|
||||||
|
|
||||||
|
offlineES.onerror = (err) => {
|
||||||
|
console.error("Offline SSE connection error:", err);
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
onlineES.close();
|
||||||
|
offlineES.close();
|
||||||
|
};
|
||||||
|
}, [options]);
|
||||||
|
}
|
|
@ -1,9 +1,158 @@
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute, useParams } from "@tanstack/react-router";
|
||||||
|
import { useQueryData } from "@/hooks/useQueryData";
|
||||||
|
import { API_ENDPOINTS, BASE_URL } from "@/config/api";
|
||||||
|
import {
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
useReactTable,
|
||||||
|
type ColumnDef,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useDeviceEvents } from "@/hooks/useDeviceEvents";
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/room/$roomName/')({
|
export const Route = createFileRoute("/_authenticated/room/$roomName/")({
|
||||||
component: RouteComponent,
|
head: ({ params }) => ({
|
||||||
})
|
meta: [{ title: `Danh sách thiết bị phòng ${params.roomName}` }],
|
||||||
|
}),
|
||||||
|
component: RoomDetailComponent,
|
||||||
|
});
|
||||||
|
|
||||||
function RouteComponent() {
|
function RoomDetailComponent() {
|
||||||
return <div>Hello "/_authenticated/room/$roomName/"!</div>
|
const { roomName } = useParams({ from: "/_authenticated/room/$roomName/" });
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data: devices = [], isLoading } = useQueryData({
|
||||||
|
queryKey: ["devices", roomName],
|
||||||
|
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.GET_DEVICE_FROM_ROOM(roomName),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lắng nghe SSE và update state
|
||||||
|
useDeviceEvents({
|
||||||
|
onDeviceOnlineInRoom: (deviceId, room) => {
|
||||||
|
if (room === roomName) {
|
||||||
|
queryClient.setQueryData(
|
||||||
|
["devices", roomName],
|
||||||
|
(oldDevices: any[] = []) =>
|
||||||
|
oldDevices.map((d) =>
|
||||||
|
d.macAddress === deviceId ? { ...d, isOffline: false } : d
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDeviceOfflineInRoom: (deviceId, room) => {
|
||||||
|
if (room === roomName) {
|
||||||
|
queryClient.setQueryData(
|
||||||
|
["devices", roomName],
|
||||||
|
(oldDevices: any[] = []) =>
|
||||||
|
oldDevices.map((d) =>
|
||||||
|
d.macAddress === deviceId ? { ...d, isOffline: true } : d
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns: ColumnDef<any>[] = [
|
||||||
|
{
|
||||||
|
header: "STT",
|
||||||
|
cell: ({ row }) => row.index + 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "MAC Address",
|
||||||
|
accessorKey: "macAddress",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "Thời gian thiết bị",
|
||||||
|
accessorKey: "deviceTime",
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const date = new Date(getValue() as string);
|
||||||
|
return date.toLocaleString();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "Phiên bản",
|
||||||
|
accessorKey: "version",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "Địa chỉ IP",
|
||||||
|
accessorKey: "ipAddress",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "Trạng thái",
|
||||||
|
accessorKey: "isOffline",
|
||||||
|
cell: ({ getValue }) =>
|
||||||
|
getValue() ? (
|
||||||
|
<span className="text-red-500 font-semibold">Offline</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-green-500 font-semibold">Online</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: devices,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) return <div>Đang tải thiết bị...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full px-6 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">Phòng: {roomName}</h1>
|
||||||
|
<p className="text-muted-foreground mt-2">
|
||||||
|
Danh sách thiết bị trong phòng
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Thiết bị</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<TableHead key={header.id}>
|
||||||
|
{flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext()
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow key={row.id}>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id}>
|
||||||
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext()
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,161 @@
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { API_ENDPOINTS, BASE_URL } from "@/config/api";
|
||||||
|
import { useQueryData } from "@/hooks/useQueryData";
|
||||||
|
import { useDeviceEvents } from "@/hooks/useDeviceEvents";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
|
import {
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
useReactTable,
|
||||||
|
type ColumnDef,
|
||||||
|
type SortingState,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/room/')({
|
export const Route = createFileRoute("/_authenticated/room/")({
|
||||||
|
head: () => ({
|
||||||
|
meta: [{ title: "Danh sách phòng" }],
|
||||||
|
}),
|
||||||
component: RoomComponent,
|
component: RoomComponent,
|
||||||
})
|
});
|
||||||
|
|
||||||
function RoomComponent() {
|
function RoomComponent() {
|
||||||
return <div>Hello "/_authenticated/room/"!</div>
|
const queryClient = useQueryClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const { data: roomData = [], isLoading } = useQueryData({
|
||||||
|
queryKey: ["rooms"],
|
||||||
|
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.GET_ROOM_LIST,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||||
|
|
||||||
|
useDeviceEvents({
|
||||||
|
onRoomDeviceOnline: (room) => {
|
||||||
|
queryClient.setQueryData(["rooms"], (oldRooms: any[] = []) =>
|
||||||
|
oldRooms.map((r) =>
|
||||||
|
r.name === room
|
||||||
|
? { ...r, offlineCount: Math.max((r.offlineCount || 0) - 1, 0) }
|
||||||
|
: r
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onRoomDeviceOffline: (room) => {
|
||||||
|
queryClient.setQueryData(["rooms"], (oldRooms: any[] = []) =>
|
||||||
|
oldRooms.map((r) =>
|
||||||
|
r.name === room
|
||||||
|
? { ...r, offlineCount: (r.offlineCount || 0) + 1 }
|
||||||
|
: r
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns: ColumnDef<any>[] = [
|
||||||
|
{
|
||||||
|
header: "STT",
|
||||||
|
cell: ({ row }) => row.index + 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "Tên phòng",
|
||||||
|
accessorKey: "name",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "Số lượng thiết bị",
|
||||||
|
accessorKey: "numberOfDevices",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "Thiết bị offline",
|
||||||
|
accessorKey: "offlineCount",
|
||||||
|
cell: ({ getValue }) => getValue() || 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: roomData,
|
||||||
|
columns,
|
||||||
|
state: { sorting },
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) return <div>Đang tải...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full px-6 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">Quản lý phòng</h1>
|
||||||
|
<p className="text-muted-foreground mt-2">
|
||||||
|
Danh sách các phòng hiện có trong hệ thống
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Danh sách phòng</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => {
|
||||||
|
const isSorted = header.column.getIsSorted();
|
||||||
|
return (
|
||||||
|
<TableHead
|
||||||
|
key={header.id}
|
||||||
|
className="cursor-pointer select-none"
|
||||||
|
onClick={header.column.getToggleSortingHandler()}
|
||||||
|
>
|
||||||
|
{flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext()
|
||||||
|
)}
|
||||||
|
{isSorted ? (isSorted === "asc" ? " ▲" : " ▼") : ""}
|
||||||
|
</TableHead>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
className="cursor-pointer hover:bg-gray-100"
|
||||||
|
onClick={() =>
|
||||||
|
navigate({
|
||||||
|
to: "/room/$roomName",
|
||||||
|
params: { roomName: row.original.name },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id}>
|
||||||
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext()
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue
Block a user