380 lines
14 KiB
TypeScript
380 lines
14 KiB
TypeScript
|
|
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 và 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>Mô 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">
|
||
|
|
Mô tả chi tiết về chức năng và 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">Có</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 và 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>
|
||
|
|
);
|
||
|
|
}
|