2766 lines
110 KiB
TypeScript
2766 lines
110 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, PortState, TestResult } from '../ultils/types.js'
|
|
import momentTZ from 'moment-timezone'
|
|
import { PhysicalPortTest } from './physical_test_service.js'
|
|
import Station from '#models/station'
|
|
import IosLicenseController from '#controllers/ios_license_controller'
|
|
|
|
type Inventory = {
|
|
pid: string
|
|
vid: string
|
|
sn: string
|
|
licenseLevel: string
|
|
licenseType: string
|
|
nextLicenseLevel: string
|
|
}
|
|
|
|
interface LineConfig {
|
|
id: number
|
|
port: number
|
|
lineNumber: number
|
|
ip: string
|
|
stationId: number
|
|
stationName: string
|
|
stationIp: string
|
|
apcName?: string
|
|
outlet: number
|
|
output: string
|
|
status: string
|
|
baud: number
|
|
openCLI: boolean
|
|
userEmailOpenCLI: string
|
|
userOpenCLI: string
|
|
inventory: any
|
|
latestScenario?: {
|
|
name: string
|
|
time: number
|
|
detectAI?: {
|
|
status: string[]
|
|
issue: string[]
|
|
summary: string
|
|
}
|
|
}
|
|
data: {
|
|
command: string
|
|
output: string
|
|
textfsm: string
|
|
}[]
|
|
ports: string[]
|
|
runningScenario: string
|
|
runningPhysical: boolean
|
|
listFeatureTested: string[]
|
|
isReady: boolean
|
|
isSkipPhysical?: boolean
|
|
reasonSkipPhysical?: string
|
|
// history: string
|
|
}
|
|
|
|
/** HISTORY
|
|
* PID
|
|
* SN
|
|
* VID
|
|
* Timestamp
|
|
* Scenario
|
|
*/
|
|
|
|
interface HistoryItem {
|
|
pid: string
|
|
vid: string
|
|
sn: string
|
|
scenario: string
|
|
id: number
|
|
number: number
|
|
stationId: number
|
|
timestamp?: number
|
|
}
|
|
|
|
interface User {
|
|
userEmail: string
|
|
userName: string
|
|
}
|
|
|
|
interface DataDPELP {
|
|
line: number
|
|
pid: any
|
|
vid: any
|
|
sn: any
|
|
ios: string
|
|
mac: string
|
|
license: any
|
|
issues: string[]
|
|
summary: string
|
|
}
|
|
|
|
export default class LineConnection {
|
|
public client: net.Socket
|
|
public config: LineConfig
|
|
public readonly socketIO: any
|
|
private outputBuffer: string
|
|
private connecting: boolean
|
|
private waitingScenario: boolean
|
|
private outputInventory: string
|
|
private outputScenario: string
|
|
private outputALLScenario: 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.outputALLScenario = ''
|
|
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) {
|
|
if (message?.includes('Password:') || message?.includes('password:')) {
|
|
this.config.runningScenario = ''
|
|
this.socketIO.emit('running_scenario', {
|
|
stationId: this.config.stationId,
|
|
lineId: this.config.id,
|
|
title: '',
|
|
password: true,
|
|
})
|
|
this.outputBuffer = ''
|
|
this.outputScenario = ''
|
|
this.outputALLScenario = ''
|
|
this.outputScenario += `\n---end-scenarios---${Date.now()}---USER---\n`
|
|
appendLog(
|
|
`\n---end-scenarios---${Date.now()}---USER---\n`,
|
|
this.config.stationId,
|
|
this.config.stationName,
|
|
this.config.stationIp,
|
|
this.config.lineNumber
|
|
)
|
|
return
|
|
}
|
|
this.waitingScenario = true
|
|
this.outputBuffer += message
|
|
this.outputScenario += message
|
|
this.outputTestLog += cleanData(data.toString())
|
|
if (!this.config.runningPhysical) this.outputALLScenario += 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.outputALLScenario = ''
|
|
this.outputTestLog = ''
|
|
// 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: '',
|
|
password: false,
|
|
})
|
|
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,
|
|
password: false,
|
|
})
|
|
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: '',
|
|
password: false,
|
|
})
|
|
this.outputBuffer = ''
|
|
this.outputScenario = ''
|
|
this.outputALLScenario = ''
|
|
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) {
|
|
this.socketIO.emit('loading_note', {
|
|
stationId: this.config.stationId,
|
|
lineId: this.config.id,
|
|
})
|
|
let detectLog = await this.detectLogWithAI(this.outputALLScenario)
|
|
if (typeof detectLog === 'string' && detectLog?.includes('[')) {
|
|
detectLog = [...detectLog.matchAll(/"((?:\\.|[^"\\])*)"/g)].map(m => m[1])
|
|
}
|
|
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']),
|
|
]
|
|
this.outputALLScenario = ''
|
|
// 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: '',
|
|
password: false,
|
|
})
|
|
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.4',
|
|
messages: [
|
|
{
|
|
role: 'user',
|
|
content: `${promptRecord.content}
|
|
|
|
Return ONLY a valid JSON array of strings.
|
|
Here is the log:
|
|
${log}`,
|
|
},
|
|
],
|
|
}
|
|
console.log(`${promptRecord.content}
|
|
|
|
Return ONLY a valid JSON array of strings.
|
|
Here is the log:
|
|
${log}`)
|
|
const remoteUrl = process.env.ERP_URL_AUTH || 'https://stage.nswteam.net'
|
|
const remoteResp = await axios.post(
|
|
remoteUrl + '/api/transferPostData',
|
|
{
|
|
urlAPI: '/api/open-ai-sfp/model-image-info',
|
|
data: payload,
|
|
},
|
|
{
|
|
headers: {
|
|
Authorization: 'Bearer ' + process.env.ERP_TOKEN,
|
|
},
|
|
}
|
|
)
|
|
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)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add cache to list history devices on this line
|
|
*/
|
|
async addHistory(
|
|
stationId: number,
|
|
lineId: number,
|
|
item: HistoryItem,
|
|
outputLog?: string,
|
|
portPhysical?: PortState[]
|
|
) {
|
|
if (!item.pid || !item.sn) return false
|
|
const key = `station:${stationId}:line:${lineId}:history`
|
|
const now = Date.now()
|
|
|
|
// Tạo object chứa các field mở rộng nếu được truyền vào
|
|
const extendedFields: any = {}
|
|
if (outputLog !== undefined) extendedFields.output = outputLog
|
|
if (portPhysical !== undefined) extendedFields.portPhysical = portPhysical
|
|
|
|
// Lấy phần tử cuối cùng trong ZSET mang tính timeline
|
|
const lastItems = await redis.zrevrange(key, 0, 0)
|
|
|
|
if (lastItems.length > 0) {
|
|
const last = JSON.parse(lastItems[0])
|
|
|
|
// TRƯỜNG HỢP 1: Trùng pid và sn -> Cập nhật lại bản ghi cũ
|
|
if (last.pid === item.pid && last.sn === item.sn) {
|
|
const updatedItemObj = {
|
|
...last,
|
|
...extendedFields,
|
|
}
|
|
const updatedItemStr = JSON.stringify(updatedItemObj)
|
|
|
|
// Nếu dữ liệu mới không khác gì dữ liệu cũ thì không cần làm gì cả
|
|
if (lastItems[0] === updatedItemStr) {
|
|
return false
|
|
}
|
|
|
|
await redis.multi().zrem(key, lastItems[0]).zadd(key, last.timestamp, updatedItemStr).exec()
|
|
|
|
return true
|
|
}
|
|
}
|
|
|
|
const line = await Line.find(lineId)
|
|
if (line) {
|
|
const listHistory = line.history ? JSON.parse(line.history) : []
|
|
listHistory.unshift({ ...item, timestamp: now })
|
|
line.history = JSON.stringify(listHistory)
|
|
await line.save()
|
|
}
|
|
|
|
// Tự động xóa item > 96h
|
|
// const expireTime = now - 96 * 60 * 60 * 1000
|
|
// await redis.zremrangebyscore(key, 0, expireTime)
|
|
|
|
// TRƯỜNG HỢP 2: Sản phẩm mới hoàn toàn -> Thêm mới vào ZSET
|
|
const newItem = JSON.stringify({
|
|
...item,
|
|
...extendedFields,
|
|
timestamp: now,
|
|
})
|
|
|
|
await redis.zadd(key, now, newItem)
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Get list history devices
|
|
*/
|
|
async getHistory(stationId: number, lineId: number) {
|
|
const key = `station:${stationId}:line:${lineId}:history`
|
|
const items = await redis.zrange(key, 0, -1)
|
|
return items.map((i) => JSON.parse(i))
|
|
}
|
|
|
|
/**
|
|
* Handle raw log to regex error
|
|
*/
|
|
handleLogLine = (line: string) => {
|
|
try {
|
|
const parsed = classifyLog(line)
|
|
this.session.applyParsedLog(parsed)
|
|
} catch (error) {
|
|
console.log('handleLogLine', error)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check raw log was regex each 5 minutes, if has error will send email report
|
|
*/
|
|
checkLog() {
|
|
const interval = setInterval(async () => {
|
|
try {
|
|
if (this.config.status !== 'connected') {
|
|
clearInterval(interval)
|
|
this.session.clear()
|
|
this.bufferLog.clear()
|
|
return
|
|
}
|
|
const result = this.session.finalize()
|
|
|
|
if (result.errors.length === 0) {
|
|
this.session.clear()
|
|
this.bufferLog.clear()
|
|
return
|
|
}
|
|
|
|
// const detectLog = await this.detectLogWithAI(this.bufferLog.allBuffer)
|
|
// console.log(detectLog)
|
|
const tableHTML = this.buildEmailContent(result)
|
|
await sendMessageToMail(
|
|
`[ATC] - [${this.config.stationName} - Line: ${this.config.lineNumber}] - Raw log issue ${result?.errors?.some((e) => e.category === 'SPECIAL_KEYWORD') ? '+ Special keywords' : ''}`,
|
|
tableHTML +
|
|
`${`
|
|
<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(', ')}
|
|
Detected by AI:
|
|
${note}
|
|
*****[Physical]*****
|
|
Total Ports: ${portPhysical?.length}
|
|
Ports Tested (Link UP): ${tested.length} (${testedPoE?.length} PoE, ${testedSFP?.length} SFP)
|
|
Ports Missing/Down: ${missing.length}\n\n`
|
|
const issueList = note
|
|
.split('\n')
|
|
.map((line) => (line[0] === '-' ? line.substring(1).trim() : line.trim()))
|
|
|
|
const detectAI = this.config?.latestScenario?.detectAI
|
|
? { ...this.config.latestScenario.detectAI, issue: issueList }
|
|
: { issue: issueList, summary: '', status: [] }
|
|
|
|
if (this.config.latestScenario) {
|
|
this.config.latestScenario = { ...this.config.latestScenario, detectAI }
|
|
}
|
|
|
|
await updateNoteToERP(sn, data)
|
|
}
|
|
|
|
/**
|
|
* Starting physical test (PoE ports testing)
|
|
*/
|
|
async runPhysicalTest(userName?: string) {
|
|
if (this.config.runningPhysical) {
|
|
console.log('Running physical test')
|
|
return
|
|
}
|
|
this.setTimeoutSendSummaryReport(1200000)
|
|
this.config.runningPhysical = true
|
|
this.config.runningScenario = 'Physical Test'
|
|
this.config.isSkipPhysical = false
|
|
this.config.reasonSkipPhysical = ''
|
|
this.testingPortPoE = true
|
|
this.outputTestingPortPoE = ''
|
|
this.userTest = { ...this.userTest, physical: { name: userName || '', time: Date.now() } }
|
|
const listPorts = await this.getPorts()
|
|
this.socketIO.emit('running_scenario', {
|
|
stationId: this.config.stationId,
|
|
lineId: this.config.id,
|
|
title: 'Physical Test',
|
|
physical: true,
|
|
ports: listPorts,
|
|
password: false,
|
|
})
|
|
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: '',
|
|
password: false,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 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',
|
|
password: false,
|
|
})
|
|
await sleep(1000)
|
|
const listIos = await this.getListIos()
|
|
const dataDevice = await this.checkDeviceFlash()
|
|
this.listDeviceIos = [...dataDevice]
|
|
console.log('Data Device Flash', dataDevice)
|
|
if (dataDevice && Array.isArray(dataDevice)) {
|
|
for (const ios of dataDevice) {
|
|
// if (ios === nameIos) {
|
|
// console.log(`SKIP active IOS: ${ios}`)
|
|
// continue
|
|
// }
|
|
if (listIos?.map((value) => value.name)?.includes(ios)) {
|
|
console.log(`Already backed up: ${ios}`)
|
|
if (ios !== nameIos) await this.deleteFileOnFlash(ios)
|
|
} else {
|
|
const ok = await this.uploadFileToServerTFTP(ios, server)
|
|
if (ok && ios !== nameIos) await this.deleteFileOnFlash(ios)
|
|
}
|
|
}
|
|
}
|
|
this.outputBuffer = ''
|
|
this.config.runningScenario = ''
|
|
await sleep(1000)
|
|
}
|
|
|
|
/**
|
|
* Handle load License for switch
|
|
* Assumes traditional licensing (PAK/file-based) via TFTP
|
|
*/
|
|
async loadLicenseSwitch(licenseFileName: string, userName: string, portName: string) {
|
|
const station = await Station.find(this.config.stationId)
|
|
if (!station) return
|
|
this.outputLoadIosLicense = true
|
|
// Setup network variables (giống hệt logic load IOS để đảm bảo thông mạng)
|
|
const network = station?.gateway || '172.25.1.1'
|
|
const tftpIp = station?.tftp_ip || '172.16.7.69'
|
|
const [a, b] = network.split('.').map(Number)
|
|
|
|
// Setup time/logging
|
|
const timeZone = process.env.TIME_ZONE || 'Australia/Sydney'
|
|
const startTime = momentTZ().tz(timeZone).format('YYYY/MM/DD, HH:mm:ss')
|
|
const address = `${a}.${b}.100.${this.config.id < 254 ? this.config.id : 254 - this.config.id}`
|
|
const gateway = `${station?.gateway ? station?.gateway : '0.0.0.0'}`
|
|
await this.configAddressGateway(address, gateway, portName)
|
|
const pingSuccess = await this.pingToServer(tftpIp)
|
|
if (!pingSuccess) return
|
|
|
|
const body = buildBody(
|
|
'SWITCH_LICENSE',
|
|
tftpIp,
|
|
licenseFileName,
|
|
address,
|
|
gateway,
|
|
this.listDeviceIos,
|
|
portName
|
|
)
|
|
|
|
const script = {
|
|
id: 0,
|
|
isReboot: false,
|
|
sendResult: false,
|
|
send_result: false,
|
|
title: 'Load License Switch',
|
|
timeout: 1000,
|
|
body: JSON.stringify(body),
|
|
}
|
|
|
|
await this.runScript(script as any, userName)
|
|
await this.sendEmailLoadLicense(licenseFileName, startTime) // Nếu bạn có hàm gửi mail báo cáo
|
|
}
|
|
|
|
/**
|
|
* Handle load License for Router
|
|
*/
|
|
async loadLicenseRouter(licenseFileName: string, userName: string, portName: string) {
|
|
const station = await Station.find(this.config.stationId)
|
|
if (!station) return
|
|
this.outputLoadIosLicense = true
|
|
const network = station?.gateway || '172.25.1.1'
|
|
const tftpIp = station?.tftp_ip || '172.16.7.69'
|
|
const [a, b] = network.split('.').map(Number)
|
|
|
|
const timeZone = process.env.TIME_ZONE || 'Australia/Sydney'
|
|
const startTime = momentTZ().tz(timeZone).format('YYYY/MM/DD, HH:mm:ss')
|
|
const address = `${a}.${b}.100.${this.config.id < 254 ? this.config.id : 254 - this.config.id}`
|
|
const gateway = `${station?.gateway ? station?.gateway : '0.0.0.0'}`
|
|
await this.configAddressGateway(address, gateway, portName, true)
|
|
const pingSuccess = await this.pingToServer(tftpIp)
|
|
if (!pingSuccess) return
|
|
|
|
const body = buildBody(
|
|
'ROUTER_LICENSE',
|
|
tftpIp,
|
|
licenseFileName,
|
|
address,
|
|
gateway,
|
|
this.listDeviceIos,
|
|
portName
|
|
)
|
|
|
|
const script = {
|
|
id: 0,
|
|
isReboot: false,
|
|
sendResult: false,
|
|
send_result: false,
|
|
title: 'Load License Router',
|
|
timeout: 1000,
|
|
body: JSON.stringify(body),
|
|
}
|
|
|
|
await this.runScript(script as any, userName)
|
|
await this.sendEmailLoadLicense(licenseFileName, startTime)
|
|
}
|
|
|
|
/**
|
|
* Detect log by call api gpt, return string[]
|
|
*/
|
|
async detectShowEnvWithAI(log: string) {
|
|
try {
|
|
// Get prompt from database
|
|
const promptRecord = await PromptAi.findBy('type', 'env')
|
|
if (!promptRecord) {
|
|
console.log('[ERROR] Prompt ENV not found in database')
|
|
return ''
|
|
}
|
|
|
|
const payload = {
|
|
model: 'gpt-4o-mini',
|
|
max_tokens: 1000,
|
|
messages: [
|
|
{
|
|
role: 'user',
|
|
content: `${promptRecord.content}
|
|
|
|
Return ONLY a valid JSON array of strings.
|
|
Here is the log:
|
|
${log}`,
|
|
},
|
|
],
|
|
}
|
|
const remoteUrl = process.env.ERP_URL_AUTH || 'https://stage.nswteam.net'
|
|
const remoteResp = await axios.post(
|
|
remoteUrl + '/api/transferPostData',
|
|
{
|
|
urlAPI: '/api/open-ai-sfp/model-image-info',
|
|
data: payload,
|
|
},
|
|
{
|
|
headers: {
|
|
Authorization: 'Bearer ' + process.env.ERP_TOKEN,
|
|
},
|
|
}
|
|
)
|
|
return remoteResp.data?.Status === 'OK' ? remoteResp.data?.data : ''
|
|
} catch (error: any) {
|
|
console.log('[ERROR] Detect log show env from AI', error)
|
|
}
|
|
return ''
|
|
}
|
|
|
|
/**
|
|
* Check config RAM and Flash, if higher config will send report
|
|
*/
|
|
async checkConfigRam(mem: string, flash: string, pid: string, output: string) {
|
|
const configRam = await detectConfigRamByModel(pid)
|
|
if (configRam) {
|
|
const isWarningRAM = isRamSufficient(mem, configRam.ram)
|
|
const isWarningFlash = isRamSufficient(flash, configRam.flash)
|
|
if (isWarningRAM || isWarningFlash) {
|
|
const subject = `[ATC] - [${this.config.stationName} - Line: ${this.config.lineNumber}] - Warning RAM, Flash Configuration`
|
|
const body = `
|
|
<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 && Array.isArray(issues) ? 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 = summaryStatus === 'FAIL' ? 'CRITICAL ISSUES' : `AI: ${summaryStatus}`
|
|
verdictMsg =
|
|
summaryStatus === 'FAIL'
|
|
? 'AI analysis failed — review required'
|
|
: 'AI detected warnings — verify results'
|
|
verdictBg = summaryStatus === 'FAIL' ? '#fef2f2' : '#fffbeb'
|
|
verdictBd = summaryStatus === 'FAIL' ? '#fecaca' : '#fde68a'
|
|
verdictTx = summaryStatus === 'FAIL' ? '#991b1b' : '#92400e'
|
|
}
|
|
|
|
const reportId = `RPT-${momentTZ().tz(timeZone).format('YYYY-MMDD')}`
|
|
const reportDate = momentTZ().tz(timeZone).format('DD MMM YYYY')
|
|
|
|
const memText = dataShowVersion?.MEMORY
|
|
? convertFromKilobytesString(dataShowVersion.MEMORY)
|
|
: '—'
|
|
const flashText = dataShowVersion?.USB_FLASH
|
|
? convertFromKilobytesString(dataShowVersion.USB_FLASH)
|
|
: '—'
|
|
|
|
// ---- Template-fallback values (use file's hardcoded content when no real data) ----
|
|
const productName = escapeHtml(String(config?.inventory?.name || ''))
|
|
const productPN = escapeHtml(String(config?.inventory?.pid || ''))
|
|
const productSN = escapeHtml(String(config?.inventory?.sn || ''))
|
|
const productVid = escapeHtml(String(config?.inventory?.vid || ''))
|
|
const iosName = escapeHtml(String(dataShowVersion?.SOFTWARE_IMAGE || ''))
|
|
const iosVersion = escapeHtml(String(dataShowVersion?.VERSION || ''))
|
|
const macAddress = escapeHtml(String(dataShowVersion?.MAC_ADDRESS || ''))
|
|
const memDisplay = escapeHtml(memText !== '—' ? memText : '-')
|
|
const flashDisplay = escapeHtml(flashText !== '—' ? flashText : '-')
|
|
const configRam = await detectConfigRamByModel(config?.inventory?.pid)
|
|
|
|
// AI issue rows (one per real AI issue, fall back to file's hardcoded row when none)
|
|
const aiIssueRowsHtml =
|
|
issues.length > 0
|
|
? issues.length > 1
|
|
? issues
|
|
.slice(0, 1)
|
|
.map(
|
|
(issue) =>
|
|
`<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;">★ 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('')
|
|
: `<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;">★ AI</span><span style="margin-left:8px;vertical-align:middle;">${escapeHtml(issues[0].split('\n')[0] || '')}</span></td><td align="right" style="padding:7px 12px;width:90px;"></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('')
|
|
: ``
|
|
|
|
// 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>Untested 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>Untested 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;">✓</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_AUTH + 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');
|
|
}
|
|
|
|
// 1. Extract, Deduplicate (Set), filter, and sort SNs
|
|
const uniqueSns = [...new Set(listInventory.map((item) => item.sn).filter(Boolean))];
|
|
const snList = uniqueSns.sort((a, b) => b.length - a.length);
|
|
|
|
if (snList.length === 0) {
|
|
return escapeHtml(text);
|
|
}
|
|
|
|
// 2. Escape regex special chars and combine into a single Regex OR statement
|
|
const escapedForRegex = snList.map((sn) => sn.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
|
const combinedRegex = new RegExp(`\\b(${escapedForRegex.join('|')})\\b`, 'g');
|
|
|
|
let result = '';
|
|
let lastIndex = 0;
|
|
let match;
|
|
|
|
// 3. Single-pass execution over the raw text
|
|
while ((match = combinedRegex.exec(text)) !== null) {
|
|
const matchedSn = match[0];
|
|
const startIndex = match.index;
|
|
|
|
// Escape and append the text BEFORE the match
|
|
result += escapeHtml(text.substring(lastIndex, startIndex));
|
|
|
|
// Escape the SN and wrap it in the highlight span
|
|
const safeSn = escapeHtml(matchedSn);
|
|
result += `<span id="${safeSn}" style="background-color:#fbbf24;color:#78350f;font-weight:600;padding:2px 6px;border-radius:3px;cursor:pointer;" title="Click Hardware Inventory link to scroll">${safeSn}</span>`;
|
|
|
|
// Update the index to move forward
|
|
lastIndex = combinedRegex.lastIndex;
|
|
}
|
|
|
|
// Append any remaining text after the final match
|
|
result += escapeHtml(text.substring(lastIndex));
|
|
|
|
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 & 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 & 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 YYYY, HH:mm') : ''}</div>
|
|
</div>
|
|
<div style="padding:10px 14px;${!dataIncomingBySN?.packagePo?.notes && !serialInfo?.notes ? "background:#f9fafb;border-left:3px solid #e5e7eb;color:#5f6978;" : "background:#fffbeb;border-left:3px solid #f59e0b;color:#92400e;"}border-radius:0 6px 6px 0;font-size:12px;margin-bottom:8px;">
|
|
<div style="font-weight:700;margin-bottom:4px;font-size:11px;">⚠ 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;">
|
|
|
|
</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;">
|
|
✓
|
|
</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 YYYY, 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;">
|
|
✓
|
|
</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 YYYY, 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;">
|
|
✓
|
|
</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 || new Date()).tz(timeZone).format('DD MMM YYYY, 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;"> </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;"> </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 YYYY, 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 || new Date()).tz(timeZone).format('DD MMM YYYY, 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 & 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>
|
|
</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.addHistory(
|
|
this.config.stationId,
|
|
this.config.id,
|
|
{
|
|
id: this.config.id,
|
|
number: this.config.lineNumber,
|
|
stationId: this.config.stationId,
|
|
pid: productPN,
|
|
sn: productSN,
|
|
vid: productVid,
|
|
scenario: '',
|
|
timestamp: Date.now(),
|
|
},
|
|
this.outputTestLog,
|
|
portPhysical
|
|
)
|
|
this.updateNote(config?.inventory?.sn, this.dataDPELP as DataDPELP)
|
|
await sendMessageToMail(
|
|
`[ATC] - [${config.stationName} - L${config.lineNumber}] - ${this.config.inventory?.pid} - ${this.config.inventory?.sn} - Test Summary`,
|
|
body
|
|
)
|
|
this.outputTestLog = ''
|
|
this.socketIO.emit('summary_tested', {
|
|
stationId: this.config.stationId,
|
|
lineId: this.config.id,
|
|
body: body,
|
|
title: `[ATC] - [${config.stationName} - L${config.lineNumber}] - ${this.config.inventory?.pid} - ${this.config.inventory?.sn} - Test Summary`,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Reset config information of line
|
|
*/
|
|
initConfig() {
|
|
this.config = {
|
|
id: 0,
|
|
port: 0,
|
|
lineNumber: 0,
|
|
ip: '',
|
|
stationId: 0,
|
|
stationName: '',
|
|
stationIp: '',
|
|
outlet: 0,
|
|
output: '',
|
|
status: '',
|
|
baud: 0,
|
|
openCLI: false,
|
|
userEmailOpenCLI: '',
|
|
userOpenCLI: '',
|
|
inventory: [],
|
|
data: [],
|
|
ports: [],
|
|
runningScenario: '',
|
|
runningPhysical: false,
|
|
listFeatureTested: [],
|
|
isReady: false,
|
|
}
|
|
this.physicalTest = new PhysicalPortTest([])
|
|
}
|
|
|
|
setTimeoutSendSummaryReport(timeout: number) {
|
|
// Debounce send summary report
|
|
if (this.debounceSendSummaryReport) clearTimeout(this.debounceSendSummaryReport)
|
|
// Snapshot toàn bộ data tại thời điểm này
|
|
const snapshot = {
|
|
snapConfig: this.config,
|
|
snapPhysical: this.physicalTest,
|
|
reason: '',
|
|
}
|
|
this.debounceSendSummaryReport = setTimeout(() => {
|
|
if (!this.config.listFeatureTested?.includes('PHYSICAL')) {
|
|
this.config.isSkipPhysical = true
|
|
this.config.reasonSkipPhysical = 'Timeout, The user has not completed the physical test'
|
|
snapshot.reason = 'Timeout, The user has not completed the physical test'
|
|
}
|
|
this.config.listFeatureTested = ['DPELP', 'PHYSICAL', 'SUMMARY']
|
|
this.sendFeatureTested()
|
|
this.sendReportSummaryV2(snapshot)
|
|
}, timeout)
|
|
}
|
|
|
|
resetDPELP() {
|
|
this.config.listFeatureTested = []
|
|
this.config.isSkipPhysical = false
|
|
this.config.reasonSkipPhysical = ''
|
|
this.dataDPELP = ''
|
|
this.sendFeatureTested()
|
|
console.log('Reset DPELP data and features', this.config.id, this.config.listFeatureTested)
|
|
}
|
|
|
|
async pingToServer(serverIP: string) {
|
|
this.isPingToServer = true
|
|
this.writeCommand('\r\n')
|
|
this.writeCommand('enable\r\n')
|
|
await sleep(500)
|
|
this.writeCommand(`ping ${serverIP}\r\n`)
|
|
await sleep(500)
|
|
const start = Date.now()
|
|
// console.log('[EXPECT]', expect, timeout)
|
|
while (Date.now() - start < 60000) {
|
|
if (this.outputPingToServer.includes('Success rate')) {
|
|
const match = this.outputPingToServer.match(/Success rate is (\d+) percent/)
|
|
if (match) {
|
|
const rate = Number(match[1])
|
|
if (rate > 0) {
|
|
this.outputPingToServer = ''
|
|
this.isPingToServer = false
|
|
return true
|
|
} else {
|
|
this.isPingToServer = false
|
|
this.outputPingToServer = ''
|
|
this.config.output += '\n[CONNECT_TO_SERVER_TFTP_FAIL]\n'
|
|
this.socketIO.emit('line_output', {
|
|
stationId: this.config.stationId,
|
|
lineId: this.config.id,
|
|
data: '\n[CONNECT_TO_SERVER_TFTP_FAIL]\n',
|
|
})
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
await sleep(500)
|
|
}
|
|
this.isPingToServer = false
|
|
this.outputPingToServer = ''
|
|
this.config.output += '\n[CONNECT_TO_SERVER_TFTP_FAIL]\n'
|
|
this.socketIO.emit('line_output', {
|
|
stationId: this.config.stationId,
|
|
lineId: this.config.id,
|
|
data: '\n[CONNECT_TO_SERVER_TFTP_FAIL]\n',
|
|
})
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Config ip address and default gateway for line
|
|
*/
|
|
async configAddressGateway(
|
|
address: string,
|
|
gateway: string,
|
|
portName: string,
|
|
isRouter?: boolean
|
|
) {
|
|
this.config.runningScenario = 'Config Network'
|
|
this.socketIO.emit('running_scenario', {
|
|
stationId: this.config.stationId,
|
|
lineId: this.config.id,
|
|
title: 'Config Network',
|
|
password: false,
|
|
})
|
|
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: '',
|
|
password: false,
|
|
})
|
|
}
|
|
}
|