From 240dfdff2c21d3f40a2c3c0b340d070f36f1afc0 Mon Sep 17 00:00:00 2001 From: nguyentrungthat <80239428+nguentrungthat@users.noreply.github.com> Date: Wed, 29 Oct 2025 16:23:00 +0700 Subject: [PATCH] Update --- BACKEND/app/services/line_connection.ts | 23 +- BACKEND/app/ultils/helper.ts | 4 +- BACKEND/providers/socket_io_provider.ts | 56 ++- FRONTEND/package-lock.json | 74 ++- FRONTEND/package.json | 6 +- FRONTEND/src/App.tsx | 473 ++++++++++--------- FRONTEND/src/components/CardLine.tsx | 19 +- FRONTEND/src/components/Component.module.css | 71 ++- FRONTEND/src/components/DragTabs.tsx | 298 ++++++++++++ FRONTEND/src/components/DrawerLogs.tsx | 193 ++++++++ FRONTEND/src/components/DrawerScenario.tsx | 2 +- FRONTEND/src/components/ModalLog.tsx | 95 ++++ FRONTEND/src/components/ModalTerminal.tsx | 202 +++++++- FRONTEND/src/components/TerminalXTerm.tsx | 19 +- FRONTEND/src/untils/types.ts | 31 ++ FRONTEND/tsconfig.app.json | 2 +- 16 files changed, 1287 insertions(+), 281 deletions(-) create mode 100644 FRONTEND/src/components/DragTabs.tsx create mode 100644 FRONTEND/src/components/DrawerLogs.tsx create mode 100644 FRONTEND/src/components/ModalLog.tsx diff --git a/BACKEND/app/services/line_connection.ts b/BACKEND/app/services/line_connection.ts index 8142624..8b86f7e 100644 --- a/BACKEND/app/services/line_connection.ts +++ b/BACKEND/app/services/line_connection.ts @@ -85,7 +85,12 @@ export default class LineConnection { lineId: id, data: message, }) - appendLog(cleanData(message), this.config.stationId, this.config.id) + appendLog( + cleanData(message), + this.config.stationId, + this.config.lineNumber, + this.config.port + ) }) this.client.on('error', (err) => { @@ -161,7 +166,8 @@ export default class LineConnection { appendLog( `\n\n---start-scenarios---${Date.now()}---\n---scenario---${script?.title}---${Date.now()}---\n`, this.config.stationId, - this.config.id + this.config.lineNumber, + this.config.port ) const steps = typeof script?.body === 'string' ? JSON.parse(script?.body) : [] let stepIndex = 0 @@ -176,7 +182,12 @@ export default class LineConnection { lineId: this.config.id, data: 'Timeout run scenario', }) - appendLog(`\n---end-scenarios---${Date.now()}---\n`, this.config.stationId, this.config.id) + appendLog( + `\n---end-scenarios---${Date.now()}---\n`, + this.config.stationId, + this.config.lineNumber, + this.config.port + ) // reject(new Error('Script timeout')) }, script.timeout || 300000) @@ -188,7 +199,8 @@ export default class LineConnection { appendLog( `\n---end-scenarios---${Date.now()}---\n`, this.config.stationId, - this.config.id + this.config.lineNumber, + this.config.port ) resolve(true) return @@ -198,7 +210,8 @@ export default class LineConnection { appendLog( `\n---send-command---"${step?.send ?? ''}"---${Date.now()}---\n`, this.config.stationId, - this.config.id + this.config.lineNumber, + this.config.port ) let repeatCount = Number(step.repeat) || 1 const sendCommand = () => { diff --git a/BACKEND/app/ultils/helper.ts b/BACKEND/app/ultils/helper.ts index 4bce2b8..32eb541 100644 --- a/BACKEND/app/ultils/helper.ts +++ b/BACKEND/app/ultils/helper.ts @@ -20,10 +20,10 @@ export function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)) } -export function appendLog(output: string, stationId: number, lineId: number) { +export function appendLog(output: string, 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_${lineId}.log`) + const logFile = path.join(logDir, `${date}-Station_${stationId}-Line_${lineNumber}_${port}.log`) // Ensure folder exists if (!fs.existsSync(logDir)) { diff --git a/BACKEND/providers/socket_io_provider.ts b/BACKEND/providers/socket_io_provider.ts index cc91d2c..9f5a24f 100644 --- a/BACKEND/providers/socket_io_provider.ts +++ b/BACKEND/providers/socket_io_provider.ts @@ -1,3 +1,4 @@ +import fs from 'node:fs' import { Server as SocketIOServer } from 'socket.io' import http from 'node:http' import LineConnection from '../app/services/line_connection.js' @@ -142,7 +143,7 @@ export class WebSocketIo { this.setTimeoutConnect( lineId, line, - scenario?.timeout ? Number(scenario?.timeout) + 120000 : 300000 + scenario?.timeout ? Number(scenario?.timeout) + 180000 : 300000 ) line.runScript(scenario) } else { @@ -205,6 +206,57 @@ export class WebSocketIo { } } }) + + socket.on('request_take_over', async (data) => { + io.emit('confirm_take_over', data) + }) + + socket.on('get_list_logs', async () => { + let getListSystemLogs = fs + .readdirSync('storage/system_logs') + .map((f) => 'storage/system_logs/' + f) + io.to(socket.id).emit('list_logs', getListSystemLogs) + }) + + socket.on('get_content_log', async (data) => { + try { + const { line, socketId } = data + const filePath = line.systemLogUrl + if (fs.existsSync(filePath)) { + // Get file stats + const stats = fs.statSync(filePath) + const fileSizeInBytes = stats.size + if (fileSizeInBytes / 1024 / 1024 > 0.5) { + // File is larger than 0.5 MB + const fileId = Date.now() // Mã định danh file + const chunkSize = 64 * 1024 // 64KB + const fileBuffer = fs.readFileSync(filePath) + const totalChunks = Math.ceil(fileBuffer.length / chunkSize) + + for (let i = 0; i < totalChunks; i++) { + const chunk = fileBuffer.slice(i * chunkSize, (i + 1) * chunkSize) + io.to(socketId).emit('response_content_log', { + type: 'chunk', + chunk: { + fileId, + chunkIndex: i, + totalChunks, + chunk, + }, + }) + } + } else { + console.log(filePath) + const content = fs.readFileSync(filePath) + socket.emit('response_content_log', content) + } + } else { + io.to(socketId).emit('response_content_log', Buffer.from('File not found', 'utf-8')) + } + } catch (error) { + console.log(error) + } + }) }) socketServer.listen(SOCKET_IO_PORT, () => { @@ -244,7 +296,7 @@ export class WebSocketIo { } } - private setTimeoutConnect = (lineId: number, lineConn: LineConnection, timeout = 120000) => { + private setTimeoutConnect = (lineId: number, lineConn: LineConnection, timeout = 180000) => { if (this.intervalMap[`${lineId}`]) { clearInterval(this.intervalMap[`${lineId}`]) delete this.intervalMap[`${lineId}`] diff --git a/FRONTEND/package-lock.json b/FRONTEND/package-lock.json index 4f1a129..7291382 100644 --- a/FRONTEND/package-lock.json +++ b/FRONTEND/package-lock.json @@ -8,6 +8,9 @@ "name": "ATC", "version": "0.0.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@mantine/core": "^8.3.5", "@mantine/dates": "^8.3.5", "@mantine/form": "^8.3.5", @@ -16,6 +19,7 @@ "@tabler/icons-react": "^3.35.0", "@xterm/addon-fit": "^0.10.0", "axios": "^1.12.2", + "moment": "^2.30.1", "react": "^19.1.1", "react-dom": "^19.1.1", "react-router-dom": "^7.9.4", @@ -24,7 +28,7 @@ }, "devDependencies": { "@eslint/js": "^9.36.0", - "@types/node": "^24.9.1", + "@types/node": "^24.9.2", "@types/react": "^19.1.16", "@types/react-dom": "^19.1.9", "@vitejs/plugin-react": "^5.0.4", @@ -328,6 +332,59 @@ "node": ">=6.9.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.11", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz", @@ -1609,9 +1666,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.9.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz", - "integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==", + "version": "24.9.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.2.tgz", + "integrity": "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==", "dev": true, "license": "MIT", "dependencies": { @@ -3273,6 +3330,15 @@ "node": "*" } }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", diff --git a/FRONTEND/package.json b/FRONTEND/package.json index 913d62b..00abdef 100644 --- a/FRONTEND/package.json +++ b/FRONTEND/package.json @@ -10,6 +10,9 @@ "preview": "vite preview" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@mantine/core": "^8.3.5", "@mantine/dates": "^8.3.5", "@mantine/form": "^8.3.5", @@ -18,6 +21,7 @@ "@tabler/icons-react": "^3.35.0", "@xterm/addon-fit": "^0.10.0", "axios": "^1.12.2", + "moment": "^2.30.1", "react": "^19.1.1", "react-dom": "^19.1.1", "react-router-dom": "^7.9.4", @@ -26,7 +30,7 @@ }, "devDependencies": { "@eslint/js": "^9.36.0", - "@types/node": "^24.9.1", + "@types/node": "^24.9.2", "@types/react": "^19.1.16", "@types/react-dom": "^19.1.9", "@vitejs/plugin-react": "^5.0.4", diff --git a/FRONTEND/src/App.tsx b/FRONTEND/src/App.tsx index 5f1b4d0..b4000a4 100644 --- a/FRONTEND/src/App.tsx +++ b/FRONTEND/src/App.tsx @@ -4,32 +4,30 @@ import "@mantine/notifications/styles.css"; import "./App.css"; import classes from "./App.module.css"; -import { Suspense, useEffect, useMemo, useState } from "react"; +import { Suspense, useCallback, useEffect, useMemo, useState } from "react"; import { Tabs, Text, Container, Flex, MantineProvider, - FloatingIndicator, Grid, ScrollArea, Button, - ActionIcon, LoadingOverlay, - Avatar, - Tooltip, } from "@mantine/core"; import type { + IDataTakeOver, IScenario, LineConfig, + ReceivedFile, + ResponseData, TLine, TStation, TUser, } from "./untils/types"; import axios from "axios"; import CardLine from "./components/CardLine"; -import { IconEdit, IconSettingsPlus } from "@tabler/icons-react"; import { SocketProvider, useSocket } from "./context/SocketContext"; import { ButtonDPELP, ButtonScenario } from "./components/ButtonAction"; import StationSetting from "./components/FormAddEdit"; @@ -37,6 +35,8 @@ import DrawerScenario from "./components/DrawerScenario"; import { Notifications } from "@mantine/notifications"; import ModalTerminal from "./components/ModalTerminal"; import PageLogin from "./components/Authentication/LoginPage"; +import DrawerLogs from "./components/DrawerLogs"; +import DraggableTabs from "./components/DragTabs"; const apiUrl = import.meta.env.VITE_BACKEND_URL; @@ -57,14 +57,6 @@ function App() { const [stations, setStations] = useState([]); const [selectedLines, setSelectedLines] = useState([]); const [activeTab, setActiveTab] = useState("0"); - const [controlsRefs, setControlsRefs] = useState< - Record - >({}); - const [rootRef, setRootRef] = useState(null); - const setControlRef = (val: string) => (node: HTMLButtonElement) => { - controlsRefs[val] = node; - setControlsRefs(controlsRefs); - }; const [showBottomShadow, setShowBottomShadow] = useState(false); const [isDisable, setIsDisable] = useState(false); const [isOpenAddStation, setIsOpenAddStation] = useState(false); @@ -75,6 +67,12 @@ function App() { const [selectedLine, setSelectedLine] = useState(); const [loadingTerminal, setLoadingTerminal] = useState(true); const [usersConnecting, setUsersConnecting] = useState([]); + const [disableRequestTakeOver, setDisableRequestTakeOver] = useState(false); + const [countDownRequest, setCountDownRequest] = useState(0); + const [dataRequestTakeOver, setDataRequestTakeOver] = + useState(); + const [testLogContent, setTestLogContent] = useState(""); + const [isLogModalOpen, setIsLogModalOpen] = useState(false); // function get list station const getStation = async () => { @@ -119,17 +117,17 @@ function App() { socket.on("line_disconnected", updateStatus); socket?.on("line_output", (data) => { - updateValueLineStation(data?.lineId, "netOutput", data.data); + updateValueLineStation(data?.lineId, { netOutput: data.data }); }); socket?.on("line_error", (data) => { - updateValueLineStation(data?.lineId, "netOutput", data.error); + updateValueLineStation(data?.lineId, { netOutput: data.error }); }); socket?.on("init", (data) => { if (Array.isArray(data)) { data.forEach((value) => { - updateValueLineStation(value?.id, "netOutput", value.output); + updateValueLineStation(value?.id, { netOutput: value.output }); updateStatus({ ...value, lineId: value.id }); }); } @@ -143,24 +141,85 @@ function App() { socket?.on("user_open_cli", (data) => { setTimeout(() => { - updateValueLineStation(data?.lineId, "cliOpened", true); - updateValueLineStation( - data?.lineId, - "userEmailOpenCLI", - data?.userEmailOpenCLI - ); - updateValueLineStation(data?.lineId, "userOpenCLI", data?.userOpenCLI); + updateValueLineStation(data.lineId, { + cliOpened: true, + userEmailOpenCLI: data.userEmailOpenCLI, + userOpenCLI: data.userOpenCLI, + }); }, 100); }); socket?.on("user_close_cli", (data) => { setTimeout(() => { - updateValueLineStation(data?.lineId, "cliOpened", false); - updateValueLineStation(data?.lineId, "userEmailOpenCLI", ""); - updateValueLineStation(data?.lineId, "userOpenCLI", ""); + updateValueLineStation(data.lineId, { + cliOpened: false, + userEmailOpenCLI: "", + userOpenCLI: "", + }); }, 100); }); + socket?.on("confirm_take_over", (data) => { + setDataRequestTakeOver(data); + if (data?.userEmail !== user?.email) { + setCountDownRequest(20); + const intervalCount = setInterval(() => { + setCountDownRequest((prev) => { + if (prev <= 1) { + setDataRequestTakeOver(undefined); + clearInterval(intervalCount); + return 0; + } + return prev - 1; + }); + }, 1000); + } + + if (!data?.userEmail) setCountDownRequest(0); + }); + + const receivedFiles: Record = {}; + socket?.on("response_content_log", (data: ResponseData) => { + if (!data.chunk) { + const decoder = new TextDecoder("utf-8"); + const str = decoder.decode(data as unknown as ArrayBuffer); + setTestLogContent(str); + return; + } + + const { fileId, chunkIndex, totalChunks, chunk } = data.chunk; + + if (!receivedFiles[fileId]) { + receivedFiles[fileId] = { + chunks: [], + receivedChunks: 0, + totalChunks, + }; + } + + let bufferChunk: Buffer; + + if (chunk instanceof ArrayBuffer) { + bufferChunk = Buffer.from(new Uint8Array(chunk)); // ✅ convert properly + } else if (chunk instanceof Uint8Array) { + bufferChunk = Buffer.from(chunk); // ✅ direct support + } else { + bufferChunk = chunk as Buffer; // fallback if server sends Buffer + } + + receivedFiles[fileId].chunks[chunkIndex] = bufferChunk; + receivedFiles[fileId].receivedChunks++; + + if (receivedFiles[fileId].receivedChunks === totalChunks) { + const fileBuffer = Buffer.concat(receivedFiles[fileId].chunks); + const decoder = new TextDecoder("utf-8"); + const str = decoder.decode(fileBuffer); + + setTestLogContent(str); + delete receivedFiles[fileId]; // cleanup ✅ + } + }); + // ✅ cleanup on unmount or when socket changes return () => { socket.off("init"); @@ -171,68 +230,66 @@ function App() { socket.off("user_connecting"); socket.off("user_open_cli"); socket.off("user_close_cli"); + socket.off("confirm_take_over"); + socket.off("response_content_log"); }; - }, [socket, stations]); + }, [socket, stations, selectedLine]); const updateStatus = (data: LineConfig) => { const line = getLine(data.lineId, data.stationId); if (line?.id) { - updateValueLineStation(line.id, "status", data.status); + updateValueLineStation(line.id, { status: data.status }); } }; - const updateValueLineStation = ( - lineId: number, - field: K, - value: TLine[K] - ) => { - setStations((el) => - el?.map((station: TStation) => - station.id.toString() === activeTab - ? { - ...station, - lines: (station?.lines || [])?.map((lineItem: TLine) => { - if (lineItem.id === lineId) { + const updateValueLineStation = useCallback( + (lineId: number, updates: Partial) => { + setStations((prevStations) => + prevStations?.map((station: TStation) => + station.id.toString() === activeTab + ? { + ...station, + lines: station.lines?.map((lineItem: TLine) => { + if (lineItem.id !== lineId) return lineItem; + + const isNetOutput = typeof updates?.netOutput !== "undefined"; + return { ...lineItem, - [field]: - field === "netOutput" - ? (lineItem.netOutput || "") + value - : value, - output: field === "netOutput" ? value : lineItem.output, - loadingOutput: - field === "netOutput" - ? lineItem.loadingOutput - ? false - : true - : false, + ...updates, + ...(isNetOutput && { + netOutput: + (lineItem.netOutput || "") + (updates.netOutput || ""), + output: updates.netOutput, // Nếu netOutput thì update luôn output + loadingOutput: lineItem.loadingOutput ? false : true, + }), }; - } - return lineItem; - }), - } - : station - ) - ); + }), + } + : station + ) + ); - if (selectedLine) { - const line = { - ...selectedLine, - [field]: - field === "netOutput" - ? (selectedLine.netOutput || "") + value - : value, - output: field === "netOutput" ? value : selectedLine.output, - loadingOutput: - field === "netOutput" - ? selectedLine.loadingOutput - ? false - : true - : false, - }; - setSelectedLine(line); - } - }; + // Update selectedLine nếu nó đang được chọn + setSelectedLine((prevSelected) => { + if (!prevSelected || prevSelected.id !== lineId) return prevSelected; + + const isNetOutput = typeof updates?.netOutput !== "undefined"; + + return { + ...prevSelected, + ...updates, + ...(isNetOutput && { + netOutput: + (prevSelected.netOutput || "") + (updates.netOutput || ""), + output: updates.netOutput, + loadingOutput: prevSelected.loadingOutput ? false : true, + }), + }; + }); + }, + [activeTab] + ); const getLine = (lineId: number, stationId: number) => { const station = stations?.find((sta) => sta.id === stationId); @@ -244,106 +301,31 @@ function App() { const openTerminal = (line: TLine) => { setOpenModalTerminal(true); - setSelectedLine(line); - socket?.emit("open_cli", { - lineId: line.id, - stationId: line.station_id, - userEmail: user?.email, - userName: user?.fullName, - }); + const data = { ...line }; + if (!line.userEmailOpenCLI) { + data.cliOpened = true; + data.userEmailOpenCLI = user?.email; + data.userOpenCLI = user?.fullName; + socket?.emit("open_cli", { + lineId: line.id, + stationId: line.station_id, + userEmail: user?.email, + userName: user?.fullName, + }); + } + setSelectedLine(data); }; return ( - { - setActiveTab(id?.toString() || "0"); - setLoadingTerminal(false); - setTimeout(() => { - setLoadingTerminal(true); - }, 100); - }} - variant="none" - keepMounted={false} - > - - - {usersConnecting.map((el) => ( - - - {el.userName.slice(0, 2)} - - - ))} - - - {stations.map((station) => ( - - {station.name} - - ))} - - - - {Number(activeTab) ? ( - { - setStationEdit( - stations.find((el) => el.id === Number(activeTab)) - ); - setIsOpenAddStation(true); - setIsEditStation(true); - }} - > - - - ) : ( - "" - )} - { - setIsOpenAddStation(true); - setIsEditStation(false); - setStationEdit(undefined); - }} - > - - - - - - {user?.fullName} - - - - - {stations.map((station) => ( + ( - - -
- - { - setSelectedLines([]); - setIsDisable(true); - setTimeout(() => { - setIsDisable(false); - }, 10000); - }} - /> - {scenarios.map((el, i) => ( - { + if (selectedLines.length !== station.lines.length) + setSelectedLines(station.lines); + else setSelectedLines([]); + }} + > + {selectedLines.length !== station.lines.length + ? "Select All" + : "Deselect All"} + + +
+ + - ))} + {scenarios.map((el, i) => ( + + typeof el?.userEmailOpenCLI === "undefined" || + el?.userEmailOpenCLI === user?.email + )} + isDisable={isDisable || selectedLines.length === 0} + onClick={() => { + setSelectedLines([]); + setIsDisable(true); + setTimeout(() => { + setIsDisable(false); + }, 10000); + }} + scenario={el} + /> + ))} +
+
))} -
+ onChange={(id) => { + setActiveTab(id?.toString() || "0"); + setLoadingTerminal(false); + setTimeout(() => { + setLoadingTerminal(true); + }, 100); + }} + /> el.id === Number(activeTab))} scenarios={scenarios} + dataRequestTakeOver={dataRequestTakeOver} + countDownRequest={countDownRequest} + setDisableRequestTakeOver={setDisableRequestTakeOver} + disableRequestTakeOver={disableRequestTakeOver} + setCountDownRequest={setCountDownRequest} + setDataRequestTakeOver={setDataRequestTakeOver} />
); diff --git a/FRONTEND/src/components/CardLine.tsx b/FRONTEND/src/components/CardLine.tsx index c7b12b1..1b06395 100644 --- a/FRONTEND/src/components/CardLine.tsx +++ b/FRONTEND/src/components/CardLine.tsx @@ -4,7 +4,7 @@ import classes from "./Component.module.css"; import TerminalCLI from "./TerminalXTerm"; import type { Socket } from "socket.io-client"; import { IconCircleCheckFilled } from "@tabler/icons-react"; -import { memo } from "react"; +import { memo, useMemo } from "react"; const CardLine = ({ line, @@ -23,6 +23,13 @@ const CardLine = ({ openTerminal: (value: TLine) => void; loadTerminal: boolean; }) => { + const user = useMemo(() => { + return localStorage.getItem("user") && + typeof localStorage.getItem("user") === "string" + ? JSON.parse(localStorage.getItem("user") || "") + : null; + }, []); + return ( - {/* - - */} ); }; diff --git a/FRONTEND/src/components/Component.module.css b/FRONTEND/src/components/Component.module.css index 697553b..2616948 100644 --- a/FRONTEND/src/components/Component.module.css +++ b/FRONTEND/src/components/Component.module.css @@ -7,14 +7,71 @@ } .info_line { - color: dimgrey; - font-size: 12px; - display: flex; - gap: 4px; - margin-top: 4px; - height: 20px; + color: dimgrey; + font-size: 12px; + display: flex; + gap: 4px; + margin-top: 4px; + height: 20px; } .buttonScenario :global(.mantine-Button-label) { white-space: normal !important; -} \ No newline at end of file +} + +.viewLog { + border: solid 1px gray; + white-space: pre-wrap; + font-size: 14px; + height: 72vh; + overflow: auto; + padding: 5px; + background-color: #f5f5f5; +} + +.logLight { + background-color: #f5f5f5; +} + +.isDisabled { + pointer-events: none; + opacity: 0.7; + -moz-user-focus: none; + -webkit-user-focus: none; + -ms-user-focus: none; + -moz-user-modify: read-only; + -webkit-user-modify: read-only; + -ms-user-modify: read-only; + -moz-user-select: none; + -webkit-user-select: none; + -ms-user-select: none; + user-select: none; + cursor: not-allowed; +} + +.downloadIcon { + color: rgb(107, 217, 60); + cursor: pointer; + padding: 2px; + border-radius: 25%; +} + +.downloadIcon:hover { + background-color: rgba(203, 203, 203, 0.809); +} + +.viewIcon { + color: rgb(60, 112, 217); + cursor: pointer; + padding: 2px; + border-radius: 25%; +} + +.viewIcon:hover { + background-color: rgba(203, 203, 203, 0.809); +} + +.optionIcon { + display: flex; + justify-content: space-evenly; +} diff --git a/FRONTEND/src/components/DragTabs.tsx b/FRONTEND/src/components/DragTabs.tsx new file mode 100644 index 0000000..acff36e --- /dev/null +++ b/FRONTEND/src/components/DragTabs.tsx @@ -0,0 +1,298 @@ +import { + ActionIcon, + Avatar, + Box, + Button, + Flex, + Tabs, + Text, + Tooltip, +} from "@mantine/core"; +import { + DndContext, + useSensor, + useSensors, + PointerSensor, + closestCenter, + type DragEndEvent, +} from "@dnd-kit/core"; +import { + arrayMove, + SortableContext, + useSortable, + horizontalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { useEffect, useMemo, useState, type JSX } from "react"; +import { IconEdit, IconSettingsPlus } from "@tabler/icons-react"; +import classes from "./Component.module.css"; +import type { TStation, TUser } from "../untils/types"; +import type { Socket } from "socket.io-client"; + +interface DraggableTabsProps { + tabsData: TStation[]; + panels: JSX.Element[]; + storageKey?: string; + onChange: (activeTabId: string | null) => void; + w?: string | number; + isStationSettings?: boolean; + socket: Socket | null; + usersConnecting: TUser[]; + setIsEditStation: (value: React.SetStateAction) => void; + setIsOpenAddStation: (value: React.SetStateAction) => void; + setStationEdit: (value: React.SetStateAction) => void; +} + +function SortableTab({ + tab, + active, + onChange, +}: { + tab: TStation; + active: string | null; + onChange: (id: string) => void; + isStationSettings?: boolean; +}) { + const { attributes, listeners, setNodeRef, transform, transition } = + useSortable({ id: tab.id.toString() }); + + return ( + { + listeners?.onPointerDown?.(e); + onChange(tab.id.toString()); + }} + value={tab.id.toString()} + style={{ + transform: CSS.Transform.toString(transform), + transition, + cursor: "grab", + userSelect: "none", + }} + color={active === tab.id.toString() ? "green" : ""} + fw={600} + fz="md" + c="#747474" + > + + + {tab.name} + + + + ); +} + +export default function DraggableTabs({ + tabsData, + panels, + storageKey = "draggable-tabs-order", + onChange, + w, + isStationSettings = false, + socket, + usersConnecting, + setIsEditStation, + setIsOpenAddStation, + setStationEdit, +}: DraggableTabsProps) { + const user = useMemo(() => { + return localStorage.getItem("user") && + typeof localStorage.getItem("user") === "string" + ? JSON.parse(localStorage.getItem("user") || "") + : null; + }, []); + const [tabs, setTabs] = useState(tabsData); + const [isChangeTab, setIsChangeTab] = useState(false); + const [isSetActive, setIsSetActive] = useState(false); + const [active, setActive] = useState( + tabsData?.length > 0 ? tabsData[0]?.id.toString() : null + ); + + const sensors = useSensors(useSensor(PointerSensor)); + + // Load saved order from localStorage + useEffect(() => { + if (isChangeTab) { + setTabs((pre) => + pre.map((t) => { + const updatedTab = tabsData.find((td) => td.id === t.id); + return updatedTab ? updatedTab : t; + }) + ); + } else { + const saved = localStorage.getItem(storageKey); + let tabSelected = + tabsData?.length > 0 ? tabsData[0]?.id.toString() : null; + if (saved) { + try { + const order = JSON.parse(saved) as { id: string; index: number }[]; + + // Find the max index in saved order + const maxIndex = Math.max(...order.map((o) => o.index), 0); + + const sorted = [...tabsData].sort((a, b) => { + const aOrder = order.find( + (o) => o.id.toString() === a.id.toString() + )?.index; + const bOrder = order.find( + (o) => o.id.toString() === b.id.toString() + )?.index; + + // If not found, assign index after all existing ones + const aIndex = aOrder !== undefined ? aOrder : maxIndex + 1; + const bIndex = bOrder !== undefined ? bOrder : maxIndex + 1; + + return aIndex - bIndex; + }); + + tabSelected = sorted?.length > 0 ? sorted[0]?.id.toString() : null; + setTabs(sorted); + } catch { + setTabs(tabsData); + } + } else { + setTabs(tabsData); + } + + if (!isSetActive && tabSelected) { + setActive(tabSelected); + setTimeout(() => { + onChange(tabSelected); + }, 100); + setIsSetActive(true); + } + } + }, [tabsData, storageKey]); + + // Handle reorder + const handleDragEnd = (event: DragEndEvent) => { + const { active: dragActive, over } = event; + if (dragActive.id !== over?.id && over?.id) { + const oldIndex = tabs.findIndex( + (t) => t.id.toString() === dragActive.id.toString() + ); + const newIndex = tabs.findIndex( + (t) => t.id.toString() === over?.id.toString() + ); + const newTabs = arrayMove(tabs, oldIndex, newIndex); + setTabs(newTabs); + + const order = newTabs.map((t, i) => ({ id: t.id, index: i })); + localStorage.setItem(storageKey, JSON.stringify(order)); + } + }; + + // Clean up + useEffect(() => { + return () => { + setIsChangeTab(false); + setIsSetActive(false); + setTabs([]); + setActive(null); + }; + }, []); + + return ( + + { + setIsChangeTab(true); + onChange(val); + setActive(val); + }} + w={w} + > + + + {usersConnecting.map((el) => ( + + + {el.userName.slice(0, 2)} + + + ))} + + + + {tabs.map((tab) => ( + { + setIsChangeTab(true); + onChange(id); + setActive(id); + }} + isStationSettings={isStationSettings} + /> + ))} + + + + {Number(active) ? ( + { + setStationEdit( + tabsData.find((el) => el.id === Number(active)) + ); + setIsOpenAddStation(true); + setIsEditStation(true); + }} + > + + + ) : ( + "" + )} + { + setIsOpenAddStation(true); + setIsEditStation(false); + setStationEdit(undefined); + }} + > + + + + + + {user?.fullName} + + + + + {panels} + + + ); +} diff --git a/FRONTEND/src/components/DrawerLogs.tsx b/FRONTEND/src/components/DrawerLogs.tsx new file mode 100644 index 0000000..ca342ad --- /dev/null +++ b/FRONTEND/src/components/DrawerLogs.tsx @@ -0,0 +1,193 @@ +import { useDisclosure } from "@mantine/hooks"; +import { + Button, + Box, + Drawer, + Grid, + Table, + Text, + ScrollArea, +} from "@mantine/core"; +import { useEffect, useState } from "react"; +import type { ISystemLog } from "../untils/types"; +import { IconDownload, IconEye } from "@tabler/icons-react"; +import classes from "./Component.module.css"; +import moment from "moment"; +import type { Socket } from "socket.io-client"; +import ModalLog from "./ModalLog"; + +function DrawerLogs({ + socket, + isLogModalOpen, + setIsLogModalOpen, + testLogContent, + setTestLogContent, +}: { + socket: Socket | null; + isLogModalOpen: boolean; + setIsLogModalOpen: (value: React.SetStateAction) => void; + testLogContent: string; + setTestLogContent: (value: React.SetStateAction) => void; +}) { + const [opened, { open, close }] = useDisclosure(false); + const [systemLogs, setSystemLogs] = useState([]); + const [isDownloadLog, setIsDownloadLog] = useState(false); + // const [testLogContent, setTestLogContent] = useState(""); + // const [isLogModalOpen, setIsLogModalOpen] = useState(false); + const [downloadName, setDownloadName] = useState(""); + + useEffect(() => { + if (opened) { + socket?.emit("get_list_logs"); + } + }, [opened]); + + useEffect(() => { + socket?.on("list_logs", (files: string[]) => { + const list: ISystemLog[] = files.map((file) => { + const filename = file.replace(/^.*[\\/]/, ""); + const createAt = filename.match(/\d{8}/); + return { + fileName: + file.split("/")[3] || file.split("/")[2] || file.split("/")[1], + createdAt: createAt ? createAt[0] : "N/A", + path: file, + }; + }); + setSystemLogs( + list.sort( + (a: ISystemLog, b: ISystemLog) => + parseInt(b.createdAt) - parseInt(a.createdAt) + ) + ); + }); + }, [socket]); + + useEffect(() => { + if (isDownloadLog && testLogContent && downloadName) { + const blob = new Blob([testLogContent], { type: "text/plain" }); + // Create a temporary link element + const link = document.createElement("a"); + link.href = URL.createObjectURL(blob); + link.download = downloadName; + // Trigger the download by clicking the link + link.click(); + // Clean up + URL.revokeObjectURL(link.href); + setIsDownloadLog(false); + setTestLogContent(""); + setDownloadName(""); + } + }, [testLogContent]); + + return ( + <> + + + + + + + + File name + Created at + + + + + {systemLogs.map((element) => ( + + {element.fileName} + + + {moment(element.createdAt).format("DD/MM/YYYY")} + + + + + { + setTestLogContent(""); + socket?.emit("get_content_log", { + line: { systemLogUrl: element.path }, + }); + setIsLogModalOpen(true); + }} + width={20} + /> + { + socket?.emit("get_content_log", { + line: { systemLogUrl: element.path }, + }); + setIsDownloadLog(true); + setTestLogContent(""); + setDownloadName( + element.path.split("/")[3] || + element.path.split("/")[2] || + element.path.split("/")[1] + ); + }} + width={20} + /> + + + + ))} + +
+
+
+
+ + {isLogModalOpen && ( + { + setIsLogModalOpen(false); + }} + testLogContent={testLogContent} + /> + )} +
+ + + + ); +} + +export default DrawerLogs; diff --git a/FRONTEND/src/components/DrawerScenario.tsx b/FRONTEND/src/components/DrawerScenario.tsx index 5611197..be03ed1 100644 --- a/FRONTEND/src/components/DrawerScenario.tsx +++ b/FRONTEND/src/components/DrawerScenario.tsx @@ -318,7 +318,7 @@ function DrawerScenario({
- + void; + testLogContent: string; +}) => { + const addTooltipsToHighlights = () => { + const highlights = document.querySelectorAll(".highlight"); + highlights.forEach((highlight) => { + const keyword = highlight.getAttribute("data-keyword"); + highlight.setAttribute("title", keyword || ""); + }); + }; + + // Function to highlight system log + const highlightSystemLog = (logText: string): string => { + const colorStart = "#7fffd4"; + const colorEnd = "#ffa589"; + const colorPhysicalStart = "#7fffd4"; + const colorPhysicalEnd = "#ffa589"; + return logText + .replace(/^---split-point-scenario---.*$/gm, "") // Remove split-point lines + .replace(/^---split-point---.*$/gm, "") // Remove split-point lines + .replace( + /^(---start-testing---|---end-testing---|---start-scenarios---|---end-scenarios---)(\d+)(---.*)?$/gm, + (_, prefix, timestamp, suffix = "") => { + const date = convertTimestampToDate(timestamp); + return `${prefix}${timestamp}${suffix}`; + } + ) + .replace( + /^(---start_physical_test_|---end_physical_test_)([A-Z0-9]+)_(\d+)---$/gm, + (_, prefix, sn, timestamp) => { + const date = convertTimestampToDate(timestamp); + const backgroundColor = prefix.includes("start") + ? colorPhysicalStart + : colorPhysicalEnd; + return `${prefix}${sn}_${timestamp}---`; + } + ); + }; + + // Function to convert timestamp to date + const convertTimestampToDate = (timestamp: number) => { + const date = new Date(Number(timestamp)); + return date.toLocaleString(); + }; + + return ( + { + onClose(); + }} + title={ + + Log Content + + } + size="90%" + styles={{ + content: { + height: "85vh", + display: "flex", + flexDirection: "column", + }, + body: { + flex: 1, + overflow: "auto", + }, + }} + > +
{ + if (el) addTooltipsToHighlights(); + }} + >
+
+ ); +}; + +export default ModalLog; diff --git a/FRONTEND/src/components/ModalTerminal.tsx b/FRONTEND/src/components/ModalTerminal.tsx index d2f18b2..836b10d 100644 --- a/FRONTEND/src/components/ModalTerminal.tsx +++ b/FRONTEND/src/components/ModalTerminal.tsx @@ -1,9 +1,23 @@ -import { Box, Button, Grid, Modal, Text } from "@mantine/core"; -import type { IScenario, TLine, TStation } from "../untils/types"; +import { + Box, + Button, + Dialog, + Flex, + Grid, + Group, + Modal, + Text, +} from "@mantine/core"; +import type { + IDataTakeOver, + IScenario, + TLine, + TStation, +} from "../untils/types"; import TerminalCLI from "./TerminalXTerm"; import type { Socket } from "socket.io-client"; import classes from "./Component.module.css"; -import { useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { IconCircleCheckFilled } from "@tabler/icons-react"; const ModalTerminal = ({ @@ -13,6 +27,12 @@ const ModalTerminal = ({ socket, stationItem, scenarios, + dataRequestTakeOver, + countDownRequest, + disableRequestTakeOver, + setDisableRequestTakeOver, + setCountDownRequest, + setDataRequestTakeOver, }: { opened: boolean; onClose: () => void; @@ -20,19 +40,65 @@ const ModalTerminal = ({ socket: Socket | null; stationItem: TStation | undefined; scenarios: IScenario[]; + dataRequestTakeOver: IDataTakeOver | undefined; + countDownRequest: number; + disableRequestTakeOver: boolean; + setDisableRequestTakeOver: (value: React.SetStateAction) => void; + setCountDownRequest: (value: React.SetStateAction) => void; + setDataRequestTakeOver: ( + value: React.SetStateAction + ) => void; }) => { + const user = useMemo(() => { + return localStorage.getItem("user") && + typeof localStorage.getItem("user") === "string" + ? JSON.parse(localStorage.getItem("user") || "") + : null; + }, []); + const [isDisable, setIsDisable] = useState(false); - // console.log(line); + const intervalTakeOverRef = useRef(null); + + useEffect(() => { + if ( + typeof dataRequestTakeOver?.userName !== "undefined" && + line?.userEmailOpenCLI === user?.email && + dataRequestTakeOver?.userName !== user?.email + ) { + if (dataRequestTakeOver?.userName) { + intervalTakeOverRef.current = setInterval(() => { + socket?.emit("open_cli", { + lineId: line?.id, + stationId: line?.station_id, + userEmail: user?.email, + userName: user?.fullName, + }); + socket?.emit("request_take_over", { + station_id: line?.station_id, + }); + setDisableRequestTakeOver(false); + setCountDownRequest(0); + if (intervalTakeOverRef.current) + clearInterval(intervalTakeOverRef.current); + }, 20000); + } + } else { + if (intervalTakeOverRef.current) + clearInterval(intervalTakeOverRef.current); + } + }, [dataRequestTakeOver?.userName]); + return ( { onClose(); - socket?.emit("close_cli", { - lineId: line?.id, - stationId: line?.station_id, - }); + if (line?.userEmailOpenCLI === user?.email) + socket?.emit("close_cli", { + lineId: line?.id, + stationId: line?.station_id, + }); }} size={"80%"} style={{ position: "absolute", left: 0 }} @@ -78,14 +144,21 @@ const ModalTerminal = ({ loadingContent={line?.loadingOutput} line_id={Number(line?.id)} station_id={Number(stationItem?.id)} - isDisabled={false} + isDisabled={ + typeof line?.userEmailOpenCLI !== "undefined" && + line?.userEmailOpenCLI !== user?.email + } line_status={line?.status || ""} /> {scenarios.map((scenario) => ( + + + + + {`${ + dataRequestTakeOver?.userName + ? `${dataRequestTakeOver?.userName} (${dataRequestTakeOver?.userEmail})` + : "" + } want to take over this line? ${ + countDownRequest > 0 && + typeof dataRequestTakeOver?.userName !== "undefined" && + line?.userEmailOpenCLI === user?.email + ? `(${countDownRequest}s)` + : "" + }`} + + + + + + + + ); }; diff --git a/FRONTEND/src/components/TerminalXTerm.tsx b/FRONTEND/src/components/TerminalXTerm.tsx index 34bf95e..ff41946 100644 --- a/FRONTEND/src/components/TerminalXTerm.tsx +++ b/FRONTEND/src/components/TerminalXTerm.tsx @@ -137,14 +137,6 @@ const TerminalCLI: React.FC = ({ setLoading(false); }, 200); } - - if (!cliOpened && terminal?.current) { - // console.log('Dispose terminal CLI') - terminal?.current.clear(); - terminal?.current.dispose(); - terminal.current = null; - setLoading(true); - } }, [cliOpened]); useEffect(() => { @@ -168,6 +160,11 @@ const TerminalCLI: React.FC = ({ useEffect(() => { return () => { setLoading(true); + // if (terminal.current) { + // terminal?.current.clear(); + // terminal?.current.dispose(); + // terminal.current = null; + // } }; }, []); @@ -179,7 +176,7 @@ const TerminalCLI: React.FC = ({ height: "100%", backgroundColor: "black", paddingBottom: customStyle.paddingBottom ?? "10px", - minHeight: customStyle.maxHeight ?? "75vh", + minHeight: customStyle.maxHeight ?? "73vh", }} >
= ({ paddingLeft: customStyle.paddingLeft ?? "10px", paddingBottom: customStyle.paddingBottom ?? "10px", fontSize: customStyle.fontSize ?? "9px", - maxHeight: customStyle.maxHeight ?? "75vh", - height: customStyle.height ?? "75vh", + maxHeight: customStyle.maxHeight ?? "73vh", + height: customStyle.height ?? "73vh", padding: customStyle.padding ?? "4px", }} onDoubleClick={(event) => { diff --git a/FRONTEND/src/untils/types.ts b/FRONTEND/src/untils/types.ts index 7cf1eba..77be370 100644 --- a/FRONTEND/src/untils/types.ts +++ b/FRONTEND/src/untils/types.ts @@ -150,3 +150,34 @@ export type IBodyScenario = { delay: string; repeat: string; }; + +export type IDataTakeOver = { + lineId: number; + stationId: number; + userName: string; + userEmail: string; +}; + +export type ISystemLog = { + fileName: string; + createdAt: string; + path: string; +}; + +export type ChunkData = { + fileId: string; + chunkIndex: number; + totalChunks: number; + chunk: ArrayBuffer | Uint8Array | Buffer; // socket may send different binary types +}; + +export type ResponseData = { + chunk?: ChunkData; + // Any other fields from socket data can go here if needed +}; + +export type ReceivedFile = { + chunks: Buffer[]; + receivedChunks: number; + totalChunks: number; +}; diff --git a/FRONTEND/tsconfig.app.json b/FRONTEND/tsconfig.app.json index a9b5a59..8f8c44f 100644 --- a/FRONTEND/tsconfig.app.json +++ b/FRONTEND/tsconfig.app.json @@ -5,7 +5,7 @@ "useDefineForClassFields": true, "lib": ["ES2022", "DOM", "DOM.Iterable"], "module": "ESNext", - "types": ["vite/client"], + "types": ["vite/client", "node"], "skipLibCheck": true, /* Bundler mode */