Add app management page
This commit is contained in:
parent
1f9336271c
commit
328d499dca
|
@ -11,7 +11,7 @@
|
||||||
/>
|
/>
|
||||||
<link rel="apple-touch-icon" href="/logo192.png" />
|
<link rel="apple-touch-icon" href="/logo192.png" />
|
||||||
<link rel="manifest" href="/manifest.json" />
|
<link rel="manifest" href="/manifest.json" />
|
||||||
<title>Create TanStack App - .</title>
|
<title>Quản lý phòng máy</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|
|
@ -1,5 +0,0 @@
|
||||||
import { Button } from "./ui/button";
|
|
||||||
|
|
||||||
export function SubmitButton(){
|
|
||||||
|
|
||||||
}
|
|
14
src/components/update-button.tsx
Normal file
14
src/components/update-button.tsx
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
interface UpdateButtonProps {
|
||||||
|
onClick: () => void;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UpdateButton({ onClick, loading }: UpdateButtonProps) {
|
||||||
|
return (
|
||||||
|
<Button variant="outline" onClick={onClick} disabled={loading}>
|
||||||
|
{loading ? "Đang gửi..." : "Yêu cầu thiết bị cập nhật"}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
110
src/components/upload-dialog.tsx
Normal file
110
src/components/upload-dialog.tsx
Normal file
|
@ -0,0 +1,110 @@
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogDescription, DialogFooter,
|
||||||
|
DialogHeader, DialogTitle, DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import { formOptions, useForm } from "@tanstack/react-form";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface UploadDialogProps {
|
||||||
|
onSubmit: (formData: FormData) => Promise<void>;
|
||||||
|
accept?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UploadFormProps {
|
||||||
|
files: FileList;
|
||||||
|
newVersion: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultInput: UploadFormProps = {
|
||||||
|
files: new DataTransfer().files,
|
||||||
|
newVersion: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const formOpts = formOptions({ defaultValues: defaultInput });
|
||||||
|
|
||||||
|
export function UploadDialog({ onSubmit, accept = ".exe,.msi,.apk" }: UploadDialogProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [uploadPercent, setUploadPercent] = useState(0);
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
...formOpts,
|
||||||
|
onSubmit: async ({ value }) => {
|
||||||
|
if (!value.newVersion || value.files.length === 0) {
|
||||||
|
toast.error("Vui lòng điền đầy đủ thông tin");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fd = new FormData();
|
||||||
|
Array.from(value.files).forEach((file) => fd.append("files", file));
|
||||||
|
fd.append("newVersion", value.newVersion);
|
||||||
|
|
||||||
|
setUploadPercent(0);
|
||||||
|
await onSubmit(fd);
|
||||||
|
setIsOpen(false);
|
||||||
|
form.reset();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button className="gap-2">
|
||||||
|
<Plus className="h-4 w-4" /> Cập nhật phiên bản mới
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Cập nhật phiên bản mới</DialogTitle>
|
||||||
|
<DialogDescription>Chọn tệp và nhập số phiên bản</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form className="space-y-4" onSubmit={form.handleSubmit}>
|
||||||
|
<form.Field name="newVersion">
|
||||||
|
{(field) => (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Phiên bản</Label>
|
||||||
|
<Input
|
||||||
|
value={field.state.value}
|
||||||
|
onChange={(e) => field.handleChange(e.target.value)}
|
||||||
|
placeholder="e.g., 1.0.0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
|
||||||
|
<form.Field name="files">
|
||||||
|
{(field) => (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>File ứng dụng</Label>
|
||||||
|
<Input
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
onChange={(e) => e.target.files && field.handleChange(e.target.files)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</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" variant="outline" onClick={() => setIsOpen(false)}>Hủy</Button>
|
||||||
|
<Button type="submit">Tải lên</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
87
src/components/version-table.tsx
Normal file
87
src/components/version-table.tsx
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
import {
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
useReactTable,
|
||||||
|
type ColumnDef,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
interface VersionTableProps<TData> {
|
||||||
|
data: TData[];
|
||||||
|
columns: ColumnDef<TData, any>[];
|
||||||
|
isLoading: boolean;
|
||||||
|
onTableInit?: (table: any) => void; // <-- thêm
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VersionTable<TData>({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
isLoading,
|
||||||
|
onTableInit,
|
||||||
|
}: VersionTableProps<TData>) {
|
||||||
|
const table = useReactTable({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getRowId: (row: any) => row.id?.toString(),
|
||||||
|
enableRowSelection: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onTableInit?.(table);
|
||||||
|
}, [table, onTableInit]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<TableHead key={header.id}>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext()
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{isLoading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={columns.length}>Đang tải dữ liệu...</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : table.getRowModel().rows.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={columns.length}>Không có dữ liệu.</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
data-state={row.getIsSelected() && "selected"}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id}>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
);
|
||||||
|
}
|
|
@ -8,12 +8,14 @@ export const API_ENDPOINTS = {
|
||||||
APP_VERSION: {
|
APP_VERSION: {
|
||||||
GET_VERSION: `${BASE_URL}/AppVersion/version`,
|
GET_VERSION: `${BASE_URL}/AppVersion/version`,
|
||||||
UPLOAD: `${BASE_URL}/AppVersion/upload`,
|
UPLOAD: `${BASE_URL}/AppVersion/upload`,
|
||||||
|
GET_SOFTWARE: `${BASE_URL}/AppVersion/msifiles`,
|
||||||
},
|
},
|
||||||
DEVICE_COMM: {
|
DEVICE_COMM: {
|
||||||
UPDATE_AGENT: `${BASE_URL}/DeviceComm/updateagent`,
|
DOWNLOAD_MSI: `${BASE_URL}/DeviceComm/installmsi`,
|
||||||
GET_ROOM_LIST: `${BASE_URL}/DeviceComm/rooms`,
|
GET_ROOM_LIST: `${BASE_URL}/DeviceComm/rooms`,
|
||||||
GET_DEVICE_FROM_ROOM: (roomName: string) =>
|
GET_DEVICE_FROM_ROOM: (roomName: string) =>
|
||||||
`${BASE_URL}/DeviceComm/room/${roomName}`,
|
`${BASE_URL}/DeviceComm/room/${roomName}`,
|
||||||
|
UPDATE_AGENT: `${BASE_URL}/DeviceComm/updateagent`,
|
||||||
},
|
},
|
||||||
SSE_EVENTS: {
|
SSE_EVENTS: {
|
||||||
DEVICE_ONLINE: `${BASE_URL}/Sse/events/onlineDevices`,
|
DEVICE_ONLINE: `${BASE_URL}/Sse/events/onlineDevices`,
|
||||||
|
|
|
@ -18,9 +18,9 @@ type AppLayoutProps = {
|
||||||
export default function AppLayout({ children }: AppLayoutProps) {
|
export default function AppLayout({ children }: AppLayoutProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const handlePrefetchApps = () => {
|
const handlePrefetchAgents = () => {
|
||||||
queryClient.prefetchQuery({
|
queryClient.prefetchQuery({
|
||||||
queryKey: ["app-version"],
|
queryKey: ["agent-version"],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
fetch(BASE_URL + API_ENDPOINTS.APP_VERSION.GET_VERSION).then((res) =>
|
fetch(BASE_URL + API_ENDPOINTS.APP_VERSION.GET_VERSION).then((res) =>
|
||||||
res.json()
|
res.json()
|
||||||
|
@ -28,6 +28,16 @@ export default function AppLayout({ children }: AppLayoutProps) {
|
||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
const handlePrefetchSofware = () => {
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ["software-version"],
|
||||||
|
queryFn: () =>
|
||||||
|
fetch(BASE_URL + API_ENDPOINTS.APP_VERSION.GET_SOFTWARE).then((res) =>
|
||||||
|
res.json()
|
||||||
|
),
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handlePrefetchRooms = () => {
|
const handlePrefetchRooms = () => {
|
||||||
queryClient.prefetchQuery({
|
queryClient.prefetchQuery({
|
||||||
|
@ -50,10 +60,13 @@ export default function AppLayout({ children }: AppLayoutProps) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Quản lý Agent",
|
title: "Quản lý Agent",
|
||||||
to: "/apps",
|
to: "/agent",
|
||||||
icon: AppWindow,
|
icon: AppWindow,
|
||||||
onPointerEnter: handlePrefetchApps,
|
onPointerEnter: handlePrefetchAgents,
|
||||||
},
|
},
|
||||||
|
{ title: "Quản lý phần mềm", to: "/apps", icon: AppWindow,
|
||||||
|
onPointerEnter: handlePrefetchSofware,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -14,6 +14,7 @@ import { Route as AuthRouteImport } from './routes/_auth'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
import { Route as AuthenticatedRoomIndexRouteImport } from './routes/_authenticated/room/index'
|
import { Route as AuthenticatedRoomIndexRouteImport } from './routes/_authenticated/room/index'
|
||||||
import { Route as AuthenticatedAppsIndexRouteImport } from './routes/_authenticated/apps/index'
|
import { Route as AuthenticatedAppsIndexRouteImport } from './routes/_authenticated/apps/index'
|
||||||
|
import { Route as AuthenticatedAgentIndexRouteImport } from './routes/_authenticated/agent/index'
|
||||||
import { Route as AuthLoginIndexRouteImport } from './routes/_auth/login/index'
|
import { Route as AuthLoginIndexRouteImport } from './routes/_auth/login/index'
|
||||||
import { Route as AuthenticatedRoomRoomNameIndexRouteImport } from './routes/_authenticated/room/$roomName/index'
|
import { Route as AuthenticatedRoomRoomNameIndexRouteImport } from './routes/_authenticated/room/$roomName/index'
|
||||||
|
|
||||||
|
@ -40,6 +41,11 @@ const AuthenticatedAppsIndexRoute = AuthenticatedAppsIndexRouteImport.update({
|
||||||
path: '/apps/',
|
path: '/apps/',
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
getParentRoute: () => AuthenticatedRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthenticatedAgentIndexRoute = AuthenticatedAgentIndexRouteImport.update({
|
||||||
|
id: '/agent/',
|
||||||
|
path: '/agent/',
|
||||||
|
getParentRoute: () => AuthenticatedRoute,
|
||||||
|
} as any)
|
||||||
const AuthLoginIndexRoute = AuthLoginIndexRouteImport.update({
|
const AuthLoginIndexRoute = AuthLoginIndexRouteImport.update({
|
||||||
id: '/login/',
|
id: '/login/',
|
||||||
path: '/login/',
|
path: '/login/',
|
||||||
|
@ -55,6 +61,7 @@ const AuthenticatedRoomRoomNameIndexRoute =
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/login': typeof AuthLoginIndexRoute
|
'/login': typeof AuthLoginIndexRoute
|
||||||
|
'/agent': typeof AuthenticatedAgentIndexRoute
|
||||||
'/apps': typeof AuthenticatedAppsIndexRoute
|
'/apps': typeof AuthenticatedAppsIndexRoute
|
||||||
'/room': typeof AuthenticatedRoomIndexRoute
|
'/room': typeof AuthenticatedRoomIndexRoute
|
||||||
'/room/$roomName': typeof AuthenticatedRoomRoomNameIndexRoute
|
'/room/$roomName': typeof AuthenticatedRoomRoomNameIndexRoute
|
||||||
|
@ -62,6 +69,7 @@ export interface FileRoutesByFullPath {
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/login': typeof AuthLoginIndexRoute
|
'/login': typeof AuthLoginIndexRoute
|
||||||
|
'/agent': typeof AuthenticatedAgentIndexRoute
|
||||||
'/apps': typeof AuthenticatedAppsIndexRoute
|
'/apps': typeof AuthenticatedAppsIndexRoute
|
||||||
'/room': typeof AuthenticatedRoomIndexRoute
|
'/room': typeof AuthenticatedRoomIndexRoute
|
||||||
'/room/$roomName': typeof AuthenticatedRoomRoomNameIndexRoute
|
'/room/$roomName': typeof AuthenticatedRoomRoomNameIndexRoute
|
||||||
|
@ -72,21 +80,23 @@ export interface FileRoutesById {
|
||||||
'/_auth': typeof AuthRouteWithChildren
|
'/_auth': typeof AuthRouteWithChildren
|
||||||
'/_authenticated': typeof AuthenticatedRouteWithChildren
|
'/_authenticated': typeof AuthenticatedRouteWithChildren
|
||||||
'/_auth/login/': typeof AuthLoginIndexRoute
|
'/_auth/login/': typeof AuthLoginIndexRoute
|
||||||
|
'/_authenticated/agent/': typeof AuthenticatedAgentIndexRoute
|
||||||
'/_authenticated/apps/': typeof AuthenticatedAppsIndexRoute
|
'/_authenticated/apps/': typeof AuthenticatedAppsIndexRoute
|
||||||
'/_authenticated/room/': typeof AuthenticatedRoomIndexRoute
|
'/_authenticated/room/': typeof AuthenticatedRoomIndexRoute
|
||||||
'/_authenticated/room/$roomName/': typeof AuthenticatedRoomRoomNameIndexRoute
|
'/_authenticated/room/$roomName/': typeof AuthenticatedRoomRoomNameIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths: '/' | '/login' | '/apps' | '/room' | '/room/$roomName'
|
fullPaths: '/' | '/login' | '/agent' | '/apps' | '/room' | '/room/$roomName'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to: '/' | '/login' | '/apps' | '/room' | '/room/$roomName'
|
to: '/' | '/login' | '/agent' | '/apps' | '/room' | '/room/$roomName'
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/'
|
| '/'
|
||||||
| '/_auth'
|
| '/_auth'
|
||||||
| '/_authenticated'
|
| '/_authenticated'
|
||||||
| '/_auth/login/'
|
| '/_auth/login/'
|
||||||
|
| '/_authenticated/agent/'
|
||||||
| '/_authenticated/apps/'
|
| '/_authenticated/apps/'
|
||||||
| '/_authenticated/room/'
|
| '/_authenticated/room/'
|
||||||
| '/_authenticated/room/$roomName/'
|
| '/_authenticated/room/$roomName/'
|
||||||
|
@ -135,6 +145,13 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof AuthenticatedAppsIndexRouteImport
|
preLoaderRoute: typeof AuthenticatedAppsIndexRouteImport
|
||||||
parentRoute: typeof AuthenticatedRoute
|
parentRoute: typeof AuthenticatedRoute
|
||||||
}
|
}
|
||||||
|
'/_authenticated/agent/': {
|
||||||
|
id: '/_authenticated/agent/'
|
||||||
|
path: '/agent'
|
||||||
|
fullPath: '/agent'
|
||||||
|
preLoaderRoute: typeof AuthenticatedAgentIndexRouteImport
|
||||||
|
parentRoute: typeof AuthenticatedRoute
|
||||||
|
}
|
||||||
'/_auth/login/': {
|
'/_auth/login/': {
|
||||||
id: '/_auth/login/'
|
id: '/_auth/login/'
|
||||||
path: '/login'
|
path: '/login'
|
||||||
|
@ -163,12 +180,14 @@ const AuthRouteChildren: AuthRouteChildren = {
|
||||||
const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
|
const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
|
||||||
|
|
||||||
interface AuthenticatedRouteChildren {
|
interface AuthenticatedRouteChildren {
|
||||||
|
AuthenticatedAgentIndexRoute: typeof AuthenticatedAgentIndexRoute
|
||||||
AuthenticatedAppsIndexRoute: typeof AuthenticatedAppsIndexRoute
|
AuthenticatedAppsIndexRoute: typeof AuthenticatedAppsIndexRoute
|
||||||
AuthenticatedRoomIndexRoute: typeof AuthenticatedRoomIndexRoute
|
AuthenticatedRoomIndexRoute: typeof AuthenticatedRoomIndexRoute
|
||||||
AuthenticatedRoomRoomNameIndexRoute: typeof AuthenticatedRoomRoomNameIndexRoute
|
AuthenticatedRoomRoomNameIndexRoute: typeof AuthenticatedRoomRoomNameIndexRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
|
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
|
||||||
|
AuthenticatedAgentIndexRoute: AuthenticatedAgentIndexRoute,
|
||||||
AuthenticatedAppsIndexRoute: AuthenticatedAppsIndexRoute,
|
AuthenticatedAppsIndexRoute: AuthenticatedAppsIndexRoute,
|
||||||
AuthenticatedRoomIndexRoute: AuthenticatedRoomIndexRoute,
|
AuthenticatedRoomIndexRoute: AuthenticatedRoomIndexRoute,
|
||||||
AuthenticatedRoomRoomNameIndexRoute: AuthenticatedRoomRoomNameIndexRoute,
|
AuthenticatedRoomRoomNameIndexRoute: AuthenticatedRoomRoomNameIndexRoute,
|
||||||
|
|
|
@ -6,6 +6,12 @@ export interface RouterContext {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Route = createRootRouteWithContext<RouterContext>()({
|
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||||
|
head: () => ({
|
||||||
|
meta: [
|
||||||
|
{ title: "Quản lý phòng máy" },
|
||||||
|
{ name: "description", content: "Ứng dụng quản lý thiết bị và phần mềm" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
component: () => (
|
component: () => (
|
||||||
<>
|
<>
|
||||||
<HeadContent />
|
<HeadContent />
|
||||||
|
@ -13,3 +19,4 @@ export const Route = createRootRouteWithContext<RouterContext>()({
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
81
src/routes/_authenticated/agent/index.tsx
Normal file
81
src/routes/_authenticated/agent/index.tsx
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { AppManagerTemplate } from "@/template/app-manager-template";
|
||||||
|
import { useQueryData } from "@/hooks/useQueryData";
|
||||||
|
import { useMutationData } from "@/hooks/useMutationData";
|
||||||
|
import { BASE_URL, API_ENDPOINTS } from "@/config/api";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
|
|
||||||
|
type Version = {
|
||||||
|
id?: string;
|
||||||
|
version: string;
|
||||||
|
fileName: string;
|
||||||
|
folderPath: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_authenticated/agent/")({
|
||||||
|
head: () => ({ meta: [{ title: "Quản lý Agent" }] }),
|
||||||
|
component: AgentsPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
function AgentsPage() {
|
||||||
|
// Lấy danh sách version
|
||||||
|
const { data, isLoading } = useQueryData({
|
||||||
|
queryKey: ["agent-version"],
|
||||||
|
url: BASE_URL + API_ENDPOINTS.APP_VERSION.GET_VERSION,
|
||||||
|
});
|
||||||
|
|
||||||
|
const versionList: Version[] = Array.isArray(data) ? data : data ? [data] : [];
|
||||||
|
|
||||||
|
// Mutation upload
|
||||||
|
const uploadMutation = useMutationData<FormData>({
|
||||||
|
url: BASE_URL + API_ENDPOINTS.APP_VERSION.UPLOAD,
|
||||||
|
method: "POST",
|
||||||
|
onSuccess: () => toast.success("Upload thành công!"),
|
||||||
|
onError: () => toast.error("Upload thất bại!"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutationData<void>({
|
||||||
|
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.UPDATE_AGENT,
|
||||||
|
method: "POST",
|
||||||
|
onSuccess: () => toast.success("Đã gửi yêu cầu update!"),
|
||||||
|
onError: () => toast.error("Gửi yêu cầu thất bại!"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns: ColumnDef<Version>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "version",
|
||||||
|
header: "Phiên bản",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "fileName",
|
||||||
|
header: "Tên file",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "folderPath",
|
||||||
|
header: "Đường dẫn",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "updatedAt",
|
||||||
|
header: "Thời gian cập nhật",
|
||||||
|
cell: ({ getValue }) =>
|
||||||
|
getValue()
|
||||||
|
? new Date(getValue() as string).toLocaleString("vi-VN")
|
||||||
|
: "N/A",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppManagerTemplate<Version>
|
||||||
|
title="Quản lý Agent"
|
||||||
|
description="Quản lý và theo dõi các phiên bản Agent"
|
||||||
|
data={versionList}
|
||||||
|
isLoading={isLoading}
|
||||||
|
columns={columns}
|
||||||
|
onUpload={(fd) => uploadMutation.mutateAsync(fd)}
|
||||||
|
onUpdate={() => updateMutation.mutateAsync()}
|
||||||
|
updateLoading={updateMutation.isPending}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,262 +1,94 @@
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import {
|
import { AppManagerTemplate } from "@/template/app-manager-template";
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { useQueryData } from "@/hooks/useQueryData";
|
import { useQueryData } from "@/hooks/useQueryData";
|
||||||
import { useMutationData } from "@/hooks/useMutationData";
|
import { useMutationData } from "@/hooks/useMutationData";
|
||||||
import { formOptions, useForm } from "@tanstack/react-form";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { useState } from "react";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { FileText, Plus } from "lucide-react";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import { Progress } from "@/components/ui/progress";
|
|
||||||
|
|
||||||
import { BASE_URL, API_ENDPOINTS } from "@/config/api";
|
import { BASE_URL, API_ENDPOINTS } from "@/config/api";
|
||||||
|
import { toast } from "sonner";
|
||||||
interface UploadAppFormProps {
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
files: FileList;
|
import { useState } from "react";
|
||||||
newVersion: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultInput: UploadAppFormProps = {
|
|
||||||
files: new DataTransfer().files,
|
|
||||||
newVersion: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
const formOpts = formOptions({
|
|
||||||
defaultValues: defaultInput,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authenticated/apps/")({
|
export const Route = createFileRoute("/_authenticated/apps/")({
|
||||||
head: () => ({
|
head: () => ({ meta: [{ title: "Quản lý phần mềm" }] }),
|
||||||
meta: [{ title: "Quản lý Agent" }],
|
|
||||||
}),
|
|
||||||
component: AppsComponent,
|
component: AppsComponent,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type Version = {
|
||||||
|
id: number;
|
||||||
|
version: string;
|
||||||
|
fileName: string;
|
||||||
|
folderPath: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
function AppsComponent() {
|
function AppsComponent() {
|
||||||
const { data: versionData, isLoading } = useQueryData({
|
const { data, isLoading } = useQueryData({
|
||||||
queryKey: ["app-version"],
|
queryKey: ["software-version"],
|
||||||
url: BASE_URL + API_ENDPOINTS.APP_VERSION.GET_VERSION,
|
url: BASE_URL + API_ENDPOINTS.APP_VERSION.GET_SOFTWARE, // API lấy danh sách file MSI
|
||||||
});
|
});
|
||||||
|
|
||||||
const versionList = Array.isArray(versionData)
|
const versionList: Version[] = Array.isArray(data)
|
||||||
? versionData
|
? data
|
||||||
: versionData
|
: data
|
||||||
? [versionData]
|
? [data]
|
||||||
: [];
|
: [];
|
||||||
|
const [table, setTable] = useState<any>();
|
||||||
const [isUploadOpen, setIsUploadOpen] = useState(false);
|
|
||||||
const [uploadPercent, setUploadPercent] = useState(0);
|
|
||||||
|
|
||||||
const uploadMutation = useMutationData<FormData>({
|
const uploadMutation = useMutationData<FormData>({
|
||||||
url: BASE_URL + API_ENDPOINTS.APP_VERSION.UPLOAD,
|
url: BASE_URL + API_ENDPOINTS.APP_VERSION.UPLOAD,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
config: {
|
onSuccess: () => toast.success("Upload thành công!"),
|
||||||
onUploadProgress: (e: ProgressEvent) => {
|
onError: () => toast.error("Upload thất bại!"),
|
||||||
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!");
|
|
||||||
setUploadPercent(0);
|
|
||||||
form.reset();
|
|
||||||
setIsUploadOpen(false);
|
|
||||||
},
|
|
||||||
onError: () => toast.error("Lỗi khi cập nhật phiên bản!"),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateAgentMutation = useMutationData<void>({
|
const installMutation = useMutationData<{ msiFileIds: number[] }>({
|
||||||
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.UPDATE_AGENT,
|
url: BASE_URL+ API_ENDPOINTS.DEVICE_COMM.DOWNLOAD_MSI,
|
||||||
method: "POST",
|
onSuccess: () => toast.success("Đã gửi yêu cầu cài đặt MSI!"),
|
||||||
onSuccess: () => toast.success("Đã gửi yêu cầu cập nhật đến thiết bị!"),
|
|
||||||
onError: () => toast.error("Gửi yêu cầu thất bại!"),
|
onError: () => toast.error("Gửi yêu cầu thất bại!"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const form = useForm({
|
const columns: ColumnDef<Version>[] = [
|
||||||
...formOpts,
|
{
|
||||||
onSubmit: async ({ value }) => {
|
id: "select",
|
||||||
const typedValue = value as UploadAppFormProps;
|
header: () => <span>Thêm vào danh sách yêu cầu</span>,
|
||||||
if (!typedValue.newVersion || typedValue.files.length === 0) {
|
cell: ({ row }) => (
|
||||||
toast.error("Vui lòng điền đầy đủ thông tin");
|
<input
|
||||||
return;
|
type="checkbox"
|
||||||
}
|
checked={row.getIsSelected?.() ?? false}
|
||||||
const formData = new FormData();
|
onChange={row.getToggleSelectedHandler?.()}
|
||||||
Array.from(typedValue.files).forEach((file) =>
|
/>
|
||||||
formData.append("files", file)
|
),
|
||||||
);
|
enableSorting: false,
|
||||||
formData.append("newVersion", typedValue.newVersion);
|
enableHiding: false,
|
||||||
|
|
||||||
await uploadMutation.mutateAsync(formData);
|
|
||||||
},
|
},
|
||||||
});
|
{ accessorKey: "version", header: "Phiên bản" },
|
||||||
|
{ accessorKey: "fileName", header: "Tên file" },
|
||||||
|
{ accessorKey: "folderPath", header: "Đường dẫn" },
|
||||||
|
{
|
||||||
|
accessorKey: "updatedAt",
|
||||||
|
header: "Thời gian cập nhật",
|
||||||
|
cell: ({ getValue }) =>
|
||||||
|
getValue()
|
||||||
|
? new Date(getValue() as string).toLocaleString("vi-VN")
|
||||||
|
: "N/A",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full px-6 space-y-4">
|
<AppManagerTemplate<Version>
|
||||||
<div className="flex items-center justify-between">
|
title="Quản lý phần mềm"
|
||||||
<div>
|
description="Quản lý và gửi yêu cầu cài đặt phần mềm MSI"
|
||||||
<h1 className="text-3xl font-bold">Quản lý Agent</h1>
|
data={versionList}
|
||||||
<p className="text-muted-foreground mt-2">
|
isLoading={isLoading}
|
||||||
Quản lý và theo dõi các phiên bản Agent
|
columns={columns}
|
||||||
</p>
|
onUpload={(fd) => uploadMutation.mutateAsync(fd)}
|
||||||
</div>
|
onTableInit={setTable}
|
||||||
|
onUpdate={() => {
|
||||||
|
const selectedIds = table
|
||||||
|
?.getSelectedRowModel()
|
||||||
|
.rows.map((row: any) => (row.original as Version).id);
|
||||||
|
|
||||||
<Dialog open={isUploadOpen} onOpenChange={setIsUploadOpen}>
|
installMutation.mutateAsync({ msiFileIds: selectedIds });
|
||||||
<DialogTrigger asChild>
|
}}
|
||||||
<Button className="gap-2">
|
updateLoading={installMutation.isPending}
|
||||||
<Plus className="h-4 w-4" />
|
/>
|
||||||
Cập nhật phiên bản mới
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="sm:max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Cập nhật phiên bản mới</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Chọn tệp và nhập số phiên bản
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<form className="space-y-4" onSubmit={form.handleSubmit}>
|
|
||||||
<form.Field name="newVersion">
|
|
||||||
{(field) => (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Phiên bản</Label>
|
|
||||||
<Input
|
|
||||||
value={field.state.value}
|
|
||||||
onChange={(e) => field.handleChange(e.target.value)}
|
|
||||||
placeholder="e.g., 1.0.0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</form.Field>
|
|
||||||
|
|
||||||
<form.Field name="files">
|
|
||||||
{(field) => (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>File ứng dụng</Label>
|
|
||||||
<Input
|
|
||||||
type="file"
|
|
||||||
accept=".exe,.msi,.apk"
|
|
||||||
onChange={(e) => {
|
|
||||||
if (e.target.files) {
|
|
||||||
field.handleChange(e.target.files);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</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"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setIsUploadOpen(false)}
|
|
||||||
>
|
|
||||||
Hủy
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={uploadMutation.isPending}>
|
|
||||||
{uploadMutation.isPending ? "Đang tải..." : "Tải lên"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="w-full">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<FileText className="h-5 w-5" />
|
|
||||||
Lịch sử phiên bản
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Tất cả các phiên bản đã tải lên của Agent
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Phiên bản</TableHead>
|
|
||||||
<TableHead>Tên tệp</TableHead>
|
|
||||||
<TableHead>Đường dẫn thư mục</TableHead>
|
|
||||||
<TableHead>Thời gian cập nhật</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{isLoading ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={4}>Đang tải dữ liệu...</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : versionList.length === 0 ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={4}>Không có dữ liệu phiên bản.</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : (
|
|
||||||
versionList.map((v: any) => (
|
|
||||||
<TableRow key={v.id || v.version}>
|
|
||||||
<TableCell>{v.version}</TableCell>
|
|
||||||
<TableCell>{v.fileName}</TableCell>
|
|
||||||
<TableCell>{v.folderPath}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{v.updatedAt
|
|
||||||
? new Date(v.updatedAt).toLocaleString("vi-VN")
|
|
||||||
: "N/A"}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => updateAgentMutation.mutateAsync()}
|
|
||||||
disabled={updateAgentMutation.isPending}
|
|
||||||
>
|
|
||||||
{updateAgentMutation.isPending
|
|
||||||
? "Đang gửi..."
|
|
||||||
: "Yêu cầu thiết bị cập nhật"}
|
|
||||||
</Button>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
71
src/template/app-manager-template.tsx
Normal file
71
src/template/app-manager-template.tsx
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
import { type ColumnDef } from "@tanstack/react-table";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { FileText } from "lucide-react";
|
||||||
|
import { UploadDialog } from "@/components/upload-dialog";
|
||||||
|
import { VersionTable } from "@/components/version-table";
|
||||||
|
import { UpdateButton } from "@/components/update-button";
|
||||||
|
|
||||||
|
interface AppManagerTemplateProps<TData> {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
data: TData[];
|
||||||
|
isLoading: boolean;
|
||||||
|
columns: ColumnDef<TData, any>[];
|
||||||
|
onUpload: (fd: FormData) => Promise<void>;
|
||||||
|
onUpdate?: () => void;
|
||||||
|
updateLoading?: boolean;
|
||||||
|
onTableInit?: (table: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppManagerTemplate<TData>({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
columns,
|
||||||
|
onUpload,
|
||||||
|
onUpdate,
|
||||||
|
updateLoading,
|
||||||
|
onTableInit,
|
||||||
|
}: AppManagerTemplateProps<TData>) {
|
||||||
|
return (
|
||||||
|
<div className="w-full px-6 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">{title}</h1>
|
||||||
|
<p className="text-muted-foreground mt-2">{description}</p>
|
||||||
|
</div>
|
||||||
|
<UploadDialog onSubmit={onUpload} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="w-full">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<FileText className="h-5 w-5" /> Lịch sử phiên bản
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Tất cả các phiên bản đã tải lên</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<VersionTable
|
||||||
|
data={data}
|
||||||
|
isLoading={isLoading}
|
||||||
|
columns={columns}
|
||||||
|
onTableInit={onTableInit}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
{onUpdate && (
|
||||||
|
<CardFooter>
|
||||||
|
<UpdateButton onClick={onUpdate} loading={updateLoading} />
|
||||||
|
</CardFooter>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user