diff --git a/nginx/nginx.conf b/nginx/nginx.conf index d97a2a7..224d814 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -27,6 +27,17 @@ server { location /api/ { proxy_pass http://backend/; + # Cho phép upload file lớn (vd: 200MB) + client_max_body_size 200M; + + # Truyền thẳng stream sang backend + proxy_request_buffering off; + + # Tăng timeout khi upload + proxy_read_timeout 300s; + proxy_connect_timeout 300s; + proxy_send_timeout 300s; + # CORS headers add_header 'Access-Control-Allow-Origin' '*' always; add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always; diff --git a/package-lock.json b/package-lock.json index a6257f4..f126cf5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "dependencies": { "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-progress": "^1.1.7", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.7", @@ -1621,6 +1622,30 @@ } } }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz", + "integrity": "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-separator": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", diff --git a/package.json b/package.json index 3515800..d13ff78 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "dependencies": { "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-progress": "^1.1.7", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.7", diff --git a/src/components/ui/progress.tsx b/src/components/ui/progress.tsx new file mode 100644 index 0000000..10af7e6 --- /dev/null +++ b/src/components/ui/progress.tsx @@ -0,0 +1,29 @@ +import * as React from "react" +import * as ProgressPrimitive from "@radix-ui/react-progress" + +import { cn } from "@/lib/utils" + +function Progress({ + className, + value, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { Progress } diff --git a/src/hooks/useDeviceEvents.ts b/src/hooks/useDeviceEvents.ts index 0533a37..32f8000 100644 --- a/src/hooks/useDeviceEvents.ts +++ b/src/hooks/useDeviceEvents.ts @@ -1,5 +1,7 @@ -import { useEffect } from "react"; -import { BASE_URL, API_ENDPOINTS } from "@/config/api"; +import { useEffect, useRef } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { API_ENDPOINTS } from "@/config/api"; + interface DeviceEventData { Message: string; DeviceId: string; @@ -7,49 +9,101 @@ interface DeviceEventData { 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(roomName?: string) { + const queryClient = useQueryClient(); + const reconnectTimeout = useRef | null>(null); -export function useDeviceEvents(options: UseDeviceEventsOptions) { useEffect(() => { - const onlineES = new EventSource(API_ENDPOINTS.SSE_EVENTS.DEVICE_ONLINE); - const offlineES = new EventSource(API_ENDPOINTS.SSE_EVENTS.DEVICE_OFFLINE); + let onlineES: EventSource | null = null; + let offlineES: EventSource | null = null; - 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); + const connect = () => { + onlineES = new EventSource(API_ENDPOINTS.SSE_EVENTS.DEVICE_ONLINE); + offlineES = new EventSource(API_ENDPOINTS.SSE_EVENTS.DEVICE_OFFLINE); + + onlineES.addEventListener("online", (event) => { + try { + const data: DeviceEventData = JSON.parse(event.data); + + if (roomName && data.Room === roomName) { + queryClient.setQueryData( + ["devices", roomName], + (oldDevices: any[] = []) => + oldDevices.map((d) => + d.macAddress === data.DeviceId + ? { ...d, isOffline: false } + : d + ) + ); + } + + queryClient.setQueryData(["rooms"], (oldRooms: any[] = []) => + oldRooms.map((room) => + room.name === data.Room + ? { + ...room, + numberOfOfflineDevices: + Math.max(0, room.numberOfOfflineDevices - 1), + } + : room + ) + ); + } catch (err) { + console.error("Error parsing online event:", err); + } + }); + + offlineES.addEventListener("offline", (event) => { + try { + const data: DeviceEventData = JSON.parse(event.data); + + if (roomName && data.Room === roomName) { + queryClient.setQueryData( + ["devices", roomName], + (oldDevices: any[] = []) => + oldDevices.map((d) => + d.macAddress === data.DeviceId + ? { ...d, isOffline: true } + : d + ) + ); + } + + queryClient.setQueryData(["rooms"], (oldRooms: any[] = []) => + oldRooms.map((room) => + room.name === data.Room + ? { + ...room, + numberOfOfflineDevices: room.numberOfOfflineDevices + 1, + } + : room + ) + ); + } catch (err) { + console.error("Error parsing offline event:", err); + } + }); + + const onError = (err: any) => { + console.error("SSE connection error:", err); + cleanup(); + reconnectTimeout.current = setTimeout(connect, 5000); + }; + + onlineES.onerror = onError; + offlineES.onerror = onError; + }; + + const cleanup = () => { + if (onlineES) onlineES.close(); + if (offlineES) offlineES.close(); + if (reconnectTimeout.current) { + clearTimeout(reconnectTimeout.current); + reconnectTimeout.current = null; } - }); - - 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]); + connect(); + return cleanup; + }, [roomName, queryClient]); } diff --git a/src/hooks/useMutationData.ts b/src/hooks/useMutationData.ts index ed8c756..225160d 100644 --- a/src/hooks/useMutationData.ts +++ b/src/hooks/useMutationData.ts @@ -6,6 +6,9 @@ type MutationDataOptions = { method?: Method // POST, PUT, PATCH, DELETE onSuccess?: (data: TOutput) => void onError?: (error: any) => void + config?: { + onUploadProgress?: (e: ProgressEvent) => void + } invalidate?: string[][] // List of queryKeys to invalidate } diff --git a/src/routes/_authenticated/apps/index.tsx b/src/routes/_authenticated/apps/index.tsx index 5afb6fa..011a0d8 100644 --- a/src/routes/_authenticated/apps/index.tsx +++ b/src/routes/_authenticated/apps/index.tsx @@ -33,6 +33,7 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import { Progress } from "@/components/ui/progress"; import { BASE_URL, API_ENDPOINTS } from "@/config/api"; @@ -70,14 +71,24 @@ function AppsComponent() { : []; const [isUploadOpen, setIsUploadOpen] = useState(false); + const [uploadPercent, setUploadPercent] = useState(0); const uploadMutation = useMutationData({ url: BASE_URL + API_ENDPOINTS.APP_VERSION.UPLOAD, method: "POST", + config: { + onUploadProgress: (e: ProgressEvent) => { + if (e.total) { + const percent = Math.round((e.loaded * 100) / e.total); + setUploadPercent(percent); + } + }, + }, onSuccess: () => { toast.success("Cập nhật thành công!"); - setIsUploadOpen(false); + setUploadPercent(0); form.reset(); + setIsUploadOpen(false); }, onError: () => toast.error("Lỗi khi cập nhật phiên bản!"), }); @@ -102,6 +113,7 @@ function AppsComponent() { formData.append("files", file) ); formData.append("newVersion", typedValue.newVersion); + await uploadMutation.mutateAsync(formData); }, }); @@ -131,13 +143,7 @@ function AppsComponent() { -
{ - e.preventDefault(); - form.handleSubmit(); - }} - > + {(field) => (
@@ -157,7 +163,7 @@ function AppsComponent() { { if (e.target.files) { field.handleChange(e.target.files); @@ -168,6 +174,14 @@ function AppsComponent() { )} + {uploadPercent > 0 && ( +
+ + + {uploadPercent}% +
+ )} + - + diff --git a/src/routes/_authenticated/room/$roomName/index.tsx b/src/routes/_authenticated/room/$roomName/index.tsx index 8a7361d..fa0becb 100644 --- a/src/routes/_authenticated/room/$roomName/index.tsx +++ b/src/routes/_authenticated/room/$roomName/index.tsx @@ -16,7 +16,6 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { useQueryClient } from "@tanstack/react-query"; import { useDeviceEvents } from "@/hooks/useDeviceEvents"; export const Route = createFileRoute("/_authenticated/room/$roomName/")({ @@ -28,7 +27,6 @@ export const Route = createFileRoute("/_authenticated/room/$roomName/")({ function RoomDetailComponent() { const { roomName } = useParams({ from: "/_authenticated/room/$roomName/" }); - const queryClient = useQueryClient(); const { data: devices = [], isLoading } = useQueryData({ queryKey: ["devices", roomName], @@ -36,30 +34,7 @@ function RoomDetailComponent() { }); // 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 - ) - ); - } - }, - }); + useDeviceEvents(roomName); const columns: ColumnDef[] = [ { diff --git a/src/routes/_authenticated/room/index.tsx b/src/routes/_authenticated/room/index.tsx index 52359e3..99f70ff 100644 --- a/src/routes/_authenticated/room/index.tsx +++ b/src/routes/_authenticated/room/index.tsx @@ -1,7 +1,6 @@ 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, @@ -30,7 +29,6 @@ export const Route = createFileRoute("/_authenticated/room/")({ }); function RoomComponent() { - const queryClient = useQueryClient(); const navigate = useNavigate(); const { data: roomData = [], isLoading } = useQueryData({ @@ -40,35 +38,7 @@ function RoomComponent() { const [sorting, setSorting] = React.useState([]); - useDeviceEvents({ - onRoomDeviceOnline: (room) => { - queryClient.setQueryData(["rooms"], (oldRooms: any[] = []) => - oldRooms.map((r) => - r.name === room - ? { - ...r, - numberOfOfflineDevices: Math.max( - (r.numberOfOfflineDevices || 0) - 1, - 0 - ), - } - : r - ) - ); - }, - onRoomDeviceOffline: (room) => { - queryClient.setQueryData(["rooms"], (oldRooms: any[] = []) => - oldRooms.map((r) => - r.name === room - ? { - ...r, - numberOfOfflineDevices: (r.numberOfOfflineDevices || 0) + 1, - } - : r - ) - ); - }, - }); + useDeviceEvents(); const columns: ColumnDef[] = [ {