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' import Station from '#models/station' import ConfigRam from '#models/config_ram' import Keyword from '#models/keywords' const mailTo = 'andrew.ng@apactech.io' const mailCC = [ 'ips@ipsupply.com.au', 'kay@ipsupply.com.au', 'joseph@apactech.io', 'kiet.phan@apactech.io', ] // const mailCC = '' 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' type KeywordMatchType = 'contains' | 'exact' interface KeywordRule extends LogRule { keywordId?: number } /** * 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: '', // ram: '', // flash: '', // license: [], // issues: ['No data'], // summary: '', // } // } // MAC let mac = '' let ios = '' let ram = '' let flash = '' 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 || '') } if (showVersion?.textfsm?.[0]?.MEMORY) { ram = showVersion.textfsm[0].MEMORY } if (showVersion?.textfsm?.[0]?.USB_FLASH) { flash = showVersion.textfsm[0].USB_FLASH } // 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, ram, flash, license, issues, summary, } } export function sendMessageToMail(subject: string, text: 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: mailTo, subject, html: text, cc: mailCC, } 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 } // Catch scenario with key longer export const detectConfigRamByModel = async (model: string) => { let configsRam = await ConfigRam.query() const normalizedModel = model.trim().toUpperCase() let matched: { conf: ConfigRam; score: number } | null = null for (const config of configsRam) { const modelsList: string[] = Array.isArray(config.models) ? config.models : JSON.parse(config.models || '[]') for (const s of modelsList) { const pattern = s.trim().toUpperCase() if (normalizedModel.startsWith(pattern)) { const score = pattern.length if (!matched || score > matched.score) { matched = { conf: config, score } } } } } return matched?.conf ? matched?.conf : 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: 'SPECIAL_KEYWORD' } } 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', }, { id: 'PS_INCOMPATIBLE', category: 'HARDWARE', match: /%PLATFORM_FEP-\d+-FRU_PS_INCOMPATIBLE/i, level: 'FAIL', message: 'Power supply incompatible', }, // 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 async function applyRules(log: ParsedLog): Promise { const KEYWORD_RULES: KeywordRule[] = await loadKeywordRules(log.raw) return [...RULES, ...KEYWORD_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, category: rule.category, evidence: { raw: log.raw, timestamp: log.timestamp, }, })) } export class TestSession { bootOk = false errors: TestError[] = [] async applyParsedLog(log: ParsedLog) { // Detect boot OK if (/IOS XE Software|System Bootstrap/.test(log.raw)) { this.bootOk = true } const matchedErrors = await 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 || (err.ruleId === e.ruleId && err.category === 'SPECIAL_KEYWORD') ) 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 (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, } ) // console.log('updateNoteToERP', responseDataSN?.data?.data) if (!responseDataSN?.data?.data || responseDataSN?.data?.data?.length === 0) { return } const dataSN = responseDataSN?.data?.data.length === 1 ? responseDataSN?.data?.data[0] : responseDataSN?.data?.data.length > 1 ? responseDataSN?.data?.data.find((el: any) => el.serialNumberA === sn) : {} if (!dataSN?.id) return 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('updateNoteToERP', 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') } type BodyType = 'ROUTER_IOS' | 'SWITCH_IOS' | 'SWITCH_LICENSE' | 'ROUTER_LICENSE' export function buildBody( type: BodyType, tftpIp: string, fileName: string, address: string, gateway: string, listDeviceIos: string[], portName?: string ) { switch (type) { /* ================= ROUTER LOAD IOS ================= */ case 'ROUTER_IOS': return [ { expect: '', send: `IP_ADDRESS=${address}`, delay: '1', repeat: '1', note: '', }, { expect: 'rommon', send: `IP_SUBNET_MASK=255.255.0.0`, delay: '1', repeat: '1', note: '', }, { expect: 'rommon', send: `DEFAULT_GATEWAY=${gateway}`, delay: '1', repeat: '1', note: '', }, { expect: 'rommon', send: `TFTP_SERVER=${tftpIp}`, delay: '1', repeat: '1', note: '', }, { expect: 'rommon', send: `TFTP_FILE=i/${fileName}`, delay: '1', repeat: '1', note: '', }, { expect: 'rommon', send: listDeviceIos?.includes(fileName) ? '' : `tftpdnld`, delay: '1', repeat: '1', note: '', }, { expect: listDeviceIos?.includes(fileName) ? '' : 'y/n', send: listDeviceIos?.includes(fileName) ? '' : `y`, delay: '2', repeat: '1', note: '', }, { expect: 'rommon', send: `boot usbflash0:${fileName}`, delay: '1', repeat: '1', note: '', }, { expect: 'Press RETURN to get started', send: ``, delay: '1', repeat: '1', note: '', }, { expect: '', send: ``, delay: '1', repeat: '1', note: '', }, { expect: '', send: `enable`, delay: '1', repeat: '1', note: '', }, { expect: '#', send: `show inventory`, delay: '1', repeat: '1', note: '', }, { expect: '#', send: `show license`, delay: '1', repeat: '1', note: 'Verify license status', }, { expect: '#', send: ` show version`, delay: '3', repeat: '1', note: '', }, { expect: '#', send: `configure terminal`, delay: '1', repeat: '1', note: '', }, { expect: '', send: `boot system usbflash0:${fileName}`, delay: '1', repeat: '1', note: '', }, { expect: '', send: `end`, delay: '1', repeat: '1', note: '', }, { expect: '', send: `write memory`, delay: '1', repeat: '1', note: '', }, { expect: '', send: ``, delay: '1', repeat: '1', note: '', }, ] /* ================= SWITCH LOAD IOS ================= */ case 'SWITCH_IOS': return [ { expect: '', send: `enable`, delay: '1', repeat: '1', note: '', }, { expect: '', send: ``, delay: '2', repeat: '1', note: '' }, { expect: '#', send: listDeviceIos?.includes(fileName) ? '' : `copy tftp: flash:`, delay: '1', repeat: '1', note: '', }, { expect: '', send: listDeviceIos?.includes(fileName) ? '' : `${tftpIp}`, delay: '1', repeat: '1', note: '', }, { expect: '', send: listDeviceIos?.includes(fileName) ? '' : `i/${fileName}`, delay: '1', repeat: '1', note: '', }, { expect: '', send: ``, delay: '1', repeat: '1', note: '' }, { expect: '', send: ``, delay: '1', repeat: '1', note: '' }, { expect: '#', send: ``, delay: '1', repeat: '1', note: '' }, { expect: '#', send: `configure terminal`, delay: '1', repeat: '1', note: '', }, { expect: '#', send: `boot system flash:${fileName}`, delay: '1', repeat: '1', note: '', }, { expect: '', send: `end`, delay: '1', repeat: '1', note: '', }, { expect: '', send: `write memory`, delay: '1', repeat: '1', note: '', }, { expect: '', send: `reload`, delay: '1', repeat: '1', note: '', }, { expect: '', // Router thường hỏi câu này send: ``, // Enter confirm delay: '1', repeat: '1', note: 'Confirm reload', }, { expect: '', send: ``, delay: '1', repeat: '1', note: 'Waiting for reboot...', }, // --- PHẦN 4: VERIFY --- { expect: 'Press RETURN to get started!', send: ``, delay: '1', repeat: '1', note: 'Router is back online', }, { expect: '', send: `enable`, delay: '3', repeat: '1', note: 'Enable again', }, { expect: '#', send: `show inventory`, delay: '1', repeat: '1', note: '', }, { expect: '#', send: `show license`, delay: '1', repeat: '1', note: 'Verify license status', }, { expect: '#', send: ` show version`, delay: '3', repeat: '1', note: 'Verify version info', }, ] /* ================= SWITCH LICENSE ================= */ case 'SWITCH_LICENSE': return [ { expect: '', send: ``, delay: '1', repeat: '1', note: 'Start session', }, { expect: '', send: `enable`, delay: '1', repeat: '1', note: 'Enter Enable mode', }, { expect: '#', send: `license install tftp://${tftpIp}/License/${fileName}`, delay: '1', repeat: '1', note: 'Install license', }, { expect: '', send: ``, delay: '1', repeat: '1', note: '', }, { expect: '#', send: `write memory`, delay: '1', repeat: '1', note: 'Save config', }, { expect: '', send: ``, delay: '1', repeat: '1', note: '', }, { expect: '#', send: `reload`, delay: '1', repeat: '1', note: 'Reload switch', }, { expect: '', // Router thường hỏi câu này send: ``, // Enter confirm delay: '1', repeat: '1', note: 'Confirm reload', }, { expect: '', send: ``, delay: '1', repeat: '1', note: 'Waiting for reboot...', }, // --- PHẦN 4: VERIFY --- { expect: 'Press RETURN to get started!', send: ``, delay: '1', repeat: '1', note: 'Router is back online', }, { expect: '', send: `enable`, delay: '3', repeat: '1', note: 'Enable again', }, { expect: '#', send: `show inventory`, delay: '1', repeat: '1', note: '', }, { expect: '#', send: `show license`, delay: '1', repeat: '1', note: 'Verify license status', }, { expect: '#', send: ` show version`, delay: '3', repeat: '1', note: 'Verify version info', }, ] /* ================= ROUTER LICENSE ================= */ case 'ROUTER_LICENSE': return [ { expect: '', send: ``, delay: '1', repeat: '1', note: 'Start session', }, { expect: '', send: `enable`, delay: '1', repeat: '1', note: 'Enter Enable mode', }, { expect: '#', send: `license install tftp://${tftpIp}/License/${fileName}`, delay: '1', repeat: '1', note: 'Install license', }, { expect: '', send: ``, delay: '1', repeat: '1', note: '', }, { expect: '#', send: `write memory`, delay: '1', repeat: '1', note: 'Save config', }, { expect: '', send: ``, delay: '1', repeat: '1', note: '', }, { expect: '#', send: `reload`, delay: '1', repeat: '1', note: 'Reload router', }, { expect: '', send: `yes`, delay: '1', repeat: '1', note: 'Confirm reload', }, { expect: '', send: ``, delay: '1', repeat: '1', note: 'Confirm reload', }, { expect: '', send: ``, delay: '1', repeat: '1', note: 'Waiting for reboot...', }, // --- PHẦN 4: VERIFY --- { expect: 'Press RETURN to get started!', send: ``, delay: '1', repeat: '1', note: 'Router is back online', }, { expect: '', send: `enable`, delay: '3', repeat: '1', note: 'Enable again', }, { expect: '#', send: `show inventory`, delay: '1', repeat: '1', note: '', }, { expect: '#', send: `show license`, delay: '1', repeat: '1', note: 'Verify license status', }, { expect: '#', send: ` show version`, delay: '3', repeat: '1', note: 'Verify version info', }, ] default: return [] } } export function parseLicenseReport(output: string) { const summary = [] const imported = [] const exist = [] const failed = [] // lấy toàn bộ feature const allFeatureRegex = /Feature:([a-z0-9_]+)/gi let match while ((match = allFeatureRegex.exec(output))) { summary.push(match[1]) } // split theo từng dòng install const lines = output.split('\n') for (let i = 0; i < lines.length; i++) { const line = lines[i] // success const successMatch = line.match(/Feature:([a-z0-9_]+)\.\.\.Successful/i) if (successMatch) { imported.push(successMatch[1]) continue } // failed const failedMatch = line.match(/Feature:([a-z0-9_]+)\.\.\.Failed:/i) if (failedMatch) { const feature = failedMatch[1] // check duplicate ở dòng sau const nextLine = lines[i + 1] || '' if (/Duplicate license/i.test(nextLine)) { exist.push(feature) } else { failed.push(feature) } } } return { summary: [...summary], imported: [...imported], exist: [...exist], failed: [...failed], } } export async function checkStationActive(stationId: string): Promise { const station = await Station.find(stationId) return station?.is_active || false } // Kiểm tra RAM total lớn hơn RAM mặc định export function isRamSufficient(deviceRam: string, defaultRamLimit: string): boolean { if (!defaultRamLimit || !deviceRam) return false // ---- Parse + cộng tất cả phần RAM của device ---- const parts = deviceRam.split('/') let deviceRamKB = 0 for (const part of parts) { const trimmed = part.trim() deviceRamKB += convertToKilobytes(trimmed) } // ---- Parse RAM mặc định ---- const defaultRamKB = convertToKilobytes(defaultRamLimit) return deviceRamKB >= defaultRamKB } export function convertToKilobytes(input: string): number { const trimmed = input.trim().toUpperCase() const match = trimmed.match(/^([\d.]+)\s*(K|M|G|T)?B?$/) if (!match) { return trimmed ? Number.parseFloat(trimmed) : 0 } const value = Number.parseFloat(match[1]) const unit = match[2] || 'K' // default to KB if no unit const unitMultipliers: { [key: string]: number } = { K: 1, M: 1024, G: 1024 * 1024, T: 1024 * 1024 * 1024, } return Math.round(value * unitMultipliers[unit]) } function escapeRegex(str: string) { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } function keywordToRule(keyword: Keyword): KeywordRule { let match: RegExp switch (keyword.match_type) { case 'exact': match = new RegExp(`^${escapeRegex(keyword.name)}$`, 'i') break case 'contains': default: match = new RegExp(escapeRegex(keyword.name), 'i') } return { id: `${keyword.name}`, keywordId: keyword.id, category: 'SPECIAL_KEYWORD', match, level: 'WARN', message: `Type: ${keyword.type}`, } } async function loadKeywordRules(log: string): Promise { const keywords = await Keyword.query() for (const keyword of keywords) { if (log.toUpperCase().includes(keyword.name.toUpperCase())) { return keywords.map(keywordToRule) } } return [] } export function convertFromKilobytesString(input: string, decimals = 0): string { if (!input) return '0 KB' const parts = input.split('/') if (parts.length === 0) return '0 KB' // ---- Step 1: parse + cộng tất cả về KB ---- const toKB: Record = { K: 1, M: 1024, G: 1024 ** 2, T: 1024 ** 3, } let totalKB = 0 for (const part of parts) { const trimmed = part.trim().toUpperCase() const match = trimmed.match(/^([\d.]+)\s*(K|M|G|T)?B?$/) if (!match) continue const value = Number.parseFloat(match[1]) const unit = match[2] || 'K' totalKB += value * toKB[unit] } if (totalKB === 0) return '0 KB' // ---- Step 2: convert KB -> đơn vị đẹp nhất ---- const units = ['KB', 'MB', 'GB', 'TB'] let unitIndex = 0 let displayValue = totalKB while (displayValue >= 1024 && unitIndex < units.length - 1) { displayValue /= 1024 unitIndex++ } return `${displayValue.toFixed(decimals)} ${units[unitIndex]}` } export function canInputCommand(buffer: string): boolean { if (!buffer) return false const data = buffer.toString() // IOS prompt (hostname> hoặc hostname#) if (/[\r\n]?[\w.-]+[>#]\s?$/.test(data)) return true // Username / Password if (data.includes('Username:')) return true if (data.includes('Password:')) return true // ROMMON if (/rommon\s+\d+\s+>/i.test(data)) return true // Switch loader if (data.includes('switch:')) return true // Press RETURN if (data.includes('Press RETURN to get started!')) return true // yes/no cases if (/\[(yes\/no|confirm)\]/i.test(data)) return true if (/\((yes\/no|y\/n)\)/i.test(data)) return true if (/yes\/no/i.test(data)) return true return false }