import fs from 'node:fs' import { textfsmResults } from './../ultils/templates/index.js' import net from 'node:net' import { appendLog, buildBody, canInputCommand, classifyLog, cleanData, convertFromKilobytesString, detectConfigRamByModel, detectScenarioByModel, escapeHtml, getIncomingInfoBySN, isRamSufficient, isValidJson, LogStreamBuffer, mapErrorsToRows, mapToLineFormat, normalizeInterface, parseLicenseReport, sendMessageToMail, sleep, TestSession, updateNoteToERP, } from '../ultils/helper.js' import Scenario from '#models/scenario' import path from 'node:path' import axios from 'axios' import redis from '@adonisjs/redis/services/main' import Line from '#models/line' import PromptAi from '#models/prompt_ai' import { CustomSocket, ErrorRow, PortState, TestResult } from '../ultils/types.js' import momentTZ from 'moment-timezone' import { PhysicalPortTest } from './physical_test_service.js' import Station from '#models/station' import IosLicenseController from '#controllers/ios_license_controller' type Inventory = { pid: string vid: string sn: string licenseLevel: string licenseType: string nextLicenseLevel: string } interface LineConfig { id: number port: number lineNumber: number ip: string stationId: number stationName: string stationIp: string apcName?: string outlet: number output: string status: string baud: number openCLI: boolean userEmailOpenCLI: string userOpenCLI: string inventory: any latestScenario?: { name: string time: number detectAI?: { status: string[] issue: string[] summary: string } } data: { command: string output: string textfsm: string }[] ports: string[] runningScenario: string runningPhysical: boolean listFeatureTested: string[] isReady: boolean isSkipPhysical?: boolean reasonSkipPhysical?: string // history: string } /** HISTORY * PID * SN * VID * Timestamp * Scenario */ interface HistoryItem { pid: string vid: string sn: string scenario: string id: number number: number stationId: number timestamp?: number } interface User { userEmail: string userName: string } interface DataDPELP { line: number pid: any vid: any sn: any ios: string mac: string license: any issues: string[] summary: string } export default class LineConnection { public client: net.Socket public config: LineConfig public readonly socketIO: any private outputBuffer: string private connecting: boolean private waitingScenario: boolean private outputInventory: string private outputScenario: string private bufferLog: LogStreamBuffer public dataDPELP: DataDPELP | string private listScenarios: number[] public handleClearLine: () => void private session: TestSession public physicalTest: PhysicalPortTest private outputPhysicalTest: string private outputLoadIosLicense: string | boolean private listDeviceIos: string[] private debounceTimer: NodeJS.Timeout | null = null private testingPortPoE: boolean private outputTestingPortPoE: string private debounceSendSummaryReport: NodeJS.Timeout | null = null private isPingToServer: boolean private outputPingToServer: string private outputTestLog: string private userTest: { dpelp: { name: string; time: number } physical: { name: string; time: number } } constructor(config: LineConfig, socketIO: any, handleClearLine: () => void) { this.config = config this.socketIO = socketIO this.client = new net.Socket() this.outputBuffer = '' this.connecting = false this.waitingScenario = false this.outputInventory = '' this.outputScenario = '' this.bufferLog = new LogStreamBuffer() this.dataDPELP = { line: this.config.lineNumber, pid: '', vid: '', sn: '', ios: '', mac: '', license: [], issues: ['No data'], summary: '', } this.listScenarios = [] this.session = new TestSession() this.handleClearLine = handleClearLine this.physicalTest = new PhysicalPortTest([]) this.outputPhysicalTest = '' this.outputLoadIosLicense = '' this.listDeviceIos = [] this.debounceTimer = null this.debounceSendSummaryReport = null this.testingPortPoE = false this.outputTestingPortPoE = '' this.isPingToServer = false this.outputPingToServer = '' this.outputTestLog = '' this.userTest = { dpelp: { name: '', time: 0 }, physical: { name: '', time: 0 } } } /** * Connect to line with socket */ connect(timeoutMs = 5000) { return new Promise((resolve, reject) => { const { ip, port, lineNumber, id, stationId } = this.config let resolvedOrRejected = false // Set timeout this.client.setTimeout(timeoutMs) console.log(`🔌 Connecting to line ${lineNumber} (${ip}:${port})...`) this.client.connect(port, ip, () => { if (resolvedOrRejected) return resolvedOrRejected = true console.log(`[${Date.now()}] ✅ Connected to line ${lineNumber} (${ip}:${port})`) this.connecting = true setTimeout(() => { this.config.status = 'connected' // this.retryConnect = 0 this.connecting = false this.socketIO.emit('line_connected', { stationId, lineId: id, lineNumber, status: 'connected', }) this.config.listFeatureTested = [] this.config.isSkipPhysical = false this.config.reasonSkipPhysical = '' this.sendFeatureTested() this.checkLog() resolve() }, 2000) }) this.client.on('data', (data) => { let message = this.connecting ? cleanData(data.toString()) : data.toString() const lines = this.bufferLog.push(data) lines.forEach(this.handleLogLine) let rawData = '' if (this.config.runningScenario) { this.waitingScenario = true this.outputBuffer += message this.outputScenario += message this.outputTestLog += cleanData(data.toString()) if (!this.config.inventory) this.outputInventory = this.outputInventory.slice(-3000) + message } if (this.outputLoadIosLicense) { if (this.outputLoadIosLicense === true) this.outputLoadIosLicense = '' this.outputLoadIosLicense += cleanData(data.toString()) } if (this.config.runningPhysical) { this.outputPhysicalTest += message this.outputTestingPortPoE += message if (this.debounceTimer) clearTimeout(this.debounceTimer) if (this.testingPortPoE) this.debounceTimer = setTimeout(() => { this.flushLogBuffer() }, 1000) // 1s debounce } if (this.isPingToServer) this.outputPingToServer += cleanData(data.toString()) if (data.toString().includes('More') || data.toString().includes('MORE')) this.writeCommand(' ') // let output = cleanData(message) // console.log(`📨 [${this.config.port}] ${message}`) // Handle netOutput with backspace support for (const char of message) { if (char === '\x7F' || char === '\x08') { this.config.output = this.config.output.slice(0, -1) // message = message.slice(0, -1) } else { rawData += char } } this.config.output += cleanData(rawData) this.config.output = this.config.output.slice(-15000) if (!this.config.isReady && canInputCommand(message)) { this.config.isReady = true this.socketIO.emit('update_status_ready', { stationId, lineId: id, isReady: true, }) } this.socketIO.emit('line_output', { stationId, lineId: id, data: message, ports: this.config.ports, }) setTimeout(() => { if (!this.config.inventory) { this.getInventory() } }, 5000) appendLog( cleanData(message), this.config.stationId, this.config.stationName, this.config.stationIp, this.config.lineNumber ) }) this.client.on('error', (err) => { if (resolvedOrRejected) return resolvedOrRejected = true console.error(`❌ Error line ${lineNumber}:`, err.message) this.config.output += '\r\n' + err.message + '\r\n' this.socketIO.emit('line_error', { stationId, lineId: id, error: '\r\n' + err.message + '\r\n', }) this.endTesting() resolve() }) this.client.on('close', async () => { console.log(`[${Date.now()}] 🔌 Line ${lineNumber} disconnected`) this.config.status = 'disconnected' this.config.output += this.config.output + '[CLEAR_TERMINAL_SCROLL_BACK]' this.config.listFeatureTested = [] this.config.isSkipPhysical = false this.config.reasonSkipPhysical = '' this.config.latestScenario = undefined this.physicalTest = new PhysicalPortTest([]) this.config.isReady = false // this.config.inventory = undefined this.socketIO.emit('line_disconnected', { stationId, lineId: id, lineNumber, status: 'disconnected', }) // if (this.retryConnect <= 5) { // await this.sleep(5000) // console.log(`Retry connect line [${this.config.lineNumber}] times`, this.retryConnect) // this.retryConnect += 1 // await this.reconnect() // } else { // this.retryConnect = 0 // } this.endTesting() }) this.client.on('timeout', () => { if (resolvedOrRejected) return resolvedOrRejected = true const message = '\r\nConnection timeout!!\r\n' this.config.output += message this.socketIO.emit('line_output', { stationId, lineId: id, data: message, }) appendLog( cleanData(message), this.config.stationId, this.config.stationName, this.config.stationIp, this.config.lineNumber ) console.log(`⏳ Connection timeout line ${lineNumber}`) this.client.destroy() resolve() // reject(new Error('Connection timeout')) }) }) } /** * Waiting with millisecond */ private sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)) } /** * Write a command with socket.write */ async writeCommand(cmd: string | Buffer) { if (this.client.destroyed) { console.log(`⚠️ Cannot send, line ${this.config.lineNumber} is closed`) this.disconnect() // this.disconnect() // await sleep(2000) // await this.connect() return } // console.log( // `Write command "${cmd.toString().replace(/\r/g, '\\r').replace(/\n/g, '\\n')}" to line ${this.config.lineNumber} of ${this.config.stationName}` // ) this.client.write(cmd) } /** * Disconnect socket with line */ async disconnect() { try { console.log('[DISCONNECT] Line', this.config.lineNumber) // this.handleClearLine() this.client.destroy() this.config.status = 'disconnected' this.socketIO.emit('line_disconnected', { ...this.config, status: 'disconnected', }) console.log(`🔻 Closed connection to line ${this.config.lineNumber}`) } catch (e) { console.error('Error closing line:', e) } } /** * Run a scenario as DPELP, Breaking password, load ios,... */ async runScript(script: Scenario, userName: string) { if (!this.client || this.client.destroyed) { console.log('Not connected') this.config.runningScenario = '' this.socketIO.emit('running_scenario', { stationId: this.config.stationId, lineId: this.config.id, title: '', }) this.outputBuffer = '' return } if (!this.config.isReady) { console.log('Device is not ready') return } if (this.config.runningScenario || this.config.runningPhysical) { console.log('Script already running') return } console.log( `Run scenario "${script?.title}" to line ${this.config.lineNumber} of ${this.config.stationName}` ) this.config.runningScenario = script?.title this.config.data = [] this.outputScenario = '' this.socketIO.emit('running_scenario', { stationId: this.config.stationId, lineId: this.config.id, title: script?.title, }) if (script?.send_result || script?.sendResult) { this.dataDPELP = '' this.userTest = { ...this.userTest, dpelp: { name: userName || '', time: Date.now() }, } // this.config.inventory = '' } if (script?.isReboot) { await sleep(10000) for (let index = 0; index < 30; index++) { await sleep(1000) this.breakSpam() } } const now = Date.now() this.outputScenario += `\n\n---start-scenarios---${now}---${userName}---${script?.title}---\n---scenario---${script?.title}---${now}---\n` appendLog( `\n\n---start-scenarios---${now}---${userName}---${script?.title}---\n---scenario---${script?.title}---${now}---\n`, this.config.stationId, this.config.stationName, this.config.stationIp, this.config.lineNumber ) this.config.latestScenario = { name: script?.title, time: now, } const steps = typeof script?.body === 'string' ? JSON.parse(script?.body) : [] let stepIndex = 0 // Create a timeout let timeoutTimer: NodeJS.Timeout | null = null const timeoutNumber = script.timeout ? Number(script.timeout) * 1000 : 300000 const onTimeout = () => { this.config.runningScenario = '' this.socketIO.emit('running_scenario', { stationId: this.config.stationId, lineId: this.config.id, title: '', }) this.outputBuffer = '' this.outputScenario = '' this.config.output += '\nTimeout run scenario\n' this.dataDPELP = { line: this.config.lineNumber, pid: '', vid: '', sn: '', ios: '', mac: '', license: [], issues: ['No data'], summary: '', } this.socketIO.emit('line_output', { stationId: this.config.stationId, lineId: this.config.id, data: '\nTimeout run scenario\n', }) this.outputScenario += `\n---end-scenarios---${now}---${userName}---\n` appendLog( `\n---end-scenarios---${now}---${userName}---\n`, this.config.stationId, this.config.stationName, this.config.stationIp, this.config.lineNumber ) // reject(new Error('Script timeout')) } const resetTimeout = () => { // console.log('resetTimeout', timeoutNumber) // this.outputBuffer = '' if (timeoutTimer) clearTimeout(timeoutTimer) timeoutTimer = setTimeout(onTimeout, timeoutNumber) } return new Promise((resolve, reject) => { timeoutTimer = setTimeout(onTimeout, timeoutNumber) const runStep = async (index: number) => { if (index >= steps.length) { if (this.waitingScenario) { this.waitingScenario = false setTimeout(() => { runStep(index) }, 5000) return } else if (timeoutTimer) clearTimeout(timeoutTimer) const logScenarios = this.outputScenario const data = textfsmResults(logScenarios, '') let pid = this.config.inventory?.pid || '' try { for (const item of data) { if (item?.textfsm && isValidJson(item?.textfsm)) { if ( ['show inventory', 'sh inventory', 'show inv', 'sh inv'].includes(item.command) ) { const listInventory = JSON.parse(item.textfsm) const dataInventory = listInventory[0] this.config.inventory = this.config.inventory ? { ...this.config.inventory, ...dataInventory, listInventory } : { ...dataInventory, listInventory } pid = dataInventory?.pid || '' this.addHistory(this.config.stationId, this.config.id, { id: this.config.id, number: this.config.lineNumber, stationId: this.config.stationId, pid: dataInventory?.pid, sn: dataInventory?.sn, vid: dataInventory?.vid, scenario: script?.title, timestamp: Date.now(), }) } if (['show version', 'sh version', 'show ver', 'sh ver'].includes(item.command)) { const dataVer = JSON.parse(item.textfsm)[0] this.config.inventory = this.config.inventory ? { ...this.config.inventory, ...dataVer } : dataVer if (pid && (dataVer?.MEMORY || dataVer?.USB_FLASH)) { await this.checkConfigRam( dataVer?.MEMORY || '', dataVer?.USB_FLASH || '', pid, cleanData(item.output) ) } } if ( item.command?.trim()?.includes('show env') || item.command?.trim()?.includes('sh env') ) { const dataEnv = await this.detectShowEnvWithAI(item.output) item.dataAI = dataEnv } item.textfsm = JSON.parse(item.textfsm) } } const scenario = await detectScenarioByModel(pid, this.listScenarios) console.log(pid, scenario?.title, this.listScenarios) if ( scenario && scenario.id !== script.id && scenario.title.includes('DPELP') && script.title.includes('DPELP') ) { this.outputScenario += `\n---end-scenarios---${now}---${userName}---\n` this.outputBuffer = '' this.config.runningScenario = '' this.listScenarios.push(scenario.id) // this.outputScenario = '' this.runScript(scenario, userName) // this.socketIO.emit('confirm_scenario', { // scenario: scenario, // id: this.config.id, // }) resolve(true) return } if (script?.send_result || script?.sendResult) { const detectLog = await this.detectLogWithAI(this.outputTestLog) const result = mapToLineFormat({ lineNumber: this.config.lineNumber, inventory: this.config.inventory, latestScenario: { detectAI: detectLog, }, data, }) // if (script?.send_result || script?.sendResult) { this.dataDPELP = result console.log( `DPELP DATA line ${this.config.lineNumber} of ${this.config.stationName}:`, this.dataDPELP ) this.config.listFeatureTested = [ ...new Set([...this.config.listFeatureTested, 'DPELP']), ] // if (!this.config.listFeatureTested.includes('PHYSICAL')) this.runPhysicalTest() this.sendFeatureTested() // Set timeout send report // this.setTimeoutSendSummaryReport( // !this.config.listFeatureTested.includes('PHYSICAL') ? 600000 : 30000 // ) // } if (this.config.latestScenario) this.config.latestScenario = { ...this.config.latestScenario, detectAI: { issue: detectLog, summary: '', status: [] }, } // if (result.sn) { // this.updateNote(result.sn, result) // } } this.outputScenario += `\n---end-scenarios---${now}---${userName}---\n` this.outputBuffer = '' this.config.runningScenario = '' this.socketIO.emit('running_scenario', { stationId: this.config.stationId, lineId: this.config.id, title: '', }) this.config.data = data this.socketIO.emit('data_textfsm', { stationId: this.config.stationId, lineId: this.config.id, data, inventory: this.config.inventory || null, latestScenario: this.config.latestScenario || null, }) } catch (error) { console.log(error) } appendLog( `\n---end-scenarios---${now}---${userName}---\n`, this.config.stationId, this.config.stationName, this.config.stationIp, this.config.lineNumber ) this.listScenarios = [] resolve(true) return } else resetTimeout() const step = steps[index] let repeatCount = Number(step.repeat) || 1 const delay = step?.delay ? Number(step?.delay) * 1000 : 1000 const sendCommand = async () => { // if (repeatCount <= 0) { // // Done → next step // stepIndex++ // return runStep(stepIndex) // } if (typeof step.send !== 'undefined') { console.log(Date.now() - now, (step?.send ?? '[ENTER]').toString()) this.outputScenario += `\n---send-command---"${(step?.send ?? '[ENTER]').toString().replace(/\r/g, '\\r').replace(/\n/g, '\\n')}"---${now}---\n` appendLog( `\n---send-command---"${(step?.send ?? '[ENTER]').toString().replace(/\r/g, '\\r').replace(/\n/g, '\\n')}"---${now}---\n`, this.config.stationId, this.config.stationName, this.config.stationIp, this.config.lineNumber ) this.writeCommand((step?.send || '') + '\r\n') } repeatCount-- if (repeatCount <= 0) { // Done → next step stepIndex++ return runStep(stepIndex) } else setTimeout(() => sendCommand(), delay) } // Nếu expect rỗng → gửi ngay if (!step?.expect || step?.expect.trim() === '') { setTimeout(() => sendCommand(), delay) return } // while (this.outputBuffer) { // await sleep(200) // if (this.outputBuffer.includes(step.expect)) { // this.outputBuffer = '' // setTimeout(() => sendCommand(), delay) // } // } const matched = await this.waitForExpect( step.expect.trim(), script?.timeout ? Number(script?.timeout) * 1000 : 60000 ) if (matched) setTimeout(() => sendCommand(), delay) } runStep(stepIndex) }) } /** * Reconnect socket with line */ public async reconnect(): Promise { try { this.disconnect() this.client = new net.Socket() await this.sleep(1000) await this.connect() return true } catch (err: any) { return false } } /** * User open CLI from front-end */ userOpenCLI(user: User) { this.config.openCLI = true this.config.userEmailOpenCLI = user.userEmail this.config.userOpenCLI = user.userName this.socketIO.emit('user_open_cli', { stationId: this.config.stationId, lineId: this.config.id, userEmailOpenCLI: user.userEmail, userOpenCLI: user.userName, }) appendLog( `\n-------${user.userName}-------\n`, this.config.stationId, this.config.stationName, this.config.stationIp, this.config.lineNumber ) } /** * User close CLI from front-end */ userCloseCLI() { this.config.openCLI = false this.config.userEmailOpenCLI = '' this.config.userOpenCLI = '' this.socketIO.emit('user_close_cli', { stationId: this.config.stationId, lineId: this.config.id, userEmailOpenCLI: '', }) } /** * Clear output buffer */ clearCLI() { this.config.output = '' this.socketIO.emit('user_clear_terminal', { stationId: this.config.stationId, lineId: this.config.id, }) setTimeout(() => this.writeCommand('\r\n'), 100) } /** * Waiting for a expect with until catch it from output */ waitForExpect = async (expect: string, timeout = 60000) => { const start = Date.now() // console.log('[EXPECT]', expect, timeout) while (Date.now() - start < timeout) { if (this.outputBuffer.includes(expect)) { this.outputBuffer = '' return true } await sleep(200) } return false } /** * Detect inventory data from output */ getInventory = () => { const data = textfsmResults(this.outputInventory, 'show inventory') try { data.forEach((item) => { if (item?.textfsm && isValidJson(item?.textfsm)) { if (['show inventory', 'sh inventory', 'show inv', 'sh inv'].includes(item.command)) { const listInventory = JSON.parse(item.textfsm) const dataInventory = listInventory[0] this.config.inventory = this.config.inventory ? { ...this.config.inventory, ...dataInventory, listInventory } : dataInventory } item.textfsm = JSON.parse(item.textfsm) } }) if (this.config.inventory) { this.config.data = data this.socketIO.emit('data_textfsm', { stationId: this.config.stationId, lineId: this.config.id, data, inventory: this.config.inventory || null, latestScenario: this.config.latestScenario || null, }) this.outputInventory = '' } } catch (error) { console.log(error) } } /** * Gửi nhiều ký tự ESC để vào ROMMON */ breakSpam() { console.log('SPAM Break to line:', this.config.lineNumber) let count = 0 const escInterval = setInterval(() => { if (count >= 10) { clearInterval(escInterval) return } this.client.write(Buffer.from([0xff, 0xf3])) // Ctrl + Break count++ }, 1) } /** * Set Baud of line */ async setBaud(baud: number) { this.writeCommand('enable\r\n') await sleep(500) this.writeCommand('configure terminal\r\n') await sleep(500) this.writeCommand('line console 0\r\n') await sleep(500) this.writeCommand(`speed ${baud.toString()}\r\n`) await sleep(500) this.writeCommand('end\r\n') await sleep(500) this.writeCommand('write memory\r\n') this.writeCommand('\r\n') } /** * Get content's log of line with date */ async getLog(date: string) { const logDir = path.join('storage', 'system_logs') const logFile = path .join( logDir, `${date}-AUTO-Session.${this.config.stationName}-${this.config.stationId}-${this.config.stationIp}-${this.config.lineNumber}.log` ) .replaceAll(' ', '_') if (!fs.existsSync(logDir) || !fs.existsSync(logFile)) { return '' } return await fs.promises.readFile(logFile, 'utf8') } /** * Detect log by call api gpt, return summary and issues */ async detectLogWithAI(log: string) { try { // Get prompt from database const promptRecord = await PromptAi.findBy('type', 'dpelp') if (!promptRecord) { console.log('[ERROR] Prompt DPELP not found in database') return '' } const payload = { model: 'gpt-5-mini', messages: [ { role: 'user', content: `${promptRecord.content} Return ONLY a valid JSON array of strings. Here is the log: ${log}`, }, ], } const remoteUrl = process.env.ERP_URL_AUTH || 'https://stage.nswteam.net' const remoteResp = await axios.post( remoteUrl + '/api/transferPostData', { urlAPI: '/api/open-ai-sfp/model-image-info', data: payload, }, { headers: { Authorization: 'Bearer ' + process.env.ERP_TOKEN, }, } ) return remoteResp.data?.Status === 'OK' ? remoteResp.data?.data : '' } catch (error: any) { console.log('[ERROR] Detect log from AI', error) } return '' } /** * Add cache to list history devices on this line */ async addHistory( stationId: number, lineId: number, item: HistoryItem, outputLog?: string, portPhysical?: PortState[] ) { if (!item.pid || !item.sn) return false const key = `station:${stationId}:line:${lineId}:history` const now = Date.now() // Tạo object chứa các field mở rộng nếu được truyền vào const extendedFields: any = {} if (outputLog !== undefined) extendedFields.output = outputLog if (portPhysical !== undefined) extendedFields.portPhysical = portPhysical // Lấy phần tử cuối cùng trong ZSET mang tính timeline const lastItems = await redis.zrevrange(key, 0, 0) if (lastItems.length > 0) { const last = JSON.parse(lastItems[0]) // TRƯỜNG HỢP 1: Trùng pid và sn -> Cập nhật lại bản ghi cũ if (last.pid === item.pid && last.sn === item.sn) { const updatedItemObj = { ...last, ...extendedFields, } const updatedItemStr = JSON.stringify(updatedItemObj) // Nếu dữ liệu mới không khác gì dữ liệu cũ thì không cần làm gì cả if (lastItems[0] === updatedItemStr) { return false } await redis.multi().zrem(key, lastItems[0]).zadd(key, last.timestamp, updatedItemStr).exec() return true } } const line = await Line.find(lineId) if (line) { const listHistory = line.history ? JSON.parse(line.history) : [] listHistory.unshift({ ...item, timestamp: now }) line.history = JSON.stringify(listHistory) await line.save() } // Tự động xóa item > 96h // const expireTime = now - 96 * 60 * 60 * 1000 // await redis.zremrangebyscore(key, 0, expireTime) // TRƯỜNG HỢP 2: Sản phẩm mới hoàn toàn -> Thêm mới vào ZSET const newItem = JSON.stringify({ ...item, ...extendedFields, timestamp: now, }) await redis.zadd(key, now, newItem) return true } /** * Get list history devices */ async getHistory(stationId: number, lineId: number) { const key = `station:${stationId}:line:${lineId}:history` const items = await redis.zrange(key, 0, -1) return items.map((i) => JSON.parse(i)) } /** * Handle raw log to regex error */ handleLogLine = (line: string) => { try { const parsed = classifyLog(line) this.session.applyParsedLog(parsed) } catch (error) { console.log('handleLogLine', error) } } /** * Check raw log was regex each 5 minutes, if has error will send email report */ checkLog() { const interval = setInterval(async () => { try { if (this.config.status !== 'connected') { clearInterval(interval) this.session.clear() this.bufferLog.clear() return } const result = this.session.finalize() if (result.errors.length === 0) { this.session.clear() this.bufferLog.clear() return } // const detectLog = await this.detectLogWithAI(this.bufferLog.allBuffer) // console.log(detectLog) const tableHTML = this.buildEmailContent(result) await sendMessageToMail( `[ATC] - [${this.config.stationName} - Line: ${this.config.lineNumber}] - Raw log issue ${result?.errors?.some((e) => e.category === 'SPECIAL_KEYWORD') ? '+ Special keywords' : ''}`, tableHTML + `${`

Logs:

${this.bufferLog.allBuffer}
`}` ) this.session.clear() this.bufferLog.clear() } catch (err: any) { console.error('Error checking log:', err) } }, 300000) } /** * Render table to view error */ renderErrorTable(rows: ErrorRow[]): string { if (!rows.length) { return `

No errors detected

` } const header = ` Level Rule Message Log Evidence ` const body = rows .map( (r) => ` ${r.level} ${r.rule} ${r.message} ${escapeHtml(r.log.trim()) .split('*') .filter((el) => el) .join('
*')} ` ) .join('') return ` ${header} ${body}
` } /** * Return a body email */ buildEmailContent(result: TestResult): string { const rows = mapErrorsToRows(result.errors) const table = this.renderErrorTable(rows) return `

Cisco Device Log Result

Line: ${this.config.lineNumber} - Station: ${this.config.stationName}

Summary: ${result.summary}


${table}
` } /** * Update note of SN to ERP after run DPELP */ async updateNote(sn: string, data: DataDPELP) { const portPhysical = Array.from(this.physicalTest.ports.values()) const missing = portPhysical.filter((p) => !p.tested) const missingPoE = missing.filter((p) => !p.name.includes('SFP')) const missingSFP = missing.filter((p) => p.name.includes('SFP')) const tested = portPhysical.filter((p) => p.tested) const testedPoE = tested.filter((p) => !p.name.includes('SFP')) const testedSFP = tested.filter((p) => p.name.includes('SFP')) const licenses = Array.isArray(data.license) ? [...new Set(data.license)] : data.license ? [data.license] : [] const timeZone = process.env.TIME_ZONE || 'Australia/Sydney' const dataFormat = momentTZ().tz(timeZone).format('YYYY/MM/DD, HH:mm') const note = `-------[ATC]-[${dataFormat}]------- *****[DPELP]***** License: ${licenses.join(', ')} Detected by AI: ${data.issues?.length ? `- ` + data.issues.join(`\n`) : ''} *****[Physical]***** Total Ports: ${portPhysical?.length} Ports Tested (Link UP): ${tested.length} (${testedPoE?.length} PoE, ${testedSFP?.length} SFP) Ports Missing/Down: ${missing.length} ${this.config.reasonSkipPhysical ? `***User skip test ports:\n- ${this.config.reasonSkipPhysical}` : ''} \n` await updateNoteToERP(sn, note) } /** * Update note of SN to ERP from user input */ async updateNoteFromUser(sn: string, note: string, licenses: string[]) { const portPhysical = Array.from(this.physicalTest.ports.values()) const missing = portPhysical.filter((p) => !p.tested) const missingPoE = missing.filter((p) => !p.name.includes('SFP')) const missingSFP = missing.filter((p) => p.name.includes('SFP')) const tested = portPhysical.filter((p) => p.tested) const testedPoE = tested.filter((p) => !p.name.includes('SFP')) const testedSFP = tested.filter((p) => p.name.includes('SFP')) const timeZone = process.env.TIME_ZONE || 'Australia/Sydney' const dataFormat = momentTZ().tz(timeZone).format('YYYY/MM/DD, HH:mm') const data = `-------[ATC]-[${dataFormat}]------- *****[DPELP]***** License: ${licenses.join(', ')} Issues: ${note} *****[Physical]***** Total Ports: ${portPhysical?.length} Ports Tested (Link UP): ${tested.length} (${testedPoE?.length} PoE, ${testedSFP?.length} SFP) Ports Missing/Down: ${missing.length}\n\n` const issueList = note .split('\n') .map((line) => (line[0] === '-' ? line.substring(1).trim() : line.trim())) const detectAI = this.config?.latestScenario?.detectAI ? { ...this.config.latestScenario.detectAI, issue: issueList } : { issue: issueList, summary: '', status: [] } if (this.config.latestScenario) { this.config.latestScenario = { ...this.config.latestScenario, detectAI } } await updateNoteToERP(sn, data) } /** * Starting physical test (PoE ports testing) */ async runPhysicalTest(userName?: string) { if (this.config.runningPhysical) { console.log('Running physical test') return } this.setTimeoutSendSummaryReport(1200000) this.config.runningPhysical = true this.config.runningScenario = 'Physical Test' this.config.isSkipPhysical = false this.config.reasonSkipPhysical = '' this.testingPortPoE = true this.outputTestingPortPoE = '' this.userTest = { ...this.userTest, physical: { name: userName || '', time: Date.now() } } const listPorts = await this.getPorts() this.socketIO.emit('running_scenario', { stationId: this.config.stationId, lineId: this.config.id, title: 'Physical Test', physical: true, ports: listPorts, }) if (listPorts.length === 0) { this.config.listFeatureTested = [...new Set([...this.config.listFeatureTested, 'PHYSICAL'])] this.config.isSkipPhysical = true this.config.reasonSkipPhysical = '' this.sendFeatureTested() console.log('End physical test') this.endTesting() return } this.physicalTest.start( listPorts.map((el) => el), this.config.inventory ) const interval = setInterval(async () => { if (!this.config.runningPhysical || this.config.status !== 'connected') { clearInterval(interval) } else if (this.physicalTest.done) { clearInterval(interval) this.sendReportPhysicalTest() this.endTesting() } else { this.checkingPhysicalPort() } }, 15000) } async checkingPhysicalPort() { try { this.writeCommand('show power inline | include on\r\n') this.writeCommand('\r\n') await this.sleep(1000) this.writeCommand('show interfaces status | include SFP\r\n') this.writeCommand('\r\n') await this.sleep(2000) const output = this.outputPhysicalTest this.outputPhysicalTest = '' if (output) { const ports = this.physicalTest.detectPorts(output) this.socketIO.emit('test_port_physical', { stationId: this.config.stationId, lineId: this.config.id, data: ports, }) if (ports.length === this.config.ports.length) { this.sendReportPhysicalTest() this.endTesting() } } } catch (error) { console.log('checkingPhysicalPort', error) } } async flushLogBuffer() { try { const lines = this.outputTestingPortPoE.split(/\r?\n/) // giữ lại dòng cuối nếu chưa kết thúc hoàn chỉnh this.outputTestingPortPoE = lines.pop() || '' const completeLines = lines.join('\n') if (completeLines.trim()) { const ports = this.physicalTest.handleLog(completeLines) if (ports?.length) this.socketIO.emit('test_port_physical', { stationId: this.config.stationId, lineId: this.config.id, data: ports, }) } } catch (error) { console.log('flushLogBuffer', error) } } /** * End all testing */ endTesting() { this.physicalTest.done = true // this.physicalTest.resetTestedPorts() this.config.runningPhysical = false this.config.runningScenario = '' this.testingPortPoE = false this.outputTestingPortPoE = '' this.outputBuffer = '' this.outputScenario = '' this.outputPhysicalTest = '' this.config.ports = [] this.socketIO.emit('running_scenario', { stationId: this.config.stationId, lineId: this.config.id, title: '', }) } /** * Get list PoE ports */ async getPorts(): Promise { this.writeCommand(' terminal length 0\r\n') this.writeCommand(' show power inline\r\n') this.writeCommand(' \r\n') await this.sleep(3000) this.writeCommand(' show interfaces status\r\n') this.writeCommand(' \r\n') await this.sleep(6000) const statusOutput = this.outputPhysicalTest this.outputPhysicalTest = '' const lines = statusOutput.split('\n') const ports = [] for (const line of lines) { // Match: "Gi1/0/1 auto off 0.0 n/a n/a 30.0 " const matchPoE = line.match(/^(\S+)\s+\S+\s+(on|off)/i) if (matchPoE) { const name = matchPoE[1] if (name.includes('/')) ports.push(normalizeInterface(name)) } // Match: "Gi0/15 notconnect 1 auto auto 1000BaseSX SFP" // Match: "Gi0/16 notconnect 1 auto auto Not Present" // Match: "Gi1/1/4 notconnect 1 auto auto unknown" const matchSFP = line.match(/^([A-Za-z0-9\/]+).*\b(SFP|Not Present|unknown)\b/i) if (matchSFP) { const name = matchSFP[1] ports.push(normalizeInterface(name) + ' (SFP)') } } this.config.ports = [...new Set(ports)] return [...new Set(ports)] } /** * Send report after done physical test */ async sendReportPhysicalTest(reason?: string) { this.config.listFeatureTested = [...new Set([...this.config.listFeatureTested, 'PHYSICAL'])] if (typeof reason === 'string' && reason.trim().length > 0) { this.config.isSkipPhysical = true this.config.reasonSkipPhysical = reason } this.sendFeatureTested() // Set timeout send report if ( this.config.listFeatureTested?.includes('PHYSICAL') && this.config.listFeatureTested?.includes('DPELP') ) this.setTimeoutSendSummaryReport(5000) const formReport = this.physicalTest.getFormReport(this.config.inventory) const reasonSkipPhysical = typeof reason === 'string' && reason.trim().length > 0 ? `User Skip Test Port
────────────────────────────────
${reason}` : '' await sendMessageToMail( `[ATC] - [${this.config.stationName} - Line: ${this.config.lineNumber}] - [${this.config.inventory?.pid}] - [${this.config.inventory?.sn}] - Physical Ports Test`, formReport + reasonSkipPhysical ) } /** * Handle load ios for router */ async loadIosRouter(nameIos: string, userName: string) { const station = await Station.find(this.config.stationId) if (!station) return this.outputLoadIosLicense = true const network = station?.gateway || '172.25.1.1' const tftpIp = station?.tftp_ip || '172.16.7.69' const [a, b] = network.split('.').map(Number) const timeZone = process.env.TIME_ZONE || 'Australia/Sydney' const startTime = momentTZ().tz(timeZone).format('YYYY/MM/DD, HH:mm:ss') const body = buildBody( 'ROUTER_IOS', tftpIp, nameIos, `${a}.${b}.100.${this.config.id < 254 ? this.config.id : 254 - this.config.id}`, `${station?.gateway ? station?.gateway : '0.0.0.0'}`, this.listDeviceIos ) const script = { id: 0, isReboot: true, sendResult: false, send_result: false, title: 'Load IOS Router', timeout: 1000, body: JSON.stringify(body), } await sleep(5000) await this.runScript(script as any, userName) await this.sendEmailLoadIos(nameIos, startTime) } /** * Handle load ios for switch */ async loadIosSwitch(nameIos: string, userName: string) { const station = await Station.find(this.config.stationId) if (!station) return this.outputLoadIosLicense = true const network = station?.gateway || '172.25.1.1' const tftpIp = station?.tftp_ip || '172.16.7.69' const [a, b] = network.split('.').map(Number) const timeZone = process.env.TIME_ZONE || 'Australia/Sydney' const startTime = momentTZ().tz(timeZone).format('YYYY/MM/DD, HH:mm:ss') const address = `${a}.${b}.100.${this.config.id < 254 ? this.config.id : 254 - this.config.id}` const gateway = `${station?.gateway ? station?.gateway : '0.0.0.0'}` await this.configAddressGateway(address, gateway, 'vlan 1') const pingSuccess = await this.pingToServer(tftpIp) if (!pingSuccess) return await this.backupIos(nameIos) const body = buildBody('SWITCH_IOS', tftpIp, nameIos, address, gateway, this.listDeviceIos) const script = { id: 0, isReboot: false, sendResult: false, send_result: false, title: 'Load IOS Switch', timeout: 1000, body: JSON.stringify(body), } await this.runScript(script as any, userName) await this.sendEmailLoadIos(nameIos, startTime) } /** * Send mail report after load ios */ async sendEmailLoadIos(nameIos: string, startTime: string) { const timeZone = process.env.TIME_ZONE || 'Australia/Sydney' const dataFormat = momentTZ().tz(timeZone).format('YYYY/MM/DD, HH:mm:ss') const body = ` Load IOS Report
────────────────────────────────
Station : ${this.config.stationName}
Line : ${this.config.lineNumber}
IOS : ${nameIos}
Started At : ${startTime}
Finished At : ${dataFormat}

`.trim() await sendMessageToMail( `[ATC] - [${this.config.stationName} - Line: ${this.config.lineNumber}] - Load IOS Report`, body + `${`

Logs:

${this.outputLoadIosLicense}
`}` ) this.outputLoadIosLicense = '' } /** * Send mail report after load license */ async sendEmailLoadLicense(nameLicense: string, startTime: string) { const timeZone = process.env.TIME_ZONE || 'Australia/Sydney' const dataFormat = momentTZ().tz(timeZone).format('YYYY/MM/DD, HH:mm:ss') const report = parseLicenseReport( typeof this.outputLoadIosLicense === 'string' ? this.outputLoadIosLicense : '' ) const body = ` Load License Report
────────────────────────────────
Station : ${this.config.stationName}
Line : ${this.config.lineNumber}
License : ${nameLicense}
Started At : ${startTime}
Finished At : ${dataFormat}
────────────────────────────────
Summary licenses: ${report.summary.join(', ')}
Successful: ${report.imported.join(', ')}
Exist: ${report.exist.join(', ')}
Failed: ${report.failed.join(', ')}

`.trim() await sendMessageToMail( `[ATC] - [${this.config.stationName} - Line: ${this.config.lineNumber}] - Load License Report`, body + `${`

Logs:

${this.outputLoadIosLicense}
`}` ) this.outputLoadIosLicense = '' } /** * Check list ios exist on flash */ async checkDeviceFlash() { this.writeCommand(' enable\r\n') this.writeCommand('show flash:\r\n') await sleep(2000) const output = this.outputBuffer const ios: string[] = [] let match const SWITCH_BIN_REGEX = /^\s*\d+\s+-rwx\s+\d+\s+.*?\s+([^\s]+\.bin)\s*$/gim const ROUTER_BIN_REGEX = /^\s*\d+\s+(\d+)\s+.*?\s+([^\s]+\.bin)\s*$/gim // 🔍 Detect device type const isSwitch = output.includes('rwx') const regex = isSwitch ? SWITCH_BIN_REGEX : ROUTER_BIN_REGEX // reset regex state regex.lastIndex = 0 while ((match = regex.exec(output)) !== null) { ios.push(isSwitch ? match[1] : match[2]) } return ios } /** * Delete File on Flash */ async deleteFileOnFlash(fileName: string) { await this.writeCommand(`delete flash:${fileName}\r\n`) await this.writeCommand(`\r\n`) await this.writeCommand(`\r\n`) await sleep(3000) } /** * Upload file from flash to TFTP server */ async uploadFileToServerTFTP(fileName: string, server: string) { this.config.runningScenario = 'Upload file' await this.writeCommand(`copy flash: tftp:\r\n`) await this.writeCommand(`${fileName}\r\n`) await this.writeCommand(`${server}\r\n`) await this.writeCommand(`i/${fileName}\r\n`) this.outputBuffer = '' await sleep(5000) while (true) { if (this.outputBuffer.includes('#')) { this.outputBuffer = '' this.config.runningScenario = '' return true } await sleep(5000) } } /** * Get list ios from TFTP server */ async getListIos() { try { const controller = new IosLicenseController() const listIos = await controller.getIos() return listIos } catch (error) { console.log('Error get ios', error) return [] } } /** * Get current boot ios of device */ async getCurrentBootIos() { this.writeCommand('show version | include System image\r\n') await sleep(2000) const match = this.outputBuffer.match(/"flash:(.+?)"/i) this.outputBuffer = '' return match ? match[1] : null } /** * Backup ios to TFTP, after that delete it on flash for free space */ async backupIos(nameIos: string) { const station = await Station.find(this.config.stationId) if (!station) return const server = station?.tftp_ip || '172.16.7.69' // const currentBootIos = await this.getCurrentBootIos() this.config.runningScenario = 'Backup IOS' this.socketIO.emit('running_scenario', { stationId: this.config.stationId, lineId: this.config.id, title: 'Backup IOS', }) await sleep(1000) const listIos = await this.getListIos() const dataDevice = await this.checkDeviceFlash() this.listDeviceIos = [...dataDevice] console.log('Data Device Flash', dataDevice) if (dataDevice && Array.isArray(dataDevice)) { for (const ios of dataDevice) { // if (ios === nameIos) { // console.log(`SKIP active IOS: ${ios}`) // continue // } if (listIos?.map((value) => value.name)?.includes(ios)) { console.log(`Already backed up: ${ios}`) if (ios !== nameIos) await this.deleteFileOnFlash(ios) } else { const ok = await this.uploadFileToServerTFTP(ios, server) if (ok && ios !== nameIos) await this.deleteFileOnFlash(ios) } } } this.outputBuffer = '' this.config.runningScenario = '' await sleep(1000) } /** * Handle load License for switch * Assumes traditional licensing (PAK/file-based) via TFTP */ async loadLicenseSwitch(licenseFileName: string, userName: string, portName: string) { const station = await Station.find(this.config.stationId) if (!station) return this.outputLoadIosLicense = true // Setup network variables (giống hệt logic load IOS để đảm bảo thông mạng) const network = station?.gateway || '172.25.1.1' const tftpIp = station?.tftp_ip || '172.16.7.69' const [a, b] = network.split('.').map(Number) // Setup time/logging const timeZone = process.env.TIME_ZONE || 'Australia/Sydney' const startTime = momentTZ().tz(timeZone).format('YYYY/MM/DD, HH:mm:ss') const address = `${a}.${b}.100.${this.config.id < 254 ? this.config.id : 254 - this.config.id}` const gateway = `${station?.gateway ? station?.gateway : '0.0.0.0'}` await this.configAddressGateway(address, gateway, portName) const pingSuccess = await this.pingToServer(tftpIp) if (!pingSuccess) return const body = buildBody( 'SWITCH_LICENSE', tftpIp, licenseFileName, address, gateway, this.listDeviceIos, portName ) const script = { id: 0, isReboot: false, sendResult: false, send_result: false, title: 'Load License Switch', timeout: 1000, body: JSON.stringify(body), } await this.runScript(script as any, userName) await this.sendEmailLoadLicense(licenseFileName, startTime) // Nếu bạn có hàm gửi mail báo cáo } /** * Handle load License for Router */ async loadLicenseRouter(licenseFileName: string, userName: string, portName: string) { const station = await Station.find(this.config.stationId) if (!station) return this.outputLoadIosLicense = true const network = station?.gateway || '172.25.1.1' const tftpIp = station?.tftp_ip || '172.16.7.69' const [a, b] = network.split('.').map(Number) const timeZone = process.env.TIME_ZONE || 'Australia/Sydney' const startTime = momentTZ().tz(timeZone).format('YYYY/MM/DD, HH:mm:ss') const address = `${a}.${b}.100.${this.config.id < 254 ? this.config.id : 254 - this.config.id}` const gateway = `${station?.gateway ? station?.gateway : '0.0.0.0'}` await this.configAddressGateway(address, gateway, portName, true) const pingSuccess = await this.pingToServer(tftpIp) if (!pingSuccess) return const body = buildBody( 'ROUTER_LICENSE', tftpIp, licenseFileName, address, gateway, this.listDeviceIos, portName ) const script = { id: 0, isReboot: false, sendResult: false, send_result: false, title: 'Load License Router', timeout: 1000, body: JSON.stringify(body), } await this.runScript(script as any, userName) await this.sendEmailLoadLicense(licenseFileName, startTime) } /** * Detect log by call api gpt, return string[] */ async detectShowEnvWithAI(log: string) { try { // Get prompt from database const promptRecord = await PromptAi.findBy('type', 'env') if (!promptRecord) { console.log('[ERROR] Prompt ENV not found in database') return '' } const payload = { model: 'gpt-4o-mini', max_tokens: 1000, messages: [ { role: 'user', content: `${promptRecord.content} Return ONLY a valid JSON array of strings. Here is the log: ${log}`, }, ], } const remoteUrl = process.env.ERP_URL_AUTH || 'https://stage.nswteam.net' const remoteResp = await axios.post( remoteUrl + '/api/transferPostData', { urlAPI: '/api/open-ai-sfp/model-image-info', data: payload, }, { headers: { Authorization: 'Bearer ' + process.env.ERP_TOKEN, }, } ) return remoteResp.data?.Status === 'OK' ? remoteResp.data?.data : '' } catch (error: any) { console.log('[ERROR] Detect log show env from AI', error) } return '' } /** * Check config RAM and Flash, if higher config will send report */ async checkConfigRam(mem: string, flash: string, pid: string, output: string) { const configRam = await detectConfigRamByModel(pid) if (configRam) { const isWarningRAM = isRamSufficient(mem, configRam.ram) const isWarningFlash = isRamSufficient(flash, configRam.flash) if (isWarningRAM || isWarningFlash) { const subject = `[ATC] - [${this.config.stationName} - Line: ${this.config.lineNumber}] - Warning RAM, Flash Configuration` const body = `

Station: ${this.config.stationName}

Line: ${this.config.lineNumber}

Model: ${pid}

RAM: ${mem ? `${convertFromKilobytesString(mem)} (default: ${configRam.ram})` : ''}

FLASH: ${flash ? `${convertFromKilobytesString(flash)} (default: ${configRam.flash})` : ''}


${escapeHtml(output) .replace('show ver', '') .replace('sh ver', '') .replace('show version', '') .replace('sh version', '') .replace(mem, `${mem}`) .replace( flash, `${flash}` )}
` await sendMessageToMail(subject, body) } } } /** * Send list feature tested */ sendFeatureTested = async () => { this.socketIO.emit('feature_tested', { stationId: this.config.stationId, lineId: this.config.id, listFeatureTested: this.config.listFeatureTested, isSkipPhysical: this.config.isSkipPhysical, reasonSkipPhysical: this.config.reasonSkipPhysical, pid: this.config.inventory?.pid, sn: this.config.inventory?.sn, vid: this.config.inventory?.vid, }) } /** * Send summary of all report (DPELP, Physical Testing) */ sendReportSummary = async (snapshot?: { snapConfig: LineConfig snapPhysical: PhysicalPortTest reason: string }) => { if (this.debounceSendSummaryReport) clearTimeout(this.debounceSendSummaryReport) const physicalTest = snapshot?.snapPhysical ? snapshot?.snapPhysical : this.physicalTest const config = snapshot?.snapConfig ? snapshot?.snapConfig : this.config const portPhysical = Array.from(physicalTest.ports.values()) const missing = portPhysical.filter((p) => !p.tested) const missingPoE = missing.filter((p) => !p.name.includes('SFP')) const missingSFP = missing.filter((p) => p.name.includes('SFP')) const tested = portPhysical.filter((p) => p.tested) const testedPoE = tested.filter((p) => !p.name.includes('SFP')) const testedSFP = tested.filter((p) => p.name.includes('SFP')) const showVersion = config?.data?.find( (d) => d.command?.trim()?.includes('show ver') || d.command?.trim()?.includes('sh ver') ) const dataShowVersion = showVersion?.textfsm && showVersion?.textfsm?.[0] ? showVersion?.textfsm?.[0] : config?.inventory const showLicense = config?.data?.find( (d) => d.command?.trim()?.includes('show lic') || d.command?.trim()?.includes('sh lic') ) const dataShowLic = showLicense?.textfsm && Array.isArray(showLicense?.textfsm) ? showLicense?.textfsm : null const issue = config?.latestScenario?.detectAI?.issue || [] const summary = config?.latestScenario?.detectAI?.summary || '' const reason = this.config.reasonSkipPhysical || snapshot?.reason const reasonSkipPhysical = typeof reason === 'string' && reason.trim().length > 0 ? `
User Skip Test Port
────────────────────────────────
${reason}` : '' const body = `
DPELP Physical Testing
Model: ${config?.inventory?.pid ?? ''} ${config?.inventory?.vid ?? ''}
Serial Number: ${config?.inventory?.sn ?? ''}
MAC: ${dataShowVersion?.MAC_ADDRESS ?? ''}
IOS: ${dataShowVersion?.SOFTWARE_IMAGE ?? ''} ${dataShowVersion?.VERSION ?? ''}
MEM: ${dataShowVersion?.MEMORY ? convertFromKilobytesString(dataShowVersion?.MEMORY) : ''}
FLASH: ${dataShowVersion?.USB_FLASH ? convertFromKilobytesString(dataShowVersion?.USB_FLASH) : ''}
Licenses: ${dataShowLic ? dataShowLic ?.filter((el) => el.LICENSE_TYPE?.toLowerCase()?.includes('permanent')) ?.map((v) => v.FEATURE) ?.join(', ') : '' }
Detect from AI: ${issue?.length ? `
- ` + issue.join(`
`) : 'No issues detected.'}

Total Ports: ${portPhysical?.length}
Ports Tested (Link UP): ${tested.length} (${testedPoE?.length} PoE, ${testedSFP?.length} SFP)
Ports Missing/Down: ${missing.length}
${missingPoE?.length ? `
Ports Missing PoE
────────────────────────────────
${missingPoE.map((p) => physicalTest.normalizePortName(p.name)).join('
')}
` : '' } ${missingSFP?.length ? `
Ports Missing SFP
────────────────────────────────
${missingSFP.map((p) => physicalTest.normalizePortName(p.name)).join('
')}
` : '' } ${reasonSkipPhysical}
` this.updateNote(config?.inventory?.sn, this.dataDPELP as DataDPELP) await sendMessageToMail( `[ATC] - [${config.stationName} - Line: ${config.lineNumber}] - [${this.config.inventory?.pid}] - [${this.config.inventory?.sn}] - Summary of Testing Results`, body ) this.socketIO.emit('summary_tested', { stationId: this.config.stationId, lineId: this.config.id, body: body, title: `[${config.stationName} - Line: ${config.lineNumber}] - Summary of Testing Results`, }) } /** * Send summary report using the new "Equipment Receiving & Testing Report" template. * Email-safe HTML: table-based layout, inline styles, no external CSS or web fonts. */ sendReportSummaryV2 = async (snapshot?: { snapConfig: LineConfig snapPhysical: PhysicalPortTest reason: string }) => { if (this.debounceSendSummaryReport) clearTimeout(this.debounceSendSummaryReport) const timeZone = process.env.TIME_ZONE || 'Australia/Sydney' const physicalTest = snapshot?.snapPhysical ? snapshot?.snapPhysical : this.physicalTest const config = snapshot?.snapConfig ? snapshot?.snapConfig : this.config const portPhysical = Array.from(physicalTest.ports.values()) const missing = portPhysical.filter((p) => !p.tested) const missingPoE = missing.filter((p) => !p.name.includes('SFP')) const missingSFP = missing.filter((p) => p.name.includes('SFP')) const tested = portPhysical.filter((p) => p.tested) const testedPoE = tested.filter((p) => !p.name.includes('SFP')) const testedSFP = tested.filter((p) => p.name.includes('SFP')) const totalPoE = testedPoE.length + missingPoE.length const totalSFP = testedSFP.length + missingSFP.length const dataIncomingBySN = await getIncomingInfoBySN(config?.inventory?.sn) const serialInfo = dataIncomingBySN?.serialNumbersInfo?.find( (s: any) => s.serialNumberA === config?.inventory?.sn ) const listImages = dataIncomingBySN?.packagePo?.listFiles?.filter( (s: any) => s.kind === 'other' ) const showVersion = config?.data?.find( (d) => d.command?.trim()?.includes('show ver') || d.command?.trim()?.includes('sh ver') ) const dataShowVersion = showVersion?.textfsm && (showVersion?.textfsm as any)?.[0] ? (showVersion?.textfsm as any)?.[0] : config?.inventory const showLicense = config?.data?.find( (d) => d.command?.trim()?.includes('show lic') || d.command?.trim()?.includes('sh lic') ) const dataShowLic = showLicense?.textfsm && Array.isArray(showLicense?.textfsm) ? (showLicense?.textfsm as any[]) : null const issues: string[] = config?.latestScenario?.detectAI?.issue || [] const skipReason = this.config.reasonSkipPhysical || snapshot?.reason || '' const isSkipped = typeof skipReason === 'string' && skipReason.trim().length > 0 const aiIssue = issues.length > 0 ? issues.join('\n') : '' let summaryStatus = 'PASS' const match = aiIssue.match(/RESULT:\s*(PASS WITH WARNING|PASS|FAIL|INSUFFICIENT DATA)/im) if (match) { const status = match[1] summaryStatus = status } // Verdict based on both physical tests & AI analysis const physicalPass = missing.length === 0 && !isSkipped const aiPass = summaryStatus === 'PASS' || summaryStatus === 'PASS WITH WARNING' const verdictPass = physicalPass && aiPass // Determine verdict status & messaging based on failures let verdictLabel = 'PASSED' let verdictMsg = 'All tests passed' let verdictBg = '#ecfdf5' let verdictBd = '#a7f3d0' let verdictTx = '#065f46' if (!physicalPass && !aiPass) { verdictLabel = 'CRITICAL ISSUES' verdictMsg = 'Physical failures + AI detected problems' verdictBg = '#fef2f2' verdictBd = '#fecaca' verdictTx = '#991b1b' } else if (!physicalPass) { verdictLabel = 'PHYSICAL INCOMPLETE' verdictMsg = `${missing.length} port(s) untested${isSkipped ? ' — testing skipped' : ''}` verdictBg = '#fef2f2' verdictBd = '#fecaca' verdictTx = '#991b1b' } else if (!aiPass) { verdictLabel = `AI: ${summaryStatus}` verdictMsg = summaryStatus === 'FAIL' ? 'AI analysis failed — review required' : 'AI detected warnings — verify results' verdictBg = summaryStatus === 'FAIL' ? '#fef2f2' : '#fffbeb' verdictBd = summaryStatus === 'FAIL' ? '#fecaca' : '#fde68a' verdictTx = summaryStatus === 'FAIL' ? '#991b1b' : '#92400e' } const reportId = `RPT-${momentTZ().tz(timeZone).format('YYYY-MMDD')}` const reportDate = momentTZ().tz(timeZone).format('DD MMM YYYY') const memText = dataShowVersion?.MEMORY ? convertFromKilobytesString(dataShowVersion.MEMORY) : '—' const flashText = dataShowVersion?.USB_FLASH ? convertFromKilobytesString(dataShowVersion.USB_FLASH) : '—' // ---- Template-fallback values (use file's hardcoded content when no real data) ---- const productName = escapeHtml(String(config?.inventory?.name || '')) const productPN = escapeHtml(String(config?.inventory?.pid || '')) const productSN = escapeHtml(String(config?.inventory?.sn || '')) const productVid = escapeHtml(String(config?.inventory?.vid || '')) const iosName = escapeHtml(String(dataShowVersion?.SOFTWARE_IMAGE || '')) const iosVersion = escapeHtml(String(dataShowVersion?.VERSION || '')) const macAddress = escapeHtml(String(dataShowVersion?.MAC_ADDRESS || '')) const memDisplay = escapeHtml(memText !== '—' ? memText : '-') const flashDisplay = escapeHtml(flashText !== '—' ? flashText : '-') const configRam = await detectConfigRamByModel(config?.inventory?.pid) // AI issue rows (one per real AI issue, fall back to file's hardcoded row when none) const aiIssueRowsHtml = issues.length > 0 ? issues.length > 1 ? issues .slice(0, 1) .map( (issue) => `
★ AI${escapeHtml(issue)}
` ) .join('') : `
★ AI${escapeHtml(issues[0].split('\n')[0] || '')}
` : `` // License boxes (real licenses if available, else file's hardcoded boxes) const licenseBoxesHtml = dataShowLic && dataShowLic.length > 0 ? dataShowLic .filter((l) => l.LICENSE_TYPE && l.FEATURE) .map( (l: any) => `
${escapeHtml(String(l.FEATURE || ''))}
${escapeHtml(String(l.LICENSE_TYPE || ''))}${l.STATUS ? ' · ' + escapeHtml(String(l.STATUS)) : ''}
` ) .join('') : `` // Port stat values (real numbers if any port data, else file's defaults) const hasPortData = portPhysical.length > 0 const poeText = hasPortData ? `${testedPoE.length}/${totalPoE}` : '0/0' const sfpText = hasPortData ? `${testedSFP.length}/${totalSFP}` : '0/0' const poeColor = !hasPortData || (totalPoE > 0 && testedPoE.length === totalPoE) ? '#10b981' : '#f59e0b' const sfpColor = !hasPortData || (totalSFP > 0 && testedSFP.length === totalSFP) ? '#10b981' : '#f59e0b' // Missing-port detail blocks (only when there is something to show) const missingParts: string[] = [] if (missingPoE.length) { missingParts.push( `
Missing PoE (${missingPoE.length}):
${missingPoE.map((p) => escapeHtml(physicalTest.normalizePortName(p.name))).join(', ')}
` ) } if (missingSFP.length) { missingParts.push( `
Missing SFP (${missingSFP.length}):
${missingSFP.map((p) => escapeHtml(physicalTest.normalizePortName(p.name))).join(', ')}
` ) } if (isSkipped) { missingParts.push( `
User Skipped Physical Test:
${escapeHtml(skipReason)}
` ) } const missingDetailsHtml = missingParts.join('') // Verdict checkmark / cross path const verdictPathSvg = verdictPass ? '' : '' // Physical Check checklist const checklistItems: Array<[string, string]> = [ [ serialInfo?.optionVisualInspection?.statusChassis ? 'ok' : 'warn', serialInfo?.optionVisualInspection?.statusChassis ? 'Chassis / Overall - Checked' : 'Chassis / Overall - Unchecked', ], [ serialInfo?.optionVisualInspection?.statusPortsPOE ? 'ok' : 'warn', serialInfo?.optionVisualInspection?.statusPortsPOE ? 'Ports - Checked' : 'Ports - Unchecked', ], ] const checklistRowsHtml = checklistItems .map(([k, t]) => k === 'ok' ? `
${t}
` : `
!${t}
` ) .join('') // Physical Check photo placeholder cell (4 of these in the photo grid) const photoCellHtml = (label: string) => `
${label}
` // Photo cell with actual image const imageCellHtml = (url: string, label: string) => `
` // Prepare image grid: get first 4 images from listImages if available const imageList = listImages && Array.isArray(listImages) ? listImages.slice(0, 4) : [] const imageLabels = ['Front', 'Rear', 'S/N Label', 'Package'] const getPhotoCell = (idx: number) => { const image = imageList[idx] const label = imageLabels[idx] return image && image.url ? imageCellHtml(process.env.ERP_URL_AUTH + image.url, label) : photoCellHtml(label) } const photoGridRowsHtml = ` ${getPhotoCell(0)} ${getPhotoCell(1)} ${getPhotoCell(2)} ${getPhotoCell(3)} ` // Helper function to highlight SNs from listInventory in outputTestLog const highlightSnInConsoleOutput = (text: string, listInventory: any[] | undefined) => { if (!text || !listInventory || listInventory.length === 0) { return escapeHtml(text || 'No test log available') } let result = escapeHtml(text) const snList = listInventory.map((item) => item.sn).filter((sn) => sn) // Sort by length descending to match longest SNs first (avoid partial matches) snList.sort((a, b) => b.length - a.length) snList.forEach((sn) => { if (sn) { // Create a regex that matches the SN as a whole word/token const regex = new RegExp(`\\b${sn.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}\\b`, 'g') result = result.replace( regex, `${escapeHtml(sn)}` ) } }) return result } // ---- Body: full template mirroring index.html, table-based + inline styles ---- const body = ` Equipment Report — Mail Summary
PROLOGY IT Equipment Receiving & Testing Report
${escapeHtml(reportDate)}
${verdictPathSvg} ${verdictLabel} ${escapeHtml(verdictMsg)}
Product Info
Name${productName}
P/N${productPN}
S/N${productSN}
MAC${macAddress || '-'}
Cond.${serialInfo?.condition || '-'}
Supplier${serialInfo?.supplier?.name || '-'}
Technical Specs
Specification Actual Default
IOS-XE Version ${iosName} ${'N/A'}
System RAM ${memDisplay} ${configRam?.ram || 'N/A'}
Flash Storage ${flashDisplay} ${configRam?.flash || 'N/A'}
Issues Found
${aiIssueRowsHtml}
Receiving & Inspection Notes
${dataIncomingBySN?.packagePo?.receivedBy?.fullName || 'Unknown'} · ${dataIncomingBySN?.packagePo?.receivedDate ? momentTZ(dataIncomingBySN?.packagePo?.receivedDate).tz(timeZone).format('DD MMM, HH:mm') : ''}
⚠ Warning from Warehouse

${dataIncomingBySN?.packagePo?.notes || ''}

${serialInfo?.notes || ''}

${!dataIncomingBySN?.packagePo?.notes && !serialInfo?.notes ? '

No notes available.

' : ''}
Accessory Checklist
Rackmount PSU (Internal) Console Cable Documents Original Box
Not Available
 
Received
${dataIncomingBySN?.packagePo?.receivedBy?.fullName || 'Unknown'}
${dataIncomingBySN?.packagePo?.receivedDate ? momentTZ(dataIncomingBySN?.packagePo?.receivedDate).tz(timeZone).format('DD MMM YYYY, HH:mm') : ''}
Visual Check
${dataIncomingBySN?.packagePo?.receivedBy?.fullName || 'Unknown'}
${dataIncomingBySN?.packagePo?.receivedDate ? momentTZ(dataIncomingBySN?.packagePo?.receivedDate).tz(timeZone).format('DD MMM YYYY, HH:mm') : ''}
Software Test
${this?.userTest?.dpelp?.name || 'Unknown'}
${momentTZ(this?.userTest?.dpelp?.time).tz(timeZone).format('DD MMM YYYY, HH:mm')}
  Detail  
Visual Check ${dataIncomingBySN?.packagePo?.receivedBy?.fullName || 'Unknown'} · ${dataIncomingBySN?.packagePo?.receivedDate ? momentTZ(dataIncomingBySN?.packagePo?.receivedDate).tz(timeZone).format('DD MMM YYYY, HH:mm') : ''}
${photoGridRowsHtml}
${checklistRowsHtml}
Software Check ${this?.userTest?.dpelp?.name || ''} · ${momentTZ(this?.userTest?.dpelp?.time).tz(timeZone).format('DD MMM YYYY, HH:mm')}
Hardware Inventory
${this.config?.inventory?.listInventory ?.map( (item: any) => ` ` ) .join('') || '' }
${item.pid}${item.sn}
System & License
${licenseBoxesHtml}
Port Test Summary
${escapeHtml(poeText)}
${hasPortData ? 'PoE UP' : 'GigE UP'}
${escapeHtml(sfpText)}
SFP+ UP
${missingSFP.length > 0 || missingPoE.length > 0 ? 'WARN' : 'PASS'}
PoE+ Test
${missingDetailsHtml}
CONSOLE RAW OUTPUT (Boot Log snippet)
${highlightSnInConsoleOutput(this?.outputTestLog, this.config?.inventory?.listInventory)}
Prology IT — Equipment QA System · Confidential — Internal Use Only
` // Save report to file (storage/report_sn/{SN}.html) const reportSN = config?.inventory?.sn if (reportSN) { const reportDir = path.join(process.cwd(), 'storage', 'report_sn') try { if (!fs.existsSync(reportDir)) { fs.mkdirSync(reportDir, { recursive: true }) } const reportPath = path.join(reportDir, `${reportSN}.html`) fs.writeFileSync(reportPath, body, 'utf-8') } catch (err) { console.error(`Failed to save report for SN ${reportSN}:`, err) } } this.addHistory( this.config.stationId, this.config.id, { id: this.config.id, number: this.config.lineNumber, stationId: this.config.stationId, pid: productPN, sn: productSN, vid: productVid, scenario: '', timestamp: Date.now(), }, this.outputTestLog, portPhysical ) this.updateNote(config?.inventory?.sn, this.dataDPELP as DataDPELP) await sendMessageToMail( `[ATC] - [${config.stationName} - L${config.lineNumber}] - ${this.config.inventory?.pid} - ${this.config.inventory?.sn} - Test Summary`, body ) this.outputTestLog = '' this.socketIO.emit('summary_tested', { stationId: this.config.stationId, lineId: this.config.id, body: body, title: `[ATC] - [${config.stationName} - L${config.lineNumber}] - ${this.config.inventory?.pid} - ${this.config.inventory?.sn} - Test Summary`, }) } /** * Reset config information of line */ initConfig() { this.config = { id: 0, port: 0, lineNumber: 0, ip: '', stationId: 0, stationName: '', stationIp: '', outlet: 0, output: '', status: '', baud: 0, openCLI: false, userEmailOpenCLI: '', userOpenCLI: '', inventory: [], data: [], ports: [], runningScenario: '', runningPhysical: false, listFeatureTested: [], isReady: false, } this.physicalTest = new PhysicalPortTest([]) } setTimeoutSendSummaryReport(timeout: number) { // Debounce send summary report if (this.debounceSendSummaryReport) clearTimeout(this.debounceSendSummaryReport) // Snapshot toàn bộ data tại thời điểm này const snapshot = { snapConfig: this.config, snapPhysical: this.physicalTest, reason: '', } this.debounceSendSummaryReport = setTimeout(() => { if (!this.config.listFeatureTested?.includes('PHYSICAL')) { this.config.isSkipPhysical = true this.config.reasonSkipPhysical = 'Timeout, The user has not completed the physical test' snapshot.reason = 'Timeout, The user has not completed the physical test' } this.config.listFeatureTested = ['DPELP', 'PHYSICAL', 'SUMMARY'] this.sendFeatureTested() this.sendReportSummaryV2(snapshot) }, timeout) } resetDPELP() { this.config.listFeatureTested = [] this.config.isSkipPhysical = false this.config.reasonSkipPhysical = '' this.dataDPELP = '' this.sendFeatureTested() console.log('Reset DPELP data and features', this.config.id, this.config.listFeatureTested) } async pingToServer(serverIP: string) { this.isPingToServer = true this.writeCommand('\r\n') this.writeCommand('enable\r\n') await sleep(500) this.writeCommand(`ping ${serverIP}\r\n`) await sleep(500) const start = Date.now() // console.log('[EXPECT]', expect, timeout) while (Date.now() - start < 60000) { if (this.outputPingToServer.includes('Success rate')) { const match = this.outputPingToServer.match(/Success rate is (\d+) percent/) if (match) { const rate = Number(match[1]) if (rate > 0) { this.outputPingToServer = '' this.isPingToServer = false return true } else { this.isPingToServer = false this.outputPingToServer = '' this.config.output += '\n[CONNECT_TO_SERVER_TFTP_FAIL]\n' this.socketIO.emit('line_output', { stationId: this.config.stationId, lineId: this.config.id, data: '\n[CONNECT_TO_SERVER_TFTP_FAIL]\n', }) return false } } } await sleep(500) } this.isPingToServer = false this.outputPingToServer = '' this.config.output += '\n[CONNECT_TO_SERVER_TFTP_FAIL]\n' this.socketIO.emit('line_output', { stationId: this.config.stationId, lineId: this.config.id, data: '\n[CONNECT_TO_SERVER_TFTP_FAIL]\n', }) return false } /** * Config ip address and default gateway for line */ async configAddressGateway( address: string, gateway: string, portName: string, isRouter?: boolean ) { this.config.runningScenario = 'Config Network' this.socketIO.emit('running_scenario', { stationId: this.config.stationId, lineId: this.config.id, title: 'Config Network', }) await this.writeCommand(`enable\r\n`) await sleep(500) await this.writeCommand(`configure terminal\r\n`) await sleep(500) await this.writeCommand(`interface ${portName}\r\n`) await sleep(500) await this.writeCommand(`ip address ${address} 255.255.0.0\r\n`) await sleep(500) await this.writeCommand(`no shutdown\r\n`) await sleep(500) await this.writeCommand(`exit\r\n`) await sleep(500) await this.writeCommand( !isRouter ? `ip default-gateway ${gateway}\r\n` : `ip route 0.0.0.0 0.0.0.0 ${gateway}` ) await sleep(500) await this.writeCommand(`end\r\n`) await sleep(500) await this.writeCommand(`\r\n`) await sleep(1000) this.config.runningScenario = '' this.socketIO.emit('running_scenario', { stationId: this.config.stationId, lineId: this.config.id, title: '', }) } }