add send cmd page

This commit is contained in:
Do Manh Phuong 2025-09-24 16:13:57 +07:00
parent 328d499dca
commit 8f41579972
16 changed files with 643 additions and 160 deletions

90
package-lock.json generated
View File

@ -13,7 +13,7 @@
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tooltip": "^1.2.7",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/react-form": "^1.15.0",
"@tanstack/react-form": "^1.23.0",
"@tanstack/react-query": "^5.83.0",
"@tanstack/react-router": "^1.121.2",
"@tanstack/react-router-devtools": "^1.121.2",
@ -31,6 +31,7 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.11",
"zod": "^3.25.76",
"zustand": "^5.0.6"
},
"devDependencies": {
@ -2385,12 +2386,26 @@
"vite": "^5.2.0 || ^6 || ^7"
}
},
"node_modules/@tanstack/devtools-event-client": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/@tanstack/devtools-event-client/-/devtools-event-client-0.2.5.tgz",
"integrity": "sha512-iVdqw879KETXyyPHc3gQR5Ld0GjlPLk7bKenBUhzr3+z1FiQZvsbfgYfRRokTSPcgwANAV7aA2Uv05nx5xWT8A==",
"engines": {
"node": ">=18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/form-core": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/@tanstack/form-core/-/form-core-1.15.0.tgz",
"integrity": "sha512-zMNyxb/J/JnFmW4Gzb1TSxaXmwNhvsaF9p3dGRpE93TMGp2ojPKK7V5LZ43ZV7iFTYWTL8NOIU8ZXuf9qZVkmw==",
"version": "1.23.0",
"resolved": "https://registry.npmjs.org/@tanstack/form-core/-/form-core-1.23.0.tgz",
"integrity": "sha512-aBJnO5iA1TtMK/SSpAvd/y6cVsBgbu5Br2FftvW6r7TkHCcZ/lB2DMx3G2dk6SG7sW0g8s6F7iaMjrvqBGooDw==",
"dependencies": {
"@tanstack/store": "^0.7.2"
"@tanstack/devtools-event-client": "^0.2.5",
"@tanstack/store": "^0.7.5",
"uuid": "^13.0.0"
},
"funding": {
"type": "github",
@ -2419,30 +2434,26 @@
}
},
"node_modules/@tanstack/react-form": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/@tanstack/react-form/-/react-form-1.15.0.tgz",
"integrity": "sha512-bAawFDxR1wLn+eXli6MSyS4Nw0vTyHuW3CybjZGtk7NIcYxyaAm9cW/jPUX2j/KMDtjVNg5RMpfmufxDrsNHyA==",
"version": "1.23.0",
"resolved": "https://registry.npmjs.org/@tanstack/react-form/-/react-form-1.23.0.tgz",
"integrity": "sha512-wZ/cBjqj5dgWG79TQOsilbs8WzdouJj3iXw1DAZvQ4nejobsTGl0huU7SZZ0mnFtdNHbre1bFSdWyw+GqFcIhQ==",
"dependencies": {
"@tanstack/form-core": "1.15.0",
"@tanstack/react-store": "^0.7.3",
"@tanstack/form-core": "1.23.0",
"@tanstack/react-store": "^0.7.5",
"decode-formdata": "^0.9.0",
"devalue": "^5.1.1"
"devalue": "^5.3.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"@tanstack/react-start": "^1.112.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"vinxi": "^0.5.0"
"@tanstack/react-start": "^1.130.10",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@tanstack/react-start": {
"optional": true
},
"vinxi": {
"optional": true
}
}
},
@ -2506,11 +2517,11 @@
}
},
"node_modules/@tanstack/react-store": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.7.3.tgz",
"integrity": "sha512-3Dnqtbw9P2P0gw8uUM8WP2fFfg8XMDSZCTsywRPZe/XqqYW8PGkXKZTvP0AHkE4mpqP9Y43GpOg9vwO44azu6Q==",
"version": "0.7.7",
"resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.7.7.tgz",
"integrity": "sha512-qqT0ufegFRDGSof9D/VqaZgjNgp4tRPHZIJq2+QIHkMUtHjaJ0lYrrXjeIUJvjnTbgPfSD1XgOMEt0lmANn6Zg==",
"dependencies": {
"@tanstack/store": "0.7.2",
"@tanstack/store": "0.7.7",
"use-sync-external-store": "^1.5.0"
},
"funding": {
@ -2685,9 +2696,9 @@
}
},
"node_modules/@tanstack/store": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.7.2.tgz",
"integrity": "sha512-RP80Z30BYiPX2Pyo0Nyw4s1SJFH2jyM6f9i3HfX4pA+gm5jsnYryscdq2aIQLnL4TaGuQMO+zXmN9nh1Qck+Pg==",
"version": "0.7.7",
"resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.7.7.tgz",
"integrity": "sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
@ -3215,9 +3226,9 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"node_modules/axios": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz",
"integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==",
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
@ -3897,9 +3908,9 @@
"license": "MIT"
},
"node_modules/devalue": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.1.1.tgz",
"integrity": "sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw=="
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.3.2.tgz",
"integrity": "sha512-UDsjUbpQn9kvm68slnrs+mfxwFkIflOhkanmyabZ8zOYk8SMEIbJ3TK+88g70hSIeytu4y18f0z/hYHMTrXIWw=="
},
"node_modules/diff": {
"version": "8.0.2",
@ -7232,6 +7243,18 @@
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
"node_modules/uuid": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"bin": {
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@ -7241,10 +7264,9 @@
}
},
"node_modules/vite": {
"version": "6.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
"license": "MIT",
"version": "6.3.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz",
"integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==",
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",

View File

@ -17,7 +17,7 @@
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tooltip": "^1.2.7",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/react-form": "^1.15.0",
"@tanstack/react-form": "^1.23.0",
"@tanstack/react-query": "^5.83.0",
"@tanstack/react-router": "^1.121.2",
"@tanstack/react-router-devtools": "^1.121.2",
@ -35,6 +35,7 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.11",
"zod": "^3.25.76",
"zustand": "^5.0.6"
},
"devDependencies": {

View File

@ -0,0 +1,90 @@
"use client";
import { useState } from "react";
import { useForm } from "@tanstack/react-form";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Loader2, Terminal, AlertTriangle, CheckCircle } from "lucide-react";
interface ShellCommandFormProps {
onExecute: (command: string) => Promise<{ success: boolean; output: string }>;
}
export function ShellCommandForm({ onExecute }: ShellCommandFormProps) {
const [isLoading, setIsLoading] = useState(false);
// init form
const form = useForm({
defaultValues: {
command: "",
},
onSubmit: async ({ value }) => {
setIsLoading(true);
try {
const res = await onExecute(value.command);
if (res.success) {
form.reset();
}
} finally {
setIsLoading(false);
}
},
});
return (
<div className="space-y-6">
{/* Form */}
<form
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
className="space-y-5"
>
{/* Field: command */}
<form.Field
name="command"
validators={{
onChange: z
.string()
.min(1, "Nhập command để thực thi")
.max(500, "Command quá dài"),
}}
children={(field) => (
<div className="w-full px-0">
<Textarea
className="w-full h-[25vh]"
placeholder="Nhập lệnh..."
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
disabled={isLoading}
/>
{field.state.meta.errors?.length > 0 && (
<p className="text-sm text-red-500">
{field.state.meta.errors.join(", ")}
</p>
)}
</div>
)}
/>
<Button type="submit" disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Yêu cầu thiết bị thực thi
</Button>
</form>
</div>
);
}

View File

@ -0,0 +1,66 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }

View File

@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@ -1,36 +1,36 @@
import { useState } from "react";
import {
Dialog, DialogContent, DialogDescription, DialogFooter,
DialogHeader, DialogTitle, DialogTrigger,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
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 { useForm, formOptions } from "@tanstack/react-form";
import { toast } from "sonner";
import type { AxiosProgressEvent } from "axios";
interface UploadDialogProps {
onSubmit: (formData: FormData) => Promise<void>;
accept?: string;
onSubmit: (
fd: FormData,
config?: { onUploadProgress?: (e: AxiosProgressEvent) => void }
) => Promise<void>;
}
interface UploadFormProps {
files: FileList;
newVersion: string;
}
const formOpts = formOptions({
defaultValues: { files: new DataTransfer().files, newVersion: "" },
});
const defaultInput: UploadFormProps = {
files: new DataTransfer().files,
newVersion: "",
};
const formOpts = formOptions({ defaultValues: defaultInput });
export function UploadDialog({ onSubmit, accept = ".exe,.msi,.apk" }: UploadDialogProps) {
export function UploadDialog({ onSubmit }: UploadDialogProps) {
const [isOpen, setIsOpen] = useState(false);
const [uploadPercent, setUploadPercent] = useState(0);
const [isUploading, setIsUploading] = useState(false);
const [isDone, setIsDone] = useState(false);
const form = useForm({
...formOpts,
@ -40,39 +40,71 @@ export function UploadDialog({ onSubmit, accept = ".exe,.msi,.apk" }: UploadDial
return;
}
const fd = new FormData();
Array.from(value.files).forEach((file) => fd.append("files", file));
fd.append("newVersion", value.newVersion);
try {
setIsUploading(true);
setUploadPercent(0);
setIsDone(false);
setUploadPercent(0);
await onSubmit(fd);
setIsOpen(false);
form.reset();
const fd = new FormData();
Array.from(value.files).forEach((f) => fd.append("FileInput", f));
fd.append("Version", value.newVersion);
await onSubmit(fd, {
onUploadProgress: (e: AxiosProgressEvent) => {
if (e.total) {
const progress = Math.round((e.loaded * 100) / e.total);
setUploadPercent(progress);
}
},
});
setIsDone(true);
} catch (error) {
console.error("Upload error:", error);
toast.error("Upload thất bại!");
} finally {
setIsUploading(false);
}
},
});
const handleDialogClose = (open: boolean) => {
if (isUploading) return;
setIsOpen(open);
if (!open) {
setUploadPercent(0);
setIsDone(false);
form.reset();
}
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<Dialog open={isOpen} onOpenChange={handleDialogClose}>
<DialogTrigger asChild>
<Button className="gap-2">
<Plus className="h-4 w-4" /> Cập nhật phiên bản mới
</Button>
<Button>Tải lên phiên bản mới</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogContent>
<DialogHeader>
<DialogTitle>Cập nhật phiên bản mới</DialogTitle>
<DialogDescription>Chọn tệp nhập số phiên bản</DialogDescription>
<DialogTitle>Cập nhật phiên bản</DialogTitle>
</DialogHeader>
<form className="space-y-4" onSubmit={form.handleSubmit}>
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
>
<form.Field name="newVersion">
{(field) => (
<div className="space-y-2">
<div>
<Label>Phiên bản</Label>
<Input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
placeholder="e.g., 1.0.0"
placeholder="1.0.0"
disabled={isUploading || isDone}
/>
</div>
)}
@ -80,28 +112,50 @@ export function UploadDialog({ onSubmit, accept = ".exe,.msi,.apk" }: UploadDial
<form.Field name="files">
{(field) => (
<div className="space-y-2">
<Label>File ng dụng</Label>
<div>
<Label>File</Label>
<Input
type="file"
accept={accept}
onChange={(e) => e.target.files && field.handleChange(e.target.files)}
accept=".exe,.msi,.apk"
onChange={(e) =>
e.target.files && field.handleChange(e.target.files)
}
disabled={isUploading || isDone}
/>
</div>
)}
</form.Field>
{uploadPercent > 0 && (
{(uploadPercent > 0 || isUploading || isDone) && (
<div className="space-y-2">
<Label>Tiến trình upload</Label>
<div className="flex justify-between text-sm">
<span>{isDone ? "Hoàn tất!" : "Đang tải lên..."}</span>
<span>{uploadPercent}%</span>
</div>
<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>
{!isDone ? (
<>
<Button
type="button"
variant="outline"
onClick={() => handleDialogClose(false)}
disabled={isUploading}
>
Hủy
</Button>
<Button type="submit" disabled={isUploading}>
{isUploading ? "Đang tải..." : "Upload"}
</Button>
</>
) : (
<Button type="button" onClick={() => handleDialogClose(false)}>
Hoàn tất
</Button>
)}
</DialogFooter>
</form>
</DialogContent>

View File

@ -16,6 +16,8 @@ export const API_ENDPOINTS = {
GET_DEVICE_FROM_ROOM: (roomName: string) =>
`${BASE_URL}/DeviceComm/room/${roomName}`,
UPDATE_AGENT: `${BASE_URL}/DeviceComm/updateagent`,
SEND_COMMAND: `${BASE_URL}/DeviceComm/shellcommand`,
CHANGE_DEVICE_ROOM: `${BASE_URL}/DeviceComm/changeroom`,
},
SSE_EVENTS: {
DEVICE_ONLINE: `${BASE_URL}/Sse/events/onlineDevices`,

View File

@ -1,16 +1,13 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"
import axios, { type Method } from "axios"
import { useMutation, useQueryClient } from "@tanstack/react-query";
import axios, { type Method } from "axios";
type MutationDataOptions<TInput, TOutput> = {
url: string
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
}
url: string;
method?: Method;
onSuccess?: (data: TOutput) => void;
onError?: (error: any) => void;
invalidate?: string[][];
};
export function useMutationData<TInput = any, TOutput = any>({
url,
@ -19,24 +16,31 @@ export function useMutationData<TInput = any, TOutput = any>({
onError,
invalidate = [],
}: MutationDataOptions<TInput, TOutput>) {
const queryClient = useQueryClient()
const queryClient = useQueryClient();
return useMutation<TOutput, any, { data: TInput; config?: any }>({
mutationFn: async ({ data, config }) => {
const isFormData = data instanceof FormData;
return useMutation<TOutput, any, TInput>({
mutationFn: async (data: TInput) => {
const response = await axios.request({
url,
method,
data,
})
return response.data
headers: {
...(isFormData
? {}
: { "Content-Type": "application/json" }),
},
...config,
});
return response.data;
},
onSuccess: (data) => {
invalidate.forEach((key) =>
queryClient.invalidateQueries({ queryKey: key })
)
onSuccess?.(data)
);
onSuccess?.(data);
},
onError,
})
});
}

View File

@ -5,7 +5,7 @@ import {
SidebarInset,
SidebarTrigger,
} from "@/components/ui/sidebar";
import { Home, Building, AppWindow } from "lucide-react";
import { Home, Building, AppWindow, Terminal } from "lucide-react";
import { Toaster } from "@/components/ui/sonner";
import { useQueryClient } from "@tanstack/react-query";
import { API_ENDPOINTS, BASE_URL } from "@/config/api";
@ -67,6 +67,7 @@ export default function AppLayout({ children }: AppLayoutProps) {
{ title: "Quản lý phần mềm", to: "/apps", icon: AppWindow,
onPointerEnter: handlePrefetchSofware,
},
{ title: "Gửi lệnh CMD", to: "/command", icon: Terminal },
];
return (

View File

@ -13,6 +13,7 @@ import { Route as AuthenticatedRouteImport } from './routes/_authenticated'
import { Route as AuthRouteImport } from './routes/_auth'
import { Route as IndexRouteImport } from './routes/index'
import { Route as AuthenticatedRoomIndexRouteImport } from './routes/_authenticated/room/index'
import { Route as AuthenticatedCommandIndexRouteImport } from './routes/_authenticated/command/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'
@ -36,6 +37,12 @@ const AuthenticatedRoomIndexRoute = AuthenticatedRoomIndexRouteImport.update({
path: '/room/',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedCommandIndexRoute =
AuthenticatedCommandIndexRouteImport.update({
id: '/command/',
path: '/command/',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedAppsIndexRoute = AuthenticatedAppsIndexRouteImport.update({
id: '/apps/',
path: '/apps/',
@ -63,6 +70,7 @@ export interface FileRoutesByFullPath {
'/login': typeof AuthLoginIndexRoute
'/agent': typeof AuthenticatedAgentIndexRoute
'/apps': typeof AuthenticatedAppsIndexRoute
'/command': typeof AuthenticatedCommandIndexRoute
'/room': typeof AuthenticatedRoomIndexRoute
'/room/$roomName': typeof AuthenticatedRoomRoomNameIndexRoute
}
@ -71,6 +79,7 @@ export interface FileRoutesByTo {
'/login': typeof AuthLoginIndexRoute
'/agent': typeof AuthenticatedAgentIndexRoute
'/apps': typeof AuthenticatedAppsIndexRoute
'/command': typeof AuthenticatedCommandIndexRoute
'/room': typeof AuthenticatedRoomIndexRoute
'/room/$roomName': typeof AuthenticatedRoomRoomNameIndexRoute
}
@ -82,14 +91,29 @@ export interface FileRoutesById {
'/_auth/login/': typeof AuthLoginIndexRoute
'/_authenticated/agent/': typeof AuthenticatedAgentIndexRoute
'/_authenticated/apps/': typeof AuthenticatedAppsIndexRoute
'/_authenticated/command/': typeof AuthenticatedCommandIndexRoute
'/_authenticated/room/': typeof AuthenticatedRoomIndexRoute
'/_authenticated/room/$roomName/': typeof AuthenticatedRoomRoomNameIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/login' | '/agent' | '/apps' | '/room' | '/room/$roomName'
fullPaths:
| '/'
| '/login'
| '/agent'
| '/apps'
| '/command'
| '/room'
| '/room/$roomName'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/login' | '/agent' | '/apps' | '/room' | '/room/$roomName'
to:
| '/'
| '/login'
| '/agent'
| '/apps'
| '/command'
| '/room'
| '/room/$roomName'
id:
| '__root__'
| '/'
@ -98,6 +122,7 @@ export interface FileRouteTypes {
| '/_auth/login/'
| '/_authenticated/agent/'
| '/_authenticated/apps/'
| '/_authenticated/command/'
| '/_authenticated/room/'
| '/_authenticated/room/$roomName/'
fileRoutesById: FileRoutesById
@ -138,6 +163,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedRoomIndexRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/command/': {
id: '/_authenticated/command/'
path: '/command'
fullPath: '/command'
preLoaderRoute: typeof AuthenticatedCommandIndexRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/apps/': {
id: '/_authenticated/apps/'
path: '/apps'
@ -182,6 +214,7 @@ const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
interface AuthenticatedRouteChildren {
AuthenticatedAgentIndexRoute: typeof AuthenticatedAgentIndexRoute
AuthenticatedAppsIndexRoute: typeof AuthenticatedAppsIndexRoute
AuthenticatedCommandIndexRoute: typeof AuthenticatedCommandIndexRoute
AuthenticatedRoomIndexRoute: typeof AuthenticatedRoomIndexRoute
AuthenticatedRoomRoomNameIndexRoute: typeof AuthenticatedRoomRoomNameIndexRoute
}
@ -189,6 +222,7 @@ interface AuthenticatedRouteChildren {
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
AuthenticatedAgentIndexRoute: AuthenticatedAgentIndexRoute,
AuthenticatedAppsIndexRoute: AuthenticatedAppsIndexRoute,
AuthenticatedCommandIndexRoute: AuthenticatedCommandIndexRoute,
AuthenticatedRoomIndexRoute: AuthenticatedRoomIndexRoute,
AuthenticatedRoomRoomNameIndexRoute: AuthenticatedRoomRoomNameIndexRoute,
}

View File

@ -5,6 +5,7 @@ import { useMutationData } from "@/hooks/useMutationData";
import { BASE_URL, API_ENDPOINTS } from "@/config/api";
import { toast } from "sonner";
import type { ColumnDef } from "@tanstack/react-table";
import type { AxiosProgressEvent } from "axios";
type Version = {
id?: string;
@ -12,6 +13,7 @@ type Version = {
fileName: string;
folderPath: string;
updatedAt?: string;
requestUpdateAt?: string;
};
export const Route = createFileRoute("/_authenticated/agent/")({
@ -26,16 +28,25 @@ function AgentsPage() {
url: BASE_URL + API_ENDPOINTS.APP_VERSION.GET_VERSION,
});
const versionList: Version[] = Array.isArray(data) ? data : data ? [data] : [];
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",
invalidate: [["agent-version"]],
onSuccess: () => toast.success("Upload thành công!"),
onError: () => toast.error("Upload thất bại!"),
onError: (error) => {
console.error("Upload error:", error)
toast.error("Upload thất bại!")
},
});
// Mutation update
const updateMutation = useMutationData<void>({
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.UPDATE_AGENT,
method: "POST",
@ -44,18 +55,9 @@ function AgentsPage() {
});
const columns: ColumnDef<Version>[] = [
{
accessorKey: "version",
header: "Phiên bản",
},
{
accessorKey: "fileName",
header: "Tên file",
},
{
accessorKey: "folderPath",
header: "Đường dẫn",
},
{ 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",
@ -64,8 +66,32 @@ function AgentsPage() {
? new Date(getValue() as string).toLocaleString("vi-VN")
: "N/A",
},
{
accessorKey: "requestUpdateAt",
header: "Thời gian yêu cầu cập nhật",
cell: ({ getValue }) =>
getValue()
? new Date(getValue() as string).toLocaleString("vi-VN")
: "N/A",
}
];
const handleUpload = async (
fd: FormData,
config?: { onUploadProgress?: (e: AxiosProgressEvent) => void }
) => {
return uploadMutation.mutateAsync({
data: fd,
config
});
};
const handleUpdate = async () => {
return updateMutation.mutateAsync({
data: undefined,
});
};
return (
<AppManagerTemplate<Version>
title="Quản lý Agent"
@ -73,9 +99,9 @@ function AgentsPage() {
data={versionList}
isLoading={isLoading}
columns={columns}
onUpload={(fd) => uploadMutation.mutateAsync(fd)}
onUpdate={() => updateMutation.mutateAsync()}
onUpload={handleUpload}
onUpdate={handleUpdate}
updateLoading={updateMutation.isPending}
/>
);
}
}

View File

@ -6,6 +6,7 @@ import { BASE_URL, API_ENDPOINTS } from "@/config/api";
import { toast } from "sonner";
import type { ColumnDef } from "@tanstack/react-table";
import { useState } from "react";
import type { AxiosProgressEvent } from "axios";
export const Route = createFileRoute("/_authenticated/apps/")({
head: () => ({ meta: [{ title: "Quản lý phần mềm" }] }),
@ -18,6 +19,7 @@ type Version = {
fileName: string;
folderPath: string;
updatedAt?: string;
requestUpdateAt?: string;
};
function AppsComponent() {
@ -29,36 +31,34 @@ function AppsComponent() {
const versionList: Version[] = Array.isArray(data)
? data
: data
? [data]
: [];
? [data]
: [];
const [table, setTable] = useState<any>();
const uploadMutation = useMutationData<FormData>({
url: BASE_URL + API_ENDPOINTS.APP_VERSION.UPLOAD,
method: "POST",
invalidate: [["software-version"]], // Add this to refresh data after upload
onSuccess: () => toast.success("Upload thành công!"),
onError: () => toast.error("Upload thất bại!"),
onError: (error) => {
console.error("Upload error:", error);
toast.error("Upload thất bại!");
},
});
const installMutation = useMutationData<{ msiFileIds: number[] }>({
url: BASE_URL+ API_ENDPOINTS.DEVICE_COMM.DOWNLOAD_MSI,
const installMutation = useMutationData<{ MsiFileIds: number[] }>({
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.DOWNLOAD_MSI,
method: "POST",
onSuccess: () => toast.success("Đã gửi yêu cầu cài đặt MSI!"),
onError: () => toast.error("Gửi yêu cầu thất bại!"),
onError: (error) => {
console.error("Install error:", error);
toast.error("Gửi yêu cầu thất bại!");
},
});
const columns: ColumnDef<Version>[] = [
{
id: "select",
header: () => <span>Thêm vào danh sách yêu cầu</span>,
cell: ({ row }) => (
<input
type="checkbox"
checked={row.getIsSelected?.() ?? false}
onChange={row.getToggleSelectedHandler?.()}
/>
),
enableSorting: false,
enableHiding: false,
},
{ accessorKey: "version", header: "Phiên bản" },
{ accessorKey: "fileName", header: "Tên file" },
{ accessorKey: "folderPath", header: "Đường dẫn" },
@ -70,8 +70,58 @@ function AppsComponent() {
? new Date(getValue() as string).toLocaleString("vi-VN")
: "N/A",
},
{
accessorKey: "requestUpdateAt",
header: "Thời gian yêu cầu cài đặt",
},
{
id: "select",
header: () => <span>Thêm vào danh sách yêu cầu</span>,
cell: ({ row }) => (
<input
type="checkbox"
checked={row.getIsSelected?.() ?? false}
onChange={row.getToggleSelectedHandler?.()}
disabled={installMutation.isPending}
/>
),
enableSorting: false,
enableHiding: false,
}
];
const handleUpload = async (
fd: FormData,
config?: { onUploadProgress?: (e: AxiosProgressEvent) => void }
) => {
return uploadMutation.mutateAsync({
data: fd,
config,
});
};
const handleInstall = async () => {
if (!table) {
toast.error("Không thể lấy thông tin bảng!");
return;
}
const selectedRows = table.getSelectedRowModel().rows;
if (selectedRows.length === 0) {
toast.error("Vui lòng chọn ít nhất một file để cài đặt!");
return;
}
const MsiFileIds = selectedRows.map((row: any) => row.original.id);
console.log("Selected MSI file IDs:", MsiFileIds);
return installMutation.mutateAsync({
data: { MsiFileIds },
});
};
return (
<AppManagerTemplate<Version>
title="Quản lý phần mềm"
@ -79,16 +129,10 @@ function AppsComponent() {
data={versionList}
isLoading={isLoading}
columns={columns}
onUpload={(fd) => uploadMutation.mutateAsync(fd)}
onTableInit={setTable}
onUpdate={() => {
const selectedIds = table
?.getSelectedRowModel()
.rows.map((row: any) => (row.original as Version).id);
installMutation.mutateAsync({ msiFileIds: selectedIds });
}}
updateLoading={installMutation.isPending}
onUpload={handleUpload}
onUpdate={handleInstall}
updateLoading={installMutation.isPending}
onTableInit={setTable}
/>
);
}
}

View File

@ -0,0 +1,46 @@
import { createFileRoute } from "@tanstack/react-router";
import { FormSubmitTemplate } from "@/template/form-submit-template";
import { ShellCommandForm } from "@/components/command-form";
import { useMutationData } from "@/hooks/useMutationData";
import { BASE_URL, API_ENDPOINTS } from "@/config/api";
import { toast } from "sonner";
export const Route = createFileRoute("/_authenticated/command/")({
head: () => ({ meta: [{ title: "Gửi lệnh CMD" }] }),
component: CommandPage,
});
function CommandPage() {
const sendCommandMutation = useMutationData<
string,
{ success: boolean; output: string }
>({
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.SEND_COMMAND,
method: "POST",
onSuccess: (data) => {
if (data.success) {
toast.success("Lệnh đã được gửi thành công!");
} else {
toast.error("Lệnh không thể thực thi trên thiết bị!");
}
},
onError: (error) => {
console.error("Send command error:", error);
toast.error("Gửi lệnh thất bại!");
},
});
return (
<FormSubmitTemplate
title="CMD Command"
description="Gửi lệnh shell xuống thiết bị để thực thi"
isLoading={sendCommandMutation.isPending}
>
<ShellCommandForm
onExecute={async (cmd: string) => {
return await sendCommandMutation.mutateAsync({ data: cmd });
}}
/>
</FormSubmitTemplate>
);
}

View File

@ -1,16 +1,9 @@
import {
createFileRoute,
Outlet,
redirect,
} from "@tanstack/react-router";
import AppLayout from "@/layouts/app-layout";
export const Route = createFileRoute("/")({
// beforeLoad: async ({context}) => {
// const {authToken} = context.auth
// if (!authToken) {
// throw redirect({to: '/login'})
// }
// },
head: () => ({
meta: [
{

View File

@ -11,6 +11,7 @@ import { FileText } from "lucide-react";
import { UploadDialog } from "@/components/upload-dialog";
import { VersionTable } from "@/components/version-table";
import { UpdateButton } from "@/components/update-button";
import type { AxiosProgressEvent } from "axios";
interface AppManagerTemplateProps<TData> {
title: string;
@ -18,7 +19,7 @@ interface AppManagerTemplateProps<TData> {
data: TData[];
isLoading: boolean;
columns: ColumnDef<TData, any>[];
onUpload: (fd: FormData) => Promise<void>;
onUpload: (fd: FormData, config?: { onUploadProgress?: (e: AxiosProgressEvent) => void }) => Promise<void>;
onUpdate?: () => void;
updateLoading?: boolean;
onTableInit?: (table: any) => void;
@ -68,4 +69,4 @@ export function AppManagerTemplate<TData>({
</Card>
</div>
);
}
}

View File

@ -0,0 +1,53 @@
import { ShellCommandForm } from "@/components/command-form";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { UpdateButton } from "@/components/update-button";
import { FileText, Terminal } from "lucide-react";
interface FormSubmitTemplateProps {
title: string;
description: string;
isLoading?: boolean;
children: React.ReactNode;
onSubmit?: () => void;
submitLoading?: boolean;
}
export function FormSubmitTemplate({
title,
description,
isLoading,
children,
onSubmit,
submitLoading,
}: FormSubmitTemplateProps) {
return (
<div className="w-full px-6 space-y-4">
<div>
<h1 className="text-3xl font-bold">{title}</h1>
<p className="text-muted-foreground mt-2">{description}</p>
</div>
<Card className="w-full">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Terminal className="h-5 w-5" /> Gửi lệnh CMD
</CardTitle>
<CardDescription>Nhập gửi lệnh xuống thiết bị</CardDescription>
</CardHeader>
<CardContent>{children}</CardContent>
{onSubmit && (
<CardFooter>
<UpdateButton onClick={onSubmit} loading={submitLoading} />
</CardFooter>
)}
</Card>
</div>
);
}