227 lines
7.4 KiB
TypeScript
227 lines
7.4 KiB
TypeScript
|
|
import { useMemo, useState } from "react";
|
||
|
|
import { AlertTriangle, CheckCircle2, Power, PowerOff, RotateCcw, ShieldBan, XCircle } from "lucide-react";
|
||
|
|
import { Button } from "@/components/ui/button";
|
||
|
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||
|
|
import { Input } from "@/components/ui/input";
|
||
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||
|
|
import { useMachineNumber } from "@/hooks/useMachineNumber";
|
||
|
|
import { useExecuteSensitiveCommand, useGetSensitiveCommands } from "@/hooks/queries/useCommandQueries";
|
||
|
|
import { CommandType } from "@/types/command-registry";
|
||
|
|
import { toast } from "sonner";
|
||
|
|
|
||
|
|
interface DeviceActionBarProps {
|
||
|
|
roomName: string;
|
||
|
|
selectedDevices: any[];
|
||
|
|
onClearSelection: () => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
const ACTIONS = [
|
||
|
|
{
|
||
|
|
type: CommandType.RESTART,
|
||
|
|
label: "Khởi động lại",
|
||
|
|
icon: Power,
|
||
|
|
variant: "outline" as const,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
type: CommandType.SHUTDOWN,
|
||
|
|
label: "Tắt máy",
|
||
|
|
icon: PowerOff,
|
||
|
|
variant: "destructive" as const,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
type: CommandType.TASKKILL,
|
||
|
|
label: "Kết thúc tác vụ",
|
||
|
|
icon: XCircle,
|
||
|
|
variant: "outline" as const,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
type: CommandType.BLOCK,
|
||
|
|
label: "Chặn",
|
||
|
|
icon: ShieldBan,
|
||
|
|
variant: "outline" as const,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
type: CommandType.RESET,
|
||
|
|
label: "Reset",
|
||
|
|
icon: RotateCcw,
|
||
|
|
variant: "destructive" as const,
|
||
|
|
},
|
||
|
|
];
|
||
|
|
|
||
|
|
const DANGER_TYPES = new Set<CommandType>([CommandType.SHUTDOWN, CommandType.RESET]);
|
||
|
|
|
||
|
|
export function DeviceActionBar({
|
||
|
|
roomName,
|
||
|
|
selectedDevices,
|
||
|
|
onClearSelection,
|
||
|
|
}: DeviceActionBarProps) {
|
||
|
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||
|
|
const [activeType, setActiveType] = useState<CommandType | null>(null);
|
||
|
|
const [password, setPassword] = useState("");
|
||
|
|
const [isExecuting, setIsExecuting] = useState(false);
|
||
|
|
|
||
|
|
const getMachineNumber = useMachineNumber();
|
||
|
|
|
||
|
|
const { data: sensitiveCommands = [] } = useGetSensitiveCommands();
|
||
|
|
const executeSensitiveMutation = useExecuteSensitiveCommand();
|
||
|
|
|
||
|
|
const commandsByType = useMemo(() => {
|
||
|
|
return (Object.values(CommandType) as Array<number | string>)
|
||
|
|
.filter((value) => typeof value === "number")
|
||
|
|
.reduce((acc: Record<number, any[]>, type) => {
|
||
|
|
acc[type as number] = (sensitiveCommands || []).filter(
|
||
|
|
(command: any) => Number(command.command) === Number(type)
|
||
|
|
);
|
||
|
|
return acc;
|
||
|
|
}, {} as Record<number, any[]>);
|
||
|
|
}, [sensitiveCommands]);
|
||
|
|
|
||
|
|
const selectedCount = selectedDevices.length;
|
||
|
|
const activeCommand = activeType ? commandsByType[activeType]?.[0] : null;
|
||
|
|
|
||
|
|
const buildDeviceLabel = (device: any) => {
|
||
|
|
const number = getMachineNumber(device?.id || "");
|
||
|
|
const ipAddress = device?.networkInfos?.[0]?.ipAddress;
|
||
|
|
if (number > 0) {
|
||
|
|
return `#${number}${ipAddress ? ` (${ipAddress})` : ""}`;
|
||
|
|
}
|
||
|
|
return `${device?.id ?? ""}${ipAddress ? ` (${ipAddress})` : ""}`;
|
||
|
|
};
|
||
|
|
|
||
|
|
const openConfirm = (type: CommandType) => {
|
||
|
|
if (!commandsByType[type]?.length) {
|
||
|
|
toast.error("Chưa có lệnh phù hợp cho thao tác này.");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
setActiveType(type);
|
||
|
|
setConfirmOpen(true);
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleClose = () => {
|
||
|
|
if (isExecuting) return;
|
||
|
|
setConfirmOpen(false);
|
||
|
|
setActiveType(null);
|
||
|
|
setPassword("");
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleConfirm = async () => {
|
||
|
|
if (!activeCommand || !activeType) return;
|
||
|
|
if (!password.trim()) {
|
||
|
|
toast.error("Vui lòng nhập mật khẩu xác nhận.");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
setIsExecuting(true);
|
||
|
|
try {
|
||
|
|
await executeSensitiveMutation.mutateAsync({
|
||
|
|
roomName,
|
||
|
|
command: activeCommand.commandName,
|
||
|
|
password,
|
||
|
|
});
|
||
|
|
toast.success(`Đã gửi lệnh: ${activeCommand.commandName}`);
|
||
|
|
handleClose();
|
||
|
|
onClearSelection();
|
||
|
|
} catch (error) {
|
||
|
|
console.error("Execute command error:", error);
|
||
|
|
toast.error("Lỗi khi gửi lệnh!");
|
||
|
|
} finally {
|
||
|
|
setIsExecuting(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
if (selectedCount === 0) return null;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<>
|
||
|
|
<div className="sticky bottom-4 z-30">
|
||
|
|
<div className="flex flex-col gap-3 rounded-xl border bg-background/95 px-4 py-3 shadow-lg backdrop-blur sm:flex-row sm:items-center sm:justify-between">
|
||
|
|
<div className="flex items-center gap-2 text-sm font-semibold">
|
||
|
|
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
|
||
|
|
Đã chọn {selectedCount} thiết bị
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="flex flex-wrap items-center gap-2">
|
||
|
|
{ACTIONS.map((action) => {
|
||
|
|
const Icon = action.icon;
|
||
|
|
const isDisabled = !commandsByType[action.type]?.length;
|
||
|
|
return (
|
||
|
|
<Button
|
||
|
|
key={action.type}
|
||
|
|
variant={action.variant}
|
||
|
|
size="sm"
|
||
|
|
disabled={isDisabled}
|
||
|
|
onClick={() => openConfirm(action.type)}
|
||
|
|
>
|
||
|
|
<Icon className="h-4 w-4" />
|
||
|
|
{action.label}
|
||
|
|
</Button>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
|
||
|
|
<Button variant="ghost" size="sm" onClick={onClearSelection}>
|
||
|
|
Bỏ chọn
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<Dialog open={confirmOpen} onOpenChange={handleClose}>
|
||
|
|
<DialogContent>
|
||
|
|
<DialogHeader>
|
||
|
|
<DialogTitle className="flex items-center gap-2">
|
||
|
|
<AlertTriangle className="h-5 w-5 text-orange-600" />
|
||
|
|
Xác nhận thực thi lệnh
|
||
|
|
</DialogTitle>
|
||
|
|
<DialogDescription className="text-left space-y-3">
|
||
|
|
<p>
|
||
|
|
Bạn có chắc chắn muốn thực thi lệnh{" "}
|
||
|
|
<strong>{activeCommand?.commandName ?? ""}</strong>?
|
||
|
|
</p>
|
||
|
|
{DANGER_TYPES.has(activeType ?? CommandType.RESTART) && (
|
||
|
|
<p className="text-sm text-destructive">
|
||
|
|
Hành động này không thể hoàn tác.
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
</DialogDescription>
|
||
|
|
</DialogHeader>
|
||
|
|
|
||
|
|
<div className="space-y-3">
|
||
|
|
<div className="text-sm font-medium">Thiết bị được chọn</div>
|
||
|
|
<ScrollArea className="max-h-40 rounded-lg border p-2">
|
||
|
|
<div className="space-y-1 text-sm">
|
||
|
|
{selectedDevices.map((device) => (
|
||
|
|
<div key={device.id} className="text-muted-foreground">
|
||
|
|
{buildDeviceLabel(device)}
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</ScrollArea>
|
||
|
|
|
||
|
|
<div>
|
||
|
|
<label className="mb-1 block text-sm font-medium">Mật khẩu</label>
|
||
|
|
<Input
|
||
|
|
type="password"
|
||
|
|
value={password}
|
||
|
|
onChange={(event) => setPassword(event.target.value)}
|
||
|
|
placeholder="Nhập mật khẩu để xác nhận"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<DialogFooter className="flex gap-2 sm:gap-3">
|
||
|
|
<Button variant="outline" onClick={handleClose} disabled={isExecuting}>
|
||
|
|
Hủy
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
variant={DANGER_TYPES.has(activeType ?? CommandType.RESTART) ? "destructive" : "default"}
|
||
|
|
onClick={handleConfirm}
|
||
|
|
disabled={isExecuting}
|
||
|
|
>
|
||
|
|
{isExecuting ? "Đang gửi..." : "Xác nhận"}
|
||
|
|
</Button>
|
||
|
|
</DialogFooter>
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
</>
|
||
|
|
);
|
||
|
|
}
|