This commit is contained in:
nguyentrungthat 2025-10-29 16:23:00 +07:00
parent cbc8397ea8
commit 240dfdff2c
16 changed files with 1287 additions and 281 deletions

View File

@ -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 = () => {

View File

@ -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)) {

View File

@ -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}`]

View File

@ -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",

View File

@ -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",

View File

@ -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<TStation[]>([]);
const [selectedLines, setSelectedLines] = useState<TLine[]>([]);
const [activeTab, setActiveTab] = useState("0");
const [controlsRefs, setControlsRefs] = useState<
Record<string, HTMLButtonElement | null>
>({});
const [rootRef, setRootRef] = useState<HTMLDivElement | null>(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<TLine | undefined>();
const [loadingTerminal, setLoadingTerminal] = useState(true);
const [usersConnecting, setUsersConnecting] = useState<TUser[]>([]);
const [disableRequestTakeOver, setDisableRequestTakeOver] = useState(false);
const [countDownRequest, setCountDownRequest] = useState(0);
const [dataRequestTakeOver, setDataRequestTakeOver] =
useState<IDataTakeOver>();
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<string, ReceivedFile> = {};
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 = <K extends keyof TLine>(
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<TLine>) => {
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 (
<Container py="md" w={"100%"} style={{ maxWidth: "100%" }}>
<Tabs
value={activeTab}
onChange={(id) => {
setActiveTab(id?.toString() || "0");
setLoadingTerminal(false);
setTimeout(() => {
setLoadingTerminal(true);
}, 100);
}}
variant="none"
keepMounted={false}
>
<Flex justify={"space-between"}>
<Flex wrap={"wrap"} style={{ maxWidth: "250px" }} gap={"4px"}>
{usersConnecting.map((el) => (
<Tooltip label={el.userName} key={el.userId}>
<Avatar color="cyan" radius="xl" size={"md"}>
{el.userName.slice(0, 2)}
</Avatar>
</Tooltip>
))}
</Flex>
<Tabs.List ref={setRootRef} className={classes.list}>
{stations.map((station) => (
<Tabs.Tab
ref={setControlRef(station.id.toString())}
className={classes.tab}
key={station.id}
value={station.id.toString()}
>
{station.name}
</Tabs.Tab>
))}
<FloatingIndicator
target={activeTab ? controlsRefs[activeTab] : null}
parent={rootRef}
className={classes.indicator}
/>
<Flex gap={"sm"}>
{Number(activeTab) ? (
<ActionIcon
title="Edit Station"
variant="outline"
onClick={() => {
setStationEdit(
stations.find((el) => el.id === Number(activeTab))
);
setIsOpenAddStation(true);
setIsEditStation(true);
}}
>
<IconEdit />
</ActionIcon>
) : (
""
)}
<ActionIcon
title="Add Station"
variant="outline"
color="green"
onClick={() => {
setIsOpenAddStation(true);
setIsEditStation(false);
setStationEdit(undefined);
}}
>
<IconSettingsPlus />
</ActionIcon>
</Flex>
</Tabs.List>
<Flex gap={"sm"} align={"baseline"}>
<Text className={classes.userName}>{user?.fullName}</Text>
<Button
variant="outline"
color="red"
style={{ height: "30px", width: "100px" }}
onClick={() => {
localStorage.removeItem("user");
window.location.href = "/";
socket?.disconnect();
}}
>
Logout
</Button>
</Flex>
</Flex>
{stations.map((station) => (
<DraggableTabs
socket={socket}
usersConnecting={usersConnecting}
setIsEditStation={setIsEditStation}
setIsOpenAddStation={setIsOpenAddStation}
setStationEdit={setStationEdit}
tabsData={stations}
panels={stations.map((station) => (
<Tabs.Panel
className={classes.content}
key={station.id}
@ -402,63 +384,55 @@ function App() {
>
<Flex
direction={"column"}
justify={"space-between"}
align={"center"}
gap={"xs"}
wrap={"wrap"}
h={"100%"}
>
<Button
variant="filled"
style={{ height: "30px", width: "100px" }}
onClick={() => {
if (selectedLines.length !== station.lines.length)
setSelectedLines(station.lines);
else setSelectedLines([]);
}}
<Flex
direction={"column"}
align={"center"}
gap={"xs"}
wrap={"wrap"}
>
{selectedLines.length !== station.lines.length
? "Select All"
: "Deselect All"}
</Button>
<Button
disabled={
selectedLines.filter((el) => el.status !== "connected")
.length === 0
}
variant="outline"
style={{ height: "30px", width: "100px" }}
onClick={() => {
const lines = selectedLines.filter(
(el) => el.status !== "connected"
);
socket?.emit("connect_lines", {
stationData: station,
linesData: lines,
});
setSelectedLines([]);
}}
>
Connect
</Button>
<hr style={{ width: "100%" }} />
<DrawerScenario
scenarios={scenarios}
setScenarios={setScenarios}
/>
<ButtonDPELP
socket={socket}
selectedLines={selectedLines}
isDisable={isDisable || selectedLines.length === 0}
onClick={() => {
setSelectedLines([]);
setIsDisable(true);
setTimeout(() => {
setIsDisable(false);
}, 10000);
}}
/>
{scenarios.map((el, i) => (
<ButtonScenario
key={i}
<Button
variant="filled"
style={{ height: "30px", width: "100px" }}
onClick={() => {
if (selectedLines.length !== station.lines.length)
setSelectedLines(station.lines);
else setSelectedLines([]);
}}
>
{selectedLines.length !== station.lines.length
? "Select All"
: "Deselect All"}
</Button>
<Button
disabled={
selectedLines.filter((el) => el.status !== "connected")
.length === 0
}
variant="outline"
style={{ height: "30px", width: "100px" }}
onClick={() => {
const lines = selectedLines.filter(
(el) => el.status !== "connected"
);
socket?.emit("connect_lines", {
stationData: station,
linesData: lines,
});
setSelectedLines([]);
}}
>
Connect
</Button>
<hr style={{ width: "100%" }} />
<DrawerScenario
scenarios={scenarios}
setScenarios={setScenarios}
/>
<ButtonDPELP
socket={socket}
selectedLines={selectedLines}
isDisable={isDisable || selectedLines.length === 0}
@ -469,15 +443,48 @@ function App() {
setIsDisable(false);
}, 10000);
}}
scenario={el}
/>
))}
{scenarios.map((el, i) => (
<ButtonScenario
key={i}
socket={socket}
selectedLines={selectedLines.filter(
(el) =>
typeof el?.userEmailOpenCLI === "undefined" ||
el?.userEmailOpenCLI === user?.email
)}
isDisable={isDisable || selectedLines.length === 0}
onClick={() => {
setSelectedLines([]);
setIsDisable(true);
setTimeout(() => {
setIsDisable(false);
}, 10000);
}}
scenario={el}
/>
))}
</Flex>
<DrawerLogs
socket={socket}
isLogModalOpen={isLogModalOpen}
setIsLogModalOpen={setIsLogModalOpen}
testLogContent={testLogContent}
setTestLogContent={setTestLogContent}
/>
</Flex>
</Grid.Col>
</Grid>
</Tabs.Panel>
))}
</Tabs>
onChange={(id) => {
setActiveTab(id?.toString() || "0");
setLoadingTerminal(false);
setTimeout(() => {
setLoadingTerminal(true);
}, 100);
}}
/>
<StationSetting
dataStation={stationEdit}
@ -504,6 +511,12 @@ function App() {
socket={socket}
stationItem={stations.find((el) => el.id === Number(activeTab))}
scenarios={scenarios}
dataRequestTakeOver={dataRequestTakeOver}
countDownRequest={countDownRequest}
setDisableRequestTakeOver={setDisableRequestTakeOver}
disableRequestTakeOver={disableRequestTakeOver}
setCountDownRequest={setCountDownRequest}
setDataRequestTakeOver={setDataRequestTakeOver}
/>
</Container>
);

View File

@ -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 (
<Card
key={line.id}
@ -91,7 +98,10 @@ const CardLine = ({
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 || ""}
fontSize={11}
miniSize={true}
@ -108,11 +118,6 @@ const CardLine = ({
/>
</Box>
</Flex>
{/* <Flex justify={"flex-end"}>
<Button variant="filled" style={{ height: "30px", width: "70px" }}>
Take
</Button>
</Flex> */}
</Card>
);
};

View File

@ -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;
}
}
.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;
}

View File

@ -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<boolean>) => void;
setIsOpenAddStation: (value: React.SetStateAction<boolean>) => void;
setStationEdit: (value: React.SetStateAction<TStation | undefined>) => 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 (
<Tabs.Tab
className={classes.tab}
ref={setNodeRef}
{...attributes}
{...listeners}
onPointerDown={(e) => {
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"
>
<Box className={classes.stationName}>
<Text fw={600} fz="md" className={classes.stationText}>
{tab.name}
</Text>
</Box>
</Tabs.Tab>
);
}
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<TStation[]>(tabsData);
const [isChangeTab, setIsChangeTab] = useState<boolean>(false);
const [isSetActive, setIsSetActive] = useState<boolean>(false);
const [active, setActive] = useState<string | null>(
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 (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<Tabs
value={active}
onChange={(val) => {
setIsChangeTab(true);
onChange(val);
setActive(val);
}}
w={w}
>
<Flex justify={"space-between"}>
<Flex wrap={"wrap"} style={{ maxWidth: "250px" }} gap={"4px"}>
{usersConnecting.map((el) => (
<Tooltip label={el.userName} key={el.userId}>
<Avatar color="cyan" radius="xl" size={"md"}>
{el.userName.slice(0, 2)}
</Avatar>
</Tooltip>
))}
</Flex>
<Tabs.List className={classes.list}>
<SortableContext
items={tabs}
strategy={horizontalListSortingStrategy}
>
{tabs.map((tab) => (
<SortableTab
key={tab.id}
tab={tab}
active={active}
onChange={(id) => {
setIsChangeTab(true);
onChange(id);
setActive(id);
}}
isStationSettings={isStationSettings}
/>
))}
</SortableContext>
<Flex gap={"sm"}>
{Number(active) ? (
<ActionIcon
title="Edit Station"
variant="outline"
onClick={() => {
setStationEdit(
tabsData.find((el) => el.id === Number(active))
);
setIsOpenAddStation(true);
setIsEditStation(true);
}}
>
<IconEdit />
</ActionIcon>
) : (
""
)}
<ActionIcon
title="Add Station"
variant="outline"
color="green"
onClick={() => {
setIsOpenAddStation(true);
setIsEditStation(false);
setStationEdit(undefined);
}}
>
<IconSettingsPlus />
</ActionIcon>
</Flex>
</Tabs.List>
<Flex gap={"sm"} align={"baseline"}>
<Text className={classes.userName}>{user?.fullName}</Text>
<Button
variant="outline"
color="red"
style={{ height: "30px", width: "100px" }}
onClick={() => {
localStorage.removeItem("user");
window.location.href = "/";
socket?.disconnect();
}}
>
Logout
</Button>
</Flex>
</Flex>
{panels}
</Tabs>
</DndContext>
);
}

View File

@ -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<boolean>) => void;
testLogContent: string;
setTestLogContent: (value: React.SetStateAction<string>) => void;
}) {
const [opened, { open, close }] = useDisclosure(false);
const [systemLogs, setSystemLogs] = useState<ISystemLog[]>([]);
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 (
<>
<Drawer
size={"50%"}
position="right"
style={{ position: "absolute", left: 0 }}
offset={8}
radius="md"
opened={opened}
onClose={close}
title={"List Logs"}
>
<Grid>
<Grid.Col span={12}>
<ScrollArea h={"85vh"} style={{ marginBottom: "20px" }}>
<Table
stickyHeader
stickyHeaderOffset={-1}
striped
highlightOnHover
withRowBorders={true}
withTableBorder={true}
withColumnBorders={true}
>
<Table.Thead>
<Table.Tr>
<Table.Th style={{ width: "50%" }}>File name</Table.Th>
<Table.Th style={{ width: "30%" }}>Created at</Table.Th>
<Table.Th style={{ width: "10%" }}></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{systemLogs.map((element) => (
<Table.Tr key={element.path}>
<Table.Td>{element.fileName}</Table.Td>
<Table.Td>
<Text>
{moment(element.createdAt).format("DD/MM/YYYY")}
</Text>
</Table.Td>
<Table.Td>
<Box
key={"action-" + element.fileName}
className={classes.optionIcon}
>
<IconEye
className={classes.viewIcon}
onClick={() => {
setTestLogContent("");
socket?.emit("get_content_log", {
line: { systemLogUrl: element.path },
});
setIsLogModalOpen(true);
}}
width={20}
/>
<IconDownload
className={[
classes.downloadIcon,
isDownloadLog ? classes.isDisabled : "",
].join(" ")}
onClick={() => {
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}
/>
</Box>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea>
</Grid.Col>
</Grid>
{isLogModalOpen && (
<ModalLog
opened={isLogModalOpen}
onClose={() => {
setIsLogModalOpen(false);
}}
testLogContent={testLogContent}
/>
)}
</Drawer>
<Button
title="Add Scenario"
variant="outline"
// color="green"
onClick={() => {
open();
}}
>
List logs
</Button>
</>
);
}
export default DrawerLogs;

View File

@ -318,7 +318,7 @@ function DrawerScenario({
</Box>
<hr style={{ width: "100%" }} />
<Box>
<ScrollArea h={500} style={{ marginBottom: "20px" }}>
<ScrollArea h={"70vh"} style={{ marginBottom: "20px" }}>
<Table
stickyHeader
stickyHeaderOffset={-1}

View File

@ -0,0 +1,95 @@
import { Modal, Text } from "@mantine/core";
import classes from "./Component.module.css";
const ModalLog = ({
opened,
onClose,
testLogContent,
}: {
opened: boolean;
onClose: () => 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 `<span style="background-color: ${
prefix.includes("start") ? colorStart : colorEnd
}" title="${date}">${prefix}${timestamp}${suffix}</span>`;
}
)
.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 `<span style="background-color: ${backgroundColor}" title="${date}">${prefix}${sn}_${timestamp}---</span>`;
}
);
};
// Function to convert timestamp to date
const convertTimestampToDate = (timestamp: number) => {
const date = new Date(Number(timestamp));
return date.toLocaleString();
};
return (
<Modal
style={{ position: "absolute", left: 0 }}
opened={opened}
onClose={() => {
onClose();
}}
title={
<Text fz={"lg"} fw={"bolder"}>
Log Content
</Text>
}
size="90%"
styles={{
content: {
height: "85vh",
display: "flex",
flexDirection: "column",
},
body: {
flex: 1,
overflow: "auto",
},
}}
>
<div
dangerouslySetInnerHTML={{
__html: highlightSystemLog(testLogContent),
}}
className={`${classes.viewLog} ${classes.logLight}`}
ref={(el) => {
if (el) addTooltipsToHighlights();
}}
></div>
</Modal>
);
};
export default ModalLog;

View File

@ -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<boolean>) => void;
setCountDownRequest: (value: React.SetStateAction<number>) => void;
setDataRequestTakeOver: (
value: React.SetStateAction<IDataTakeOver | undefined>
) => void;
}) => {
const user = useMemo(() => {
return localStorage.getItem("user") &&
typeof localStorage.getItem("user") === "string"
? JSON.parse(localStorage.getItem("user") || "")
: null;
}, []);
const [isDisable, setIsDisable] = useState<boolean>(false);
// console.log(line);
const intervalTakeOverRef = useRef<NodeJS.Timeout | null>(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 (
<Box>
<Modal
opened={opened}
onClose={() => {
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 || ""}
/>
</Grid.Col>
<Grid.Col span={2}>
{scenarios.map((scenario) => (
<Button
disabled={isDisable}
disabled={
isDisable ||
(typeof line?.userEmailOpenCLI !== "undefined" &&
line?.userEmailOpenCLI !== user?.email)
}
className={classes.buttonScenario}
key={scenario.id}
miw={"100px"}
@ -112,7 +185,116 @@ const ModalTerminal = ({
))}
</Grid.Col>
</Grid>
<Flex justify={"space-between"}>
<Box></Box>
<Button
disabled={
disableRequestTakeOver ||
!line?.userEmailOpenCLI ||
line?.userEmailOpenCLI === user?.email
}
variant="filled"
size="xs"
radius="xs"
mt={"md"}
ml={"20px"}
onClick={() => {
socket?.emit("request_take_over", {
line_id: line?.id,
station_id: Number(line?.station_id),
userName: user?.fullName?.trim() || "",
userEmail: user?.email || "",
});
setDisableRequestTakeOver(true);
setTimeout(() => {
setDisableRequestTakeOver(false);
}, 20000);
setCountDownRequest(20);
const intervalCount = setInterval(() => {
setCountDownRequest((prev) => {
if (prev <= 1) {
clearInterval(intervalCount);
return 0;
}
return prev - 1;
});
}, 1000);
}}
>
Take over{" "}
{countDownRequest > 0 &&
(typeof dataRequestTakeOver?.userName === "undefined" ||
dataRequestTakeOver?.userEmail === user?.email)
? `(${countDownRequest}s)`
: ""}
</Button>
</Flex>
</Modal>
<Dialog
opened={
typeof dataRequestTakeOver?.userName !== "undefined" &&
line?.userEmailOpenCLI === user?.email &&
dataRequestTakeOver?.userName !== user?.email
}
position={{ bottom: 20, right: 20 }}
withCloseButton
style={{ border: "solid 2px #ff6c6b", left: 0 }}
shadow="md"
onClose={close}
size="lg"
radius="md"
>
<Text size="sm" mb="xs" fw={700} c={"#ff6c6b"}>
{`${
dataRequestTakeOver?.userName
? `${dataRequestTakeOver?.userName} (${dataRequestTakeOver?.userEmail})`
: ""
} want to take over this line? ${
countDownRequest > 0 &&
typeof dataRequestTakeOver?.userName !== "undefined" &&
line?.userEmailOpenCLI === user?.email
? `(${countDownRequest}s)`
: ""
}`}
</Text>
<Group style={{ display: "flex", justifyContent: "center" }}>
<Button
variant="gradient"
gradient={{ from: "pink", to: "red", deg: 90 }}
size="xs"
onClick={async () => {
socket?.emit("open_cli", {
lineId: line?.id,
stationId: line?.station_id,
userEmail: dataRequestTakeOver?.userEmail,
userName: dataRequestTakeOver?.userName,
});
socket?.emit("request_take_over", {
station_id: Number(line?.station_id),
});
setDisableRequestTakeOver(false);
setDataRequestTakeOver(undefined);
setCountDownRequest(0);
}}
>
Yes
</Button>
<Button
variant="gradient"
size="xs"
onClick={async () => {
setDisableRequestTakeOver(false);
setDataRequestTakeOver(undefined);
setCountDownRequest(0);
}}
>
No
</Button>
</Group>
</Dialog>
</Box>
);
};

View File

@ -137,14 +137,6 @@ const TerminalCLI: React.FC<TerminalCLIProps> = ({
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<TerminalCLIProps> = ({
useEffect(() => {
return () => {
setLoading(true);
// if (terminal.current) {
// terminal?.current.clear();
// terminal?.current.dispose();
// terminal.current = null;
// }
};
}, []);
@ -179,7 +176,7 @@ const TerminalCLI: React.FC<TerminalCLIProps> = ({
height: "100%",
backgroundColor: "black",
paddingBottom: customStyle.paddingBottom ?? "10px",
minHeight: customStyle.maxHeight ?? "75vh",
minHeight: customStyle.maxHeight ?? "73vh",
}}
>
<div
@ -189,8 +186,8 @@ const TerminalCLI: React.FC<TerminalCLIProps> = ({
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) => {

View File

@ -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;
};

View File

@ -5,7 +5,7 @@
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"types": ["vite/client", "node"],
"skipLibCheck": true,
/* Bundler mode */