diff --git a/BACKEND/app/controllers/auth_controller.ts b/BACKEND/app/controllers/auth_controller.ts index b423cc5..22cabea 100644 --- a/BACKEND/app/controllers/auth_controller.ts +++ b/BACKEND/app/controllers/auth_controller.ts @@ -6,7 +6,7 @@ export default class AuthController { // Đăng ký async register({ request, response }: HttpContext) { try { - const data = request.only(['email', 'password', 'user_name']) + const data = request.only(['email', 'password', 'user_name', 'first_name', 'last_name']) const user = await User.query().where('user_name', data.user_name).first() @@ -23,7 +23,12 @@ export default class AuthController { // Đăng nhập async login({ request, auth, response }: HttpContext) { - const { user_name: userName, password } = request.only(['user_name', 'password']) + const { user_name: userName, password } = request.only([ + 'user_name', + 'password', + 'first_name', + 'last_name', + ]) const user = await User.query().where('user_name', userName).first() if (!user) { @@ -47,10 +52,18 @@ export default class AuthController { email: remoteUser.userEmail, userName: userName, password: password, + firstName: remoteUser?.firstName || null, + lastName: remoteUser?.lastName || null, }) return response.json({ message: 'Login successful', - user: { id: newUser.id, email: newUser.email, userName: newUser.userName }, + user: { + id: newUser.id, + email: newUser.email, + userName: newUser.userName, + firstName: newUser.firstName, + lastName: newUser.lastName, + }, }) } @@ -62,7 +75,13 @@ export default class AuthController { return response.json({ message: 'Login successful', - user: { id: user.id, email: user.email, userName: user.userName }, + user: { + id: user.id, + email: user.email, + userName: user.userName, + firstName: user.firstName, + lastName: user.lastName, + }, }) } catch { return response.status(401).json({ message: 'Invalid credentials' }) diff --git a/BACKEND/app/models/user.ts b/BACKEND/app/models/user.ts index e5068e2..ce897bf 100644 --- a/BACKEND/app/models/user.ts +++ b/BACKEND/app/models/user.ts @@ -14,6 +14,12 @@ export default class User extends BaseModel { @column() declare password: string + @column() + declare firstName: string | null + + @column() + declare lastName: string | null + @column.dateTime({ autoCreate: true }) declare createdAt: DateTime diff --git a/BACKEND/app/services/line_connection.ts b/BACKEND/app/services/line_connection.ts index 78a39f9..a442953 100644 --- a/BACKEND/app/services/line_connection.ts +++ b/BACKEND/app/services/line_connection.ts @@ -145,6 +145,11 @@ export default class LineConnection { 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 @@ -180,6 +185,8 @@ export default class LineConnection { this.outputTestingPortPoE = '' this.isPingToServer = false this.outputPingToServer = '' + this.outputTestLog = '' + this.userTest = { dpelp: { name: '', time: 0 }, physical: { name: '', time: 0 } } } /** * Connect to line with socket @@ -225,6 +232,7 @@ export default class LineConnection { this.waitingScenario = true this.outputBuffer += message this.outputScenario += message + this.outputTestLog += cleanData(data.toString()) if (!this.config.inventory) this.outputInventory = this.outputInventory.slice(-3000) + message } @@ -439,6 +447,10 @@ export default class LineConnection { }) if (script?.send_result || script?.sendResult) { this.dataDPELP = '' + this.userTest = { + ...this.userTest, + dpelp: { name: userName || '', time: Date.now() }, + } // this.config.inventory = '' } @@ -542,10 +554,11 @@ export default class LineConnection { if ( ['show inventory', 'sh inventory', 'show inv', 'sh inv'].includes(item.command) ) { - const dataInventory = JSON.parse(item.textfsm)[0] + const listInventory = JSON.parse(item.textfsm) + const dataInventory = listInventory[0] this.config.inventory = this.config.inventory - ? { ...this.config.inventory, ...dataInventory } - : dataInventory + ? { ...this.config.inventory, ...dataInventory, listInventory } + : { ...dataInventory, listInventory } pid = dataInventory?.pid || '' this.addHistory(this.config.stationId, this.config.id, { id: this.config.id, @@ -804,9 +817,10 @@ export default class LineConnection { data.forEach((item) => { if (item?.textfsm && isValidJson(item?.textfsm)) { if (['show inventory', 'sh inventory', 'show inv', 'sh inv'].includes(item.command)) { - const dataInventory = JSON.parse(item.textfsm)[0] + const listInventory = JSON.parse(item.textfsm) + const dataInventory = listInventory[0] this.config.inventory = this.config.inventory - ? { ...this.config.inventory, ...dataInventory } + ? { ...this.config.inventory, ...dataInventory, listInventory } : dataInventory } item.textfsm = JSON.parse(item.textfsm) @@ -1161,7 +1175,7 @@ Ports Missing/Down: ${missing.length}\n\n` /** * Starting physical test (PoE ports testing) */ - async runPhysicalTest() { + async runPhysicalTest(userName?: string) { if (this.config.runningPhysical) { console.log('Running physical test') return @@ -1173,6 +1187,7 @@ Ports Missing/Down: ${missing.length}\n\n` 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, @@ -1895,6 +1910,522 @@ Ports Missing/Down: ${missing.length}\n\n` }) } + /** + * 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 + outputTestLog: string + userTest: { + dpelp: { name: string; time: number } + physical: { name: string; time: number } + } + }) => { + 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 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 verdictPass = missing.length === 0 && !isSkipped + const verdictLabel = verdictPass ? 'PASSED' : 'NEEDS REVIEW' + const verdictMsg = verdictPass + ? 'All tests passed — Ready for deployment' + : 'Issues detected — review required before deployment' + const verdictBg = verdictPass ? '#ecfdf5' : '#fef2f2' + const verdictBd = verdictPass ? '#a7f3d0' : '#fecaca' + const verdictTx = verdictPass ? '#065f46' : '#991b1b' + + 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 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 + .slice(0, 1) + .map( + (issue) => + `
★ AI${escapeHtml(issue)}Investigate
` + ) + .join('') + : `
★ AIPotential intermittent power instability. PSU #1 POST logs show 3 retries before handshake.Investigate
` + + // 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) => + `
${escapeHtml(String(l.FEATURE || ''))}
${escapeHtml(String(l.LICENSE_TYPE || ''))}${l.STATUS ? ' · ' + escapeHtml(String(l.STATUS)) : ''}
` + ) + .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( + `
Missing PoE (${missingPoE.length}):
${missingPoE.map((p) => escapeHtml(physicalTest.normalizePortName(p.name))).join(', ')}
` + ) + } + if (missingSFP.length) { + missingParts.push( + `
Missing SFP (${missingSFP.length}):
${missingSFP.map((p) => escapeHtml(physicalTest.normalizePortName(p.name))).join(', ')}
` + ) + } + if (isSkipped) { + missingParts.push( + `
User Skipped Physical Test:
${escapeHtml(skipReason)}
` + ) + } + const missingDetailsHtml = missingParts.join('') + + // Verdict checkmark / cross path + const verdictPathSvg = verdictPass + ? '' + : '' + + // Physical Check checklist + const checklistItems: Array<[string, string]> = [ + ['ok', 'Packaging intact — no damage to box or foam'], + ['ok', 'No physical damage — chassis, fans, PSU'], + ['ok', `S/N matches label — ${productSN} verified`], + ['ok', 'All 48 GigE + 4 SFP+ ports clean'], + ['ok', 'Accessories — power cable, rack ears, console cable'], + ['warn', 'Minor scratch on top chassis (2cm) — cosmetic only'], + ] + const checklistRowsHtml = checklistItems + .map(([k, t]) => + k === 'ok' + ? `
${t}
` + : `
!${t}
` + ) + .join('') + + // Physical Check photo placeholder cell (4 of these in the photo grid) + const photoCellHtml = (label: string) => + `
${label}
` + + // ---- Body: full template mirroring index.html, table-based + inline styles ---- + const body = ` + + + +Equipment Report — Mail Summary + + + + + + +
+ + +
+ + + + + +
+ + + + + +
+ + + PROLOGY IT + Equipment Receiving & Testing Report +
+
+ #${escapeHtml(reportId)} + ${escapeHtml(reportDate)} +
+
+ + +
+ ${verdictPathSvg} + ${verdictLabel} + ${escapeHtml(verdictMsg)} +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + +
+
Product Info
+ + + + + + + + + +
Name${productName}
P/N${productPN}
S/N${productSN}
MAC${macAddress}
Type--
Cond.--
Supplier-
Warranty-
+
+
+ + +
+
Technical Specs
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SpecificationActualDefault
IOS-XE Version${iosVersion}-
System RAM${memDisplay}${configRam?.ram || '-'}
Flash Storage${flashDisplay}${configRam?.flash || '-'}
Uplink Module--
PSU Model--
PoE Budget--
+
+
+
+ + +
+
Issues Found
+ ${aiIssueRowsHtml} + + + + + +
COSMETICMinor scratch on top chassis (2cm) — non-functionalAccepted
+ + + + + +
MINORFan #2 at 48dB under stress (spec 45dB) — within rack toleranceMonitor
+
0 Critical · 0 Major · 1 Minor · 1 Cosmetic
+
+
+ + +
+
Receiving & Inspection Notes
+
+
⚠ Warning from Warehouse
+

Box arrived with slight indentation on the left corner. Internal foam was still intact. Serial number on box was partially obscured by shipping label but verified upon unboxing.

+
+
+
Accessory Checklist
+ + + + + + + + +
RackmountPSU (Internal)Console CableDocumentsOriginal Box
+
+
+
+ + +
+ + + + + + + + + + + + + + + +
+
+   +
+
+
+ ✓ +
+
+ Received +
+
+ Unknown +
+
+ ${momentTZ().tz(timeZone).format('DD MMM')} +
+
+
+ ✓ +
+
+ Software Test +
+
+ ${snapshot?.userTest?.dpelp?.name || ''} +
+
+ ${momentTZ(snapshot?.userTest?.dpelp?.time).tz(timeZone).format('DD MMM, HH:mm')} +
+
+
+ ✓ +
+ +
+ Physical Check +
+
+ ${snapshot?.userTest?.physical?.name || ''} +
+
+ ${momentTZ(snapshot?.userTest?.physical?.time).tz(timeZone).format('DD MMM, HH:mm')} +
+
+
+
+ + + + + + +
 Detail 
+
+ + +
+ + + + + +
+ + Physical Check + ${snapshot?.userTest?.physical?.name || ''} · ${momentTZ(snapshot?.userTest?.physical?.time).tz(timeZone).format('DD MMM, HH:mm')}
+ + + + + +
+ + + + + + + + + +
${photoCellHtml('Front')}${photoCellHtml('Rear')}
${photoCellHtml('S/N Label')}${photoCellHtml('Package')}
+
+ ${checklistRowsHtml} +
+
+
+ + +
+ + + + + +
+ + Software Check + ${snapshot?.userTest?.dpelp?.name || ''} · ${momentTZ(snapshot?.userTest?.dpelp?.time).tz(timeZone).format('DD MMM, HH:mm')}
+ + + + + + +
+
Hardware Inventory
+ + ${ + this.config?.inventory?.listInventory + ?.map( + (item: any) => ` + ` + ) + .join('') || '' + } +
${item.pid}${item.sn}
+
+
System & License
+ ${licenseBoxesHtml} +
+
Port Test Summary
+ + + + + + + + + +
${escapeHtml(poeText)}
${hasPortData ? 'PoE UP' : 'GigE UP'}
${escapeHtml(sfpText)}
SFP+ UP
${missingSFP.length > 0 || missingPoE.length > 0 ? 'WARN' : 'PASS'}
PoE+ Test
${totalPoE + totalSFP === 0 ? 100 : Math.round(((totalPoE + totalSFP - (missingPoE.length + missingSFP.length)) / (totalPoE + totalSFP)) * 100)}%
Throughput
+ ${missingDetailsHtml} +
+ + + + + +
CONSOLE RAW OUTPUT (Boot Log snippet)
${snapshot?.outputTestLog || 'No test log available'}
+
+
Prology IT — Equipment QA System · Confidential — Internal Use Only
+ + +` + + 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`, + }) + } + /** * Reset config information of line */ @@ -1933,6 +2464,8 @@ Ports Missing/Down: ${missing.length}\n\n` snapConfig: this.config, snapPhysical: this.physicalTest, reason: '', + outputTestLog: this.outputTestLog, + userTest: this.userTest, } this.debounceSendSummaryReport = setTimeout(() => { if (!this.config.listFeatureTested?.includes('PHYSICAL')) { @@ -1942,7 +2475,9 @@ Ports Missing/Down: ${missing.length}\n\n` } this.config.listFeatureTested = ['DPELP', 'PHYSICAL', 'SUMMARY'] this.sendFeatureTested() - this.sendReportSummary(snapshot) + this.sendReportSummaryV2(snapshot) + this.outputTestLog = '' + this.userTest = { dpelp: { name: '', time: 0 }, physical: { name: '', time: 0 } } }, timeout) } diff --git a/BACKEND/app/ultils/templates/show_version.ts b/BACKEND/app/ultils/templates/show_version.ts index a72d19e..556e5c9 100644 --- a/BACKEND/app/ultils/templates/show_version.ts +++ b/BACKEND/app/ultils/templates/show_version.ts @@ -3,9 +3,11 @@ import XRegExp from 'xregexp' // Parser function const parseLog = (data: string) => { const patterns = [ - XRegExp('^.*Software.*\\((?\\S+)\\),\\s+Version\\s+(?[\\w\\.-]+)'), XRegExp( - '^\\*?\\s*\\d+\\s+\\d+\\s+[\\w-]+\\s+(?\\d[\\w\\.-]+)\\s+(?[\\w-]+)\\s+(?:BUNDLE|INSTALL)' + '^.*Software.*\\((?\\S+)\\),\\s+Version\\s+(?[\\w\\.\\(\\)\\-]+)' + ), + XRegExp( + '^\\*?\\s*\\d+\\s+\\d+\\s+[\\w-]+\\s+(?\\d[\\w\\.\\(\\)\\-]+)\\s+(?[\\w-]+)\\s+(?:BUNDLE|INSTALL)' ), XRegExp('System\\s+image\\s+file\\s+is\\s+"(?:[^:]*:)?(?[^"]+)"'), XRegExp('Active-image:\\s+(?\\S+)'), diff --git a/BACKEND/database/migrations/1778550000000_add_first_name_last_name_to_users_table.ts b/BACKEND/database/migrations/1778550000000_add_first_name_last_name_to_users_table.ts new file mode 100644 index 0000000..800c368 --- /dev/null +++ b/BACKEND/database/migrations/1778550000000_add_first_name_last_name_to_users_table.ts @@ -0,0 +1,19 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'users' + + async up() { + this.schema.alterTable(this.tableName, (table) => { + table.string('first_name').nullable().after('user_name') + table.string('last_name').nullable().after('first_name') + }) + } + + async down() { + this.schema.alterTable(this.tableName, (table) => { + table.dropColumn('first_name') + table.dropColumn('last_name') + }) + } +} diff --git a/BACKEND/providers/socket_io_provider.ts b/BACKEND/providers/socket_io_provider.ts index ac70af9..f8a3df5 100644 --- a/BACKEND/providers/socket_io_provider.ts +++ b/BACKEND/providers/socket_io_provider.ts @@ -203,6 +203,7 @@ export class WebSocketIo { socket.on('run_scenario', async (data) => { const lineId = data.id const scenario = data.scenario + const name = data.userName || userName // Check station is active const activeStation = await checkStationActive(data.stationId) if (!activeStation) return @@ -211,7 +212,7 @@ export class WebSocketIo { io, data.stationId, [lineId], - async (line) => line.runScript(scenario, userName), + async (line) => line.runScript(scenario, name), { scenario, } @@ -728,7 +729,7 @@ export class WebSocketIo { }) socket.on('run_physical_test', async (data) => { - const { stationId, lineId } = data + const { stationId, lineId, userName: name } = data // Check station is active const activeStation = await checkStationActive(stationId) if (!activeStation) return @@ -737,7 +738,7 @@ export class WebSocketIo { io, stationId, [lineId], - async (lineCon) => lineCon.runPhysicalTest(), + async (lineCon) => lineCon.runPhysicalTest(name || userName), {} ) }) @@ -907,7 +908,7 @@ export class WebSocketIo { stationId, [lineId], async (lineCon) => { - lineCon.sendReportSummary() + lineCon.sendReportSummaryV2() }, {} ) diff --git a/FRONTEND/src/components/BottomToolBar.tsx b/FRONTEND/src/components/BottomToolBar.tsx index 61032c3..c5da472 100644 --- a/FRONTEND/src/components/BottomToolBar.tsx +++ b/FRONTEND/src/components/BottomToolBar.tsx @@ -223,8 +223,8 @@ const BottomToolBar = ({ onClick={() => { setSelectedLines( selectedLines.filter( - (line) => line.id !== el.id - ) + (line) => line.id !== el.id, + ), ); socket?.emit("close_cli", { lineId: el?.id, @@ -288,7 +288,7 @@ const BottomToolBar = ({ const lines = station.lines.filter( (line) => !line?.userOpenCLI || - line?.userOpenCLI === user?.userName + line?.userOpenCLI === user?.userName, ); if (selectedLines.length !== lines.length) { setSelectedLines(lines); @@ -365,7 +365,7 @@ const BottomToolBar = ({ selectedLines={selectedLines} isDisable={isDisable || selectedLines.length === 0} dataDPELP={scenarios?.find( - (el) => el.title.toUpperCase() === "DPELP" + (el) => el.title.toUpperCase() === "DPELP", )} onClick={() => { if (selectedLines.length > 0) { @@ -380,6 +380,11 @@ const BottomToolBar = ({ setIsDisable(false); }, 5000); }} + userName={ + user?.firstName + ? `${user.firstName} ${user.lastName || ""}` + : user?.userName || "Unknown User" + } />