From d908cf204cfab99ca5d41340a63e664f34e4883b Mon Sep 17 00:00:00 2001 From: nguyentrungthat <80239428+nguentrungthat@users.noreply.github.com> Date: Tue, 18 Nov 2025 16:59:27 +0700 Subject: [PATCH] Add ticket management to lines and improve UI Backend changes add a tickets relationship to lines and preload tickets (ordered by updated_at) when fetching stations. The frontend now displays ticket information in CardLine, adds ticket history and management (create, update, close, issue) in ModalTerminal, and introduces new types for tickets. Various UI improvements include consistent button font weights, port name normalization, and enhanced log/tooltips. Obsolete takeover logic was removed from App and ModalTerminal. --- .../app/controllers/stations_controller.ts | 6 +- BACKEND/app/models/line.ts | 6 + BACKEND/providers/socket_io_provider.ts | 70 +- FRONTEND/src/App.tsx | 33 +- FRONTEND/src/components/BottomToolBar.tsx | 2 + FRONTEND/src/components/ButtonAction.tsx | 5 + FRONTEND/src/components/CardLine.tsx | 13 +- FRONTEND/src/components/Component.module.css | 3 +- FRONTEND/src/components/DrawerControl.tsx | 30 +- FRONTEND/src/components/DrawerLogs.tsx | 30 +- FRONTEND/src/components/ModalTerminal.tsx | 889 +++++++++++++----- FRONTEND/src/untils/types.ts | 20 + 12 files changed, 817 insertions(+), 290 deletions(-) diff --git a/BACKEND/app/controllers/stations_controller.ts b/BACKEND/app/controllers/stations_controller.ts index b1fbb25..f658fdd 100644 --- a/BACKEND/app/controllers/stations_controller.ts +++ b/BACKEND/app/controllers/stations_controller.ts @@ -4,7 +4,11 @@ import Line from '#models/line' export default class StationsController { public async index({}: HttpContext) { - return await Station.query().preload('lines') + return await Station.query().preload('lines', (query) => { + query.preload('tickets', (ticketQuery) => { + ticketQuery.orderBy('updated_at', 'desc') + }) + }) } public async store({ request, response }: HttpContext) { diff --git a/BACKEND/app/models/line.ts b/BACKEND/app/models/line.ts index 2c42405..50a66bc 100644 --- a/BACKEND/app/models/line.ts +++ b/BACKEND/app/models/line.ts @@ -3,6 +3,7 @@ import { BaseModel, belongsTo, column, hasMany } from '@adonisjs/lucid/orm' import type { BelongsTo, HasMany } from '@adonisjs/lucid/types/relations' import Station from './station.js' import Log from './log.js' +import Ticket from './ticket.js' export default class Line extends BaseModel { @column({ isPrimary: true }) @@ -40,6 +41,11 @@ export default class Line extends BaseModel { }) declare logs: HasMany + @hasMany(() => Ticket, { + foreignKey: 'lineId', + }) + declare tickets: HasMany + @column.dateTime({ autoCreate: true }) declare createdAt: DateTime diff --git a/BACKEND/providers/socket_io_provider.ts b/BACKEND/providers/socket_io_provider.ts index f1bf195..62a1bba 100644 --- a/BACKEND/providers/socket_io_provider.ts +++ b/BACKEND/providers/socket_io_provider.ts @@ -9,7 +9,7 @@ import { CustomServer, CustomSocket } from '../app/ultils/types.js' import Line from '#models/line' import Station from '#models/station' import APCController from '#services/apc_connection' -import { sleep } from '../app/ultils/helper.js' +import { appendLog, sleep } from '../app/ultils/helper.js' import SwitchController from '#services/switch_connection' import redis from '@adonisjs/redis/services/main' @@ -172,20 +172,13 @@ export class WebSocketIo { const lineId = data.lineId const baud = data.baud const line = await Line.find(lineId) - if (line) { - Object.assign(line, { baud }) - line?.save() + if (!line) { + console.log(`Line [${lineId}] not found!!!`) + return } - await this.handleLineOperation( - io, - data.stationId, - [lineId], - async (value) => value.setBaud(baud), - { - baud, - timeout: 120000, - } - ) + Object.assign(line, { baud }) + line?.save() + await this.setBaudByLineNumber(data.stationId, line?.lineNumber, baud) }) socket.on('open_cli', async (data) => { @@ -751,4 +744,53 @@ export class WebSocketIo { this.intervalKeepConnect[`${ip}`] = interval } + + private async setBaudByLineNumber(stationId: number, lineNumber: number, baud: number) { + const station = await Station.find(stationId) + if (!station) { + console.log('[ERROR connect station] Not found!') + return + } + + // Kết nối tới station qua Telnet / Socket + const client = new net.Socket() + return new Promise((resolve, reject) => { + client.setTimeout(5000) + client.connect(station.port, station.ip, async () => { + console.log(`Connected to station ${station.name} (${station.ip})`) + // Gửi lệnh clear line + client.write(`conf t\r\n`) + await sleep(500) + client.write(`line ${lineNumber}\r\n`) + await sleep(500) + client.write(`speed ${baud.toString()}\r\n`) + await sleep(500) + client.write(`end`) + await sleep(500) + client.write(`\r\n`) + await sleep(500) + client.destroy() + resolve() + }) + + client.on('data', (data) => { + appendLog(data.toString(), 0, 0, lineNumber) + }) + + client.on('error', (err) => { + console.error(`Error clearing line ${lineNumber}:`, err) + resolve() + }) + + client.on('close', () => { + console.log(`Station connection closed (line ${lineNumber})`) + resolve() + }) + client.on('timeout', () => { + console.log(`Station connection timeout (line ${lineNumber})`) + client.destroy() + resolve() + }) + }) + } } diff --git a/FRONTEND/src/App.tsx b/FRONTEND/src/App.tsx index 17497dc..20a5ade 100644 --- a/FRONTEND/src/App.tsx +++ b/FRONTEND/src/App.tsx @@ -16,7 +16,6 @@ import { LoadingOverlay, } from "@mantine/core"; import type { - IDataTakeOver, IScenario, ReceivedFile, ResponseData, @@ -76,10 +75,6 @@ function App() { const [selectedLine, setSelectedLine] = useState(); const [loadingTerminal, setLoadingTerminal] = useState(true); const [usersConnecting, setUsersConnecting] = useState([]); - const [disableRequestTakeOver, setDisableRequestTakeOver] = useState(false); - const [countDownRequest, setCountDownRequest] = useState(0); - const [dataRequestTakeOver, setDataRequestTakeOver] = - useState(); const [testLogContent, setTestLogContent] = useState(""); const [isLogModalOpen, setIsLogModalOpen] = useState(false); @@ -230,25 +225,6 @@ function App() { }, 100); }); - socket?.on("confirm_take_over", (data) => { - setDataRequestTakeOver(data); - if (data?.userEmail !== user?.email) { - setCountDownRequest(20); - const intervalCount = setInterval(() => { - setCountDownRequest((prev) => { - if (prev <= 1) { - setDataRequestTakeOver(undefined); - clearInterval(intervalCount); - return 0; - } - return prev - 1; - }); - }, 1000); - } - - if (!data?.userEmail) setCountDownRequest(0); - }); - const receivedFiles: Record = {}; socket?.on("response_content_log", (data: ResponseData) => { if (!data.chunk) { @@ -315,7 +291,6 @@ function App() { socket.off("user_connecting"); socket.off("user_open_cli"); socket.off("user_close_cli"); - socket.off("confirm_take_over"); socket.off("response_content_log"); socket.off("data_textfsm"); }; @@ -336,6 +311,8 @@ function App() { return { ...lineItem, ...updates, + lineNumber: lineItem.lineNumber, + line_number: lineItem.line_number, ...(isNetOutput && { netOutput: (lineItem.netOutput || "") + (updates.netOutput || ""), @@ -581,12 +558,6 @@ function App() { socket={socket} stationItem={stations.find((el) => el.id === Number(activeTab))} scenarios={scenarios} - dataRequestTakeOver={dataRequestTakeOver} - countDownRequest={countDownRequest} - setDisableRequestTakeOver={setDisableRequestTakeOver} - disableRequestTakeOver={disableRequestTakeOver} - setCountDownRequest={setCountDownRequest} - setDataRequestTakeOver={setDataRequestTakeOver} /> ); diff --git a/FRONTEND/src/components/BottomToolBar.tsx b/FRONTEND/src/components/BottomToolBar.tsx index 72f3026..6711248 100644 --- a/FRONTEND/src/components/BottomToolBar.tsx +++ b/FRONTEND/src/components/BottomToolBar.tsx @@ -144,6 +144,7 @@ const BottomToolBar = ({ ); diff --git a/FRONTEND/src/components/ModalTerminal.tsx b/FRONTEND/src/components/ModalTerminal.tsx index cfbe1a6..5a503cf 100644 --- a/FRONTEND/src/components/ModalTerminal.tsx +++ b/FRONTEND/src/components/ModalTerminal.tsx @@ -1,26 +1,36 @@ import { Box, Button, - Dialog, + CloseButton, Flex, Grid, - Group, + Input, + Menu, Modal, ScrollArea, Text, + Tooltip, } from "@mantine/core"; import type { - IDataTakeOver, IScenario, + TDataTicket, + THistoryTicket, TLine, TStation, } from "../untils/types"; import TerminalCLI from "./TerminalXTerm"; import type { Socket } from "socket.io-client"; -import classes from "./Component.module.css"; -import { useEffect, useMemo, useRef, useState } from "react"; -import { IconCircleCheckFilled } from "@tabler/icons-react"; -import { ButtonDPELP } from "./ButtonAction"; +import { useEffect, useMemo, useState } from "react"; +import { + IconCircleCheckFilled, + IconCircleDot, + IconInfoCircle, +} from "@tabler/icons-react"; +import { ButtonDPELP, ButtonScenario } from "./ButtonAction"; +import moment from "moment"; +import axios from "axios"; +import { notifications } from "@mantine/notifications"; +const apiUrl = import.meta.env.VITE_BACKEND_URL; const ModalTerminal = ({ opened, @@ -29,12 +39,6 @@ const ModalTerminal = ({ socket, stationItem, scenarios, - dataRequestTakeOver, - countDownRequest, - disableRequestTakeOver, - setDisableRequestTakeOver, - setCountDownRequest, - setDataRequestTakeOver, selectedLines, }: { opened: boolean; @@ -43,15 +47,7 @@ const ModalTerminal = ({ socket: Socket | null; stationItem: TStation | undefined; scenarios: IScenario[]; - dataRequestTakeOver: IDataTakeOver | undefined; - countDownRequest: number; - disableRequestTakeOver: boolean; selectedLines: TLine[]; - setDisableRequestTakeOver: (value: React.SetStateAction) => void; - setCountDownRequest: (value: React.SetStateAction) => void; - setDataRequestTakeOver: ( - value: React.SetStateAction - ) => void; }) => { const user = useMemo(() => { return localStorage.getItem("user") && @@ -59,38 +55,212 @@ const ModalTerminal = ({ ? JSON.parse(localStorage.getItem("user") || "") : null; }, []); - + const [inputTicket, setInputTicket] = useState(""); const [isDisable, setIsDisable] = useState(false); - const intervalTakeOverRef = useRef(null); + const [isDisableTicket, setIsDisableTicket] = useState(false); + const [latestTicket, setLatestTicket] = useState({ + description: "", + sn: "", + model: "", + station_id: 0, + history: "", + status: "open", + }); + const [dataTicket, setDataTicket] = useState({ + description: "", + sn: "", + model: "", + station_id: 0, + history: "", + status: "open", + }); useEffect(() => { - if ( - typeof dataRequestTakeOver?.userName !== "undefined" && - line?.userOpenCLI === user?.userName && - dataRequestTakeOver?.userName !== user?.userName - ) { - if (dataRequestTakeOver?.userName) { - intervalTakeOverRef.current = setInterval(() => { - socket?.emit("open_cli", { - lineId: line?.id, - stationId: line?.station_id, - userEmail: user?.userName, - userName: user?.userName, - }); - socket?.emit("request_take_over", { - station_id: line?.station_id, - }); - setDisableRequestTakeOver(false); - setCountDownRequest(0); - if (intervalTakeOverRef.current) - clearInterval(intervalTakeOverRef.current); - }, 20000); - } - } else { - if (intervalTakeOverRef.current) - clearInterval(intervalTakeOverRef.current); + if (opened && line?.tickets && line?.tickets?.length > 0) { + const data = line?.tickets[0]; + setLatestTicket(data); + setDataTicket({ ...data, description: "" }); } - }, [dataRequestTakeOver?.userName]); + }, [opened, line?.tickets]); + + const renderHistory = (data: TDataTicket) => { + const latest = JSON.parse(latestTicket?.history || "[]"); + const list = + data?.history && data?.id + ? JSON.parse(data?.history) + : latest?.length > 0 + ? [latest[0], latest[latest?.length - 1]] + : []; + return ( +
+ {list.reverse().map((item: THistoryTicket, index: number) => ( +
+
+ + + @{item?.userName}: + + + + {item?.description} + + + + {moment(Number(item?.time)).format("HH:m DD/M")} + +
+
+ ))} +
+ ); + }; + + const handleCreate = async () => { + if (!dataTicket?.description.trim()) { + notifications.show({ + title: "Error", + message: "Description is required", + color: "red", + }); + return; + } + + const payload = { + id: dataTicket.id || 0, + description: dataTicket.description.trim(), + model: dataTicket.model.trim(), + sn: dataTicket.sn.trim(), + station_id: Number(dataTicket.station_id), + status: "open", + }; + + try { + const res = await axios.post(apiUrl + "api/ticket/create", payload); + if (res.status) { + // setDataTicket(res.data) + + // notifications.show({ + // title: 'Success', + // message: res.message, + // color: 'green', + // }) + + // socket?.emit( + // "create_ticket", + // payload, + // ) + return; + } + } catch (error) { + console.log("Error create ticket", error); + notifications.show({ + title: "Error", + message: "Failed to create ticket, please try again!", + color: "red", + }); + } + }; + + const handleUpdate = async (status: string) => { + if (!dataTicket?.description.trim()) { + notifications.show({ + title: "Error", + message: "Description is required", + color: "red", + }); + return; + } + + const payload = { + id: dataTicket.id || 0, + description: dataTicket.description.trim() + ? dataTicket.description.trim() + : status === "closed" + ? "Closed" + : "", + model: dataTicket.model.trim(), + sn: dataTicket.sn.trim(), + station_id: Number(dataTicket.station_id), + status: status, + }; + + try { + const res = await axios.put( + `${apiUrl + "api/ticket/create" + "/" + dataTicket.id}`, + payload + ); + if (res.status) { + if (res?.data?.status !== "closed") + setDataTicket({ ...res.data, description: "" }); + else + setDataTicket({ + id: 0, + description: "", + model: latestTicket.model.trim(), + sn: latestTicket.sn.trim(), + station_id: latestTicket.station_id, + history: "", + status: "open", + }); + + // notifications.show({ + // title: 'Success', + // message: res.message, + // color: 'green', + // }) + // socket?.emit( + // SOCKET_EVENTS.RELOAD_TICKET.RELOAD_TICKET_FROM_WEB, + // payload, + // ) + return; + } + } catch (error) { + console.log("Error update ticket", error); + notifications.show({ + title: "Error", + message: "Failed to update ticket, please try again!", + color: "red", + }); + } + }; return ( @@ -107,10 +277,10 @@ const ModalTerminal = ({ stationId: line?.station_id, }); }} - size={"80%"} + size={"100%"} style={{ position: "absolute", left: 0 }} title={ - + - - Line number:{" "} - {line?.lineNumber || line?.line_number || ""} - - - - {line?.port || ""} - - {line?.status === "connected" && ( - - )}
- -
- PID: {line?.inventory?.pid || ""} -
-
- SN: {line?.inventory?.sn || ""} -
-
- VID: {line?.inventory?.vid || ""} -
-
- } > - + + + + + + + Line {line?.lineNumber || line?.line_number || ""} + + + + - {line?.port || ""} + + {line?.status === "connected" && ( + + )} + + + + BAUD: + + + {line?.baud || ""} + + + + + PID: + + {line?.inventory?.pid || ""} + {line?.inventory?.vid ? ( + + {line?.inventory?.vid} + + ) : ( + "" + )} + + + + SN: + + {line?.inventory?.sn || ""} + + + + IOS: + + {""} + + + + + License: + + {""} + + + + Sh env/module: + + {""} + + + + Mem/Flash: + + {""} + + + + Warning from test report: + + {""} + + + + + + Internet Connected + + + + + + + + + + + + + + - - - - - { - setIsDisable(true); - setTimeout(() => { - setIsDisable(false); - }, 10000); - }} - /> - {scenarios.map((scenario) => ( + + { + setIsDisable(true); + setTimeout(() => { + setIsDisable(false); + }, 10000); + }} + /> + + + + + - {scenario.title} - - ))} + {scenarios.map((el, i) => ( + + !el?.userEmailOpenCLI || + el?.userEmailOpenCLI === user?.email + )} + isDisable={ + isDisable || + selectedLines.filter( + (el) => + !el?.userEmailOpenCLI || + el?.userEmailOpenCLI === user?.email + ).length === 0 + } + onClick={() => { + // setSelectedLines([]); + setIsDisable(true); + setTimeout(() => { + setIsDisable(false); + }, 5000); + }} + scenario={el} + /> + ))} + + + + + + + + + + + + + + + + + + Text + + : is opened + + + + Text + + : is issue + + + + Text + + : is closed + +
+ } + position="right" + > + + + Ticket:{" "} + + + + +
+ + {renderHistory(latestTicket)} + + { + const newValue = event.currentTarget.value; + setInputTicket(newValue); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + setInputTicket(""); + if (dataTicket?.status === "closed") { + handleCreate(); + } else handleUpdate("open"); + setIsDisableTicket(true); + setTimeout(() => { + setIsDisableTicket(false); + }, 2000); + } + }} + rightSectionPointerEvents="all" + rightSection={ + setInputTicket("")} + style={{ + display: inputTicket ? undefined : "none", + }} + /> + } + /> + + + + {dataTicket?.status === "closed" ? ( + + ) : ( + + )} + + + + - - - - - - - - {`${`${dataRequestTakeOver?.userName} ${ - dataRequestTakeOver?.userEmail - ? `(${dataRequestTakeOver?.userEmail})` - : "" - }`} want to take over this line? ${ - countDownRequest > 0 && - typeof dataRequestTakeOver?.userName !== "undefined" && - line?.userOpenCLI === user?.userName - ? `(${countDownRequest}s)` - : "" - }`} - - - - - - - -
); }; diff --git a/FRONTEND/src/untils/types.ts b/FRONTEND/src/untils/types.ts index ab80d45..624fc16 100644 --- a/FRONTEND/src/untils/types.ts +++ b/FRONTEND/src/untils/types.ts @@ -95,6 +95,7 @@ export type TLine = { commands?: string[]; interface?: string; baud?: number; + tickets?: TDataTicket[]; }; export type TUser = { @@ -199,3 +200,22 @@ export type TextFSM = { output: string; textfsm: any; }; + +export type TDataTicket = { + id?: number; + description: string; + sn: string; + model: string; + station_id: number; + history: string; + status: string; +}; + +export type THistoryTicket = { + time: number; + description: string; + closed: string; + status: string; + userName: string; + userId: number; +};