ATC_SIMPLE/BACKEND/app/services/line_connection.ts

2564 lines
104 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
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 = ''
}
/**
* 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 += message
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.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<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-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 +
`${`
<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 = ''
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
outputTestLog: 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 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 && issues.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-${config.stationId}-L${config.lineNumber}-${Date.now().toString().slice(-6)}`
const reportDate = momentTZ()
.tz(process.env.TIME_ZONE || 'UTC')
.format('DD MMM YYYY HH:mm')
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 memDisplay = escapeHtml(memText !== '—' ? memText : '')
const flashDisplay = escapeHtml(flashText !== '—' ? flashText : '')
// 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;"><span style="display:inline-block;padding:1px 7px;border-radius:50px;font-size:11px;font-weight:600;background:#eff6ff;color:#1d4ed8;border:1px solid #bfdbfe;">Investigate</span></td></tr></table>`
)
.join('')
: `<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;">Potential intermittent power instability. PSU #1 POST logs show 3 retries before handshake.</span></td><td align="right" style="padding:7px 12px;width:90px;"><span style="display:inline-block;padding:1px 7px;border-radius:50px;font-size:11px;font-weight:600;background:#eff6ff;color:#1d4ed8;border:1px solid #bfdbfe;">Investigate</span></td></tr></table>`
// 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('')
: `<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;">Network Advantage</div><div style="font-size:10px;color:#9ca3af;">Permanent · Smart License: Active</div></div><div style="background:#f9fafb;border:1px solid #f0f1f3;border-radius:6px;padding:8px 12px;"><div style="font-weight:700;color:#3b82f6;font-size:13px;">DNA Premier</div><div style="font-size:10px;color:#9ca3af;">Evaluation · 85 days remaining</div></div>`
// 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]> = [
['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'
? `<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>`
// ---- 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:12px;font-weight:700;color:#3b82f6;font-family:'Courier New',monospace;display:block;">#${escapeHtml(reportId)}</span>
<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="50%" valign="top" style="padding-right:5px;">
<table cellpadding="0" cellspacing="0" border="0" height="265px" 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;"><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;">${productPN}</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;">${productSN}</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;">Type</td><td style="padding:3px 0;vertical-align:top;"><span style="display:inline-block;padding:1px 7px;border-radius:50px;font-size:11px;font-weight:600;background:#eff6ff;color:#1d4ed8;border:1px solid #bfdbfe;">Switch — Layer 3</span></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;vertical-align:top;"><span style="display:inline-block;padding:1px 7px;border-radius:50px;font-size:11px;font-weight:600;background:#ecfdf5;color:#065f46;border:1px solid #a7f3d0;">Refurb — Grade A</span></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;vertical-align:top;font-weight:500;">TechData AU — PO #TD-88432</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;">Warranty</td><td style="padding:3px 0;vertical-align:top;font-weight:500;">12 Months (→ May 2027)</td></tr>
</table>
</td></tr>
</table>
</td>
<td width="50%" valign="top" style="padding-left:5px;">
<table cellpadding="0" cellspacing="0" border="0" height="265px" 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;">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;">${iosVersion}</td>
<td style="padding:6px 0;border-bottom:1px dashed #f0f1f3;font-weight:500;color:#9ca3af;font-family:Consolas,monospace;">17.06.01</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;">8 GB</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;">16 GB</td>
</tr>
<tr>
<td style="padding:6px 0;border-bottom:1px dashed #f0f1f3;font-weight:600;color:#5f6978;">Uplink Module</td>
<td style="padding:6px 0;border-bottom:1px dashed #f0f1f3;font-weight:700;font-family:Consolas,monospace;">C9300-NM-4G</td>
<td style="padding:6px 0;border-bottom:1px dashed #f0f1f3;font-style:italic;color:#cbd5e1;font-family:Consolas,monospace;">N/A</td>
</tr>
<tr>
<td style="padding:6px 0;border-bottom:1px dashed #f0f1f3;font-weight:600;color:#5f6978;">PSU Model</td>
<td style="padding:6px 0;border-bottom:1px dashed #f0f1f3;font-weight:700;font-family:Consolas,monospace;">715W AC</td>
<td style="padding:6px 0;border-bottom:1px dashed #f0f1f3;font-weight:500;color:#9ca3af;font-family:Consolas,monospace;">715W AC</td>
</tr>
<tr>
<td style="padding:6px 0;font-weight:600;color:#5f6978;">PoE Budget</td>
<td style="padding:6px 0;font-weight:700;font-family:Consolas,monospace;">437 Watts</td>
<td style="padding:6px 0;font-weight:500;color:#9ca3af;font-family:Consolas,monospace;">437 Watts</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}
<table cellpadding="0" cellspacing="0" border="0" width="100%" style="background:#fffbeb;border:1px solid #fde68a;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;font-size:9px;font-weight:700;letter-spacing:.5px;text-transform:uppercase;padding:2px 6px;border-radius:4px;background:#fef3c7;color:#92400e;vertical-align:middle;">COSMETIC</span><span style="margin-left:8px;vertical-align:middle;">Minor scratch on top chassis (2cm) — non-functional</span></td>
<td align="right" style="padding:7px 12px;width:90px;"><span style="display:inline-block;padding:1px 7px;border-radius:50px;font-size:11px;font-weight:600;background:#ecfdf5;color:#065f46;border:1px solid #a7f3d0;">Accepted</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:6px;border-collapse:separate;">
<tr>
<td style="padding:7px 12px;font-size:12px;color:#5f6978;font-weight:500;"><span style="display:inline-block;font-size:9px;font-weight:700;letter-spacing:.5px;text-transform:uppercase;padding:2px 6px;border-radius:4px;background:#ffedd5;color:#9a3412;vertical-align:middle;">MINOR</span><span style="margin-left:8px;vertical-align:middle;">Fan #2 at 48dB under stress (spec 45dB) — within rack tolerance</span></td>
<td align="right" style="padding:7px 12px;width:90px;"><span style="display:inline-block;padding:1px 7px;border-radius:50px;font-size:11px;font-weight:600;background:#eff6ff;color:#1d4ed8;border:1px solid #bfdbfe;">Monitor</span></td>
</tr>
</table>
<div style="text-align:center;font-size:11px;color:#9ca3af;padding-top:6px;border-top:1px solid #f0f1f3;"><b>0</b> Critical · <b>0</b> Major · <b>1</b> Minor · <b>1</b> Cosmetic</div>
</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="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;">Receiving &amp; Inspection Notes</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;">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.</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>
<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>
</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: 12px;">
&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;">
Received
</div>
<div style="font-size:11px;font-weight:600;color:#1a1d23;">
Trung Nguyen
</div>
<div style="font-size:10px;color:#9ca3af;">
06 May 10:30
</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;">
Physical Check
</div>
<div style="font-size:11px;font-weight:600;color:#1a1d23;">
Khanh Le
</div>
<div style="font-size:10px;color:#9ca3af;">
06 May 11:15
</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;">
Software Test
</div>
<div style="font-size:11px;font-weight:600;color:#1a1d23;">
Duy Pham (remote)
</div>
<div style="font-size:10px;color:#9ca3af;">
06 May 14:00
</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>
<!-- Physical 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;">Physical Check</span>
</td>
<td align="right" style="padding:7px 12px;color:#166534;font-size:11px;font-weight:500;opacity:.65;">Khanh Le · 06 May 11:15</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%">
<tr>
<td width="50%" style="padding:0 3px 6px 0;">${photoCellHtml('Front')}</td>
<td width="50%" style="padding:0 0 6px 3px;">${photoCellHtml('Rear')}</td>
</tr>
<tr>
<td width="50%" style="padding:0 3px 0 0;">${photoCellHtml('S/N Label')}</td>
<td width="50%" style="padding:0 0 0 3px;">${photoCellHtml('Package')}</td>
</tr>
</table>
</td>
<td valign="top">
${checklistRowsHtml}
</td>
</tr>
</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;">Duy Pham (remote) · 06 May 14:0017:45</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="padding:2px 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;">${item.sn}</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 ? '#dc2626' : '#10b981'};">${missingSFP.length > 0 || missingPoE.length > 0 ? 'FAIL' : '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 ? '#dc2626' : '#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;">${snapshot?.outputTestLog || 'No test log available'}</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>`
// 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,
}
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 = ''
}, 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: '',
})
}
}