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, 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, 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) 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: '', }) 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.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(logScenarios) 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.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-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 || '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) { if (!item.pid || !item.sn) return const key = `station:${stationId}:line:${lineId}:history` const now = Date.now() const newItem = JSON.stringify({ ...item, timestamp: now, }) // LαΊ₯y phαΊ§n tα»­ cuα»‘i const lastItems = await redis.zrevrange(key, 0, 0) if (lastItems.length > 0) { const last = JSON.parse(lastItems[0]) if (last.pid === item.pid && last.sn === item.sn) { return false // khΓ΄ng thay Δ‘α»•i } } const line = await Line.find(lineId) if (line) { const listHistory = line.history ? JSON.parse(line.history) : [] listHistory.unshift(newItem) line.history = JSON.stringify(listHistory) await line.save() } // Add vΓ o ZSET await redis.zadd(key, now, newItem) // Tα»± Δ‘α»™ng xΓ³a item > 96h const expireTime = now - 96 * 60 * 60 * 1000 await redis.zremrangebyscore(key, 0, expireTime) 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(600000) 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 || '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 outputTestLog: string userTest: { dpelp: { name: string; time: number } physical: { name: string; time: number } } }) => { 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 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 verdictPass = missing.length === 0 && !isSkipped const verdictLabel = verdictPass ? 'PASSED' : 'NEEDS REVIEW' const verdictMsg = verdictPass ? 'All tests passed β€” Ready for deployment' : 'Issues detected β€” review required before deployment' const verdictBg = verdictPass ? '#ecfdf5' : '#fef2f2' const verdictBd = verdictPass ? '#a7f3d0' : '#fecaca' const verdictTx = verdictPass ? '#065f46' : '#991b1b' 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 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 .slice(0, 1) .map( (issue) => `
★ AI${escapeHtml(issue)}Investigate
` ) .join('') : `
★ AIPotential intermittent power instability. PSU #1 POST logs show 3 retries before handshake.Investigate
` // 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]> = [ ['ok', 'Packaging intact β€” no damage to box or foam'], ['ok', 'No physical damage β€” chassis, fans, PSU'], ['ok', `S/N matches label β€” ${productSN} verified`], ['ok', 'All 48 GigE + 4 SFP+ ports clean'], ['ok', 'Accessories β€” power cable, rack ears, console cable'], ['warn', 'Minor scratch on top chassis (2cm) β€” cosmetic only'], ] 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}
` // ---- Body: full template mirroring index.html, table-based + inline styles ---- const body = ` Equipment Report β€” Mail Summary
PROLOGY IT Equipment Receiving & Testing Report
#${escapeHtml(reportId)} ${escapeHtml(reportDate)}
${verdictPathSvg} ${verdictLabel} ${escapeHtml(verdictMsg)}
Product Info
Name${productName}
P/N${productPN}
S/N${productSN}
MAC${macAddress}
Type--
Cond.--
Supplier-
Warranty-
Technical Specs
Specification Actual Default
IOS-XE Version ${iosVersion} -
System RAM ${memDisplay} ${configRam?.ram || '-'}
Flash Storage ${flashDisplay} ${configRam?.flash || '-'}
Uplink Module - -
PSU Model - -
PoE Budget - -
Issues Found
${aiIssueRowsHtml}
COSMETICMinor scratch on top chassis (2cm) β€” non-functional Accepted
MINORFan #2 at 48dB under stress (spec 45dB) β€” within rack tolerance Monitor
0 Critical Β· 0 Major Β· 1 Minor Β· 1 Cosmetic
Receiving & Inspection Notes
⚠ Warning from Warehouse

Box arrived with slight indentation on the left corner. Internal foam was still intact. Serial number on box was partially obscured by shipping label but verified upon unboxing.

Accessory Checklist
Rackmount PSU (Internal) Console Cable Documents Original Box
 
Received
Unknown
${momentTZ().tz(timeZone).format('DD MMM')}
Software Test
${snapshot?.userTest?.dpelp?.name || ''}
${momentTZ(snapshot?.userTest?.dpelp?.time).tz(timeZone).format('DD MMM, HH:mm')}
Physical Check
${snapshot?.userTest?.physical?.name || ''}
${momentTZ(snapshot?.userTest?.physical?.time).tz(timeZone).format('DD MMM, HH:mm')}
  Detail  
Physical Check ${snapshot?.userTest?.physical?.name || ''} Β· ${momentTZ(snapshot?.userTest?.physical?.time).tz(timeZone).format('DD MMM, HH:mm')}
${photoCellHtml('Front')} ${photoCellHtml('Rear')}
${photoCellHtml('S/N Label')} ${photoCellHtml('Package')}
${checklistRowsHtml}
Software Check ${snapshot?.userTest?.dpelp?.name || ''} Β· ${momentTZ(snapshot?.userTest?.dpelp?.time).tz(timeZone).format('DD MMM, 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
${totalPoE + totalSFP === 0 ? 100 : Math.round(((totalPoE + totalSFP - (missingPoE.length + missingSFP.length)) / (totalPoE + totalSFP)) * 100)}%
Throughput
${missingDetailsHtml}
CONSOLE RAW OUTPUT (Boot Log snippet)
${snapshot?.outputTestLog || 'No test log available'}
Prology IT β€” Equipment QA System Β· Confidential β€” Internal Use Only
` 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`, }) } /** * 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: '', outputTestLog: this.outputTestLog, userTest: this.userTest, } 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) this.outputTestLog = '' this.userTest = { dpelp: { name: '', time: 0 }, physical: { name: '', time: 0 } } }, 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: '', }) } }