This commit is contained in:
nguyentrungthat 2025-10-25 11:30:57 +07:00
parent 077a2ddc35
commit ef1d585b61
8 changed files with 332 additions and 58 deletions

View File

@ -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)
})
}
}

View File

@ -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, '')

View File

@ -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
}

View File

@ -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 = <K extends keyof TLine>(
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}
/>
))}
</Flex>
@ -223,7 +218,7 @@ export function App() {
>
<Button
variant="filled"
style={{ height: "30px", width: "120px" }}
style={{ height: "30px", width: "100px" }}
onClick={() => {
if (selectedLines.length !== station.lines.length)
setSelectedLines(station.lines);
@ -240,7 +235,7 @@ export function App() {
.length === 0
}
variant="outline"
style={{ height: "30px", width: "120px" }}
style={{ height: "30px", width: "100px" }}
onClick={() => {
const lines = selectedLines.filter(
(el) => el.status !== "connected"
@ -254,6 +249,18 @@ export function App() {
>
Connect
</Button>
<ButtonDPELP
socket={socket}
selectedLines={selectedLines}
isDisable={isDisable || selectedLines.length === 0}
onClick={() => {
setSelectedLines([]);
setIsDisable(true);
setTimeout(() => {
setIsDisable(false);
}, 10000);
}}
/>
</Flex>
</Grid.Col>
</Grid>

View File

@ -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 (
<Button
disabled={isDisable}
miw={"100px"}
// radius="lg"
h={"24px"}
mr={"5px"}
variant="filled"
color="#00a164"
onClick={async () => {
onClick();
selectedLines?.forEach((el) => {
const body = [
{
expect: "",
send: " show diag",
delay: "1000",
repeat: "1",
note: "",
},
{
expect: "",
send: " ",
delay: "1000",
repeat: "1",
note: "",
},
{
expect: "",
send: " show post",
delay: "1000",
repeat: "1",
note: "",
},
{
expect: "",
send: " ",
delay: "1000",
repeat: "1",
note: "",
},
{
expect: "",
send: " show env",
delay: "1000",
repeat: "1",
note: "",
},
{
expect: "",
send: " ",
delay: "1000",
repeat: "1",
note: "",
},
{
expect: "",
send: " show license",
delay: "1000",
repeat: "1",
note: "",
},
{
expect: "",
send: " ",
delay: "1000",
repeat: "1",
note: "",
},
{
expect: "",
send: " show log",
delay: "1000",
repeat: "1",
note: "",
},
{
expect: "",
send: " ",
delay: "1000",
repeat: "2",
note: "",
},
{
expect: "",
send: " show platform",
delay: "1000",
repeat: "1",
note: "",
},
{
expect: "",
send: " ",
delay: "7000",
repeat: "15",
note: "",
},
];
socket?.emit(
"run_scenario",
Object.assign(el, {
scenario: {
id: 0,
is_reboot: 0,
title: "DPELP",
timeout: 300000,
body: JSON.stringify(body),
},
})
);
});
}}
>
DPELP
</Button>
);
};

View File

@ -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<TLine[]>) => void;
socket: Socket | null;
stationItem: TStation;
updateStatus: (value: LineConfig) => void;
}) => {
return (
<Card
@ -81,6 +83,7 @@ const CardLine = ({
paddingBottom: "0px",
}}
onDoubleClick={() => {}}
updateStatus={updateStatus}
/>
</Box>
</Flex>

View File

@ -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<TerminalCLIProps> = ({
@ -33,11 +35,11 @@ const TerminalCLI: React.FC<TerminalCLIProps> = ({
station_id,
cliOpened = false,
isDisabled = false,
line_status = "",
customStyle = {},
onDoubleClick = () => {},
fontSize = 14,
miniSize = false,
updateStatus,
}) => {
const xtermRef = useRef<HTMLDivElement>(null);
const terminal = useRef<Terminal>(null);
@ -148,6 +150,7 @@ const TerminalCLI: React.FC<TerminalCLIProps> = ({
data.forEach((value) => {
if (value?.id === line_id && terminal.current) {
terminal.current?.write(value.output);
updateStatus({ ...value, lineId: value.id });
}
});
}

View File

@ -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;
};