import Scenario from '#models/scenario' import fs from 'node:fs' import path from 'node:path' import nodeMailer from 'nodemailer' import zulip from 'zulip-js' import { ErrorRow, LogRule, ParsedLog, TestError, TestResult } from './types.js' import axios from 'axios' import moment from 'moment' type DetectAI = { status: string[] issue: string[] summary: string } type InputData = { lineNumber: number inventory: any latestScenario?: { detectAI?: DetectAI } data?: any[] } // Types type SendMailResponse = string type SendMessageType = 'stream' | 'private' /** * Function to clean up unwanted characters from the output data. * @param {string} data - The raw data to be cleaned. * @returns {string} - The cleaned data. */ export const cleanData = (data: string) => { return ( data // 1️⃣ Xóa chuỗi "--More--" (Cisco/Unix pager) .replace(/--More--[\s\x08\x1b\[K]*/g, '') // 2️⃣ Xóa toàn bộ chuỗi ANSI escape sequences // Ví dụ: ESC[2J, ESC[K, ESC[?25h, ESC[0m, ... .replace(/\x1B\[[0-9;?]*[A-Za-z]/g, '') // 3️⃣ Xóa ký tự Backspace (BS) hoặc Delete (DEL) .replace(/[\x08\x7F]/g, '') // 4️⃣ Xóa ký tự NUL và các control char khác (trừ \r, \n, \t) .replace(/[^\x09\x0A\x0D\x20-\x7E]/g, '') ) // 5️⃣ Chuẩn hóa xuống dòng nếu cần // .replace(/\r\n/g, '\n') } export function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)) } // 20250527-AUTO-Session.Station_1-13-192.168.171.9-2.log // {DATE}-AUTO-Session.{Station name}-{Station ID}-{Station IP}-{Line number}.log export function appendLog( output: string, stationId: number, stationName: string, stationIP: string, lineNumber: number | string ) { const date = new Date().toISOString().slice(0, 10).replace(/-/g, '') // YYYYMMDD const logDir = path.join('storage', 'system_logs') const logFile = path .join(logDir, `${date}-AUTO-Session.${stationName}-${stationId}-${stationIP}-${lineNumber}.log`) .replaceAll(' ', '_') // Ensure folder exists if (!fs.existsSync(logDir)) { fs.mkdirSync(logDir, { recursive: true }) } fs.appendFile(logFile, output, (err) => { if (err) { console.error('❌ Failed to write log:', err.message) } }) } export const getPathLog = (stationId: number, lineNumber: number, port: number) => { const date = new Date().toISOString().slice(0, 10).replace(/-/g, '') // YYYYMMDD const logDir = path.join('storage', 'system_logs') const logFile = path.join(logDir, `${date}-Station_${stationId}-Line_${lineNumber}_${port}.log`) // Ensure folder exists if (!fs.existsSync(logDir)) { fs.mkdirSync(logDir, { recursive: true }) return null } else return logFile } /** * Utility function get scope log with timestamp. * @param {string} text - content log. * @param {number} time - Timestamp. */ export const getLogWithTimeScenario = (text: string, time: number) => { try { // Match all start and end blocks const regex = /---(start|end)-scenarios---(\d+)---/g let match const blocks = [] while ((match = regex.exec(text)) !== null) { blocks.push({ type: match[1], timestamp: match[2], index: match.index, }) } // Find the matching block for the end timestamp let result = null for (let i = 0; i < blocks.length; i++) { const block = blocks[i] if (block.type === 'end' && block.timestamp === time.toString()) { // Find nearest preceding "start" for (let j = i - 1; j >= 0; j--) { if (blocks[j].type === 'start') { const startIndex = blocks[j].index const endIndex = block.index + text.slice(block.index).indexOf('\n') // or manually offset length of the line result = text.slice(startIndex, endIndex).trim() break } } break } } return result } catch (err) { console.error('Error get log:', err) return '' } } export function isValidJson(string: string) { try { JSON.parse(string) return true // Chuỗi là định dạng JSON hợp lệ } catch (e) { return false // Chuỗi không phải là định dạng JSON hợp lệ } } export function mapToLineFormat(input: InputData) { const line = input.lineNumber const pid = input.inventory?.pid || '' const vid = input.inventory?.vid || '' const sn = input.inventory?.sn || '' if (!pid || !sn) { return { line, pid: '', vid: '', sn: '', ios: '', mac: '', license: [], issues: ['No data'], summary: '', } } // MAC let mac = '' let ios = '' const showVersion = input.data?.find( (d) => d.command === 'show version' || d.command === 'sh version' || d.command === 'show ver' || d.command === 'sh ver' ) if (showVersion?.textfsm?.[0]?.MAC_ADDRESS) { mac = showVersion.textfsm[0].MAC_ADDRESS } if (showVersion?.textfsm?.[0]?.SOFTWARE_IMAGE) { ios = showVersion.textfsm[0].SOFTWARE_IMAGE + ' ' + (showVersion?.textfsm?.[0]?.VERSION || '') } // License const dataLicense = input.data?.find((comm) => comm.command?.trim() === 'show license') const license = dataLicense?.textfsm && Array.isArray(dataLicense.textfsm) ? dataLicense.textfsm ?.filter((el: any) => el.LICENSE_TYPE === 'Permanent') .map((v: any) => v.FEATURE) : '' // // Mode (DPEL / DPELP) // const dataPlatform = input.data?.find((el) => el.command?.trim() === 'show platform') // const mode = dataPlatform && !dataPlatform.output?.includes('Incomplete') ? 'DPELP' : 'DPEL' // Issues const issues = Array.isArray(input.latestScenario?.detectAI?.issue) ? input.latestScenario.detectAI.issue : input.latestScenario?.detectAI?.issue ? [input.latestScenario.detectAI.issue] : [] // Issues const summary = input.latestScenario?.detectAI?.summary || '' return { line, pid, vid, sn, ios, mac, license, issues, summary, } } export function sendMessageToMail( email: string, subject: string, text: string, cc?: string[] ): Promise { return new Promise((resolve, reject) => { const transporter = nodeMailer.createTransport({ pool: true, host: process.env.SMTP_HOST, port: Number(process.env.SMTP_PORT), secure: true, auth: { user: process.env.SMTP_USERNAME, pass: process.env.SMTP_PASSWORD, }, }) const mailOptions = { from: process.env.SMTP_USERNAME, to: email, subject, html: text, cc: cc, } transporter.sendMail(mailOptions, (error: any, info: any) => { if (error) { console.error(error) reject(error) } else { console.log('Email sent: ' + info.response) resolve(info.response) } }) }) } export function sendMessageToZulip( type: SendMessageType, to: string | number | string[], topic: string | undefined, content: string ): Promise | null { return new Promise((resolve, reject) => { const config = { realm: process.env.ZULIP_REALM as string, username: process.env.ZULIP_USERNAME as string, apiKey: process.env.ZULIP_API_KEY as string, } zulip(config).then((client: any) => { if (type === 'stream') { client.messages .send({ type, to, topic: topic || '', content, }) .then((response: any) => { console.log('Message sent: ' + JSON.stringify(response)) resolve(response) }) .catch((error: any) => { console.error(error) reject(error) }) } else if (type === 'private') { client.messages .send({ type, to, content, }) .then((response: any) => { console.log('Message sent: ' + JSON.stringify(response)) resolve(response) }) .catch((error: any) => { console.error(error) reject(error) }) } }) }) } // Catch scenario with key longer export const detectScenarioByModel = async (model: string, listScenarios: number[]) => { let scenarios = await Scenario.query().preload('brand').preload('category') let scenarioDefault = await Scenario.findBy('title', 'DPELP DEFAULT') const normalizedModel = model.trim().toUpperCase() let matched: { scenario: Scenario; score: number } | null = null for (const scenario of scenarios) { if (listScenarios.includes(scenario.id)) continue const seriesList: string[] = Array.isArray(scenario.series) ? scenario.series : JSON.parse(scenario.series || '[]') for (const s of seriesList) { const pattern = s.trim().toUpperCase() if (normalizedModel.startsWith(pattern)) { const score = pattern.length if (!matched || score > matched.score) { matched = { scenario, score } } } } } return matched?.scenario ? matched?.scenario : listScenarios.length === 0 ? scenarioDefault : null } export function classifyLog(line: string): ParsedLog { if (/System Bootstrap|IOS XE Software|Booting/.test(line)) return { raw: line, category: 'BOOT' } if (/LICENSE|Smart Licensing|Evaluation/.test(line)) return { raw: line, category: 'LICENSE' } if (/LINK-3-UPDOWN|line protocol/.test(line)) return { raw: line, category: 'INTERFACE' } if (/FAN|TEMP|POWER|PSU/.test(line)) return { raw: line, category: 'HARDWARE' } if (/ERROR|FAIL|CRITICAL|Traceback/.test(line)) return { raw: line, category: 'ERROR' } return { raw: line, category: 'UNKNOWN' } } export const RULES: LogRule[] = [ // BOOT { id: 'BOOT_OK', category: 'BOOT', match: /IOS XE Software|System Bootstrap|Boot successful/i, level: 'PASS', message: 'Boot successful', }, { id: 'BOOT_LOOP', category: 'BOOT', match: /boot loop|reloading|restart/i, level: 'FAIL', message: 'Boot loop detected', }, { id: 'BOOT_CRASH', category: 'BOOT', match: /crashinfo|Traceback|Kernel panic/i, level: 'FAIL', message: 'System crash detected during boot', }, { id: 'BOOT_SLOW', category: 'BOOT', match: /Booting.*takes longer than expected/i, level: 'WARN', message: 'Boot time abnormal', }, // LICENSE { id: 'LICENSE_OK', category: 'LICENSE', match: /License State:\s*ACTIVE|Smart Licensing Status:\s*AUTHORIZED/i, level: 'PASS', message: 'License active', }, { id: 'LICENSE_EXPIRED', category: 'LICENSE', match: /Evaluation.*expired|license expired/i, level: 'WARN', message: 'License expired', }, { id: 'LICENSE_NOT_REGISTERED', category: 'LICENSE', match: /NOT REGISTERED|Registration failed/i, level: 'WARN', message: 'License not registered', }, { id: 'LICENSE_DISABLED', category: 'LICENSE', match: /Feature.*disabled due to license/i, level: 'FAIL', message: 'Critical features disabled by license', }, // INTERFACE // { // id: 'INTERFACE_UP', // category: 'INTERFACE', // match: /LINK-3-UPDOWN: Interface .* up/i, // level: 'PASS', // message: 'Interface up', // }, // { // id: 'INTERFACE_FLAP', // category: 'INTERFACE', // match: /LINK-3-UPDOWN: Interface .* down/i, // level: 'WARN', // message: 'Interface flapping detected', // }, { id: 'INTERFACE_ERROR', category: 'INTERFACE', match: /input errors|CRC|frame error/i, level: 'WARN', message: 'Interface errors detected', }, // HARDWARE { id: 'FAN_FAIL', category: 'HARDWARE', match: /FAN.*(FAIL|CRITICAL|NOT PRESENT)/i, level: 'FAIL', message: 'Fan failure', }, { id: 'PSU_FAIL', category: 'HARDWARE', match: /PSU.*(FAIL|CRITICAL|NOT PRESENT)/i, level: 'FAIL', message: 'Power supply failure', }, { id: 'TEMP_HIGH', category: 'HARDWARE', match: /TEMP.*(HIGH|CRITICAL)/i, level: 'FAIL', message: 'Over temperature detected', }, { id: 'HW_WARNING', category: 'HARDWARE', match: /ENVIRONMENT WARNING/i, level: 'WARN', message: 'Hardware environment warning', }, // ERROR { id: 'MEMORY_ERROR', category: 'ERROR', match: /malloc|out of memory|memory corruption/i, level: 'FAIL', message: 'Memory error detected', }, { id: 'FLASH_ERROR', category: 'ERROR', match: /flash.*(error|corrupt|fail)/i, level: 'FAIL', message: 'Flash storage error', }, { id: 'CONFIG_MISSING', category: 'ERROR', match: /startup-config is missing|No configuration found/i, level: 'WARN', message: 'Startup configuration missing', }, { id: 'SECURE_BOOT_FAIL', category: 'ERROR', match: /Secure Boot.*(FAIL|ERROR)/i, level: 'FAIL', message: 'Secure boot failed', }, ] export function applyRules(log: ParsedLog): TestError[] { return RULES.filter( (rule): rule is LogRule & { level: 'FAIL' | 'WARN' } => rule.category === log.category && rule.match.test(log.raw) && rule.level !== 'PASS' ).map((rule) => ({ ruleId: rule.id, level: rule.level, // ✅ giờ TS biết chắc chỉ FAIL | WARN message: rule.message, evidence: { raw: log.raw, timestamp: log.timestamp, }, })) } export class TestSession { bootOk = false errors: TestError[] = [] applyParsedLog(log: ParsedLog) { // Detect boot OK if (/IOS XE Software|System Bootstrap/.test(log.raw)) { this.bootOk = true } const matchedErrors = applyRules(log) matchedErrors.forEach((err) => this.addError(err)) } private addError(err: TestError) { const fingerprint = `${err.ruleId}|${this.normalize(err.evidence.raw)}` const existing = this.errors.find( (e) => `${e.ruleId}|${this.normalize(e.evidence.raw)}` === fingerprint ) if (existing) { existing.evidence.count = (existing.evidence.count ?? 1) + 1 return } err.evidence.count = 1 this.errors.push(err) } private normalize(raw: string): string { return raw .toLowerCase() .replace(/\d+/g, '#') // thay số (PSU 1, PSU 2 → PSU #) .replace(/\s+/g, ' ') .trim() } finalize(): TestResult { const hasFail = this.errors.some((e) => e.level === 'FAIL') const hasWarn = this.errors.some((e) => e.level === 'WARN') let status: TestResult['status'] = 'PASS' if (!this.bootOk || hasFail) status = 'FAIL' else if (hasWarn) status = 'PARTIAL' return { status, summary: this.buildSummary(status), errors: this.errors, } } clear() { this.errors = [] } private buildSummary(status: TestResult['status']): string { switch (status) { case 'PASS': return 'All tests passed' case 'FAIL': return 'Critical errors detected' case 'PARTIAL': return 'Warnings detected during test' } } } export class LogStreamBuffer { public allBuffer = '' private buffer = '' public push(chunk: Buffer): string[] { this.buffer += chunk.toString('utf8').replace('--More--', '').trim() this.allBuffer += cleanData(chunk.toString()) const lines = this.buffer.split(/\r?\n/) this.buffer = lines.pop() || '' return lines.map((l) => l.replaceAll('--More--', '').trim()).filter(Boolean) } public flush(): string | null { if (!this.buffer) return null const last = this.buffer this.buffer = '' return last } public clear() { this.allBuffer = '' } } export function mapErrorsToRows(errors: TestError[]): ErrorRow[] { return errors.map((e) => ({ level: e.level, rule: e.ruleId, message: e.message, log: e.evidence.raw, count: e.evidence.count ?? 1, })) } export function escapeHtml(str: string): string { return str.replace(/&/g, '&').replace(//g, '>') } export async function updateNoteToERP(sn: string, note: string) { try { const remoteUrl = process.env.ERP_URL || 'https://stage.nswteam.net' const header = { Authorization: 'Bearer ' + process.env.ERP_TOKEN, } const responseDataSN = await axios.post( remoteUrl + '/api/transferGetData', { urlAPI: '/api/stock-model-serial/get-list-regex', filter: { where: { _q: sn, }, }, orgId: ['5fadc798f070e4b64b53ac9c', '5fadc7b0f070e4b64b53ac9d'], }, { headers: header, } ) if (!responseDataSN?.data?.data || responseDataSN?.data?.data?.length === 0) { console.log('updateNoteToERP', responseDataSN?.data) return } const dataSN = responseDataSN?.data?.data[0] || {} const payload = { id: dataSN?.id, serialNumberA: dataSN?.serialNumberA, productModelId: dataSN?.productModelId, orgId: dataSN?.orgId, testNotes: note + (dataSN?.testNotes || ''), } // console.log(payload) await axios.post( remoteUrl + '/api/transferPostData', { urlAPI: '/api/stock-model-serial/data-save', data: payload, }, { headers: header, } ) } catch (error) { console.log(error) } } export function normalizeInterface(name: string): string { return name .replace(/^Gi(?=\d)/, 'GigabitEthernet') .replace(/^Fa(?=\d)/, 'FastEthernet') .replace(/^Te(?=\d)/, 'TenGigabitEthernet') .replace(/^Hu(?=\d)/, 'HundredGigE') .replace(/^Eth(?=\d)/, 'Ethernet') }