diff --git a/BACKEND/app/controllers/config_ram_controller.ts b/BACKEND/app/controllers/config_ram_controller.ts new file mode 100644 index 0000000..f55e4c0 --- /dev/null +++ b/BACKEND/app/controllers/config_ram_controller.ts @@ -0,0 +1,125 @@ +import ConfigRam from '#models/config_ram' +import type { HttpContext } from '@adonisjs/core/http' + +export default class ConfigRamController { + /** + * Display a list of resource + */ + async get({}: HttpContext) { + const configRams = await ConfigRam.all() + return { status: true, data: configRams } + } + + /** + * Display form to create a new record + */ + async create({ auth, request, response }: HttpContext) { + let payload = request.only([...Array.from(ConfigRam.$columnsDefinitions.keys())]) + try { + // Check exist models + const inputModels: string[] = payload.models.map((s: string) => s.trim().toUpperCase()) + const existedConfigs = await ConfigRam.query().select('id', 'models') + const duplicatedModels: string[] = [] + for (const sc of existedConfigs) { + const scModels: string[] = JSON.parse(sc.models || '[]').map((s: string) => + s.trim().toUpperCase() + ) + for (const s of inputModels) { + if (scModels.includes(s)) { + duplicatedModels.push(s) + } + } + } + if (duplicatedModels.length) { + return response.badRequest({ + status: false, + message: 'Models already exists in another config', + duplicatedModels: [...new Set(duplicatedModels)], + }) + } + + const configRam = await ConfigRam.create({ + ...payload, + models: JSON.stringify(payload.models), + }) + return response.created({ + status: true, + message: 'Config created successfully', + data: configRam, + }) + } catch (error) { + return response.badRequest({ error: error, message: 'Config create failed', status: false }) + } + } + + async update({ request, response, auth }: HttpContext) { + let payload = request.only( + Array.from(ConfigRam.$columnsDefinitions.keys()).filter( + (f) => f !== 'created_at' && f !== 'updated_at' + ) + ) + try { + const configRam = await ConfigRam.find(request.body().id) + if (!configRam) { + return response.status(404).json({ message: 'Config not found' }) + } + + // Check exist models + const inputModels: string[] = payload.models.map((s: string) => s.trim().toUpperCase()) + const existedConfigRams = await ConfigRam.query().select('id', 'models') + const duplicatedModels: string[] = [] + for (const sc of existedConfigRams) { + if (sc.id === configRam.id) continue + const scModels: string[] = JSON.parse(sc.models || '[]').map((s: string) => + s.trim().toUpperCase() + ) + + for (const s of inputModels) { + if (scModels.includes(s)) { + duplicatedModels.push(s) + } + } + } + + if (duplicatedModels.length) { + return response.badRequest({ + status: false, + message: 'Models already exists in another config', + duplicatedModels: [...new Set(duplicatedModels)], + }) + } + + Object.assign(configRam, { ...payload, models: JSON.stringify(payload.models) }) + await configRam.save() + return response.ok({ status: true, message: 'Config update successfully', data: configRam }) + } catch (error) { + return response.badRequest({ error: error, message: 'Config update failed', status: false }) + } + } + + /** + * Delete record + */ + async delete({ auth, request, response }: HttpContext) { + try { + const configRam = await ConfigRam.find(request.body().id) + if (!configRam) { + return response.status(404).json({ message: 'Config not found' }) + } + + // Delete the configRam + await configRam.delete() + return response.ok({ + status: true, + message: 'Config Ram delete successfully', + data: configRam, + }) + } catch (error) { + return response.badRequest({ + error: error, + message: 'Config Ram delete failed', + status: false, + }) + } + } +} diff --git a/BACKEND/app/models/config_ram.ts b/BACKEND/app/models/config_ram.ts new file mode 100644 index 0000000..65bb855 --- /dev/null +++ b/BACKEND/app/models/config_ram.ts @@ -0,0 +1,22 @@ +import { DateTime } from 'luxon' +import { BaseModel, column } from '@adonisjs/lucid/orm' + +export default class ConfigRam extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare models: string + + @column() + declare ram: string + + @column() + declare flash: string + + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime +} diff --git a/BACKEND/app/services/line_connection.ts b/BACKEND/app/services/line_connection.ts index 08ec574..141f04c 100644 --- a/BACKEND/app/services/line_connection.ts +++ b/BACKEND/app/services/line_connection.ts @@ -6,8 +6,10 @@ import { buildBody, classifyLog, cleanData, + detectConfigRamByModel, detectScenarioByModel, escapeHtml, + isRamSufficient, isValidJson, LogStreamBuffer, mapErrorsToRows, @@ -29,6 +31,7 @@ import momentTZ from 'moment-timezone' import { PhysicalPortTest } from './physical_test_service.js' import Station from '#models/station' import IosLicenseController from '#controllers/ios_license_controller' +import ConfigRam from '#models/config_ram' type Inventory = { pid: string @@ -520,6 +523,9 @@ export default class LineConnection { this.config.inventory = this.config.inventory ? { ...this.config.inventory, ...dataVer } : dataVer + if (pid && dataVer?.MEMORY) { + await this.checkConfigRam(dataVer?.MEMORY, pid, cleanData(item.output)) + } } if ( item.command?.trim()?.includes('show env') || @@ -1591,4 +1597,24 @@ ${log} } return '' } + + async checkConfigRam(mem: string, pid: string, output: string) { + const configRam = await detectConfigRamByModel(pid) + if (configRam) { + const isWarningRAM = isRamSufficient(mem, configRam.ram) + if (isWarningRAM) { + const subject = `[ATC] - [${this.config.stationName} - Line: ${this.config.lineNumber}] - Warning RAM Configuration` + const body = ` +

Station: ${this.config.stationName}

+

Line: ${this.config.lineNumber}

+

Model: ${pid}

+

RAM: ${mem + ' bytes'} (default: ${configRam.ram})

+
+
+ ${escapeHtml(output).replace('show ver', '').replace('sh ver', '').replace('show version', '').replace('sh version', '').replace(mem, `${mem}`)}
+` + await sendMessageToMail(subject, body) + } + } + } } diff --git a/BACKEND/app/ultils/helper.ts b/BACKEND/app/ultils/helper.ts index 19af249..2727f84 100644 --- a/BACKEND/app/ultils/helper.ts +++ b/BACKEND/app/ultils/helper.ts @@ -7,15 +7,16 @@ 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' 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 = '' +// const mailCC = [ +// 'ips@ipsupply.com.au', +// 'kay@ipsupply.com.au', +// 'joseph@apactech.io', +// 'kiet.phan@apactech.io', +// ] +const mailCC = '' type DetectAI = { status: string[] @@ -173,6 +174,8 @@ export function mapToLineFormat(input: InputData) { sn: '', ios: '', mac: '', + ram: '', + flash: '', license: [], issues: ['No data'], summary: '', @@ -182,6 +185,8 @@ export function mapToLineFormat(input: InputData) { // MAC let mac = '' let ios = '' + let ram = '' + let flash = '' const showVersion = input.data?.find( (d) => d.command === 'show version' || @@ -195,6 +200,12 @@ export function mapToLineFormat(input: InputData) { 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') @@ -225,6 +236,8 @@ export function mapToLineFormat(input: InputData) { sn, ios, mac, + ram, + flash, license, issues, summary, @@ -343,6 +356,32 @@ export const detectScenarioByModel = async (model: string, listScenarios: number 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' } @@ -1403,3 +1442,37 @@ 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 + + const parts = deviceRam.split('/') + if (parts.length === 0) return false + + const totalRamStr = parts[0].trim() // lấy phần total (thường là trước dấu '/') + const totalRamKB = convertToKilobytes(totalRamStr) + const defaultRamKB = convertToKilobytes(defaultRamLimit) + return totalRamKB >= 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]) +} diff --git a/BACKEND/database/migrations/1769393382710_create_config_rams_table.ts b/BACKEND/database/migrations/1769393382710_create_config_rams_table.ts new file mode 100644 index 0000000..1a1de81 --- /dev/null +++ b/BACKEND/database/migrations/1769393382710_create_config_rams_table.ts @@ -0,0 +1,19 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'config_rams' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.increments('id') + table.text('models').notNullable() + table.string('ram') + table.string('flash') + table.timestamps() + }) + } + + async down() { + this.schema.dropTable(this.tableName) + } +} diff --git a/BACKEND/start/routes.ts b/BACKEND/start/routes.ts index 4db01ba..247278d 100644 --- a/BACKEND/start/routes.ts +++ b/BACKEND/start/routes.ts @@ -112,3 +112,12 @@ router router.get('/license', '#controllers/ios_license_controller.getLicense') }) .prefix('/api') + +router + .group(() => { + router.get('/', '#controllers/config_ram_controller.get') + router.post('/create', '#controllers/config_ram_controller.create') + router.post('/update', '#controllers/config_ram_controller.update') + router.post('/delete', '#controllers/config_ram_controller.delete') + }) + .prefix('/api/config-ram') diff --git a/FRONTEND/src/components/DragTabs.tsx b/FRONTEND/src/components/DragTabs.tsx index 10c34c1..de99f44 100644 --- a/FRONTEND/src/components/DragTabs.tsx +++ b/FRONTEND/src/components/DragTabs.tsx @@ -48,6 +48,7 @@ import type { Socket } from "socket.io-client"; import ModalHistory from "./Modal/ModalHistory"; import ModalConfig from "./Modal/ModalConfig"; import DrawerScenario from "./Modal/ModalScenario"; +import ModalConfigRamFlash from "./Modal/ModalConfigRamFlash"; interface DraggableTabsProps { tabsData: TStation[]; @@ -151,6 +152,7 @@ export default function DraggableTabs({ const [isHistoryModalOpen, setIsHistoryModalOpen] = useState(false); const [openConfig, setOpenConfig] = useState(false); const [openDrawerScenario, setOpenDrawerScenario] = useState(false); + const [openConfigRam, setOpenConfigRam] = useState(false); const sensors = useSensors(useSensor(PointerSensor)); @@ -271,6 +273,15 @@ export default function DraggableTabs({ > Scenario + + + setOpenConfigRam(false)} + /> ); } diff --git a/FRONTEND/src/components/Modal/ModalConfigRamFlash.tsx b/FRONTEND/src/components/Modal/ModalConfigRamFlash.tsx new file mode 100644 index 0000000..166b822 --- /dev/null +++ b/FRONTEND/src/components/Modal/ModalConfigRamFlash.tsx @@ -0,0 +1,498 @@ +import { useEffect, useState } from "react"; +import { + Modal, + Button, + Grid, + ScrollArea, + Table, + Box, + Flex, + TextInput, + TagsInput, + Badge, +} from "@mantine/core"; +import axios from "axios"; +import type { ConfigRam } from "../../untils/types"; +import { IconX } from "@tabler/icons-react"; +import { notifications } from "@mantine/notifications"; +import DialogConfirm from "../DialogConfirm"; + +const apiUrl = import.meta.env.VITE_BACKEND_URL; + +interface Props { + opened: boolean; + onClose: () => void; +} + +export default function ModalConfigRamFlash({ opened, onClose }: Props) { + const [configs, setConfigs] = useState([]); + const [dataConfig, setDataConfig] = useState(null); + const [newConfig, setNewConfig] = useState({ + flash: "", + models: [], + ram: "", + }); + const [inputModels, setInputModels] = useState(""); + const [disabled, setDisabled] = useState(false); + const [openConfirm, setOpenConfirm] = useState(false); + + // get list config + const getListConfig = async () => { + try { + const response = await axios.get(apiUrl + "api/config-ram"); + if (response.data.data && Array.isArray(response.data.data)) { + setConfigs( + response.data.data.map((item: ConfigRam) => ({ + ...item, + models: + typeof item.models === "string" + ? JSON.parse(item.models) + : item.models, + })) + ); + } + } catch (error) { + console.log("Error get config", error); + } + }; + + useEffect(() => { + if (opened) getListConfig(); + }, [opened]); + + const handleChange = (field: keyof ConfigRam, value: string | string[]) => { + if (!dataConfig) return; + setDataConfig({ + ...dataConfig, + [field]: value, + }); + }; + + const handleAdd = async () => { + setDisabled(true); + try { + const response = await axios.post(apiUrl + `api/config-ram/create`, { + ...newConfig, + models: newConfig.models, + }); + + // update local list + setConfigs((prev) => [ + ...prev, + { + ...response.data.data, + models: + typeof response.data.data.models === "string" + ? JSON.parse(response.data.data.models) + : response.data.data.models, + }, + ]); + notifications.show({ + title: "Success", + message: response.data.message, + color: "green", + }); + setNewConfig({ + flash: "", + models: [], + ram: "", + }); + setDisabled(false); + } catch (error) { + console.log("Error save config", error); + setDisabled(false); + if (axios.isAxiosError(error)) { + notifications.show({ + title: "Error", + message: error?.response?.data?.message, + color: "red", + }); + } else { + notifications.show({ + title: "Error", + message: "Create fail, please try again!", + color: "red", + }); + } + } + }; + + const handleSave = async () => { + setDisabled(true); + if (!dataConfig) return; + + try { + const response = await axios.post(apiUrl + `api/config-ram/update`, { + ...dataConfig, + models: dataConfig.models, + }); + + // update local list + setConfigs((prev) => + prev.map((item) => (item.id === dataConfig.id ? dataConfig : item)) + ); + + setDataConfig(null); + setDisabled(false); + notifications.show({ + title: "Success", + message: response.data.message, + color: "green", + }); + } catch (error) { + console.log("Error save config", error); + setDisabled(false); + if (axios.isAxiosError(error)) { + notifications.show({ + title: "Error", + message: error?.response?.data?.message, + color: "red", + }); + } else { + notifications.show({ + title: "Error", + message: "Update fail, please try again!", + color: "red", + }); + } + } + }; + + const handleDelete = async () => { + setDisabled(true); + if (!dataConfig) return; + + try { + const response = await axios.post(apiUrl + `api/config-ram/delete`, { + ...dataConfig, + }); + + // update local list + setConfigs((prev) => prev.filter((item) => item.id !== dataConfig.id)); + + setDataConfig(null); + setDisabled(false); + notifications.show({ + title: "Success", + message: response.data.message, + color: "green", + }); + } catch (error) { + console.log("Error save config", error); + setDisabled(false); + if (axios.isAxiosError(error)) { + notifications.show({ + title: "Error", + message: error?.response?.data?.message, + color: "red", + }); + } else { + notifications.show({ + title: "Error", + message: "Delete fail, please try again!", + color: "red", + }); + } + } + }; + + const handleCancel = () => { + setDataConfig(null); + }; + + const filteredConfigs = () => { + const listConfigs = [...configs].filter((el) => { + const models: string[] = el.models || []; + if (!models.length) return false; + return models.some((model) => + model.toLowerCase().includes(inputModels.trim().toLowerCase()) + ); + }); + return listConfigs; + }; + + return ( + + + + + setInputModels(e.currentTarget.value)} + size="xs" + rightSection={ + inputModels ? ( + setInputModels("")} + /> + ) : null + } + rightSectionPointerEvents="auto" + /> + +
+ + + + setNewConfig({ + ...newConfig, + models: value.map((v) => v.toUpperCase()), + }) + } + size="xs" + /> + + setNewConfig({ + ...newConfig, + ram: e.currentTarget.value.toUpperCase(), + }) + } + size="xs" + /> + + setNewConfig({ + ...newConfig, + flash: e.currentTarget.value.toUpperCase(), + }) + } + size="xs" + /> + + + + + + + + + Models + + Ram + + + Flash + + + Action + + + + + + {filteredConfigs().length > 0 ? ( + filteredConfigs().map((element) => { + const isEditing = + dataConfig?.id === element.id && !openConfirm; + + return ( + + {/* MODELS */} + + {" "} + + {isEditing ? ( + + handleChange( + "models", + value.map((v) => v.toUpperCase()) + ) + } + /> + ) : element.models ? ( + element.models?.map((el: string, i: number) => ( + + {el} + + )) + ) : ( + "" + )} + + + + {/* RAM */} + + {isEditing ? ( + + handleChange( + "ram", + e.currentTarget.value.toUpperCase() + ) + } + size="xs" + /> + ) : ( + element.ram + )} + + + {/* FLASH */} + + {isEditing ? ( + + handleChange( + "flash", + e.currentTarget.value.toUpperCase() + ) + } + size="xs" + /> + ) : ( + element.flash + )} + + + {/* ACTION */} + + + {isEditing ? ( + <> + + + + ) : ( + <> + + + + )} + + + + ); + }) + ) : ( + + + No configurations found. + + + )} + +
+
+
+
+
+ + { + setDataConfig(null); + setOpenConfirm(false); + }} + message={"Are you sure delete this config?"} + handle={() => { + setOpenConfirm(false); + handleDelete(); + }} + centered={true} + /> +
+ ); +} diff --git a/FRONTEND/src/untils/types.ts b/FRONTEND/src/untils/types.ts index 3b7b412..f7cca6c 100644 --- a/FRONTEND/src/untils/types.ts +++ b/FRONTEND/src/untils/types.ts @@ -281,3 +281,10 @@ export type FileInfo = { fileSize: number; dateModify: string; }; + +export type ConfigRam = { + id?: number; + models: string[]; + ram: string; + flash: string; +};