From ef1d585b610465c67ffd41b3b539817f88bda570 Mon Sep 17 00:00:00 2001 From: nguyentrungthat <80239428+nguentrungthat@users.noreply.github.com> Date: Sat, 25 Oct 2025 11:30:57 +0700 Subject: [PATCH] Update --- BACKEND/app/services/line_connection.ts | 111 +++++++++++++++--- BACKEND/app/ultils/helper.ts | 2 +- BACKEND/providers/socket_io_provider.ts | 74 +++++++++--- FRONTEND/src/App.tsx | 49 ++++---- FRONTEND/src/components/ButtonAction.tsx | 132 ++++++++++++++++++++++ FRONTEND/src/components/CardLine.tsx | 5 +- FRONTEND/src/components/TerminalXTerm.tsx | 5 +- FRONTEND/src/untils/types.ts | 12 ++ 8 files changed, 332 insertions(+), 58 deletions(-) create mode 100644 FRONTEND/src/components/ButtonAction.tsx diff --git a/BACKEND/app/services/line_connection.ts b/BACKEND/app/services/line_connection.ts index af8416b..aaba016 100644 --- a/BACKEND/app/services/line_connection.ts +++ b/BACKEND/app/services/line_connection.ts @@ -1,5 +1,6 @@ import net from 'node:net' import { cleanData } from '../ultils/helper.js' +import Scenario from '#models/scenario' interface LineConfig { id: number @@ -16,11 +17,17 @@ export default class LineConnection { public client: net.Socket public readonly config: LineConfig public readonly socketIO: any + private outputBuffer: string + private isRunningScript: boolean + private connecting: boolean constructor(config: LineConfig, socketIO: any) { this.config = config this.socketIO = socketIO this.client = new net.Socket() + this.outputBuffer = '' + this.isRunningScript = false + this.connecting = false } connect(timeoutMs = 5000) { @@ -35,18 +42,24 @@ export default class LineConnection { resolvedOrRejected = true console.log(`✅ Connected to line ${lineNumber} (${ip}:${port})`) - this.config.status = 'connected' - this.socketIO.emit('line_connected', { - stationId, - lineId: id, - lineNumber, - status: 'connected', - }) - resolve() + this.connecting = true + setTimeout(() => { + this.config.status = 'connected' + this.connecting = false + this.socketIO.emit('line_connected', { + stationId, + lineId: id, + lineNumber, + status: 'connected', + }) + resolve() + }, 1000) }) this.client.on('data', (data) => { + if (this.connecting) return let message = data.toString() + this.outputBuffer += message // let output = cleanData(message) // console.log(`📨 [${this.config.port}] ${message}`) // Handle netOutput with backspace support @@ -74,7 +87,7 @@ export default class LineConnection { this.socketIO.emit('line_error', { stationId, lineId: id, - error: err.message, + error: err.message + '\r\n', }) reject(err) }) @@ -101,15 +114,6 @@ export default class LineConnection { }) } - sendCommand(cmd: string) { - if (this.client.destroyed) { - console.log(`⚠️ Cannot send, line ${this.config.lineNumber} is closed`) - return - } - // console.log(`➡️ [${this.config.apcName}] SEND:`, cmd) - this.client.write(`${cmd}\r\n`) - } - writeCommand(cmd: string) { if (this.client.destroyed) { console.log(`⚠️ Cannot send, line ${this.config.lineNumber} is closed`) @@ -131,4 +135,75 @@ export default class LineConnection { console.error('Error closing line:', e) } } + + async runScript(script: Scenario) { + if (!this.client || this.client.destroyed) { + throw new Error('Not connected') + } + if (this.isRunningScript) { + throw new Error('Script already running') + } + + this.isRunningScript = true + const steps = typeof script?.body === 'string' ? JSON.parse(script?.body) : [] + let stepIndex = 0 + + return new Promise((resolve, reject) => { + const timeoutTimer = setTimeout(() => { + this.isRunningScript = false + this.outputBuffer = '' + reject(new Error('Script timeout')) + }, script.timeout || 300000) + + const runStep = (index: number) => { + if (index >= steps.length) { + clearTimeout(timeoutTimer) + this.isRunningScript = false + this.outputBuffer = '' + resolve(true) + return + } + + const step = steps[index] + let repeatCount = Number(step.repeat) || 1 + + const sendCommand = () => { + if (repeatCount <= 0) { + // Done → next step + this.client.off('data', onOutput) + stepIndex++ + return runStep(stepIndex) + } + + if (step.send) { + this.writeCommand(step?.send + '\r\n') + } + + repeatCount-- + setTimeout(() => sendCommand(), Number(step?.delay) || 500) + } + + // Lắng nghe output cho expect + const onOutput = (data: string) => { + const output = data.toString() + this.outputBuffer += output + + if (output.includes(step.expect)) { + this.client.off('data', onOutput) + setTimeout(() => sendCommand(), Number(step?.delay) || 500) + } + } + + // Nếu expect rỗng → gửi ngay + if (!step?.expect || step?.expect.trim() === '') { + setTimeout(() => sendCommand(), Number(step?.delay) || 500) + return + } + + this.client.on('data', onOutput) + } + + runStep(stepIndex) + }) + } } diff --git a/BACKEND/app/ultils/helper.ts b/BACKEND/app/ultils/helper.ts index 4f311df..c5ddc46 100644 --- a/BACKEND/app/ultils/helper.ts +++ b/BACKEND/app/ultils/helper.ts @@ -3,7 +3,7 @@ * @param {string} data - The raw data to be cleaned. * @returns {string} - The cleaned data. */ -export const cleanData = (data) => { +export const cleanData = (data: string) => { return data .replace(/--More--\s*BS\s*BS\s*BS\s*BS\s*BS\s*BS/g, '') .replace(/\s*--More--\s*/g, '') diff --git a/BACKEND/providers/socket_io_provider.ts b/BACKEND/providers/socket_io_provider.ts index 823b6ae..4061e23 100644 --- a/BACKEND/providers/socket_io_provider.ts +++ b/BACKEND/providers/socket_io_provider.ts @@ -5,13 +5,7 @@ import { ApplicationService } from '@adonisjs/core/types' import env from '#start/env' import { CustomServer, CustomSocket } from '../app/ultils/types.js' import Line from '#models/line' - -interface Station { - id: number - name: string - ip: string - lines: any[] -} +import Station from '#models/station' export default class SocketIoProvider { private static _io: CustomServer @@ -78,11 +72,12 @@ export class WebSocketIo { console.log('Socket connected:', socket.id) socket.connectionTime = new Date() - const lineConnectionArray: LineConnection[] = Array.from(this.lineMap.values()) - io.to(socket.id).emit( - 'init', - lineConnectionArray.map((el) => el.config) - ) + setTimeout(() => { + io.to(socket.id).emit( + 'init', + Array.from(this.lineMap.values()).map((el) => el.config) + ) + }, 200) socket.on('disconnect', () => { console.log(`FE disconnected: ${socket.id}`) @@ -94,16 +89,59 @@ export class WebSocketIo { await this.connectLine(io, linesData, stationData) }) - socket.on('write_command_line_from_web', (data) => { + socket.on('write_command_line_from_web', async (data) => { const { lineIds, stationId, command } = data for (const lineId of lineIds) { const line = this.lineMap.get(lineId) if (line) { this.setTimeoutConnect(lineId, line) line.writeCommand(command) + } else { + const linesData = await Line.findBy('id', lineId) + const stationData = await Station.findBy('id', stationId) + if (linesData && stationData) { + await this.connectLine(io, [linesData], stationData) + const lineReconnect = this.lineMap.get(lineId) + if (lineReconnect) { + this.setTimeoutConnect(lineId, lineReconnect) + lineReconnect.writeCommand(command) + } + } else { + io.emit('line_disconnected', { + stationId, + lineId, + status: 'disconnected', + }) + io.emit('line_error', { lineId, error: 'Line not connected\r\n' }) + } + } + } + }) + + socket.on('run_scenario', async (data) => { + const lineId = data.id + const scenario = data.scenario + const line = this.lineMap.get(lineId) + if (line) { + this.setTimeoutConnect( + lineId, + line, + scenario?.timeout ? Number(scenario?.timeout) + 120000 : 300000 + ) + line.runScript(scenario) + } else { + const linesData = await Line.findBy('id', lineId) + const stationData = await Station.findBy('id', data.stationId) + if (linesData && stationData) { + await this.connectLine(io, [linesData], stationData) + const lineReconnect = this.lineMap.get(lineId) + if (lineReconnect) { + this.setTimeoutConnect(lineId, lineReconnect, 300000) + lineReconnect.runScript(scenario) + } } else { io.emit('line_disconnected', { - stationId, + stationId: data.stationId, lineId, status: 'disconnected', }) @@ -168,7 +206,7 @@ export class WebSocketIo { console.log(`🔻 Station ${station.name} disconnected`) } - private setTimeoutConnect = (lineId: number, lineConn: LineConnection) => { + private setTimeoutConnect = (lineId: number, lineConn: LineConnection, timeout = 120000) => { if (this.intervalMap[`${lineId}`]) { clearInterval(this.intervalMap[`${lineId}`]) delete this.intervalMap[`${lineId}`] @@ -176,7 +214,11 @@ export class WebSocketIo { const interval = setInterval(() => { lineConn.disconnect() this.lineMap.delete(lineId) - }, 120000) + if (this.intervalMap[`${lineId}`]) { + clearInterval(this.intervalMap[`${lineId}`]) + delete this.intervalMap[`${lineId}`] + } + }, timeout) this.intervalMap[`${lineId}`] = interval } diff --git a/FRONTEND/src/App.tsx b/FRONTEND/src/App.tsx index 230da99..c1b9f2a 100644 --- a/FRONTEND/src/App.tsx +++ b/FRONTEND/src/App.tsx @@ -17,11 +17,12 @@ import { Button, ActionIcon, } from "@mantine/core"; -import type { TLine, TStation } from "./untils/types"; +import type { LineConfig, TLine, TStation } from "./untils/types"; import axios from "axios"; import CardLine from "./components/CardLine"; import { IconEdit, IconSettingsPlus } from "@tabler/icons-react"; import { SocketProvider, useSocket } from "./context/SocketContext"; +import { ButtonDPELP } from "./components/ButtonAction"; const apiUrl = import.meta.env.VITE_BACKEND_URL; @@ -43,6 +44,7 @@ export function App() { setControlsRefs(controlsRefs); }; const [showBottomShadow, setShowBottomShadow] = useState(false); + const [isDisable, setIsDisable] = useState(false); // function get list station const getStation = async () => { @@ -67,35 +69,27 @@ export function App() { useEffect(() => { if (!socket || !stations?.length) return; - const updateStatus = (data: any) => { - const line = getLine(data.lineId, data.stationId); - if (line) { - updateValueLineStation(line, "status", data.status); - } - }; - socket.on("line_connected", updateStatus); socket.on("line_disconnected", updateStatus); - socket?.on("init", (data) => { - if (Array.isArray(data)) { - data.forEach((value) => { - updateStatus(value); - }); - } - }); // ✅ cleanup on unmount or when socket changes return () => { socket.off("line_connected"); socket.off("line_disconnected"); - socket.off("line_disconnected"); }; }, [socket, stations]); - const updateValueLineStation = ( + const updateStatus = (data: LineConfig) => { + const line = getLine(data.lineId, data.stationId); + if (line) { + updateValueLineStation(line, "status", data.status); + } + }; + + const updateValueLineStation = ( currentLine: TLine, - field: string, - value: any + field: K, + value: TLine[K] ) => { setStations((el) => el?.map((station: TStation) => @@ -201,6 +195,7 @@ export function App() { line={line} selectedLines={selectedLines} setSelectedLines={setSelectedLines} + updateStatus={updateStatus} /> ))} @@ -223,7 +218,7 @@ export function App() { > + { + setSelectedLines([]); + setIsDisable(true); + setTimeout(() => { + setIsDisable(false); + }, 10000); + }} + /> diff --git a/FRONTEND/src/components/ButtonAction.tsx b/FRONTEND/src/components/ButtonAction.tsx new file mode 100644 index 0000000..880372c --- /dev/null +++ b/FRONTEND/src/components/ButtonAction.tsx @@ -0,0 +1,132 @@ +import type { Socket } from "socket.io-client"; +import type { TLine } from "../untils/types"; +import { Button } from "@mantine/core"; + +export const ButtonDPELP = ({ + socket, + isDisable, + onClick, + selectedLines, +}: { + socket: Socket | null; + isDisable: boolean; + onClick: () => void; + selectedLines: TLine[]; +}) => { + return ( + + ); +}; diff --git a/FRONTEND/src/components/CardLine.tsx b/FRONTEND/src/components/CardLine.tsx index 3d21454..140cc0e 100644 --- a/FRONTEND/src/components/CardLine.tsx +++ b/FRONTEND/src/components/CardLine.tsx @@ -1,5 +1,5 @@ import { Card, Text, Box, Flex } from "@mantine/core"; -import type { TLine, TStation } from "../untils/types"; +import type { LineConfig, TLine, TStation } from "../untils/types"; import classes from "./Component.module.css"; import TerminalCLI from "./TerminalXTerm"; import type { Socket } from "socket.io-client"; @@ -12,12 +12,14 @@ const CardLine = ({ setSelectedLines, socket, stationItem, + updateStatus, }: { line: TLine; selectedLines: TLine[]; setSelectedLines: (lines: React.SetStateAction) => void; socket: Socket | null; stationItem: TStation; + updateStatus: (value: LineConfig) => void; }) => { return ( {}} + updateStatus={updateStatus} /> diff --git a/FRONTEND/src/components/TerminalXTerm.tsx b/FRONTEND/src/components/TerminalXTerm.tsx index 31e2e8f..a80bcb6 100644 --- a/FRONTEND/src/components/TerminalXTerm.tsx +++ b/FRONTEND/src/components/TerminalXTerm.tsx @@ -4,6 +4,7 @@ import "xterm/css/xterm.css"; import { FitAddon } from "@xterm/addon-fit"; import { SOCKET_EVENTS } from "../untils/constanst"; import type { Socket } from "socket.io-client"; +import type { LineConfig } from "../untils/types"; interface TerminalCLIProps { socket: Socket | null; @@ -24,6 +25,7 @@ interface TerminalCLIProps { onDoubleClick?: () => void; fontSize?: number; miniSize?: boolean; + updateStatus: (value: LineConfig) => void; } const TerminalCLI: React.FC = ({ @@ -33,11 +35,11 @@ const TerminalCLI: React.FC = ({ station_id, cliOpened = false, isDisabled = false, - line_status = "", customStyle = {}, onDoubleClick = () => {}, fontSize = 14, miniSize = false, + updateStatus, }) => { const xtermRef = useRef(null); const terminal = useRef(null); @@ -148,6 +150,7 @@ const TerminalCLI: React.FC = ({ data.forEach((value) => { if (value?.id === line_id && terminal.current) { terminal.current?.write(value.output); + updateStatus({ ...value, lineId: value.id }); } }); } diff --git a/FRONTEND/src/untils/types.ts b/FRONTEND/src/untils/types.ts index 6cb2bd4..91df3d2 100644 --- a/FRONTEND/src/untils/types.ts +++ b/FRONTEND/src/untils/types.ts @@ -126,3 +126,15 @@ export type SwitchPortsProps = { status: string; poe: string; }; + +export type LineConfig = { + id: number; + lineId: number; + port: number; + lineNumber: number; + ip: string; + stationId: number; + apcName?: string; + output: string; + status: string; +};