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 net from 'node:net'
import { cleanData } from '../ultils/helper.js' import { cleanData } from '../ultils/helper.js'
import Scenario from '#models/scenario'
interface LineConfig { interface LineConfig {
id: number id: number
@ -16,11 +17,17 @@ export default class LineConnection {
public client: net.Socket public client: net.Socket
public readonly config: LineConfig public readonly config: LineConfig
public readonly socketIO: any public readonly socketIO: any
private outputBuffer: string
private isRunningScript: boolean
private connecting: boolean
constructor(config: LineConfig, socketIO: any) { constructor(config: LineConfig, socketIO: any) {
this.config = config this.config = config
this.socketIO = socketIO this.socketIO = socketIO
this.client = new net.Socket() this.client = new net.Socket()
this.outputBuffer = ''
this.isRunningScript = false
this.connecting = false
} }
connect(timeoutMs = 5000) { connect(timeoutMs = 5000) {
@ -35,18 +42,24 @@ export default class LineConnection {
resolvedOrRejected = true resolvedOrRejected = true
console.log(`✅ Connected to line ${lineNumber} (${ip}:${port})`) console.log(`✅ Connected to line ${lineNumber} (${ip}:${port})`)
this.config.status = 'connected' this.connecting = true
this.socketIO.emit('line_connected', { setTimeout(() => {
stationId, this.config.status = 'connected'
lineId: id, this.connecting = false
lineNumber, this.socketIO.emit('line_connected', {
status: 'connected', stationId,
}) lineId: id,
resolve() lineNumber,
status: 'connected',
})
resolve()
}, 1000)
}) })
this.client.on('data', (data) => { this.client.on('data', (data) => {
if (this.connecting) return
let message = data.toString() let message = data.toString()
this.outputBuffer += message
// let output = cleanData(message) // let output = cleanData(message)
// console.log(`📨 [${this.config.port}] ${message}`) // console.log(`📨 [${this.config.port}] ${message}`)
// Handle netOutput with backspace support // Handle netOutput with backspace support
@ -74,7 +87,7 @@ export default class LineConnection {
this.socketIO.emit('line_error', { this.socketIO.emit('line_error', {
stationId, stationId,
lineId: id, lineId: id,
error: err.message, error: err.message + '\r\n',
}) })
reject(err) 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) { writeCommand(cmd: string) {
if (this.client.destroyed) { if (this.client.destroyed) {
console.log(`⚠️ Cannot send, line ${this.config.lineNumber} is closed`) console.log(`⚠️ Cannot send, line ${this.config.lineNumber} is closed`)
@ -131,4 +135,75 @@ export default class LineConnection {
console.error('Error closing line:', e) 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. * @param {string} data - The raw data to be cleaned.
* @returns {string} - The cleaned data. * @returns {string} - The cleaned data.
*/ */
export const cleanData = (data) => { export const cleanData = (data: string) => {
return data return data
.replace(/--More--\s*BS\s*BS\s*BS\s*BS\s*BS\s*BS/g, '') .replace(/--More--\s*BS\s*BS\s*BS\s*BS\s*BS\s*BS/g, '')
.replace(/\s*--More--\s*/g, '') .replace(/\s*--More--\s*/g, '')

View File

@ -5,13 +5,7 @@ import { ApplicationService } from '@adonisjs/core/types'
import env from '#start/env' import env from '#start/env'
import { CustomServer, CustomSocket } from '../app/ultils/types.js' import { CustomServer, CustomSocket } from '../app/ultils/types.js'
import Line from '#models/line' import Line from '#models/line'
import Station from '#models/station'
interface Station {
id: number
name: string
ip: string
lines: any[]
}
export default class SocketIoProvider { export default class SocketIoProvider {
private static _io: CustomServer private static _io: CustomServer
@ -78,11 +72,12 @@ export class WebSocketIo {
console.log('Socket connected:', socket.id) console.log('Socket connected:', socket.id)
socket.connectionTime = new Date() socket.connectionTime = new Date()
const lineConnectionArray: LineConnection[] = Array.from(this.lineMap.values()) setTimeout(() => {
io.to(socket.id).emit( io.to(socket.id).emit(
'init', 'init',
lineConnectionArray.map((el) => el.config) Array.from(this.lineMap.values()).map((el) => el.config)
) )
}, 200)
socket.on('disconnect', () => { socket.on('disconnect', () => {
console.log(`FE disconnected: ${socket.id}`) console.log(`FE disconnected: ${socket.id}`)
@ -94,16 +89,59 @@ export class WebSocketIo {
await this.connectLine(io, linesData, stationData) 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 const { lineIds, stationId, command } = data
for (const lineId of lineIds) { for (const lineId of lineIds) {
const line = this.lineMap.get(lineId) const line = this.lineMap.get(lineId)
if (line) { if (line) {
this.setTimeoutConnect(lineId, line) this.setTimeoutConnect(lineId, line)
line.writeCommand(command) 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 { } else {
io.emit('line_disconnected', { io.emit('line_disconnected', {
stationId, stationId: data.stationId,
lineId, lineId,
status: 'disconnected', status: 'disconnected',
}) })
@ -168,7 +206,7 @@ export class WebSocketIo {
console.log(`🔻 Station ${station.name} disconnected`) console.log(`🔻 Station ${station.name} disconnected`)
} }
private setTimeoutConnect = (lineId: number, lineConn: LineConnection) => { private setTimeoutConnect = (lineId: number, lineConn: LineConnection, timeout = 120000) => {
if (this.intervalMap[`${lineId}`]) { if (this.intervalMap[`${lineId}`]) {
clearInterval(this.intervalMap[`${lineId}`]) clearInterval(this.intervalMap[`${lineId}`])
delete this.intervalMap[`${lineId}`] delete this.intervalMap[`${lineId}`]
@ -176,7 +214,11 @@ export class WebSocketIo {
const interval = setInterval(() => { const interval = setInterval(() => {
lineConn.disconnect() lineConn.disconnect()
this.lineMap.delete(lineId) this.lineMap.delete(lineId)
}, 120000) if (this.intervalMap[`${lineId}`]) {
clearInterval(this.intervalMap[`${lineId}`])
delete this.intervalMap[`${lineId}`]
}
}, timeout)
this.intervalMap[`${lineId}`] = interval this.intervalMap[`${lineId}`] = interval
} }

View File

@ -17,11 +17,12 @@ import {
Button, Button,
ActionIcon, ActionIcon,
} from "@mantine/core"; } from "@mantine/core";
import type { TLine, TStation } from "./untils/types"; import type { LineConfig, TLine, TStation } from "./untils/types";
import axios from "axios"; import axios from "axios";
import CardLine from "./components/CardLine"; import CardLine from "./components/CardLine";
import { IconEdit, IconSettingsPlus } from "@tabler/icons-react"; import { IconEdit, IconSettingsPlus } from "@tabler/icons-react";
import { SocketProvider, useSocket } from "./context/SocketContext"; import { SocketProvider, useSocket } from "./context/SocketContext";
import { ButtonDPELP } from "./components/ButtonAction";
const apiUrl = import.meta.env.VITE_BACKEND_URL; const apiUrl = import.meta.env.VITE_BACKEND_URL;
@ -43,6 +44,7 @@ export function App() {
setControlsRefs(controlsRefs); setControlsRefs(controlsRefs);
}; };
const [showBottomShadow, setShowBottomShadow] = useState(false); const [showBottomShadow, setShowBottomShadow] = useState(false);
const [isDisable, setIsDisable] = useState(false);
// function get list station // function get list station
const getStation = async () => { const getStation = async () => {
@ -67,35 +69,27 @@ export function App() {
useEffect(() => { useEffect(() => {
if (!socket || !stations?.length) return; 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_connected", updateStatus);
socket.on("line_disconnected", 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 // ✅ cleanup on unmount or when socket changes
return () => { return () => {
socket.off("line_connected"); socket.off("line_connected");
socket.off("line_disconnected"); socket.off("line_disconnected");
socket.off("line_disconnected");
}; };
}, [socket, stations]); }, [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, currentLine: TLine,
field: string, field: K,
value: any value: TLine[K]
) => { ) => {
setStations((el) => setStations((el) =>
el?.map((station: TStation) => el?.map((station: TStation) =>
@ -201,6 +195,7 @@ export function App() {
line={line} line={line}
selectedLines={selectedLines} selectedLines={selectedLines}
setSelectedLines={setSelectedLines} setSelectedLines={setSelectedLines}
updateStatus={updateStatus}
/> />
))} ))}
</Flex> </Flex>
@ -223,7 +218,7 @@ export function App() {
> >
<Button <Button
variant="filled" variant="filled"
style={{ height: "30px", width: "120px" }} style={{ height: "30px", width: "100px" }}
onClick={() => { onClick={() => {
if (selectedLines.length !== station.lines.length) if (selectedLines.length !== station.lines.length)
setSelectedLines(station.lines); setSelectedLines(station.lines);
@ -240,7 +235,7 @@ export function App() {
.length === 0 .length === 0
} }
variant="outline" variant="outline"
style={{ height: "30px", width: "120px" }} style={{ height: "30px", width: "100px" }}
onClick={() => { onClick={() => {
const lines = selectedLines.filter( const lines = selectedLines.filter(
(el) => el.status !== "connected" (el) => el.status !== "connected"
@ -254,6 +249,18 @@ export function App() {
> >
Connect Connect
</Button> </Button>
<ButtonDPELP
socket={socket}
selectedLines={selectedLines}
isDisable={isDisable || selectedLines.length === 0}
onClick={() => {
setSelectedLines([]);
setIsDisable(true);
setTimeout(() => {
setIsDisable(false);
}, 10000);
}}
/>
</Flex> </Flex>
</Grid.Col> </Grid.Col>
</Grid> </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 { 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 classes from "./Component.module.css";
import TerminalCLI from "./TerminalXTerm"; import TerminalCLI from "./TerminalXTerm";
import type { Socket } from "socket.io-client"; import type { Socket } from "socket.io-client";
@ -12,12 +12,14 @@ const CardLine = ({
setSelectedLines, setSelectedLines,
socket, socket,
stationItem, stationItem,
updateStatus,
}: { }: {
line: TLine; line: TLine;
selectedLines: TLine[]; selectedLines: TLine[];
setSelectedLines: (lines: React.SetStateAction<TLine[]>) => void; setSelectedLines: (lines: React.SetStateAction<TLine[]>) => void;
socket: Socket | null; socket: Socket | null;
stationItem: TStation; stationItem: TStation;
updateStatus: (value: LineConfig) => void;
}) => { }) => {
return ( return (
<Card <Card
@ -81,6 +83,7 @@ const CardLine = ({
paddingBottom: "0px", paddingBottom: "0px",
}} }}
onDoubleClick={() => {}} onDoubleClick={() => {}}
updateStatus={updateStatus}
/> />
</Box> </Box>
</Flex> </Flex>

View File

@ -4,6 +4,7 @@ import "xterm/css/xterm.css";
import { FitAddon } from "@xterm/addon-fit"; import { FitAddon } from "@xterm/addon-fit";
import { SOCKET_EVENTS } from "../untils/constanst"; import { SOCKET_EVENTS } from "../untils/constanst";
import type { Socket } from "socket.io-client"; import type { Socket } from "socket.io-client";
import type { LineConfig } from "../untils/types";
interface TerminalCLIProps { interface TerminalCLIProps {
socket: Socket | null; socket: Socket | null;
@ -24,6 +25,7 @@ interface TerminalCLIProps {
onDoubleClick?: () => void; onDoubleClick?: () => void;
fontSize?: number; fontSize?: number;
miniSize?: boolean; miniSize?: boolean;
updateStatus: (value: LineConfig) => void;
} }
const TerminalCLI: React.FC<TerminalCLIProps> = ({ const TerminalCLI: React.FC<TerminalCLIProps> = ({
@ -33,11 +35,11 @@ const TerminalCLI: React.FC<TerminalCLIProps> = ({
station_id, station_id,
cliOpened = false, cliOpened = false,
isDisabled = false, isDisabled = false,
line_status = "",
customStyle = {}, customStyle = {},
onDoubleClick = () => {}, onDoubleClick = () => {},
fontSize = 14, fontSize = 14,
miniSize = false, miniSize = false,
updateStatus,
}) => { }) => {
const xtermRef = useRef<HTMLDivElement>(null); const xtermRef = useRef<HTMLDivElement>(null);
const terminal = useRef<Terminal>(null); const terminal = useRef<Terminal>(null);
@ -148,6 +150,7 @@ const TerminalCLI: React.FC<TerminalCLIProps> = ({
data.forEach((value) => { data.forEach((value) => {
if (value?.id === line_id && terminal.current) { if (value?.id === line_id && terminal.current) {
terminal.current?.write(value.output); terminal.current?.write(value.output);
updateStatus({ ...value, lineId: value.id });
} }
}); });
} }

View File

@ -126,3 +126,15 @@ export type SwitchPortsProps = {
status: string; status: string;
poe: string; poe: string;
}; };
export type LineConfig = {
id: number;
lineId: number;
port: number;
lineNumber: number;
ip: string;
stationId: number;
apcName?: string;
output: string;
status: string;
};