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
+ }
+ onClick={() => setOpenConfigRam(true)}
+ >
+ Config Ram
+
+
+ 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;
+};