diff --git a/BACKEND/app/controllers/scenarios_controller.ts b/BACKEND/app/controllers/scenarios_controller.ts index a05d17e..4b3e805 100644 --- a/BACKEND/app/controllers/scenarios_controller.ts +++ b/BACKEND/app/controllers/scenarios_controller.ts @@ -12,7 +12,7 @@ export default class ScenariosController { const perPage = request.input('per_page', 10) const page = request.input('page', 1) - const query = Scenario.query() + const query = Scenario.query().preload('brand').preload('category') const scenarios = await query.orderBy('scenarios.created_at', 'asc').paginate(page, perPage) return response.ok({ @@ -80,10 +80,16 @@ export default class ScenariosController { await trx.commit() + // Lấy lại station kèm lines + const resScenario = await Scenario.query() + .where('id', scenario.id) + .preload('brand') + .preload('category') + return response.ok({ status: true, message: 'Scenario created successfully', - data: scenario, + data: resScenario, }) } catch (error) { await trx.rollback() @@ -153,7 +159,17 @@ export default class ScenariosController { scenario.merge(payload) await scenario.save() - return response.ok({ status: true, message: 'Scenario updated successfully', data: scenario }) + // Lấy lại station kèm lines + const resScenario = await Scenario.query() + .where('id', scenario.id) + .preload('brand') + .preload('category') + + return response.ok({ + status: true, + message: 'Scenario updated successfully', + data: resScenario, + }) } catch (error) { return response.internalServerError({ status: false, diff --git a/FRONTEND/src/components/Authentication/Login.tsx b/FRONTEND/src/components/Authentication/Login.tsx index 136f55f..c581183 100644 --- a/FRONTEND/src/components/Authentication/Login.tsx +++ b/FRONTEND/src/components/Authentication/Login.tsx @@ -58,11 +58,19 @@ const Login = () => { } catch (error) { setIsSubmit(false); console.log(error); - notifications.show({ - title: "Error", - message: "Login fail, please try again!", - color: "red", - }); + if (axios.isAxiosError(error)) { + notifications.show({ + title: "Error", + message: error?.response?.data?.message, + color: "red", + }); + } else { + notifications.show({ + title: "Error", + message: "Login fail, please try again!", + color: "red", + }); + } } }; diff --git a/FRONTEND/src/components/BottomToolBar.tsx b/FRONTEND/src/components/BottomToolBar.tsx index eee9149..a4e72b9 100644 --- a/FRONTEND/src/components/BottomToolBar.tsx +++ b/FRONTEND/src/components/BottomToolBar.tsx @@ -8,10 +8,8 @@ import { ScrollArea, Tabs, Text, - Card, - Badge, } from "@mantine/core"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useMemo, useState } from "react"; import classes from "./Component.module.css"; import type { IScenario, @@ -19,7 +17,6 @@ import type { TCategories, TLine, TStation, - TUser, } from "../untils/types"; import type { Socket } from "socket.io-client"; import { ButtonDPELP, ButtonSelect } from "./ButtonAction"; @@ -28,13 +25,9 @@ import { DrawerAPCControl, DrawerSwitchControl } from "./Drawer/DrawerControl"; import DrawerScenario from "./Modal/ModalScenario"; import { isJsonString } from "../untils/helper"; import { motion } from "motion/react"; -import { - IconCaretDown, - IconCaretUp, - IconPlayerPlay, - IconPlus, -} from "@tabler/icons-react"; +import { IconCaretDown, IconCaretUp } from "@tabler/icons-react"; import InputHistory from "./InputHistory"; +import ModalRunScenario from "./Modal/ModalRunScenario"; interface TabsProps { selectedLines: TLine[]; @@ -58,318 +51,6 @@ interface TabsProps { listCategories: TCategories[]; } -// Component cho từng Scenario Card -const ScenarioCard = ({ - scenario, - index, - isDisable, - selectedLines, - user, - socket, - setOpenScenarioModal, - setIsDisable, - station, -}: { - scenario: IScenario; - index: number; - isDisable: boolean; - selectedLines: TLine[]; - user: TUser; - socket: Socket | null; - setOpenScenarioModal: (value: boolean) => void; - setIsDisable: (value: boolean) => void; - station: TStation; -}) => { - const [isHovered, setIsHovered] = useState(false); - const [overlayPosition, setOverlayPosition] = useState({ top: 0, left: 0 }); - const cardRef = useRef(null); - const steps = JSON.parse(scenario.body || "[]"); - - const handleMouseEnter = () => { - if (cardRef.current) { - const rect = cardRef.current.getBoundingClientRect(); - const overlayWidth = 400; - const viewportWidth = window.innerWidth; - const gap = 10; // Khoảng cách giữa card và overlay - - // Đặt overlay bên phải card - let left = rect.right + gap; - - // Nếu overlay tràn ra ngoài màn hình bên phải → đặt bên trái card - if (left + overlayWidth > viewportWidth) { - left = rect.left - overlayWidth - gap; - - // Nếu vẫn tràn ra ngoài bên trái → đặt ở giữa màn hình - if (left < 10) { - left = (viewportWidth - overlayWidth) / 2; - } - } - - setOverlayPosition({ - top: rect.top, - left: left, - }); - } - setIsHovered(true); - }; - - useEffect(() => { - if (isHovered && cardRef.current) { - const updatePosition = () => { - if (cardRef.current) { - const rect = cardRef.current.getBoundingClientRect(); - const overlayWidth = 400; - const viewportWidth = window.innerWidth; - const gap = 10; // Khoảng cách giữa card và overlay - - // Đặt overlay bên phải card - let left = rect.right + gap; - - // Nếu overlay tràn ra ngoài màn hình bên phải → đặt bên trái card - if (left + overlayWidth > viewportWidth) { - left = rect.left - overlayWidth - gap; - - // Nếu vẫn tràn ra ngoài bên trái → đặt ở giữa màn hình - if (left < 10) { - left = (viewportWidth - overlayWidth) / 2; - } - } - - setOverlayPosition({ - top: rect.top, - left: left, - }); - } - }; - - // Update position immediately - updatePosition(); - - // Listen to scroll events (including inside modal) - const scrollContainers = document.querySelectorAll('[style*="overflow"]'); - scrollContainers.forEach((container) => { - container.addEventListener("scroll", updatePosition, true); - }); - - window.addEventListener("scroll", updatePosition, true); - window.addEventListener("resize", updatePosition); - - return () => { - scrollContainers.forEach((container) => { - container.removeEventListener("scroll", updatePosition, true); - }); - window.removeEventListener("scroll", updatePosition, true); - window.removeEventListener("resize", updatePosition); - }; - } - }, [isHovered]); - - return ( - -
- - - setIsHovered(false)} - > - {scenario.title} - - - - - - - {/* Hover overlay - Fixed position để không bị cắt bởi modal */} - {isHovered && ( -
e.stopPropagation()} - onMouseEnter={() => setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - > - - - - {scenario.title} - - - #{index + 1} - - - - - - Timeout: {scenario.timeout}ms - - {scenario.isReboot && ( - - Reboot - - )} - - {steps.length} steps - - - - - Commands Preview: - - - {steps.slice(0, 5).map((step: { send: string }, i: number) => ( - - {i + 1}. {step.send || "(empty)"} - - ))} - {steps.length > 5 && ( - - ... and {steps.length - 5} more - - )} - - -
- )} -
-
- ); -}; - const BottomToolBar = ({ selectedLines, socket, @@ -948,129 +629,20 @@ const BottomToolBar = ({ - {openScenarioModal && ( -
setOpenScenarioModal(false)} - > -
e.stopPropagation()} - > - {/* Header */} - - - 🎯 Select Scenario to Run - - - - setOpenScenarioModal(false)} - /> - - - - {/* Content */} -
- {scenarios.length > 0 ? ( - - {scenarios.map((scenario, index) => ( - - ))} - - ) : ( - - - 📋 - - - No scenarios available - - - Please create a new scenario to get started - - - )} -
-
-
- )} + {/* Drawer Scenario để Add/Edit */} ) => void; + setOpenEdit: (value: React.SetStateAction) => void; + scenarios: IScenario[]; + isDisable: boolean; + selectedLines: TLine[]; + user: TUser; + socket: Socket | null; + setIsDisable: (value: boolean) => void; + station: TStation; + listBrands: TBrands[]; + listCategories: TCategories[]; +}) => { + const [optionBrand, setOptionBrand] = useState(""); + const [optionCategory, setOptionCategory] = useState(""); + const [inputSeries, setInputSeries] = useState([]); + const [inputTitle, setInputTitle] = useState(""); + + const filteredScenarios = () => { + let listScenarios = [...scenarios]; + if (inputTitle.trim()) + listScenarios = listScenarios.filter((el) => + el.title?.toLowerCase()?.includes(inputTitle?.toLowerCase()) + ); + if (optionBrand) + listScenarios = listScenarios.filter( + (el) => + el.brandId === Number(optionBrand) || + el.brand_id === Number(optionBrand) + ); + if (optionCategory) + listScenarios = listScenarios.filter( + (el) => + el.categoryId === Number(optionCategory) || + el.category_id === Number(optionCategory) + ); + if (inputSeries.length) + listScenarios = listScenarios.filter((el) => { + const series: string[] = JSON.parse(el.series) || []; + if (!series.length) return false; + + return series.some((serial) => + inputSeries.some((input) => + serial.toLowerCase().includes(input.toLowerCase()) + ) + ); + }); + return listScenarios; + }; + return ( + open && ( +
setOpen(false)} + > +
e.stopPropagation()} + > + {/* Header */} + + + 🎯 Select Scenario to Run + + + + setOpen(false)} /> + + + + + ({ + value: el.id.toString(), + label: el.name, + }))} + value={optionCategory || null} + onChange={(value) => setOptionCategory(value || "")} + clearable + /> + + + setInputSeries(value)} + /> + + + setInputTitle(event.currentTarget.value)} + rightSection={ + inputTitle ? ( + setInputTitle("")} + /> + ) : null + } + rightSectionPointerEvents="auto" + /> + + + + {/* Content */} +
+ + + + + + Brand + + + Category + + + Series + + + Title + + + Note + + + Action + + + + + {filteredScenarios().length > 0 ? ( + filteredScenarios().map((scenario, index) => ( + + + {scenario?.brand?.name || ""} + + + {scenario?.category?.name || ""} + + + + {scenario.series + ? JSON.parse(scenario.series)?.map( + (el: string, i: number) => ( + + {el} + + ) + ) + : ""} + + + + {scenario.title} + + + {scenario.note || ""} + + + e.stopPropagation()} + label={ + + + + + {scenario.title} + + + + #{index + 1} + + + + + + Timeout: {scenario.timeout}s + + {scenario.isReboot && ( + + Reboot + + )} + + {(JSON.parse(scenario?.body) || []).length}{" "} + steps + + + + + Commands Preview: + + + {(JSON.parse(scenario?.body) || []) + .slice(0, 5) + .map( + (step: { send: string }, i: number) => ( + + {i + 1}. {step.send || "(empty)"} + + ) + )} + {(JSON.parse(scenario?.body) || []).length > + 5 && ( + + ... and{" "} + {(JSON.parse(scenario?.body) || []) + .length - 5}{" "} + more + + )} + + + } + > + + + + + )) + ) : ( + + + + + 📋 + + + No scenarios available + + + + + )} + +
+
+
+
+
+ ) + ); +}; + +export default ModalRunScenario; diff --git a/FRONTEND/src/components/Modal/ModalScenario.tsx b/FRONTEND/src/components/Modal/ModalScenario.tsx index 041391a..e982cfb 100644 --- a/FRONTEND/src/components/Modal/ModalScenario.tsx +++ b/FRONTEND/src/components/Modal/ModalScenario.tsx @@ -156,7 +156,7 @@ function ModalScenario({ isEdit ? { ...payload, id: dataScenario?.id } : payload ); if (res.data.status === true) { - const scenario = res.data.data; + const scenario = res.data.data[0]; setScenarios((pre) => isEdit ? pre.map((el) => @@ -181,11 +181,19 @@ function ModalScenario({ } } catch (error) { console.log(error); - notifications.show({ - title: "Error", - message: "Failed to create scenario, please try again!", - color: "red", - }); + if (axios.isAxiosError(error)) { + notifications.show({ + title: "Error", + message: error?.response?.data?.message, + color: "red", + }); + } else { + notifications.show({ + title: "Error", + message: "Failed to create scenario, please try again!", + color: "red", + }); + } } finally { setIsSubmit(false); } @@ -294,7 +302,7 @@ function ModalScenario({ {/* Sidebar - List Scenarios */} form.setFieldValue("note", e.target.value) } - required /> diff --git a/FRONTEND/src/components/Modal/Scenario/ScenarioCard.tsx b/FRONTEND/src/components/Modal/Scenario/ScenarioCard.tsx new file mode 100644 index 0000000..c15e686 --- /dev/null +++ b/FRONTEND/src/components/Modal/Scenario/ScenarioCard.tsx @@ -0,0 +1,320 @@ +import type { Socket } from "socket.io-client"; +import type { IScenario, TLine, TStation, TUser } from "../../../untils/types"; +import { useEffect, useRef, useState } from "react"; +import { Badge, Box, Button, Card, Flex, Grid, Text } from "@mantine/core"; +import classes from "../../Component.module.css"; +import { IconPlayerPlay } from "@tabler/icons-react"; + +// Component cho từng Scenario Card +const ScenarioCard = ({ + scenario, + index, + isDisable, + selectedLines, + user, + socket, + setOpenScenarioModal, + setIsDisable, + station, +}: { + scenario: IScenario; + index: number; + isDisable: boolean; + selectedLines: TLine[]; + user: TUser; + socket: Socket | null; + setOpenScenarioModal: (value: boolean) => void; + setIsDisable: (value: boolean) => void; + station: TStation; +}) => { + const [isHovered, setIsHovered] = useState(false); + const [overlayPosition, setOverlayPosition] = useState({ top: 0, left: 0 }); + const cardRef = useRef(null); + const steps = JSON.parse(scenario.body || "[]"); + + const handleMouseEnter = () => { + if (cardRef.current) { + const rect = cardRef.current.getBoundingClientRect(); + const overlayWidth = 400; + const viewportWidth = window.innerWidth; + const gap = 10; // Khoảng cách giữa card và overlay + + // Đặt overlay bên phải card + let left = rect.right + gap; + + // Nếu overlay tràn ra ngoài màn hình bên phải → đặt bên trái card + if (left + overlayWidth > viewportWidth) { + left = rect.left - overlayWidth - gap; + + // Nếu vẫn tràn ra ngoài bên trái → đặt ở giữa màn hình + if (left < 10) { + left = (viewportWidth - overlayWidth) / 2; + } + } + + setOverlayPosition({ + top: rect.top, + left: left, + }); + } + setIsHovered(true); + }; + + useEffect(() => { + if (isHovered && cardRef.current) { + const updatePosition = () => { + if (cardRef.current) { + const rect = cardRef.current.getBoundingClientRect(); + const overlayWidth = 400; + const viewportWidth = window.innerWidth; + const gap = 10; // Khoảng cách giữa card và overlay + + // Đặt overlay bên phải card + let left = rect.right + gap; + + // Nếu overlay tràn ra ngoài màn hình bên phải → đặt bên trái card + if (left + overlayWidth > viewportWidth) { + left = rect.left - overlayWidth - gap; + + // Nếu vẫn tràn ra ngoài bên trái → đặt ở giữa màn hình + if (left < 10) { + left = (viewportWidth - overlayWidth) / 2; + } + } + + setOverlayPosition({ + top: rect.top, + left: left, + }); + } + }; + + // Update position immediately + updatePosition(); + + // Listen to scroll events (including inside modal) + const scrollContainers = document.querySelectorAll('[style*="overflow"]'); + scrollContainers.forEach((container) => { + container.addEventListener("scroll", updatePosition, true); + }); + + window.addEventListener("scroll", updatePosition, true); + window.addEventListener("resize", updatePosition); + + return () => { + scrollContainers.forEach((container) => { + container.removeEventListener("scroll", updatePosition, true); + }); + window.removeEventListener("scroll", updatePosition, true); + window.removeEventListener("resize", updatePosition); + }; + } + }, [isHovered]); + + return ( + +
+ + + setIsHovered(false)} + > + {scenario.title} + + + + + + + {/* Hover overlay - Fixed position để không bị cắt bởi modal */} + {isHovered && ( +
e.stopPropagation()} + onMouseEnter={() => setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + + + + {scenario.title} + + + #{index + 1} + + + + + + Timeout: {scenario.timeout}ms + + {scenario.isReboot && ( + + Reboot + + )} + + {steps.length} steps + + + + + Commands Preview: + + + {steps.slice(0, 5).map((step: { send: string }, i: number) => ( + + {i + 1}. {step.send || "(empty)"} + + ))} + {steps.length > 5 && ( + + ... and {steps.length - 5} more + + )} + + +
+ )} +
+
+ ); +}; + +export default ScenarioCard; diff --git a/FRONTEND/src/components/Modal/Scenario/TableRows.tsx b/FRONTEND/src/components/Modal/Scenario/TableRows.tsx index efbf242..a325a1b 100644 --- a/FRONTEND/src/components/Modal/Scenario/TableRows.tsx +++ b/FRONTEND/src/components/Modal/Scenario/TableRows.tsx @@ -2,13 +2,48 @@ import { Table, TextInput } from "@mantine/core"; import { IconRowInsertTop, IconX } from "@tabler/icons-react"; import classes from "./Scenario.module.css"; import { numberOnly } from "../../../untils/helper"; +import type { IBodyScenario } from "../../../untils/types"; +import type { UseFormReturnType } from "@mantine/form"; interface IPayload { - element: any; - i: any; - form: any; - deleteRow: any; - addRowUnder: any; + element: IBodyScenario; + i: number; + form: UseFormReturnType< + { + title: string; + body: IBodyScenario[]; + timeout: string; + isReboot: boolean; + send_result: boolean; + note: string; + series: string[]; + brandId: string; + categoryId: string; + }, + (values: { + title: string; + body: IBodyScenario[]; + timeout: string; + isReboot: boolean; + send_result: boolean; + note: string; + series: string[]; + brandId: string; + categoryId: string; + }) => { + title: string; + body: IBodyScenario[]; + timeout: string; + isReboot: boolean; + send_result: boolean; + note: string; + series: string[]; + brandId: string; + categoryId: string; + } + >; + deleteRow: (index: number) => void; + addRowUnder: (index: number) => void; } const TableRows = ({ element, i, form, deleteRow, addRowUnder }: IPayload) => { @@ -28,7 +63,7 @@ const TableRows = ({ element, i, form, deleteRow, addRowUnder }: IPayload) => {
*/} { @@ -49,7 +84,7 @@ const TableRows = ({ element, i, form, deleteRow, addRowUnder }: IPayload) => { { diff --git a/FRONTEND/src/components/TerminalXTerm.tsx b/FRONTEND/src/components/TerminalXTerm.tsx index 177835d..b85a5b8 100644 --- a/FRONTEND/src/components/TerminalXTerm.tsx +++ b/FRONTEND/src/components/TerminalXTerm.tsx @@ -208,7 +208,7 @@ const TerminalCLI: React.FC = ({ useEffect(() => { const el = xtermRef.current; - if (el) { + if (el && miniSize) { if (!isSelected) { // tắt pointer events để terminal không bắt wheel/click nữa el.style.pointerEvents = "none"; diff --git a/FRONTEND/src/untils/types.ts b/FRONTEND/src/untils/types.ts index 5a13695..d24ed94 100644 --- a/FRONTEND/src/untils/types.ts +++ b/FRONTEND/src/untils/types.ts @@ -167,8 +167,10 @@ export type IScenario = { send_result: boolean; brandId: number; brand_id?: number; + brand?: TBrands; categoryId: number; category_id?: number; + category?: TCategories; note: string; series: string; updated_at: string;