change quick command button in room detail page

This commit is contained in:
Do Manh Phuong 2026-03-19 16:35:43 +07:00
parent 67f5dbbb08
commit dc7ed4c71a
17 changed files with 234 additions and 157 deletions

View File

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/TTMT.ManageWebGUI.iml" filepath="$PROJECT_DIR$/.idea/TTMT.ManageWebGUI.iml" />
</modules>
</component>
</project>

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

View File

@ -1,74 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AutoImportSettings">
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="94f75d5b-3c15-4d7e-9da7-ad6f5328faf8" name="Changes" comment="">
<change beforePath="$PROJECT_DIR$/src/hooks/useAuth.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/hooks/useAuth.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/routeTree.gen.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/routeTree.gen.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/routes/_auth/blacklist/index.tsx" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/src/routes/_auth/command/index.tsx" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/src/routes/_auth/room/$roomName/index.tsx" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/src/routes/_auth/room/index.tsx" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/src/types/app-sidebar.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/types/app-sidebar.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/types/permission.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/types/permission.ts" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="Git.Settings">
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
</component>
<component name="ProjectColorInfo"><![CDATA[{
"associatedIndex": 1
}]]></component>
<component name="ProjectId" id="3AQVfIkiaizPRlnpMICDG3COfJV" />
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
"ModuleVcsDetector.initialDetectionPerformed": "true",
"RunOnceActivity.ShowReadmeOnStart": "true",
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
"RunOnceActivity.git.unshallow": "true",
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
"dart.analysis.tool.window.visible": "false",
"git-widget-placeholder": "main",
"ignore.virus.scanning.warn.message": "true",
"javascript.preferred.runtime.type.id": "node",
"node.js.detected.package.eslint": "true",
"node.js.detected.package.tslint": "true",
"node.js.selected.package.eslint": "(autodetect)",
"node.js.selected.package.tslint": "(autodetect)",
"nodejs_package_manager_path": "npm",
"ts.external.directory.path": "D:\\MyProject\\NAVISProject\\TTMT.ManageWebGUI\\node_modules\\typescript\\lib",
"vue.rearranger.settings.migration": "true"
}
}]]></component>
<component name="SharedIndexes">
<attachedChunks>
<set>
<option value="bundled-js-predefined-d6986cc7102b-9b0f141eb926-JavaScript-WS-253.31033.133" />
</set>
</attachedChunks>
</component>
<component name="TaskManager">
<task active="true" id="Default" summary="Default task">
<changelist id="94f75d5b-3c15-4d7e-9da7-ad6f5328faf8" name="Changes" comment="" />
<created>1772524885874</created>
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1772524885874</updated>
<workItem from="1772524887267" duration="1839000" />
</task>
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
</component>
</project>

View File

@ -14,8 +14,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useGetCommandsByTypes } from "@/hooks/queries/useCommandQueries";
import { useSendCommand } from "@/hooks/queries";
import { useGetSensitiveCommands, useExecuteSensitiveCommand } from "@/hooks/queries/useCommandQueries";
import { CommandType } from "@/types/command-registry";
import {
Power,
@ -58,6 +57,12 @@ const COMMAND_TYPE_CONFIG = {
color: "text-purple-600",
bgColor: "bg-purple-50 hover:bg-purple-100",
},
[CommandType.RESET]: {
label : "Reset",
icon: Loader2,
color: "text-green-600",
bgColor: "bg-green-50 hover:bg-green-100",
}
};
export function CommandActionButtons({ roomName, selectedDevices = [] }: CommandActionButtonsProps) {
@ -65,55 +70,52 @@ export function CommandActionButtons({ roomName, selectedDevices = [] }: Command
open: boolean;
command: any;
commandType: CommandType;
isSensitive?: boolean;
}>({
open: false,
command: null,
commandType: CommandType.RESTART,
});
const [isExecuting, setIsExecuting] = useState(false);
const [sensitivePassword, setSensitivePassword] = useState("");
// Query commands for each type
const { data: restartCommands = [] } = useGetCommandsByTypes(CommandType.RESTART.toString());
const { data: shutdownCommands = [] } = useGetCommandsByTypes(CommandType.SHUTDOWN.toString());
const { data: taskkillCommands = [] } = useGetCommandsByTypes(CommandType.TASKKILL.toString());
const { data: blockCommands = [] } = useGetCommandsByTypes(CommandType.BLOCK.toString());
const { data: sensitiveCommands = [] } = useGetSensitiveCommands();
// Send command mutation
const sendCommandMutation = useSendCommand();
// Send command mutation (sensitive)
const executeSensitiveMutation = useExecuteSensitiveCommand();
const commandsByType = {
[CommandType.RESTART]: restartCommands,
[CommandType.SHUTDOWN]: shutdownCommands,
[CommandType.TASKKILL]: taskkillCommands,
[CommandType.BLOCK]: blockCommands,
};
// Build commands mapped by CommandType using the `command` field from sensitive data
const commandsByType: Record<number, any[]> = (Object.values(CommandType) as Array<number | string>)
.filter((v) => typeof v === "number")
.reduce((acc: Record<number, any[]>, type) => {
acc[type as number] = (sensitiveCommands || []).filter((c: any) => Number(c.command) === Number(type));
return acc;
}, {} as Record<number, any[]>);
const handleCommandClick = (command: any, commandType: CommandType) => {
// When building from sensitiveCommands, all items here are sensitive
setConfirmDialog({
open: true,
command,
commandType,
isSensitive: true,
});
};
const handleConfirmExecute = async () => {
setIsExecuting(true);
try {
// Chuẩn bị data theo format API (PascalCase)
const apiData = {
Command: confirmDialog.command.commandContent,
QoS: confirmDialog.command.qoS,
IsRetained: confirmDialog.command.isRetained,
};
// Gửi lệnh đến phòng
await sendCommandMutation.mutateAsync({
// All rendered commands are sourced from sensitiveCommands — send via sensitive mutation
await executeSensitiveMutation.mutateAsync({
roomName,
data: apiData as any,
command: confirmDialog.command.commandContent,
password: sensitivePassword,
});
toast.success(`Đã gửi lệnh: ${confirmDialog.command.commandName}`);
setConfirmDialog({ open: false, command: null, commandType: CommandType.RESTART });
setConfirmDialog({ open: false, command: null, commandType: CommandType.RESTART, isSensitive: false });
setSensitivePassword("");
// Reload page để tránh freeze
setTimeout(() => {
@ -128,7 +130,8 @@ export function CommandActionButtons({ roomName, selectedDevices = [] }: Command
const handleCloseDialog = () => {
if (!isExecuting) {
setConfirmDialog({ open: false, command: null, commandType: CommandType.RESTART });
setConfirmDialog({ open: false, command: null, commandType: CommandType.RESTART, isSensitive: false });
setSensitivePassword("");
// Reload để tránh freeze
setTimeout(() => {
window.location.reload();
@ -148,7 +151,7 @@ export function CommandActionButtons({ roomName, selectedDevices = [] }: Command
variant="outline"
disabled
size="sm"
className="gap-2"
className="gap-2 flex-shrink-0"
>
<Icon className={`h-4 w-4 ${config.color}`} />
{config.label}
@ -163,7 +166,7 @@ export function CommandActionButtons({ roomName, selectedDevices = [] }: Command
<Button
variant="outline"
size="sm"
className={`gap-2 ${config.bgColor} border-${config.color.split('-')[1]}-200`}
className={`gap-2 ${config.bgColor} border-${config.color.split('-')[1]}-200 flex-shrink-0`}
>
<Icon className={`h-4 w-4 ${config.color}`} />
{config.label}
@ -202,7 +205,7 @@ export function CommandActionButtons({ roomName, selectedDevices = [] }: Command
return (
<>
<div className="flex flex-wrap gap-2">
<div className="flex gap-2 flex-nowrap overflow-x-auto items-center whitespace-nowrap">
{Object.values(CommandType)
.filter((value) => typeof value === "number")
.map((commandType) => renderCommandButton(commandType as CommandType))}
@ -225,6 +228,18 @@ export function CommandActionButtons({ roomName, selectedDevices = [] }: Command
{confirmDialog.command.description}
</p>
)}
{confirmDialog.isSensitive && (
<div className="mt-2">
<label className="block text-sm font-medium mb-1">Mật khẩu</label>
<input
type="password"
value={sensitivePassword}
onChange={(e) => setSensitivePassword(e.target.value)}
className="w-full px-2 py-1 rounded border"
placeholder="Nhập mật khẩu để xác nhận"
/>
</div>
)}
<div className="bg-muted p-3 rounded-md space-y-1">
<p className="text-sm">
<span className="font-medium">Phòng:</span> {roomName}
@ -255,7 +270,7 @@ export function CommandActionButtons({ roomName, selectedDevices = [] }: Command
</Button>
<Button
onClick={handleConfirmExecute}
disabled={isExecuting}
disabled={isExecuting || (confirmDialog.isSensitive && !sensitivePassword)}
className="gap-2"
>
{isExecuting ? (

View File

@ -1,8 +1,10 @@
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Badge } from "@/components/ui/badge";
import { Monitor, Wifi, WifiOff } from "lucide-react";
import { Monitor, Wifi, WifiOff, Loader2 } from "lucide-react";
import { useState } from "react";
import { cn } from "@/lib/utils";
import { FolderStatusPopover } from "../folder-status-popover";
import { useGetClientFolderStatusForDevice } from "@/hooks/queries";
import type { ClientFolderStatus } from "@/types/folder";
export function ComputerCard({
device,
@ -31,6 +33,62 @@ export function ComputerCard({
const firstNetworkInfo = device.networkInfos?.[0];
const agentVersion = device.version;
function DeviceFolderCheck() {
const deviceId = device.id;
const room = device.room;
const [checking, setChecking] = useState(false);
const { data: status, isLoading } = useGetClientFolderStatusForDevice(
deviceId,
room,
checking
);
const handleCheck = () => setChecking((s) => !s);
return (
<div>
<button
onClick={handleCheck}
className="inline-flex items-center gap-2 px-3 py-1 rounded border bg-background text-sm"
>
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
Kiểm tra thư mục Setup
</button>
{checking && isLoading && (
<div className="text-xs text-muted-foreground mt-2">Đang kiểm tra...</div>
)}
{checking && !isLoading && status && (
<div className="text-xs mt-2">
<div className="font-medium">Các file trong thư mục Setup({status.currentFiles?.length ?? 0})</div>
<div className="mt-1 max-h-36 overflow-auto space-y-1">
{(status.currentFiles ?? []).length === 0 ? (
<div className="text-muted-foreground">Không file hiện tại</div>
) : (
(status.currentFiles ?? []).map((f: any) => (
<div key={f.fileName} className="font-mono text-xs">
<div className="truncate">{f.fileName}</div>
{f.lastModified && (
<div className="text-muted-foreground text-[10px]">
{new Date(f.lastModified).toLocaleString()}
</div>
)}
</div>
))
)}
</div>
</div>
)}
{checking && !isLoading && !status && (
<div className="text-xs text-muted-foreground mt-2">Không dữ liệu</div>
)}
</div>
);
}
const DeviceInfo = () => (
<div className="space-y-3 min-w-[280px]">
<div>
@ -69,6 +127,11 @@ export function ComputerCard({
</div>
)}
<div>
<div className="text-xs text-muted-foreground mb-1">Kiểm tra thư mục</div>
<DeviceFolderCheck />
</div>
<div>
<div className="text-xs text-muted-foreground mb-1">Trạng thái</div>
<Badge

View File

@ -53,6 +53,8 @@ export const API_ENDPOINTS = {
GET_COMMAND_BY_TYPES: (types: string) => `${BASE_URL}/Command/types/${types}`,
UPDATE_COMMAND: (commandId: number) => `${BASE_URL}/Command/update/${commandId}`,
DELETE_COMMAND: (commandId: number) => `${BASE_URL}/Command/delete/${commandId}`,
GET_SENSITIVE_COMMANDS: `${BASE_URL}/Command/sensitive`,
REQUEST_SEND_SENSITIVE_COMMAND: `${BASE_URL}/Command/send-sensitive`,
},
SSE_EVENTS: {
DEVICE_ONLINE: `${BASE_URL}/Sse/events/onlineDevices`,

View File

@ -82,3 +82,47 @@ export function useDeleteCommand() {
},
});
}
/**
* Hook đ lấy danh sách lệnh nhạy cảm
*/
export function useGetSensitiveCommands(enabled = true) {
return useQuery({
queryKey: [...COMMAND_QUERY_KEYS.all, "sensitive"],
queryFn: () => commandService.getSensitiveCommands(),
enabled,
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
/**
* Hook đ gửi lệnh nhạy cảm
*/
export function useExecuteSensitiveCommand() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
roomName,
command,
password,
}: {
roomName: string;
command: any;
password: string;
}) =>
// API expects a SensitiveCommandRequest with PascalCase keys
commandService.requestSendSensitiveCommand({
Command: command,
Password: password,
RoomName: roomName,
}),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [...COMMAND_QUERY_KEYS.all, "sensitive"],
});
},
});
}

View File

@ -171,3 +171,28 @@ export function useGetClientFolderStatus(roomName?: string, enabled = true) {
staleTime: 30 * 1000,
});
}
/**
* Hook to get folder status for a single device. The hook will fetch the
* folder status list for the device's room and return the matching entry
* for the provided `deviceId`.
*/
export function useGetClientFolderStatusForDevice(
deviceId?: string,
roomName?: string,
enabled = true
) {
return useQuery<ClientFolderStatus | undefined>({
queryKey: deviceId
? [...DEVICE_COMM_QUERY_KEYS.all, "folder-status-device", deviceId]
: ["disabled"],
queryFn: async () => {
if (!roomName) return Promise.reject("No room");
const list = await deviceCommService.getClientFolderStatus(roomName);
if (!Array.isArray(list)) return undefined;
return list.find((s: ClientFolderStatus) => s.deviceId === deviceId);
},
enabled: enabled && !!deviceId && !!roomName,
staleTime: 30 * 1000,
});
}

View File

@ -74,7 +74,6 @@ 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: "updatedAt",
header: "Thời gian cập nhật",

View File

@ -55,7 +55,6 @@ function AppsComponent() {
const columns: ColumnDef<Version>[] = [
{ accessorKey: "version", header: "Phiên bản" },
{ accessorKey: "fileName", header: "Tên file" },
{ accessorKey: "folderPath", header: "Đường dẫn" },
{
accessorKey: "updatedAt",
header: () => <div className="whitespace-normal max-w-xs">Thời gian cập nhật</div>,

View File

@ -11,6 +11,7 @@ import {
useSendCommand,
} from "@/hooks/queries";
import { toast } from "sonner";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { Check, X, Edit2, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { ColumnDef } from "@tanstack/react-table";
@ -73,11 +74,19 @@ function CommandPage() {
accessorKey: "commandName",
header: "Tên lệnh",
size: 100,
cell: ({ getValue }) => (
<div className="max-w-[100px]">
<span className="font-semibold truncate block">{getValue() as string}</span>
</div>
),
cell: ({ getValue, row }) => {
const full = (getValue() as string) || row.original.commandName || "";
return (
<div className="max-w-[100px]">
<Tooltip>
<TooltipTrigger asChild>
<span className="font-semibold truncate block cursor-help">{full}</span>
</TooltipTrigger>
<TooltipContent side="bottom">{full}</TooltipContent>
</Tooltip>
</div>
);
},
},
{
accessorKey: "commandType",
@ -93,18 +102,6 @@ function CommandPage() {
return <span>{typeMap[type] || "UNKNOWN"}</span>;
},
},
{
accessorKey: "description",
header: "Mô tả",
size: 120,
cell: ({ getValue }) => (
<div className="max-w-[120px]">
<span className="text-sm text-muted-foreground truncate block">
{(getValue() as string) || "-"}
</span>
</div>
),
},
{
accessorKey: "commandContent",
header: "Nội dung lệnh",
@ -153,7 +150,7 @@ function CommandPage() {
},
{
id: "select",
header: () => <div className="text-center text-xs">Chọn đ thực thi</div>,
header: () => <div className="text-center text-xs">Thực thi</div>,
cell: ({ row }) => (
<input
type="checkbox"

View File

@ -80,7 +80,7 @@ function RoomDetailPage() {
Thực thi lệnh
</div>
<div className="flex items-center gap-3 flex-wrap justify-end">
<div className="flex items-center gap-3 justify-end">
{/* Command Action Buttons */}
{devices.length > 0 && (
<>

View File

@ -51,3 +51,22 @@ export async function deleteCommand(commandId: number): Promise<any> {
);
return response.data;
}
/**
* Lấy danh sách lệnh nhạy cảm
* @return - Danh sách lệnh nhạy cảm
* */
export async function getSensitiveCommands(): Promise<any[]> {
const response = await axios.get<any[]>(API_ENDPOINTS.COMMAND.GET_SENSITIVE_COMMANDS);
return response.data;
}
/**
* Gửi yêu cầu thực thi lệnh nhạy cảm
* @param data - Dữ liệu lệnh nhạy cảm
* @return - Kết quả thực thi
* */
export async function requestSendSensitiveCommand(data: any): Promise<any> {
const response = await axios.post(API_ENDPOINTS.COMMAND.REQUEST_SEND_SENSITIVE_COMMAND, data);
return response.data;
}

View File

@ -18,10 +18,10 @@ export const appSidebarSection = {
versions: ["1.0.1", "1.1.0-alpha", "2.0.0-beta1"],
navMain: [
{
title: "Thống kê tổng quan",
title: "Tổng quan",
items: [
{
title: "Dashboard",
title: "Thống kê",
url: "/dashboard",
code: AppSidebarSectionCode.DASHBOARD,
icon: Home,
@ -30,7 +30,7 @@ export const appSidebarSection = {
],
},
{
title: "Quan lý phòng máy",
title: "Qun lý phòng máy",
items: [
{
title: "Danh sách phòng máy",
@ -42,17 +42,17 @@ export const appSidebarSection = {
],
},
{
title: "Agent và phần mềm",
title: "Quản lý agent/thư mục Setup",
items: [
{
title: "Danh sách Agent",
title: "Agent",
url: "/agent",
code: AppSidebarSectionCode.AGENT_MANAGEMENT,
icon: AppWindow,
permissions: [PermissionEnum.VIEW_AGENT],
},
{
title: "Quản lý phần mềm",
title: "Thư mục Setup",
url: "/apps",
icon: AppWindow,
permissions: [PermissionEnum.VIEW_APPS],
@ -60,7 +60,7 @@ export const appSidebarSection = {
],
},
{
title: "Lệnh và các ứng dụng bị chặn",
title: "Quản lý lệnh/blacklist",
items:
[
{
@ -70,7 +70,7 @@ export const appSidebarSection = {
permissions: [PermissionEnum.VIEW_COMMAND],
},
{
title: "Danh sách ứng dụng/web bị chặn",
title: "Chặn ứng dụng/website",
url: "/blacklists",
icon: CircleX,
permissions: [PermissionEnum.ALLOW_ALL],
@ -78,7 +78,7 @@ export const appSidebarSection = {
]
},
{
title: "Phân quyền và người dùng",
title: "Quản lý tài khoản/phân quyền",
items: [
{
title: "Danh sách roles",

View File

@ -15,4 +15,5 @@ export enum CommandType {
SHUTDOWN = 2,
TASKKILL = 3,
BLOCK = 4,
RESET = 5,
}

View File

@ -0,0 +1,9 @@
import type { CommandType } from "./command-registry";
export type SensitiveCommand = {
commandName: string;
commandType: CommandType;
commandContent: string;
qoS: 0 | 1 | 2;
isRetained: boolean;
}