yeah chay duoc roi

This commit is contained in:
Do Manh Phuong 2025-12-11 14:29:06 +07:00
parent b50dd91b7a
commit 890b27b96d
5 changed files with 1230 additions and 47 deletions

View File

@ -3,23 +3,72 @@
import { useForm } from "@tanstack/react-form";
import { z } from "zod";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Info } from "lucide-react";
import { useState } from "react";
export interface ShellCommandData {
command: string;
qos: 0 | 1 | 2;
isRetained: boolean;
}
interface ShellCommandFormProps {
command: string;
onCommandChange: (value: string) => void;
qos?: 0 | 1 | 2;
onQoSChange?: (value: 0 | 1 | 2) => void;
isRetained?: boolean;
onIsRetainedChange?: (value: boolean) => void;
disabled?: boolean;
}
const QoSDescriptions = {
0: {
name: "At Most Once (Fire and Forget)",
description:
"Gửi lệnh một lần mà không đảm bảo. Nhanh nhất, tiêu tốn ít tài nguyên.",
},
1: {
name: "At Least Once",
description:
"Đảm bảo lệnh sẽ được nhận ít nhất một lần. Cân bằng giữa tốc độ và độ tin cậy.",
},
2: {
name: "Exactly Once",
description:
"Đảm bảo lệnh được nhận chính xác một lần. Chậm nhất nhưng đáng tin cậy nhất.",
},
};
export function ShellCommandForm({
command,
onCommandChange,
qos = 0,
onQoSChange,
isRetained = false,
onIsRetainedChange,
disabled,
}: ShellCommandFormProps) {
const [selectedQoS, setSelectedQoS] = useState<0 | 1 | 2>(qos);
const form = useForm({
defaultValues: { command },
onSubmit: () => {},
});
const handleQoSChange = (value: string) => {
const newQoS = Number(value) as 0 | 1 | 2;
setSelectedQoS(newQoS);
onQoSChange?.(newQoS);
};
const handleRetainedChange = (checked: boolean) => {
onIsRetainedChange?.(checked);
};
return (
<form
onSubmit={(e) => {
@ -28,6 +77,7 @@ export function ShellCommandForm({
}}
className="space-y-5"
>
{/* Command Input */}
<form.Field
name="command"
validators={{
@ -44,10 +94,11 @@ export function ShellCommandForm({
},
}}
children={(field) => (
<div className="w-full px-0">
<div className="w-full space-y-2">
<Label>Nội Dung Lệnh *</Label>
<Textarea
className="w-full h-[25vh]"
placeholder="Nhập lệnh..."
className="w-full h-[20vh] font-mono"
placeholder="VD: shutdown /s /t 60 /c 'Máy sẽ tắt trong 60 giây'"
value={field.state.value}
onChange={(e) => {
field.handleChange(e.target.value);
@ -63,6 +114,51 @@ export function ShellCommandForm({
</div>
)}
/>
{/* QoS Selection */}
<div className="space-y-2">
<Label>QoS (Quality of Service) *</Label>
<select
value={selectedQoS}
onChange={(e) => handleQoSChange(e.target.value)}
disabled={disabled}
className="w-full h-10 rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="0">QoS 0 - At Most Once (Tốc đ cao)</option>
<option value="1">QoS 1 - At Least Once (Cân bằng)</option>
<option value="2">QoS 2 - Exactly Once (Đ tin cậy cao)</option>
</select>
{/* QoS Description */}
<Alert className="border-l-4 border-l-blue-500 bg-blue-50 mt-2">
<Info className="h-4 w-4 text-blue-600" />
<AlertDescription className="text-sm text-blue-800 mt-1">
<div className="font-semibold">
{QoSDescriptions[selectedQoS].name}
</div>
<div className="mt-1">{QoSDescriptions[selectedQoS].description}</div>
</AlertDescription>
</Alert>
</div>
{/* Retained Checkbox */}
<div className="flex items-center gap-3 rounded-lg border p-4">
<Checkbox
id="retained"
checked={isRetained}
onCheckedChange={handleRetainedChange}
disabled={disabled}
/>
<div className="flex-1">
<Label htmlFor="retained" className="text-base cursor-pointer">
Lưu giữ lệnh (Retained)
</Label>
<p className="text-sm text-muted-foreground mt-1">
Broker MQTT sẽ lưu lệnh này gửi cho client mới khi kết nối. Hữu ích
cho các lệnh cấu hình cần duy trì trạng thái.
</p>
</div>
</div>
</form>
);
}

View File

@ -0,0 +1,379 @@
import { useForm } from "@tanstack/react-form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Info } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { z } from "zod";
interface CommandRegistryFormProps {
onSubmit: (data: CommandRegistryFormData) => Promise<void>;
closeDialog?: () => void;
initialData?: Partial<CommandRegistryFormData>;
title?: string;
}
export interface CommandRegistryFormData {
commandName: string;
description?: string;
commandContent: string;
qos: 0 | 1 | 2;
isRetained: boolean;
}
// Zod validation schema
const commandRegistrySchema = z.object({
commandName: z
.string()
.min(1, "Tên lệnh không được để trống")
.min(3, "Tên lệnh phải có ít nhất 3 ký tự")
.max(100, "Tên lệnh tối đa 100 ký tự")
.trim(),
description: z.string().max(500, "Mô tả tối đa 500 ký tự").optional(),
commandContent: z
.string()
.min(1, "Nội dung lệnh không được để trống")
.min(5, "Nội dung lệnh phải có ít nhất 5 ký tự")
.trim(),
qos: z.union([z.literal(0), z.literal(1), z.literal(2)]),
isRetained: z.boolean(),
});
const QoSLevels = [
{
level: 0,
name: "At Most Once (Fire and Forget)",
description:
"Gửi lệnh một lần mà không đảm bảo. Nếu broker hoặc client bị ngắt kết nối, lệnh có thể bị mất. Tốc độ nhanh nhất, tiêu tốn ít tài nguyên.",
useCase: "Các lệnh không quan trọng, có thể mất mà không ảnh hưởng",
},
{
level: 1,
name: "At Least Once",
description:
"Đảm bảo lệnh sẽ được nhận ít nhất một lần. Có thể gửi lại nếu chưa nhận được ACK. Lệnh có thể được nhận nhiều lần.",
useCase: "Hầu hết các lệnh bình thường cần đảm bảo gửi thành công",
},
{
level: 2,
name: "Exactly Once",
description:
"Đảm bảo lệnh được nhận chính xác một lần. Sử dụng bắt tay 4 chiều để đảm bảo độ tin cậy cao nhất. Tốc độ chậm hơn, tiêu tốn nhiều tài nguyên.",
useCase: "Các lệnh quan trọng như xóa dữ liệu, thay đổi cấu hình",
},
];
export function CommandRegistryForm({
onSubmit,
closeDialog,
initialData,
title = "Đăng ký Lệnh Mới",
}: CommandRegistryFormProps) {
const [selectedQoS, setSelectedQoS] = useState<number>(
initialData?.qos ?? 0
);
const [isSubmitting, setIsSubmitting] = useState(false);
const form = useForm({
defaultValues: {
commandName: initialData?.commandName || "",
description: initialData?.description || "",
commandContent: initialData?.commandContent || "",
qos: (initialData?.qos || 0) as 0 | 1 | 2,
isRetained: initialData?.isRetained || false,
},
onSubmit: async ({ value }) => {
try {
// Validate using Zod
const validatedData = commandRegistrySchema.parse(value);
setIsSubmitting(true);
await onSubmit(validatedData as CommandRegistryFormData);
toast.success("Lưu lệnh thành công!");
if (closeDialog) {
closeDialog();
}
} catch (error: any) {
if (error.errors?.length > 0) {
toast.error(error.errors[0].message);
} else {
console.error("Submit error:", error);
toast.error("Có lỗi xảy ra khi lưu lệnh!");
}
} finally {
setIsSubmitting(false);
}
},
});
return (
<div className="w-full space-y-6">
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription>
Tạo cấu hình lệnh MQTT mới đ điều khiển thiết bị
</CardDescription>
</CardHeader>
<CardContent>
<form
className="space-y-6"
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
>
{/* Tên lệnh */}
<form.Field name="commandName">
{(field: any) => (
<div className="space-y-2">
<Label>
Tên Lệnh <span className="text-red-500">*</span>
</Label>
<Input
placeholder="VD: RestartDevice, ShutdownPC, UpdateSoftware..."
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
disabled={isSubmitting}
/>
{field.state.meta.errors?.length > 0 && (
<p className="text-sm text-red-500">
{String(field.state.meta.errors[0])}
</p>
)}
<p className="text-sm text-muted-foreground">
Tên đnh danh duy nhất cho lệnh này
</p>
</div>
)}
</form.Field>
{/* Mô tả */}
<form.Field name="description">
{(field: any) => (
<div className="space-y-2">
<Label> Tả (Tùy chọn)</Label>
<Textarea
placeholder="Nhập mô tả chi tiết về lệnh này..."
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
disabled={isSubmitting}
rows={3}
/>
{field.state.meta.errors?.length > 0 && (
<p className="text-sm text-red-500">
{String(field.state.meta.errors[0])}
</p>
)}
<p className="text-sm text-muted-foreground">
tả chi tiết về chức năng cách sử dụng lệnh
</p>
</div>
)}
</form.Field>
{/* Nội dung lệnh */}
<form.Field name="commandContent">
{(field: any) => (
<div className="space-y-2">
<Label>
Nội Dung Lệnh <span className="text-red-500">*</span>
</Label>
<Textarea
placeholder="VD: shutdown /s /t 30 /c 'Máy sẽ tắt trong 30 giây'"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
disabled={isSubmitting}
rows={5}
className="font-mono text-sm"
/>
{field.state.meta.errors?.length > 0 && (
<p className="text-sm text-red-500">
{String(field.state.meta.errors[0])}
</p>
)}
<p className="text-sm text-muted-foreground">
Nội dung lệnh sẽ đưc gửi tới thiết bị (PowerShell, CMD, bash...)
</p>
</div>
)}
</form.Field>
{/* QoS Level */}
<form.Field name="qos">
{(field: any) => (
<div className="space-y-2">
<Label>
QoS (Quality of Service) <span className="text-red-500">*</span>
</Label>
<select
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2"
value={field.state.value}
onChange={(e) => {
const value = Number(e.target.value) as 0 | 1 | 2;
field.handleChange(value);
setSelectedQoS(value);
}}
onBlur={field.handleBlur}
disabled={isSubmitting}
>
<option value="0">QoS 0 - At Most Once (Tốc đ cao)</option>
<option value="1">QoS 1 - At Least Once (Cân bằng)</option>
<option value="2">QoS 2 - Exactly Once (Đ tin cậy cao)</option>
</select>
{field.state.meta.errors?.length > 0 && (
<p className="text-sm text-red-500">
{String(field.state.meta.errors[0])}
</p>
)}
</div>
)}
</form.Field>
{/* Chú thích QoS */}
{selectedQoS !== null && (
<Alert className="border-l-4 border-l-blue-500 bg-blue-50">
<Info className="h-4 w-4 text-blue-600" />
<AlertDescription className="text-sm space-y-3 mt-2">
<div>
<div className="font-semibold text-blue-900">
{QoSLevels[selectedQoS].name}
</div>
<div className="text-blue-800 mt-1">
{QoSLevels[selectedQoS].description}
</div>
<div className="text-blue-700 mt-2">
<span className="font-medium">Trường hợp sử dụng:</span>{" "}
{QoSLevels[selectedQoS].useCase}
</div>
</div>
</AlertDescription>
</Alert>
)}
{/* Bảng so sánh QoS */}
<Card className="bg-muted/50">
<CardHeader className="pb-3">
<CardTitle className="text-base">
Bảng So Sánh Các Mức QoS
</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b">
<th className="text-left py-2 px-2 font-semibold">
Tiêu Chí
</th>
<th className="text-center py-2 px-2 font-semibold">
QoS 0
</th>
<th className="text-center py-2 px-2 font-semibold">
QoS 1
</th>
<th className="text-center py-2 px-2 font-semibold">
QoS 2
</th>
</tr>
</thead>
<tbody>
<tr className="border-b bg-white">
<td className="py-2 px-2">Đm bảo gửi</td>
<td className="text-center">Không</td>
<td className="text-center"></td>
<td className="text-center">Chính xác</td>
</tr>
<tr className="border-b bg-white">
<td className="py-2 px-2">Tốc đ</td>
<td className="text-center">Nhanh nhất</td>
<td className="text-center">Trung bình</td>
<td className="text-center">Chậm nhất</td>
</tr>
<tr className="border-b bg-white">
<td className="py-2 px-2">Tài nguyên</td>
<td className="text-center">Ít nhất</td>
<td className="text-center">Trung bình</td>
<td className="text-center">Nhiều nhất</td>
</tr>
<tr className="border-b bg-white">
<td className="py-2 px-2">Đ tin cậy</td>
<td className="text-center">Thấp</td>
<td className="text-center">Cao</td>
<td className="text-center">Cao nhất</td>
</tr>
<tr className="bg-white">
<td className="py-2 px-2">Số lần nhận tối đa</td>
<td className="text-center">1 (hoặc 0)</td>
<td className="text-center"> 1</td>
<td className="text-center">1</td>
</tr>
</tbody>
</table>
</div>
</CardContent>
</Card>
{/* IsRetained Checkbox */}
<form.Field name="isRetained">
{(field: any) => (
<div className="flex items-center gap-3 rounded-lg border p-4">
<Checkbox
checked={field.state.value}
onCheckedChange={field.handleChange}
disabled={isSubmitting}
/>
<div className="flex-1">
<Label className="text-base cursor-pointer">
Lưu giữ lệnh (Retained)
</Label>
<p className="text-sm text-muted-foreground mt-1">
Broker MQTT sẽ lưu lệnh này gửi cho client mới khi
kết nối. Hữu ích cho các lệnh cấu hình cần duy trì trạng
thái.
</p>
</div>
</div>
)}
</form.Field>
{/* Submit Button */}
<div className="flex gap-3 pt-4">
<Button
type="submit"
disabled={isSubmitting}
className="flex-1"
>
{isSubmitting ? "Đang lưu..." : "Lưu Lệnh"}
</Button>
{closeDialog && (
<Button
type="button"
variant="outline"
disabled={isSubmitting}
className="flex-1"
onClick={closeDialog}
>
Hủy
</Button>
)}
</div>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@ -38,6 +38,13 @@ export const API_ENDPOINTS = {
REQUEST_GET_CLIENT_FOLDER_STATUS: (roomName: string) =>
`${BASE_URL}/DeviceComm/clientfolderstatus/${roomName}`,
},
COMMAND:
{
ADD_COMMAND: `${BASE_URL}/Command/add`,
GET_COMMANDS: `${BASE_URL}/Command/all`,
UPDATE_COMMAND: (commandId: number) => `${BASE_URL}/Command/update/${commandId}`,
DELETE_COMMAND: (commandId: number) => `${BASE_URL}/Command/delete/${commandId}`,
},
SSE_EVENTS: {
DEVICE_ONLINE: `${BASE_URL}/Sse/events/onlineDevices`,
DEVICE_OFFLINE: `${BASE_URL}/Sse/events/offlineDevices`,

View File

@ -1,67 +1,344 @@
import { createFileRoute } from "@tanstack/react-router";
import { FormSubmitTemplate } from "@/template/form-submit-template";
import { ShellCommandForm } from "@/components/forms/command-form";
import { useMutationData } from "@/hooks/useMutationData";
import { useState } from "react";
import { CommandSubmitTemplate } from "@/template/command-submit-template";
import { CommandRegistryForm, type CommandRegistryFormData } from "@/components/forms/command-registry-form";
import { useQueryData } from "@/hooks/useQueryData";
import { API_ENDPOINTS } from "@/config/api";
import { useMutationData } from "@/hooks/useMutationData";
import { API_ENDPOINTS, BASE_URL } from "@/config/api";
import type { ColumnDef } from "@tanstack/react-table";
import { toast } from "sonner";
import { Check, X, Edit2, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { ShellCommandData } from "@/components/forms/command-form";
type SendCommandRequest = { Command: string };
type SendCommandResponse = { status: string; message: string };
interface CommandRegistry {
id: number;
commandName: string;
description?: string;
commandContent: string;
qoS: 0 | 1 | 2;
isRetained: boolean;
createdAt?: string;
updatedAt?: string;
}
export const Route = createFileRoute("/_authenticated/command/")({
head: () => ({ meta: [{ title: "Gửi lệnh CMD" }] }),
head: () => ({ meta: [{ title: "Gửi lệnh từ xa" }] }),
component: CommandPage,
});
function CommandPage() {
// Lấy danh sách phòng từ API
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [selectedCommand, setSelectedCommand] = useState<CommandRegistry | null>(null);
const [table, setTable] = useState<any>();
// Fetch commands
const { data: commands = [], isLoading } = useQueryData({
queryKey: ["commands"],
url: API_ENDPOINTS.COMMAND.GET_COMMANDS,
});
// Fetch rooms
const { data: roomData } = useQueryData({
queryKey: ["rooms"],
url: API_ENDPOINTS.DEVICE_COMM.GET_ROOM_LIST,
});
// Mutation gửi lệnh
const sendCommandMutation = useMutationData<
SendCommandRequest,
SendCommandResponse
>({
url: "", // sẽ set động theo roomName khi gọi
const commandList: CommandRegistry[] = Array.isArray(commands)
? commands.map((cmd: any) => ({
...cmd,
qoS: cmd.qoS ?? 0,
isRetained: cmd.isRetained ?? false,
}))
: commands
? [{
...commands,
qoS: commands.qoS ?? 0,
isRetained: commands.isRetained ?? false,
}]
: [];
// Add command mutation
const addCommandMutation = useMutationData<CommandRegistryFormData>({
url: API_ENDPOINTS.COMMAND.ADD_COMMAND,
method: "POST",
onSuccess: (data) => {
if (data.status === "OK") {
toast.success("Gửi lệnh thành công!");
} else {
toast.error("Gửi lệnh thất bại!");
}
},
invalidate: [["commands"]],
onSuccess: () => toast.success("Thêm lệnh thành công!"),
onError: (error) => {
console.error("Send command error:", error);
toast.error("Gửi lệnh thất bại!");
console.error("Add command error:", error);
toast.error("Thêm 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}
rooms={roomData}
onSubmit={(roomName, command) => {
sendCommandMutation.mutateAsync({
url: API_ENDPOINTS.DEVICE_COMM.SEND_COMMAND(roomName),
data: { Command: command },
});
}}
submitLoading={sendCommandMutation.isPending}
>
{({ command, setCommand }) => (
<ShellCommandForm
command={command}
onCommandChange={setCommand}
disabled={sendCommandMutation.isPending}
// Update command mutation
const updateCommandMutation = useMutationData<CommandRegistryFormData>({
url: "",
method: "POST",
invalidate: [["commands"]],
onSuccess: () => toast.success("Cập nhật lệnh thành công!"),
onError: (error) => {
console.error("Update command error:", error);
toast.error("Cập nhật lệnh thất bại!");
},
});
// Delete command mutation
const deleteCommandMutation = useMutationData<any>({
url: "",
method: "DELETE",
invalidate: [["commands"]],
onSuccess: () => toast.success("Xóa lệnh thành công!"),
onError: (error) => {
console.error("Delete command error:", error);
toast.error("Xóa lệnh thất bại!");
},
});
// Execute command mutation
const executeCommandMutation = useMutationData<{
commandIds?: number[];
command?: ShellCommandData;
}>({
url: "",
method: "POST",
onSuccess: () => toast.success("Gửi yêu cầu thực thi lệnh thành công!"),
onError: (error) => {
console.error("Execute command error:", error);
toast.error("Gửi yêu cầu thực thi thất bại!");
},
});
// Columns for command table
const columns: ColumnDef<CommandRegistry>[] = [
{
accessorKey: "commandName",
header: "Tên lệnh",
cell: ({ getValue }) => (
<span className="font-semibold break-words">{getValue() as string}</span>
),
},
{
accessorKey: "description",
header: "Mô tả",
cell: ({ getValue }) => (
<span className="text-sm text-muted-foreground break-words whitespace-normal">
{(getValue() as string) || "-"}
</span>
),
},
{
accessorKey: "commandContent",
header: "Nội dung lệnh",
cell: ({ getValue }) => (
<code className="text-xs bg-muted/50 p-1 rounded break-words whitespace-normal block">
{(getValue() as string).substring(0, 100)}...
</code>
),
},
{
accessorKey: "qoS",
header: "QoS",
cell: ({ getValue }) => {
const qos = getValue() as number | undefined;
const qosValue = qos !== undefined ? qos : 0;
const colors = {
0: "text-blue-600",
1: "text-amber-600",
2: "text-red-600",
};
return (
<span className={colors[qosValue as 0 | 1 | 2]}>{qosValue}</span>
);
},
},
{
accessorKey: "isRetained",
header: "Lưu trữ",
cell: ({ getValue }) => {
const retained = getValue() as boolean;
return retained ? (
<div className="flex items-center gap-1">
<Check className="h-4 w-4 text-green-600" />
<span className="text-sm text-green-600"></span>
</div>
) : (
<div className="flex items-center gap-1">
<X className="h-4 w-4 text-gray-400" />
<span className="text-sm text-gray-400">Không</span>
</div>
);
},
},
{
id: "select",
header: () => <div className="text-center text-xs">Chọn đ thực thi</div>,
cell: ({ row }) => (
<input
type="checkbox"
checked={row.getIsSelected?.() ?? false}
onChange={row.getToggleSelectedHandler?.()}
/>
)}
</FormSubmitTemplate>
),
enableSorting: false,
enableHiding: false,
},
{
id: "actions",
header: () => <div className="text-center text-xs">Hành đng</div>,
cell: ({ row }) => (
<div className="flex gap-2 justify-center">
<Button
size="sm"
variant="ghost"
onClick={() => {
setSelectedCommand(row.original);
setIsDialogOpen(true);
}}
>
<Edit2 className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDeleteCommand(row.original.id)}
disabled={deleteCommandMutation.isPending}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
),
enableSorting: false,
enableHiding: false,
},
];
// Handle form submit
const handleFormSubmit = async (data: CommandRegistryFormData) => {
if (selectedCommand) {
// Update
await updateCommandMutation.mutateAsync({
url: BASE_URL + API_ENDPOINTS.COMMAND.UPDATE_COMMAND(selectedCommand.id),
data,
});
} else {
// Add
await addCommandMutation.mutateAsync({
data,
});
}
setIsDialogOpen(false);
setSelectedCommand(null);
};
// Handle delete
const handleDeleteCommand = async (commandId: number) => {
if (!confirm("Bạn có chắc muốn xóa lệnh này?")) return;
try {
await deleteCommandMutation.mutateAsync({
url: API_ENDPOINTS.COMMAND.DELETE_COMMAND(commandId),
data: null,
});
} catch (error) {
console.error("Delete error:", error);
}
};
// Handle execute commands from list
const handleExecuteSelected = async (targets: string[]) => {
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 lệnh để thực thi!");
return;
}
try {
for (const target of targets) {
for (const row of selectedRows) {
// API expects PascalCase directly
const apiData = {
Command: row.original.commandContent,
QoS: row.original.qoS,
IsRetained: row.original.isRetained,
};
console.log("[DEBUG] Sending to:", target, "Data:", apiData);
await executeCommandMutation.mutateAsync({
url: API_ENDPOINTS.DEVICE_COMM.SEND_COMMAND(target),
data: apiData as any,
});
}
}
toast.success("Đã gửi yêu cầu thực thi lệnh cho các mục đã chọn!");
if (table) {
table.setRowSelection({});
}
} catch (error) {
console.error("[DEBUG] Execute error:", error);
console.error("[DEBUG] Response:", (error as any)?.response?.data);
toast.error("Có lỗi xảy ra khi thực thi!");
}
};
// Handle execute custom command
const handleExecuteCustom = async (targets: string[], commandData: ShellCommandData) => {
try {
for (const target of targets) {
// API expects PascalCase directly
const apiData = {
Command: commandData.command,
QoS: commandData.qos,
IsRetained: commandData.isRetained,
};
console.log("[DEBUG] Sending custom to:", target, "Data:", apiData);
await executeCommandMutation.mutateAsync({
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.SEND_COMMAND(target),
data: apiData as any,
});
}
toast.success("Đã gửi lệnh tùy chỉnh cho các mục đã chọn!");
} catch (error) {
console.error("[DEBUG] Execute custom error:", error);
console.error("[DEBUG] Response:", (error as any)?.response?.data);
toast.error("Gửi lệnh tùy chỉnh thất bại!");
}
};
return (
<CommandSubmitTemplate
title="Gửi lệnh từ xa"
description="Quản lý và thực thi các lệnh trên thiết bị"
data={commandList}
isLoading={isLoading}
columns={columns}
dialogOpen={isDialogOpen}
onDialogOpen={setIsDialogOpen}
dialogTitle={selectedCommand ? "Sửa Lệnh" : "Thêm Lệnh Mới"}
onAddNew={() => {
setSelectedCommand(null);
setIsDialogOpen(true);
}}
onTableInit={setTable}
formContent={
<CommandRegistryForm
onSubmit={handleFormSubmit}
closeDialog={() => setIsDialogOpen(false)}
initialData={selectedCommand || undefined}
title={selectedCommand ? "Sửa Lệnh" : "Thêm Lệnh Mới"}
/>
}
onExecuteSelected={handleExecuteSelected}
onExecuteCustom={handleExecuteCustom}
isExecuting={executeCommandMutation.isPending}
rooms={roomData}
/>
);
}

View File

@ -0,0 +1,424 @@
import { useState } from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Plus, CommandIcon, Zap, Building2 } from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type { ColumnDef } from "@tanstack/react-table";
import { VersionTable } from "@/components/tables/version-table";
import {
ShellCommandForm,
type ShellCommandData,
} from "@/components/forms/command-form";
import { RequestUpdateMenu } from "@/components/menu/request-update-menu";
import { SelectDialog } from "@/components/dialogs/select-dialog";
import { DeviceSearchDialog } from "@/components/bars/device-searchbar";
import { mapRoomsToSelectItems } from "@/helpers/mapRoomToSelectItems";
import { fetchDevicesFromRoom } from "@/services/device.service";
import type { Room } from "@/types/room";
import { toast } from "sonner";
interface CommandSubmitTemplateProps<T extends { id: number }> {
title: string;
description: string;
// Data & Loading
data: T[];
isLoading?: boolean;
// Table config
columns: ColumnDef<T>[];
// Dialog
dialogOpen: boolean;
onDialogOpen: (open: boolean) => void;
dialogTitle?: string;
formContent?: React.ReactNode;
dialogContentClassName?: string;
// Actions
onAddNew?: () => void;
onTableInit?: (table: any) => void;
// Execute
onExecuteSelected?: (targets: string[]) => void;
onExecuteCustom?: (targets: string[], commandData: ShellCommandData) => void;
isExecuting?: boolean;
// Execution scope
rooms?: Room[];
devices?: string[];
}
export function CommandSubmitTemplate<T extends { id: number }>({
title,
description,
data,
isLoading = false,
columns,
dialogOpen,
onDialogOpen,
dialogTitle = "Thêm Mục Mới",
formContent,
dialogContentClassName,
onAddNew,
onTableInit,
onExecuteSelected,
onExecuteCustom,
isExecuting = false,
rooms = [],
devices = [],
}: CommandSubmitTemplateProps<T>) {
const [activeTab, setActiveTab] = useState<"list" | "execute">("list");
const [customCommand, setCustomCommand] = useState("");
const [customQoS, setCustomQoS] = useState<0 | 1 | 2>(0);
const [customRetained, setCustomRetained] = useState(false);
const [table, setTable] = useState<any>();
const [dialogOpen2, setDialogOpen2] = useState(false);
const [dialogType, setDialogType] = useState<"room" | "device" | "room-custom" | "device-custom" | null>(null);
const handleTableInit = (t: any) => {
setTable(t);
onTableInit?.(t);
};
const openRoomDialog = () => {
if (rooms.length > 0 && onExecuteSelected) {
setDialogType("room");
setDialogOpen2(true);
}
};
const openDeviceDialog = () => {
if (onExecuteSelected) {
setDialogType("device");
setDialogOpen2(true);
}
};
const handleExecuteSelected = () => {
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 mục để thực thi!");
return;
}
onExecuteSelected?.([]);
};
const handleExecuteAll = () => {
if (!onExecuteSelected) return;
try {
const roomNames = rooms.map((room) =>
typeof room === "string" ? room : room.name
);
const allTargets = [...roomNames, ...devices];
onExecuteSelected(allTargets);
} catch (e) {
console.error("Execute error:", e);
}
};
const handleExecuteCustom = async (targets: string[]) => {
if (!customCommand.trim()) {
toast.error("Vui lòng nhập lệnh!");
return;
}
const shellCommandData: ShellCommandData = {
command: customCommand,
qos: customQoS,
isRetained: customRetained,
};
try {
await onExecuteCustom?.(targets, shellCommandData);
setCustomCommand("");
setCustomQoS(0);
setCustomRetained(false);
} catch (e) {
console.error("Execute custom command error:", e);
}
};
const handleExecuteCustomAll = () => {
if (!onExecuteCustom) return;
try {
const roomNames = rooms.map((room) =>
typeof room === "string" ? room : room.name
);
const allTargets = [...roomNames, ...devices];
handleExecuteCustom(allTargets);
} catch (e) {
console.error("Execute error:", e);
}
};
const openRoomDialogCustom = () => {
if (rooms.length > 0 && onExecuteCustom) {
setDialogType("room-custom");
setDialogOpen2(true);
}
};
const openDeviceDialogCustom = () => {
if (onExecuteCustom) {
setDialogType("device-custom");
setDialogOpen2(true);
}
};
return (
<div className="w-full px-6 space-y-6">
<div>
<h1 className="text-3xl font-bold">{title}</h1>
<p className="text-muted-foreground mt-2">{description}</p>
</div>
<Card className="shadow-sm">
<CardHeader className="bg-muted/50 flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<CommandIcon className="h-5 w-5" />
{title}
</CardTitle>
{onAddNew && (
<Button onClick={onAddNew} className="gap-2">
<Plus className="h-4 w-4" />
Thêm Mới
</Button>
)}
</CardHeader>
<CardContent className="p-6">
{/* Tabs Navigation */}
<div className="flex gap-4 mb-6 border-b">
<button
onClick={() => setActiveTab("list")}
className={`pb-3 px-2 font-medium text-sm flex items-center gap-2 transition-colors ${
activeTab === "list"
? "text-primary border-b-2 border-primary"
: "text-muted-foreground hover:text-foreground"
}`}
>
<CommandIcon className="h-4 w-4" />
Danh sách lệnh sẵn
</button>
<button
onClick={() => setActiveTab("execute")}
className={`pb-3 px-2 font-medium text-sm flex items-center gap-2 transition-colors ${
activeTab === "execute"
? "text-primary border-b-2 border-primary"
: "text-muted-foreground hover:text-foreground"
}`}
>
<Zap className="h-4 w-4" />
Lệnh thủ công
</button>
</div>
{/* Tab 1: Danh sách */}
{activeTab === "list" && (
<div className="space-y-4">
<VersionTable<T>
data={data}
columns={columns}
isLoading={isLoading}
onTableInit={handleTableInit}
/>
<RequestUpdateMenu
onUpdateDevice={openDeviceDialog}
onUpdateRoom={openRoomDialog}
onUpdateAll={handleExecuteAll}
loading={isExecuting}
label="Thực Thi"
deviceLabel="Thực thi cho thiết bị cụ thể"
roomLabel="Thực thi cho phòng"
allLabel="Thực thi cho tất cả thiết bị"
icon={<Zap className="h-4 w-4" />}
/>
</div>
)}
{/* Tab 2: Thực thi */}
{activeTab === "execute" && (
<div className="space-y-4">
{/* Lệnh tùy chỉnh */}
<Card className="border-dashed">
<CardHeader>
<CardTitle className="text-base">
Thực Thi Lệnh Tùy Chỉnh
</CardTitle>
<CardDescription>
Nhập lệnh tuỳ chỉnh với QoS Retained settings
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<ShellCommandForm
command={customCommand}
onCommandChange={setCustomCommand}
qos={customQoS}
onQoSChange={setCustomQoS}
isRetained={customRetained}
onIsRetainedChange={setCustomRetained}
disabled={isExecuting}
/>
<RequestUpdateMenu
onUpdateDevice={openDeviceDialogCustom}
onUpdateRoom={openRoomDialogCustom}
onUpdateAll={handleExecuteCustomAll}
loading={isExecuting}
label="Thực Thi"
deviceLabel="Thực thi cho thiết bị cụ thể"
roomLabel="Thực thi cho phòng"
allLabel="Thực thi cho tất cả thiết bị"
icon={<Zap className="h-4 w-4" />}
/>
</div>
</CardContent>
</Card>
</div>
)}
</CardContent>
</Card>
{/* Dialog chọn phòng - Thực thi */}
{dialogType === "room" && (
<SelectDialog
open={dialogOpen2}
onClose={() => {
setDialogOpen2(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}}
title="Chọn phòng"
description="Chọn các phòng để thực thi lệnh"
icon={<Building2 className="w-6 h-6 text-primary" />}
items={mapRoomsToSelectItems(rooms)}
onConfirm={async (selectedItems) => {
if (!onExecuteSelected) return;
try {
await onExecuteSelected(selectedItems);
} catch (e) {
console.error("Execute error:", e);
} finally {
setDialogOpen2(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}
}}
/>
)}
{/* Dialog tìm thiết bị - Thực thi */}
{dialogType === "device" && (
<DeviceSearchDialog
open={dialogOpen2 && dialogType === "device"}
onClose={() => {
setDialogOpen2(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}}
rooms={rooms}
fetchDevices={fetchDevicesFromRoom}
onSelect={async (deviceIds) => {
if (!onExecuteSelected) {
setDialogOpen2(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
return;
}
try {
await onExecuteSelected(deviceIds);
} catch (e) {
console.error("Execute error:", e);
} finally {
setDialogOpen2(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}
}}
/>
)}
{/* Dialog chọn phòng - Thực thi lệnh tùy chỉnh */}
{dialogType === "room-custom" && (
<SelectDialog
open={dialogOpen2}
onClose={() => {
setDialogOpen2(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}}
title="Chọn phòng"
description="Chọn các phòng để thực thi lệnh tùy chỉnh"
icon={<Building2 className="w-6 h-6 text-primary" />}
items={mapRoomsToSelectItems(rooms)}
onConfirm={async (selectedItems) => {
try {
await handleExecuteCustom(selectedItems);
} catch (e) {
console.error("Execute error:", e);
} finally {
setDialogOpen2(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}
}}
/>
)}
{/* Dialog tìm thiết bị - Thực thi lệnh tùy chỉnh */}
{dialogType === "device-custom" && (
<DeviceSearchDialog
open={dialogOpen2 && dialogType === "device-custom"}
onClose={() => {
setDialogOpen2(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}}
rooms={rooms}
fetchDevices={fetchDevicesFromRoom}
onSelect={async (deviceIds) => {
try {
await handleExecuteCustom(deviceIds);
} catch (e) {
console.error("Execute error:", e);
} finally {
setDialogOpen2(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}
}}
/>
)}
{/* Dialog for add/edit */}
{formContent && (
<Dialog open={dialogOpen} onOpenChange={onDialogOpen}>
<DialogContent className={dialogContentClassName || "max-w-2xl max-h-[90vh] overflow-y-auto"}>
<DialogHeader>
<DialogTitle>{dialogTitle}</DialogTitle>
</DialogHeader>
{formContent}
</DialogContent>
</Dialog>
)}
</div>
);
}