2025-09-24 16:13:57 +07:00
|
|
|
import { createFileRoute } from "@tanstack/react-router";
|
2025-12-11 14:29:06 +07:00
|
|
|
import { useState } from "react";
|
|
|
|
|
import { CommandSubmitTemplate } from "@/template/command-submit-template";
|
|
|
|
|
import { CommandRegistryForm, type CommandRegistryFormData } from "@/components/forms/command-registry-form";
|
2025-12-22 14:53:19 +07:00
|
|
|
import {
|
|
|
|
|
useGetCommandList,
|
|
|
|
|
useGetRoomList,
|
|
|
|
|
useAddCommand,
|
|
|
|
|
useUpdateCommand,
|
|
|
|
|
useDeleteCommand,
|
|
|
|
|
useSendCommand,
|
|
|
|
|
} from "@/hooks/queries";
|
2025-09-24 16:13:57 +07:00
|
|
|
import { toast } from "sonner";
|
2025-12-11 14:29:06 +07:00
|
|
|
import { Check, X, Edit2, Trash2 } from "lucide-react";
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
2026-01-18 22:52:19 +07:00
|
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
2025-12-22 14:53:19 +07:00
|
|
|
import type { ColumnDef } from "@tanstack/react-table";
|
2025-12-11 14:29:06 +07:00
|
|
|
import type { ShellCommandData } from "@/components/forms/command-form";
|
2026-01-18 22:52:19 +07:00
|
|
|
import type { CommandRegistry } from "@/types/command-registry";
|
2025-09-24 16:13:57 +07:00
|
|
|
|
2026-03-04 14:41:34 +07:00
|
|
|
export const Route = createFileRoute("/_auth/commands/")({
|
2025-12-11 14:29:06 +07:00
|
|
|
head: () => ({ meta: [{ title: "Gửi lệnh từ xa" }] }),
|
2025-09-24 16:13:57 +07:00
|
|
|
component: CommandPage,
|
2026-03-04 14:41:34 +07:00
|
|
|
loader: async ({ context }) => {
|
|
|
|
|
context.breadcrumbs = [
|
|
|
|
|
{ title: "Quản lý lệnh", path: "/_auth/commands/" },
|
|
|
|
|
];
|
|
|
|
|
},
|
2025-09-24 16:13:57 +07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function CommandPage() {
|
2025-12-11 14:29:06 +07:00
|
|
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
|
|
|
|
const [selectedCommand, setSelectedCommand] = useState<CommandRegistry | null>(null);
|
2026-01-18 22:52:19 +07:00
|
|
|
const [detailPanelCommand, setDetailPanelCommand] = useState<CommandRegistry | null>(null);
|
2025-12-11 14:29:06 +07:00
|
|
|
const [table, setTable] = useState<any>();
|
|
|
|
|
|
|
|
|
|
// Fetch commands
|
2025-12-22 14:53:19 +07:00
|
|
|
const { data: commands = [], isLoading } = useGetCommandList();
|
2025-12-11 14:29:06 +07:00
|
|
|
|
|
|
|
|
// Fetch rooms
|
2025-12-22 14:53:19 +07:00
|
|
|
const { data: roomData = [] } = useGetRoomList();
|
2025-09-26 17:56:55 +07:00
|
|
|
|
2025-12-11 14:29:06 +07:00
|
|
|
const commandList: CommandRegistry[] = Array.isArray(commands)
|
|
|
|
|
? commands.map((cmd: any) => ({
|
|
|
|
|
...cmd,
|
|
|
|
|
qoS: cmd.qoS ?? 0,
|
|
|
|
|
isRetained: cmd.isRetained ?? false,
|
|
|
|
|
}))
|
2025-12-22 14:53:19 +07:00
|
|
|
: [];
|
2025-12-11 14:29:06 +07:00
|
|
|
|
2025-12-22 14:53:19 +07:00
|
|
|
// Mutations
|
|
|
|
|
const addCommandMutation = useAddCommand();
|
|
|
|
|
const updateCommandMutation = useUpdateCommand();
|
|
|
|
|
const deleteCommandMutation = useDeleteCommand();
|
|
|
|
|
const sendCommandMutation = useSendCommand();
|
2025-12-11 14:29:06 +07:00
|
|
|
|
|
|
|
|
// Columns for command table
|
|
|
|
|
const columns: ColumnDef<CommandRegistry>[] = [
|
|
|
|
|
{
|
|
|
|
|
accessorKey: "commandName",
|
|
|
|
|
header: "Tên lệnh",
|
2026-01-18 22:52:19 +07:00
|
|
|
size: 100,
|
2025-12-11 14:29:06 +07:00
|
|
|
cell: ({ getValue }) => (
|
2026-01-18 22:52:19 +07:00
|
|
|
<div className="max-w-[100px]">
|
|
|
|
|
<span className="font-semibold truncate block">{getValue() as string}</span>
|
|
|
|
|
</div>
|
2025-12-11 14:29:06 +07:00
|
|
|
),
|
|
|
|
|
},
|
2026-01-18 22:52:19 +07:00
|
|
|
{
|
|
|
|
|
accessorKey: "commandType",
|
|
|
|
|
header: "Loại lệnh",
|
|
|
|
|
cell: ({ getValue }) => {
|
|
|
|
|
const type = getValue() as number;
|
|
|
|
|
const typeMap: Record<number, string> = {
|
|
|
|
|
1: "RESTART",
|
|
|
|
|
2: "SHUTDOWN",
|
|
|
|
|
3: "TASKKILL",
|
|
|
|
|
4: "BLOCK",
|
|
|
|
|
};
|
|
|
|
|
return <span>{typeMap[type] || "UNKNOWN"}</span>;
|
|
|
|
|
},
|
|
|
|
|
},
|
2025-12-11 14:29:06 +07:00
|
|
|
{
|
|
|
|
|
accessorKey: "description",
|
|
|
|
|
header: "Mô tả",
|
2026-01-18 22:52:19 +07:00
|
|
|
size: 120,
|
2025-12-11 14:29:06 +07:00
|
|
|
cell: ({ getValue }) => (
|
2026-01-18 22:52:19 +07:00
|
|
|
<div className="max-w-[120px]">
|
|
|
|
|
<span className="text-sm text-muted-foreground truncate block">
|
|
|
|
|
{(getValue() as string) || "-"}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
2025-12-11 14:29:06 +07:00
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
accessorKey: "commandContent",
|
|
|
|
|
header: "Nội dung lệnh",
|
2026-01-18 22:52:19 +07:00
|
|
|
size: 130,
|
2025-12-11 14:29:06 +07:00
|
|
|
cell: ({ getValue }) => (
|
2026-01-18 22:52:19 +07:00
|
|
|
<div className="max-w-[130px]">
|
|
|
|
|
<code className="text-xs bg-muted/50 px-1.5 py-0.5 rounded truncate block">
|
|
|
|
|
{(getValue() as string).substring(0, 40)}...
|
|
|
|
|
</code>
|
|
|
|
|
</div>
|
2025-12-11 14:29:06 +07:00
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
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">Có</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?.()}
|
|
|
|
|
/>
|
|
|
|
|
),
|
|
|
|
|
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"
|
2026-01-18 22:52:19 +07:00
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
2025-12-11 14:29:06 +07:00
|
|
|
setSelectedCommand(row.original);
|
|
|
|
|
setIsDialogOpen(true);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Edit2 className="h-4 w-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="ghost"
|
2026-01-18 22:52:19 +07:00
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
handleDeleteCommand(row.original.id);
|
|
|
|
|
}}
|
2025-12-11 14:29:06 +07:00
|
|
|
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) => {
|
2025-12-22 14:53:19 +07:00
|
|
|
try {
|
|
|
|
|
if (selectedCommand) {
|
|
|
|
|
// Update
|
|
|
|
|
await updateCommandMutation.mutateAsync({
|
|
|
|
|
commandId: selectedCommand.id,
|
|
|
|
|
data,
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
// Add
|
|
|
|
|
await addCommandMutation.mutateAsync(data);
|
|
|
|
|
}
|
|
|
|
|
setIsDialogOpen(false);
|
|
|
|
|
setSelectedCommand(null);
|
|
|
|
|
toast.success(selectedCommand ? "Cập nhật lệnh thành công!" : "Thêm lệnh thành công!");
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("Form submission error:", error);
|
|
|
|
|
toast.error(selectedCommand ? "Cập nhật lệnh thất bại!" : "Thêm lệnh thất bại!");
|
2025-12-11 14:29:06 +07:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Handle delete
|
|
|
|
|
const handleDeleteCommand = async (commandId: number) => {
|
|
|
|
|
if (!confirm("Bạn có chắc muốn xóa lệnh này?")) return;
|
|
|
|
|
|
|
|
|
|
try {
|
2025-12-22 14:53:19 +07:00
|
|
|
await deleteCommandMutation.mutateAsync(commandId);
|
|
|
|
|
toast.success("Xóa lệnh thành công!");
|
2025-12-11 14:29:06 +07:00
|
|
|
} catch (error) {
|
|
|
|
|
console.error("Delete error:", error);
|
2025-12-22 14:53:19 +07:00
|
|
|
toast.error("Xóa lệnh thất bại!");
|
2025-12-11 14:29:06 +07:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 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,
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-22 14:53:19 +07:00
|
|
|
await sendCommandMutation.mutateAsync({
|
|
|
|
|
roomName: target,
|
2025-12-11 14:29:06 +07:00
|
|
|
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) {
|
|
|
|
|
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,
|
|
|
|
|
};
|
2025-12-22 14:53:19 +07:00
|
|
|
await sendCommandMutation.mutateAsync({
|
|
|
|
|
roomName: target,
|
2025-12-11 14:29:06 +07:00
|
|
|
data: apiData as any,
|
2025-09-26 17:56:55 +07:00
|
|
|
});
|
2025-12-11 14:29:06 +07:00
|
|
|
}
|
|
|
|
|
toast.success("Đã gửi lệnh tùy chỉnh cho các mục đã chọn!");
|
|
|
|
|
} catch (error) {
|
|
|
|
|
toast.error("Gửi lệnh tùy chỉnh thất bại!");
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
2026-01-18 22:52:19 +07:00
|
|
|
<>
|
|
|
|
|
<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={sendCommandMutation.isPending}
|
|
|
|
|
rooms={roomData}
|
|
|
|
|
onRowClick={(row) => setDetailPanelCommand(row)}
|
|
|
|
|
scrollable={true}
|
|
|
|
|
maxHeight="500px"
|
2026-03-04 16:43:45 +07:00
|
|
|
enablePagination
|
|
|
|
|
defaultPageSize={10}
|
2026-01-18 22:52:19 +07:00
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{/* Detail Dialog Popup */}
|
|
|
|
|
<Dialog open={!!detailPanelCommand} onOpenChange={(open) => !open && setDetailPanelCommand(null)}>
|
|
|
|
|
<DialogContent className="max-w-2xl max-h-[85vh]">
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
<DialogTitle>Chi tiết lệnh</DialogTitle>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
|
|
|
|
|
{detailPanelCommand && (
|
|
|
|
|
<div className="space-y-6 max-h-[calc(85vh-120px)] overflow-y-auto pr-2">
|
|
|
|
|
{/* Command Name */}
|
|
|
|
|
<div>
|
|
|
|
|
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Tên lệnh</h3>
|
|
|
|
|
<p className="text-base font-medium break-words">{detailPanelCommand.commandName}</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Command Type */}
|
|
|
|
|
<div>
|
|
|
|
|
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Loại lệnh</h3>
|
|
|
|
|
<p className="text-base">
|
|
|
|
|
{
|
|
|
|
|
{
|
|
|
|
|
1: "RESTART",
|
|
|
|
|
2: "SHUTDOWN",
|
|
|
|
|
3: "TASKKILL",
|
|
|
|
|
4: "BLOCK",
|
|
|
|
|
}[detailPanelCommand.commandType] || "UNKNOWN"
|
|
|
|
|
}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Description */}
|
|
|
|
|
<div>
|
|
|
|
|
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Mô tả</h3>
|
|
|
|
|
<p className="text-sm text-foreground whitespace-pre-wrap break-words">
|
|
|
|
|
{detailPanelCommand.description || "-"}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Command Content */}
|
|
|
|
|
<div>
|
|
|
|
|
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Nội dung lệnh</h3>
|
|
|
|
|
<div className="bg-muted/50 p-4 rounded-md border">
|
|
|
|
|
<code className="text-sm whitespace-pre-wrap break-all block font-mono">
|
|
|
|
|
{detailPanelCommand.commandContent}
|
|
|
|
|
</code>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* QoS */}
|
|
|
|
|
<div>
|
|
|
|
|
<h3 className="text-sm font-semibold text-muted-foreground mb-2">QoS</h3>
|
|
|
|
|
<p className="text-base">
|
|
|
|
|
<span
|
|
|
|
|
className={
|
|
|
|
|
{
|
|
|
|
|
0: "text-blue-600",
|
|
|
|
|
1: "text-amber-600",
|
|
|
|
|
2: "text-red-600",
|
|
|
|
|
}[(detailPanelCommand.qoS ?? 0) as 0 | 1 | 2]
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
{detailPanelCommand.qoS ?? 0}
|
|
|
|
|
</span>
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Retention */}
|
|
|
|
|
<div>
|
|
|
|
|
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Lưu trữ</h3>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
{detailPanelCommand.isRetained ? (
|
|
|
|
|
<>
|
|
|
|
|
<Check className="h-4 w-4 text-green-600" />
|
|
|
|
|
<span className="text-sm text-green-600">Có</span>
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<X className="h-4 w-4 text-gray-400" />
|
|
|
|
|
<span className="text-sm text-gray-400">Không</span>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
</>
|
2025-09-24 16:13:57 +07:00
|
|
|
);
|
|
|
|
|
}
|