config in nginx and app page for real time refetch and upload from website

This commit is contained in:
Do Manh Phuong 2025-08-27 22:36:27 +07:00
parent c9726d00a0
commit 1f9336271c
9 changed files with 192 additions and 108 deletions

View File

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

25
package-lock.json generated
View File

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

View File

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

View File

@ -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<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
}
export { Progress }

View File

@ -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<ReturnType<typeof setTimeout> | 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]);
}

View File

@ -6,6 +6,9 @@ type MutationDataOptions<TInput, TOutput> = {
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
}

View File

@ -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<FormData>({
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() {
</DialogDescription>
</DialogHeader>
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
>
<form className="space-y-4" onSubmit={form.handleSubmit}>
<form.Field name="newVersion">
{(field) => (
<div className="space-y-2">
@ -157,7 +163,7 @@ function AppsComponent() {
<Label>File ng dụng</Label>
<Input
type="file"
accept=".exe,.zip,.apk"
accept=".exe,.msi,.apk"
onChange={(e) => {
if (e.target.files) {
field.handleChange(e.target.files);
@ -168,6 +174,14 @@ function AppsComponent() {
)}
</form.Field>
{uploadPercent > 0 && (
<div className="space-y-2">
<Label>Tiến trình upload</Label>
<Progress value={uploadPercent} className="w-full" />
<span>{uploadPercent}%</span>
</div>
)}
<DialogFooter>
<Button
type="button"
@ -176,7 +190,9 @@ function AppsComponent() {
>
Hủy
</Button>
<Button type="submit">Tải lên</Button>
<Button type="submit" disabled={uploadMutation.isPending}>
{uploadMutation.isPending ? "Đang tải..." : "Tải lên"}
</Button>
</DialogFooter>
</form>
</DialogContent>

View File

@ -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<any>[] = [
{

View File

@ -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<SortingState>([]);
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<any>[] = [
{