ATC_SIMPLE/BACKEND/app/services/line_connection.ts

2661 lines
106 KiB
TypeScript

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, 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<void>((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<ArrayBuffer>) {
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(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.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<boolean> {
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 || '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,
},
}
)
console.log('AI detect log response:', remoteResp.data)
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 +
`${`
<hr />
<p>Logs:</p>
<div style="white-space: break-spaces; background-color: #f5f5f5; color: black; padding: 8px; max-height: 500px; overflow-y: scroll; border: 1px #ccc solid;"><span style="color: black;">
${this.bufferLog.allBuffer}</span></div>`}`
)
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 `<p style="color: green;">No errors detected</p>`
}
const header = `
<tr>
<th style="padding:6px; text-align:center;">Level</th>
<th style="padding:6px; text-align:center;">Rule</th>
<th style="padding:6px; text-align:center;">Message</th>
<th style="padding:6px; width:1000px;">Log Evidence</th>
</tr>
`
const body = rows
.map(
(r) => `
<tr>
<td style="padding:6px; text-align:center;">
<span style="color:${r.level === 'FAIL' ? 'red' : 'orange'};">${r.level}</span>
</td>
<td style="padding:6px; text-align:center;">${r.rule}</td>
<td style="padding:6px; text-align:center;">${r.message}</td>
<td style="padding:6px; font-family:monospace;">${escapeHtml(r.log.trim())
.split('*')
.filter((el) => el)
.join('<br/>*')}</td>
</tr>
`
)
.join('')
return `
<table border="1" cellpadding="6" style="border-collapse: collapse; width:100%;">
${header}
${body}
</table>
`
}
/**
* Return a body email
*/
buildEmailContent(result: TestResult): string {
const rows = mapErrorsToRows(result.errors)
const table = this.renderErrorTable(rows)
return `
<h3>Cisco Device Log Result</h3>
<p>Line: <b>${this.config.lineNumber}</b> - Station: <b>${this.config.stationName}</b></p>
<p><b>Summary:</b> ${result.summary}</p>
<hr />
${table}
<br />
`
}
/**
* 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<string[]> {
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
? `<b style="color: #ff0000;">User Skip Test Port</b><br/>
────────────────────────────────<br/>
${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<br/>
────────────────────────────────<br/>
Station : <b>${this.config.stationName}</b><br/>
Line : <b>${this.config.lineNumber}</b><br/>
IOS : <b>${nameIos}</b> <br/>
Started At : ${startTime}<br/>
Finished At : ${dataFormat}<br/>
<br/>
`.trim()
await sendMessageToMail(
`[ATC] - [${this.config.stationName} - Line: ${this.config.lineNumber}] - Load IOS Report`,
body +
`${`
<hr />
<p>Logs:</p>
<div style="white-space: break-spaces; background-color: #f5f5f5; color: black; padding: 8px; max-height: 500px; overflow-y: scroll; border: 1px #ccc solid;"><span style="color: black;">
${this.outputLoadIosLicense}</span></div>`}`
)
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<br/>
────────────────────────────────<br/>
Station : <b>${this.config.stationName}</b><br/>
Line : <b>${this.config.lineNumber}</b><br/>
License : <b>${nameLicense}</b> <br/>
Started At : ${startTime}<br/>
Finished At : ${dataFormat}<br/>
────────────────────────────────<br/>
Summary licenses: <b>${report.summary.join(', ')}</b><br/>
Successful: <b>${report.imported.join(', ')}</b><br/>
Exist: <b>${report.exist.join(', ')}</b><br/>
Failed: <b>${report.failed.join(', ')}</b><br/>
<br/>
`.trim()
await sendMessageToMail(
`[ATC] - [${this.config.stationName} - Line: ${this.config.lineNumber}] - Load License Report`,
body +
`${`
<hr />
<p>Logs:</p>
<div style="white-space: break-spaces; background-color: #f5f5f5; color: black; padding: 8px; max-height: 500px; overflow-y: scroll; border: 1px #ccc solid;"><span style="color: black;">
${this.outputLoadIosLicense}</span></div>`}`
)
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 = `
<p>Station: <b>${this.config.stationName}</b></p>
<p>Line: <b>${this.config.lineNumber}</b></p>
<p>Model: <b>${pid}</b></p>
<p>RAM: ${mem ? `<b>${convertFromKilobytesString(mem)} (<span style="color: ${isWarningRAM ? 'red' : 'black'};">default: ${configRam.ram}</span>)</b>` : ''}</p>
<p>FLASH: ${flash ? `<b>${convertFromKilobytesString(flash)} (<span style="color: ${isWarningFlash ? 'red' : 'black'};">default: ${configRam.flash}</span>)</b>` : ''}</p>
<hr />
<div style="white-space: break-spaces; background-color: #f5f5f5; color: black; padding: 8px; max-height: 500px; overflow-y: scroll; border: 1px #ccc solid;"><span style="color: black;">
${escapeHtml(output)
.replace('show ver', '')
.replace('sh ver', '')
.replace('show version', '')
.replace('sh version', '')
.replace(mem, `<span style="color: ${isWarningRAM ? 'red' : 'black'};">${mem}</span>`)
.replace(
flash,
`<span style="color: ${isWarningFlash ? 'red' : 'black'};">${flash}</span>`
)}</span></div>
`
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
? `<br/><b style="color: #ff0000;">User Skip Test Port</b><br/>
────────────────────────────────<br/>
${reason}`
: ''
const body = `<table cellpadding="6" cellspacing="0" border="1" style="margin-top: 10px; border-collapse: collapse; width: 100%">
<tr>
<td style="width: 600px; text-align: center;">DPELP</td>
<td style="text-align: center;">Physical Testing</td>
</tr>
<tr>
<td>
Model: <b>${config?.inventory?.pid ?? ''}</b> <b>${config?.inventory?.vid ?? ''}</b><br/>
Serial Number: <b>${config?.inventory?.sn ?? ''}</b><br/>
MAC: <b>${dataShowVersion?.MAC_ADDRESS ?? ''}</b><br/>
IOS: <b>${dataShowVersion?.SOFTWARE_IMAGE ?? ''}</b> <b>${dataShowVersion?.VERSION ?? ''}</b><br/>
MEM: <b>${dataShowVersion?.MEMORY ? convertFromKilobytesString(dataShowVersion?.MEMORY) : ''}</b><br/>
FLASH: <b>${dataShowVersion?.USB_FLASH ? convertFromKilobytesString(dataShowVersion?.USB_FLASH) : ''}</b><br/>
Licenses: <b>${
dataShowLic
? dataShowLic
?.filter((el) => el.LICENSE_TYPE?.toLowerCase()?.includes('permanent'))
?.map((v) => v.FEATURE)
?.join(', ')
: ''
}</b><br/>
Detect from AI: <b style="color: 'black';">${issue?.length ? `<br>- ` + issue.join(`<br>`) : 'No issues detected.'}</b><br/>
</td>
<td>
Total Ports: ${portPhysical?.length}<br/>
Ports Tested (Link UP): <b style="color: #008000;">${tested.length} (${testedPoE?.length} PoE, ${testedSFP?.length} SFP)</b><br/>
Ports Missing/Down: <b style="color: #ff0000;">${missing.length}</b><br/>
${
missingPoE?.length
? `
<br/><b style="color: #ff0000;">Ports Missing PoE</b><br/>
────────────────────────────────<br/>
<div style="column-count: 6;">${missingPoE.map((p) => physicalTest.normalizePortName(p.name)).join('<br/>')}</div>
`
: ''
}
${
missingSFP?.length
? `
<br/><b style="color: #ff0000;">Ports Missing SFP</b><br/>
────────────────────────────────<br/>
<div style="column-count: 6;">${missingSFP.map((p) => physicalTest.normalizePortName(p.name)).join('<br/>')}</div>`
: ''
}
${reasonSkipPhysical}
</td>
</tr>
</table>`
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
.slice(0, 1)
.map(
(issue) =>
`<table cellpadding="0" cellspacing="0" border="0" width="100%" style="background:#f5f3ff;border:1px solid #c4b5fd;border-radius:6px;margin-bottom:5px;border-collapse:separate;"><tr><td style="padding:7px 12px;font-size:12px;color:#5f6978;font-weight:500;"><span style="display:inline-block;background:#7c3aed;color:#fff;font-size:9px;font-weight:700;letter-spacing:.5px;padding:2px 6px;border-radius:4px;vertical-align:middle;">&#9733; AI</span><span style="margin-left:8px;vertical-align:middle;">${escapeHtml(issue)}</span></td><td align="right" style="padding:7px 12px;width:90px;"></td></tr></table>`
)
.join('')
: ``
// 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) =>
`<div style="background:#f9fafb;border:1px solid #f0f1f3;border-radius:6px;padding:8px 12px;margin-bottom:6px;"><div style="font-weight:700;color:#3b82f6;font-size:13px;">${escapeHtml(String(l.FEATURE || ''))}</div><div style="font-size:10px;color:#9ca3af;">${escapeHtml(String(l.LICENSE_TYPE || ''))}${l.STATUS ? ' · ' + escapeHtml(String(l.STATUS)) : ''}</div></div>`
)
.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(
`<div style="margin-top:8px;padding:8px 12px;background:#fef2f2;border-left:3px solid #ef4444;border-radius:0 6px 6px 0;font-size:10px;color:#991b1b;"><b>Missing PoE (${missingPoE.length}):</b><br/><span style="font-family:Consolas,monospace;color:#5f6978;">${missingPoE.map((p) => escapeHtml(physicalTest.normalizePortName(p.name))).join(', ')}</span></div>`
)
}
if (missingSFP.length) {
missingParts.push(
`<div style="margin-top:6px;padding:8px 12px;background:#fef2f2;border-left:3px solid #ef4444;border-radius:0 6px 6px 0;font-size:10px;color:#991b1b;"><b>Missing SFP (${missingSFP.length}):</b><br/><span style="font-family:Consolas,monospace;color:#5f6978;">${missingSFP.map((p) => escapeHtml(physicalTest.normalizePortName(p.name))).join(', ')}</span></div>`
)
}
if (isSkipped) {
missingParts.push(
`<div style="margin-top:6px;padding:8px 12px;background:#fffbeb;border-left:3px solid #f59e0b;border-radius:0 6px 6px 0;font-size:10px;color:#92400e;"><b>User Skipped Physical Test:</b><br/>${escapeHtml(skipReason)}</div>`
)
}
const missingDetailsHtml = missingParts.join('')
// Verdict checkmark / cross path
const verdictPathSvg = verdictPass
? '<path d="M6.5 10l2.5 2.5 4.5-4.5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>'
: '<path d="M7 7l6 6M13 7l-6 6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>'
// 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'
? `<table cellpadding="0" cellspacing="0" border="0" width="100%" style="background:#ecfdf5;border:1px solid #a7f3d0;border-radius:6px;margin-bottom:4px;border-collapse:separate;"><tr><td style="padding:6px 10px;font-size:13px;font-weight:600;color:#065f46;"><span style="display:inline-block;width:18px;height:18px;background:#a7f3d0;color:#065f46;border-radius:50%;text-align:center;line-height:18px;font-size:11px;font-weight:800;vertical-align:middle;">&#10003;</span><span style="margin-left:8px;vertical-align:middle;">${t}</span></td></tr></table>`
: `<table cellpadding="0" cellspacing="0" border="0" width="100%" style="background:#fffbeb;border:1px solid #fde68a;border-radius:6px;margin-bottom:4px;border-collapse:separate;"><tr><td style="padding:6px 10px;font-size:13px;font-weight:600;color:#92400e;"><span style="display:inline-block;width:18px;height:18px;background:#fde68a;color:#92400e;border-radius:50%;text-align:center;line-height:18px;font-size:11px;font-weight:800;vertical-align:middle;">!</span><span style="margin-left:8px;vertical-align:middle;">${t}</span></td></tr></table>`
)
.join('')
// Physical Check photo placeholder cell (4 of these in the photo grid)
const photoCellHtml = (label: string) =>
`<table cellpadding="0" cellspacing="0" border="0" width="100%" style="border:1px dashed #e5e7eb;border-radius:6px;background:#f9fafb;border-collapse:separate;"><tr><td align="center" style="padding:18px 0;color:#9ca3af;"><svg viewBox="0 0 40 40" width="22" height="22" fill="none" style="display:inline-block;color:#9ca3af;"><rect x="4" y="8" width="32" height="24" rx="3" stroke="currentColor" stroke-width="1.5"/><circle cx="14" cy="18" r="3" stroke="currentColor" stroke-width="1.5"/><path d="M4 28l8-6 6 4 8-8 10 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg><div style="font-size:9px;font-weight:600;margin-top:3px;">${label}</div></td></tr></table>`
// Photo cell with actual image
const imageCellHtml = (url: string, label: string) =>
`<a href="${url}" target="_blank" style="display:block;text-decoration:none;"><table cellpadding="0" cellspacing="0" border="0" width="100%" style="border:1px solid #e5e7eb;border-radius:6px;background:#f9fafb;border-collapse:separate;overflow:hidden;height:120px;cursor:pointer;"><tr><td align="center" style="padding:0;background-size:cover;background-position:center;background-image:url('${url}');position:relative;"></td></tr></table></a>`
// 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 + image.url, label)
: photoCellHtml(label)
}
const photoGridRowsHtml = `
<tr>
<td width="50%" style="padding:0 3px 6px 0;">${getPhotoCell(0)}</td>
<td width="50%" style="padding:0 0 6px 3px;">${getPhotoCell(1)}</td>
</tr>
<tr>
<td width="50%" style="padding:0 3px 0 0;">${getPhotoCell(2)}</td>
<td width="50%" style="padding:0 0 0 3px;">${getPhotoCell(3)}</td>
</tr>`
// 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,
`<span id="${escapeHtml(sn)}" style="background-color:#fbbf24;color:#78350f;font-weight:600;padding:2px 6px;border-radius:3px;cursor:pointer;" title="Click Hardware Inventory link to scroll">${escapeHtml(sn)}</span>`
)
}
})
return result
}
// ---- Body: full template mirroring index.html, table-based + inline styles ----
const body = `<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8" />
<title>Equipment Report — Mail Summary</title>
</head>
<body style="margin:0;padding:24px 16px 48px;background:#f3f4f6;color:#1a1d23;font-family:Arial,Helvetica,sans-serif;font-size:13px;line-height:1.5;">
<!-- HEADER + VERDICT -->
<table align="center" cellpadding="0" cellspacing="0" border="0" width="100%" style="max-width:880px;margin:0 auto 12px;">
<tr><td>
<table cellpadding="0" cellspacing="0" border="0" width="100%" style="background:#fff;border:1px solid #e5e7eb;border-bottom:none;border-radius:10px 10px 0 0;border-collapse:separate;">
<tr><td style="padding:14px 20px;">
<table cellpadding="0" cellspacing="0" border="0" width="100%">
<tr>
<td style="vertical-align:middle;">
<table cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="background:#1e293b;border-radius:8px;width:34px;height:34px;text-align:center;vertical-align:middle;color:#cbd5e1;padding:6px;">
<svg viewBox="0 0 32 32" width="22" height="22" fill="none" style="display:block;margin:auto;color:#cbd5e1;"><rect x="2" y="6" width="28" height="20" rx="3" stroke="currentColor" stroke-width="2"/><path d="M8 16h16M8 12h10M8 20h6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><circle cx="24" cy="20" r="2" fill="currentColor"/></svg>
</td>
<td style="padding-left:10px;vertical-align:middle;">
<strong style="font-size:15px;letter-spacing:1.2px;display:block;">PROLOGY IT</strong>
<span style="font-size:11px;color:#5f6978;font-weight:500;">Equipment Receiving &amp; Testing Report</span>
</td>
</tr>
</table>
</td>
<td align="right" style="vertical-align:middle;">
<span style="font-size:11px;color:#9ca3af;">${escapeHtml(reportDate)}</span>
</td>
</tr>
</table>
</td></tr>
</table>
<table cellpadding="0" cellspacing="0" border="0" width="100%" style="background:${verdictBg};border:1px solid ${verdictBd};border-radius:0 0 10px 10px;border-collapse:separate;">
<tr><td style="padding:9px 20px;color:${verdictTx};font-size:12px;font-weight:600;">
<svg viewBox="0 0 20 20" width="18" height="18" fill="none" style="vertical-align:middle;color:${verdictTx};"><circle cx="10" cy="10" r="8" stroke="currentColor" stroke-width="1.8"/>${verdictPathSvg}</svg>
<b style="letter-spacing:.8px;vertical-align:middle;margin-left:8px;">${verdictLabel}</b>
<span style="opacity:.7;font-weight:500;vertical-align:middle;margin-left:8px;">${escapeHtml(verdictMsg)}</span>
</td></tr>
</table>
</td></tr>
</table>
<!-- MAIN -->
<table align="center" cellpadding="0" cellspacing="0" border="0" width="100%" style="max-width:880px;margin:0 auto;">
<!-- ZONE 1: AT-A-GLANCE — Product Info + Tech Specs -->
<tr><td style="padding-bottom:10px;">
<table cellpadding="0" cellspacing="0" border="0" width="100%">
<tr>
<td width="40%" valign="top" style="padding-right:5px;">
<table cellpadding="0" cellspacing="0" border="0" height="200px" width="100%" style="background:#fff;border:1px solid #e5e7eb;border-radius:10px;border-collapse:separate; font-size:14px;">
<tr><td style="padding:16px 20px;">
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.8px;color:#9ca3af;padding-bottom:7px;border-bottom:1px solid #f0f1f3;margin-bottom:8px;">Product Info</div>
<table cellpadding="0" cellspacing="0" border="0" width="100%">
<tr><td style="font-size:10px;font-weight:600;color:#9ca3af;text-transform:uppercase;letter-spacing:.4px;width:68px;padding:3px 8px 3px 0;white-space:nowrap;vertical-align:top;">Name</td><td style="padding:3px 0;font-weight:500;vertical-align:top;font-size:14px;"><strong>${productName}</strong></td></tr>
<tr><td style="font-size:10px;font-weight:600;color:#9ca3af;text-transform:uppercase;letter-spacing:.4px;padding:3px 8px 3px 0;white-space:nowrap;vertical-align:top;">P/N</td><td style="padding:3px 0;font-weight:500;vertical-align:top;font-size:12px;"><strong>${productPN}</strong></td></tr>
<tr><td style="font-size:10px;font-weight:600;color:#9ca3af;text-transform:uppercase;letter-spacing:.4px;padding:3px 8px 3px 0;white-space:nowrap;vertical-align:top;">S/N</td><td style="padding:3px 0;font-weight:500;vertical-align:top;font-size:12px;"><strong>${productSN}</strong></td></tr>
<tr><td style="font-size:10px;font-weight:600;color:#9ca3af;text-transform:uppercase;letter-spacing:.4px;padding:3px 8px 3px 0;white-space:nowrap;vertical-align:top;">MAC</td><td style="padding:3px 0;font-weight:500;vertical-align:top;font-size:12px;">${macAddress || '-'}</td></tr>
<tr><td style="font-size:10px;font-weight:600;color:#9ca3af;text-transform:uppercase;letter-spacing:.4px;padding:3px 8px 3px 0;white-space:nowrap;vertical-align:top;">Cond.</td><td style="padding:3px 0;font-weight:500;vertical-align:top;font-size:12px;">${serialInfo?.condition || '-'}</td></tr>
<tr><td style="font-size:10px;font-weight:600;color:#9ca3af;text-transform:uppercase;letter-spacing:.4px;padding:3px 8px 3px 0;white-space:nowrap;vertical-align:top;">Supplier</td><td style="padding:3px 0;font-weight:500;vertical-align:top;font-size:12px;">${serialInfo?.supplier?.name || '-'}</td></tr>
</table>
</td></tr>
</table>
</td>
<td width="60%" valign="top" style="padding-left:5px;">
<table cellpadding="0" cellspacing="0" border="0" height="200px" width="100%" style="background:#fff;border:1px solid #e5e7eb;border-radius:10px;border-collapse:separate;">
<tr><td style="padding:16px 20px;">
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.8px;color:#9ca3af;padding-bottom:7px;border-bottom:1px solid #f0f1f3;margin-bottom:8px;">Technical Specs</div>
<table cellpadding="0" cellspacing="0" border="0" width="100%" style="font-size:11px;">
<tr>
<th align="left" style="font-size:9px;text-transform:uppercase;color:#9ca3af;padding:0 0 6px 0;border-bottom:1px solid #f0f1f3;">Specification</th>
<th align="left" style="font-size:9px;text-transform:uppercase;color:#9ca3af;padding:0 0 6px 0;border-bottom:1px solid #f0f1f3;text-align:center;">Actual</th>
<th align="left" style="font-size:9px;text-transform:uppercase;color:#9ca3af;padding:0 0 6px 0;border-bottom:1px solid #f0f1f3;">Default</th>
</tr>
<tr>
<td style="padding:6px 0;border-bottom:1px dashed #f0f1f3;font-weight:600;color:#5f6978;">IOS-XE Version</td>
<td style="padding:6px 0;border-bottom:1px dashed #f0f1f3;font-weight:700;font-family:Consolas,monospace;">${iosName}</td>
<td style="padding:6px 0;border-bottom:1px dashed #f0f1f3;font-weight:500;color:#9ca3af;font-family:Consolas,monospace;font-style:italic;">${'N/A'}</td>
</tr>
<tr>
<td style="padding:6px 0;border-bottom:1px dashed #f0f1f3;font-weight:600;color:#5f6978;">System RAM</td>
<td style="padding:6px 0;border-bottom:1px dashed #f0f1f3;font-weight:700;font-family:Consolas,monospace;">${memDisplay}</td>
<td style="padding:6px 0;border-bottom:1px dashed #f0f1f3;font-weight:500;color:#9ca3af;font-family:Consolas,monospace; font-style:${configRam?.ram ? 'normal' : 'italic'};">${configRam?.ram || 'N/A'}</td>
</tr>
<tr>
<td style="padding:6px 0;border-bottom:1px dashed #f0f1f3;font-weight:600;color:#5f6978;">Flash Storage</td>
<td style="padding:6px 0;border-bottom:1px dashed #f0f1f3;font-weight:700;font-family:Consolas,monospace;">${flashDisplay}</td>
<td style="padding:6px 0;border-bottom:1px dashed #f0f1f3;font-weight:500;color:#9ca3af;font-family:Consolas,monospace; font-style:${configRam?.flash ? 'normal' : 'italic'};">${configRam?.flash || 'N/A'}</td>
</tr>
</table>
</td></tr>
</table>
</td>
</tr>
</table>
</td></tr>
<!-- Issues Found -->
<tr><td style="padding-bottom:10px;">
<table cellpadding="0" cellspacing="0" border="0" width="100%" style="background:#fff;border:1px solid #e5e7eb;border-radius:10px;border-collapse:separate;">
<tr><td style="padding:16px 20px;">
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.8px;color:#9ca3af;padding-bottom:7px;border-bottom:1px solid #f0f1f3;margin-bottom:10px;">Issues Found</div>
${aiIssueRowsHtml}
</td></tr>
</table>
</td></tr>
<!-- Receiving & Inspection Notes -->
<tr><td style="padding-bottom:10px;">
<table cellpadding="0" cellspacing="0" border="0" width="100%" style="background:#fff;border:1px solid #e5e7eb;border-radius:10px;border-collapse:separate;">
<tr><td style="padding:16px 20px;">
<div style="display:flex;justify-content:space-between; align-items:center;border-bottom:1px solid #f0f1f3;padding-bottom:10px;">
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.8px;color:#9ca3af;">Receiving &amp; Inspection Notes</div>
<div style="color:#222222;font-size:11px;font-weight:500;opacity:.65;">${dataIncomingBySN?.packagePo?.receivedBy?.fullName || 'Unknown'} · ${dataIncomingBySN?.packagePo?.receivedDate ? momentTZ(dataIncomingBySN?.packagePo?.receivedDate).tz(timeZone).format('DD MMM, HH:mm') : ''}</div>
</div>
<div style="padding:10px 14px;background:#fffbeb;border-left:3px solid #f59e0b;border-radius:0 6px 6px 0;font-size:12px;color:#92400e;margin-bottom:8px;">
<div style="font-weight:700;margin-bottom:4px;font-size:11px;">&#9888; Warning from Warehouse</div>
<p style="margin:0;">${dataIncomingBySN?.packagePo?.notes || ''}</p>
<p style="margin:0;">${serialInfo?.notes || ''}</p>
${!dataIncomingBySN?.packagePo?.notes && !serialInfo?.notes ? '<p style="margin:0;">No notes available.</p>' : ''}
</div>
<div style="padding:10px 14px;background:#f9fafb;border-left:3px solid #e5e7eb;border-radius:0 6px 6px 0;font-size:12px;color:#5f6978;">
<div style="font-weight:700;margin-bottom:4px;font-size:11px;">Accessory Checklist</div>
<table cellpadding="0" cellspacing="0" border="0" style="margin-top:6px;">
<tr style="display:none;">
<td style="padding:0 4px 0 0;"><span style="display:inline-block;padding:3px 10px;background:#f0fdf4;border:1px solid #bbf7d0;border-radius:4px;font-size:11px;font-weight:600;color:#166534;"><span style="display:inline-block;width:6px;height:6px;border-radius:50%;background:#10b981;margin-right:5px;vertical-align:middle;"></span>Rackmount</span></td>
<td style="padding:0 4px;"><span style="display:inline-block;padding:3px 10px;background:#f0fdf4;border:1px solid #bbf7d0;border-radius:4px;font-size:11px;font-weight:600;color:#166534;"><span style="display:inline-block;width:6px;height:6px;border-radius:50%;background:#10b981;margin-right:5px;vertical-align:middle;"></span>PSU (Internal)</span></td>
<td style="padding:0 4px;"><span style="display:inline-block;padding:3px 10px;background:#f0fdf4;border:1px solid #bbf7d0;border-radius:4px;font-size:11px;font-weight:600;color:#166534;"><span style="display:inline-block;width:6px;height:6px;border-radius:50%;background:#10b981;margin-right:5px;vertical-align:middle;"></span>Console Cable</span></td>
<td style="padding:0 4px;"><span style="display:inline-block;padding:3px 10px;background:#fef2f2;border:1px solid #fecaca;border-radius:4px;font-size:11px;font-weight:600;color:#991b1b;"><span style="display:inline-block;width:6px;height:6px;border-radius:50%;background:#ef4444;margin-right:5px;vertical-align:middle;"></span>Documents</span></td>
<td style="padding:0 0 0 4px;"><span style="display:inline-block;padding:3px 10px;background:#fef2f2;border:1px solid #fecaca;border-radius:4px;font-size:11px;font-weight:600;color:#991b1b;"><span style="display:inline-block;width:6px;height:6px;border-radius:50%;background:#ef4444;margin-right:5px;vertical-align:middle;"></span>Original Box</span></td>
</tr>
<tr>
<td style="padding:6px 0;border-bottom:1px dashed #f0f1f3;font-weight:600;color:#5f6978; font-size:11px; font-style:italic;">Not Available</td>
</tr>
</table>
</div>
</td></tr>
</table>
</td></tr>
<!-- Inspection Log Workflow -->
<tr><td style="padding-bottom:10px;">
<table cellpadding="0" cellspacing="0" border="0" width="100%" style="background:#fff;border:1px solid #e5e7eb;border-radius:10px;border-collapse:separate;">
<tr><td style="padding:16px 20px;">
<table cellpadding="0" cellspacing="0" border="0" width="100%">
<!-- line -->
<tbody style="position:relative; z-index:0;">
<tr>
<td colspan="3" style="padding:0 55px;">
<div style="height:2px;background:#e2e8f0;font-size:0;line-height:0; position:absolute; z-index:-1; width: 90%; top: 10px;">
&nbsp;
</div>
</td>
</tr>
<!-- steps -->
<tr>
<!-- Step 1 -->
<td width="33%" align="center" valign="top" style="padding:0 4px 4px 4px;">
<div style="display:inline-block;width:26px;height:26px;background:#fff;border:2px solid #10b981;border-radius:50%;color:#10b981;font-size:14px;font-weight:800;line-height:22px;text-align:center;margin-top:-14px;margin-bottom:8px;">
&#10003;
</div>
<div style="font-size:10px;font-weight:700;text-transform:uppercase;color:#5f6978;margin-bottom:2px;letter-spacing:0.5px;">
Received
</div>
<div style="font-size:11px;font-weight:600;color:#1a1d23;">
${dataIncomingBySN?.packagePo?.receivedBy?.fullName || 'Unknown'}
</div>
<div style="margin-top:4px;font-size:10px;color:#9ca3af;">
${dataIncomingBySN?.packagePo?.receivedDate ? momentTZ(dataIncomingBySN?.packagePo?.receivedDate).tz(timeZone).format('DD MMM, HH:mm') : ''}
</div>
</td>
<!-- Step 2 -->
<td width="33%" align="center" valign="top" style="padding:0 4px 4px 4px;">
<div style="display:inline-block;width:26px;height:26px;background:#fff;border:2px solid #10b981;border-radius:50%;color:#10b981;font-size:14px;font-weight:800;line-height:22px;text-align:center;margin-top:-14px;margin-bottom:8px;">
&#10003;
</div>
<div style="font-size:10px;font-weight:700;text-transform:uppercase;color:#5f6978;margin-bottom:2px;letter-spacing:0.5px;">
Visual Check
</div>
<div style="font-size:11px;font-weight:600;color:#1a1d23">
${dataIncomingBySN?.packagePo?.receivedBy?.fullName || 'Unknown'}
</div>
<div style="margin-top:4px;font-size:10px;color:#9ca3af;">
${dataIncomingBySN?.packagePo?.receivedDate ? momentTZ(dataIncomingBySN?.packagePo?.receivedDate).tz(timeZone).format('DD MMM, HH:mm') : ''}
</div>
</td>
<!-- Step 3 -->
<td width="34%" align="center" valign="top" style="padding:0 4px 4px 4px;">
<div style="display:inline-block;width:26px;height:26px;background:#fff;border:2px solid #10b981;border-radius:50%;color:#10b981;font-size:14px;font-weight:800;line-height:22px;text-align:center;margin-top:-14px;margin-bottom:8px;">
&#10003;
</div>
<div style="font-size:10px;font-weight:700;text-transform:uppercase;color:#5f6978;margin-bottom:2px;letter-spacing:0.5px;">
Software Test
</div>
<div style="font-size:11px;font-weight:600;color:#1a1d23;">
${this?.userTest?.dpelp?.name || 'Unknown'}
</div>
<div style="margin-top:4px;font-size:10px;color:#9ca3af;">
${momentTZ(this?.userTest?.dpelp?.time).tz(timeZone).format('DD MMM, HH:mm')}
</div>
</td>
</tr></tbody>
</table>
</td></tr>
</table>
</td></tr>
<!-- Divider -->
<tr><td style="padding:6px 0;">
<table cellpadding="0" cellspacing="0" border="0" width="100%">
<tr>
<td style="border-top:1px solid #e5e7eb;line-height:1px;font-size:1px;">&nbsp;</td>
<td width="60" align="center" style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:1.5px;color:#9ca3af;padding:0 12px;">Detail</td>
<td style="border-top:1px solid #e5e7eb;line-height:1px;font-size:1px;">&nbsp;</td>
</tr>
</table>
</td></tr>
<!-- Visual Check section -->
<tr><td style="padding-bottom:10px;">
<table cellpadding="0" cellspacing="0" border="0" width="100%" style="background:#fff;border:1px solid #e5e7eb;border-radius:10px;border-collapse:separate;">
<tr><td style="padding:16px 20px;">
<table cellpadding="0" cellspacing="0" border="0" width="100%" style="background:#f0fdf4;border:1px solid #bbf7d0;border-radius:6px;margin-bottom:12px;border-collapse:separate;">
<tr>
<td style="padding:7px 12px;color:#166534;font-size:13px;font-weight:700;">
<svg viewBox="0 0 20 20" width="17" height="17" fill="none" style="vertical-align:middle;color:#166534;"><rect x="2" y="2" width="16" height="16" rx="3" stroke="currentColor" stroke-width="1.5"/><path d="M7 10h6M10 7v6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
<span style="vertical-align:middle;margin-left:8px;">Visual Check</span>
</td>
<td align="right" style="padding:7px 12px;color:#166534;font-size:11px;font-weight:500;opacity:.65;">${dataIncomingBySN?.packagePo?.receivedBy?.fullName || 'Unknown'} · ${dataIncomingBySN?.packagePo?.receivedDate ? momentTZ(dataIncomingBySN?.packagePo?.receivedDate).tz(timeZone).format('DD MMM, HH:mm') : ''}</td>
</tr>
</table>
<table cellpadding="0" cellspacing="0" border="0" width="100%">
<tr>
<td width="200" valign="top" style="padding-right:14px;">
<table cellpadding="0" cellspacing="0" border="0" width="100%">
${photoGridRowsHtml}
</table>
</td>
<td valign="top">
${checklistRowsHtml}
</td>
</table>
</td></tr>
</table>
</td></tr>
<!-- Software Check section -->
<tr><td style="padding-bottom:10px;">
<table cellpadding="0" cellspacing="0" border="0" width="100%" style="background:#fff;border:1px solid #e5e7eb;border-radius:10px;border-collapse:separate;">
<tr><td style="padding:16px 20px;">
<table cellpadding="0" cellspacing="0" border="0" width="100%" style="background:#eff6ff;border:1px solid #93c5fd;border-radius:6px;margin-bottom:12px;border-collapse:separate;">
<tr>
<td style="padding:7px 12px;color:#1e40af;font-size:13px;font-weight:700;">
<svg viewBox="0 0 20 20" width="17" height="17" fill="none" style="vertical-align:middle;color:#1e40af;"><rect x="2" y="3" width="16" height="11" rx="2" stroke="currentColor" stroke-width="1.5"/><path d="M7 17h6M10 14v3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
<span style="vertical-align:middle;margin-left:8px;">Software Check</span>
</td>
<td align="right" style="padding:7px 12px;color:#1e40af;font-size:11px;font-weight:500;opacity:.65;">${this?.userTest?.dpelp?.name || ''} · ${momentTZ(this?.userTest?.dpelp?.time).tz(timeZone).format('DD MMM, HH:mm')}</td>
</tr>
</table>
<table cellpadding="0" cellspacing="0" border="0" width="100%">
<tr>
<td width="33%" valign="top" style="padding-right:8px;">
<div style="font-size:10px;font-weight:700;text-transform:uppercase;color:#9ca3af;letter-spacing:.5px;margin-bottom:8px;">Hardware Inventory</div>
<table cellpadding="0" cellspacing="0" border="0" width="100%" style="font-size:11px;">
${
this.config?.inventory?.listInventory
?.map(
(item: any) => `
<tr><td style="margin-top:4px;padding:4px 0;border-bottom:1px solid #f0f1f3;font-weight:600;color:#5f6978;">${item.pid}</td><td style="padding:2px 0;border-bottom:1px solid #f0f1f3;font-family:Consolas,monospace;color:#9ca3af;text-align:right;"><a href="#${item.sn}" style="text-decoration: underline;">${item.sn}</a></td></tr>`
)
.join('') || ''
}
</table>
</td>
<td width="33%" valign="top" style="padding:0 4px;">
<div style="font-size:10px;font-weight:700;text-transform:uppercase;color:#9ca3af;letter-spacing:.5px;margin-bottom:8px;">System &amp; License</div>
${licenseBoxesHtml}
</td>
<td width="34%" valign="top" style="padding-left:8px;">
<div style="font-size:10px;font-weight:700;text-transform:uppercase;color:#9ca3af;letter-spacing:.5px;margin-bottom:8px;">Port Test Summary</div>
<table cellpadding="0" cellspacing="0" border="0" width="100%">
<tr>
<td width="50%" style="padding:0 3px 6px 0;"><table cellpadding="0" cellspacing="0" border="0" width="100%" style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:6px;border-collapse:separate;"><tr><td align="center" style="padding:6px;"><div style="font-weight:800;font-size:14px;color:${poeColor};">${escapeHtml(poeText)}</div><div style="font-size:9px;font-weight:600;color:#9ca3af;text-transform:uppercase;">${hasPortData ? 'PoE UP' : 'GigE UP'}</div></td></tr></table></td>
<td width="50%" style="padding:0 0 6px 3px;"><table cellpadding="0" cellspacing="0" border="0" width="100%" style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:6px;border-collapse:separate;"><tr><td align="center" style="padding:6px;"><div style="font-weight:800;font-size:14px;color:${sfpColor};">${escapeHtml(sfpText)}</div><div style="font-size:9px;font-weight:600;color:#9ca3af;text-transform:uppercase;">SFP+ UP</div></td></tr></table></td>
</tr>
<tr>
<td width="50%" style="padding:0 3px 0 0;"><table cellpadding="0" cellspacing="0" border="0" width="100%" style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:6px;border-collapse:separate;"><tr><td align="center" style="padding:6px;"><div style="font-weight:800;font-size:14px;color: ${missingSFP.length > 0 || missingPoE.length > 0 ? '#f59e0b' : '#10b981'};">${missingSFP.length > 0 || missingPoE.length > 0 ? 'WARN' : 'PASS'}</div><div style="font-size:9px;font-weight:600;color:#9ca3af;text-transform:uppercase;">PoE+ Test</div></td></tr></table></td>
<td width="50%" style="padding:0 0 0 3px;"><table cellpadding="0" cellspacing="0" border="0" width="100%" style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:6px;border-collapse:separate;"><tr><td align="center" style="padding:6px;"><div style="font-weight:800;font-size:14px;color: ${missingSFP.length > 0 || missingPoE.length > 0 ? '#f59e0b' : '#10b981'};">${totalPoE + totalSFP === 0 ? 100 : Math.round(((totalPoE + totalSFP - (missingPoE.length + missingSFP.length)) / (totalPoE + totalSFP)) * 100)}%</div><div style="font-size:9px;font-weight:600;color:#9ca3af;text-transform:uppercase;">Throughput</div></td></tr></table></td>
</tr>
</table>
${missingDetailsHtml}
</td>
</tr>
</table>
<!-- CONSOLE RAW OUTPUT -->
<table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin-top:16px;background:#1e293b;border-radius:6px;border:1px solid #334155;border-collapse:separate;">
<tr><td style="padding:6px 12px;background:#334155;color:#94a3b8;font-size:10px;font-weight:700;letter-spacing:.5px;border-radius:6px 6px 0 0;">CONSOLE RAW OUTPUT (Boot Log snippet)</td></tr>
<tr><td><pre style="overflow-y: auto; max-height: 300px; padding:12px;color:#cbd5e1;font-family:Consolas,'Courier New',monospace;font-size:11px;line-height:1.6;white-space:pre-wrap;word-break:break-all;">${highlightSnInConsoleOutput(this?.outputTestLog, this.config?.inventory?.listInventory)}</pre></td></tr>
</table>
</td></tr>
</table>
</td></tr>
<!-- Footer -->
<tr><td align="center" style="padding-top:16px;font-size:10px;color:#9ca3af;">Prology IT — Equipment QA System · Confidential — Internal Use Only</td></tr>
</table>
</body>
</html>`
// 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.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: '',
})
}
}