This commit is contained in:
nguyentrungthat 2025-10-31 15:37:37 +07:00
parent fb1554d857
commit 1682a28029
23 changed files with 456 additions and 220 deletions

View File

@ -5,39 +5,39 @@ export default class AuthController {
// Đăng ký
async register({ request, response }: HttpContext) {
try {
const data = request.only(['email', 'password', 'full_name'])
const data = request.only(['email', 'password', 'user_name'])
const user = await User.query().where('email', data.email).first()
const user = await User.query().where('user_name', data.user_name).first()
if (user) {
return response.status(401).json({ status: false, message: 'Email is exist' })
return response.status(401).json({ status: false, message: 'Username is exist' })
}
const newUser = await User.create(data)
return response.json({ status: true, message: 'User created', user: newUser })
} catch (error) {
return response.status(401).json({ status: false, message: 'Invalid credentials' })
return response.status(401).json({ error, status: false, message: 'Invalid credentials' })
}
}
// Đăng nhập
async login({ request, auth, response }: HttpContext) {
const { email, password } = request.only(['email', 'password'])
const user = await User.query().where('email', email).first()
const { user_name: userName, password } = request.only(['user_name', 'password'])
const user = await User.query().where('user_name', userName).first()
if (!user) {
return response.status(401).json({ message: 'Invalid email or password' })
return response.status(401).json({ message: 'Invalid Username or password' })
}
try {
// So sánh password
if (user.password !== password) {
return response.status(401).json({ message: 'Invalid email or password' })
return response.status(401).json({ message: 'Invalid username or password' })
}
return response.json({
message: 'Login successful',
user: { id: user.id, email: user.email, fullName: user.fullName },
user: { id: user.id, email: user.email, userName: user.userName },
})
} catch {
return response.status(401).json({ message: 'Invalid credentials' })

View File

@ -71,13 +71,6 @@ export default class StationsController {
let lines: Line[] = request.body().lines || []
try {
// Kiểm tra trùng name hoặc ip
if (await Station.findBy('name', payload.name))
return response.status(400).json({ message: 'Station name exist!' })
if (await Station.findBy('ip', payload.ip))
return response.status(400).json({ message: 'Ip of station is exist!' })
const station = await Station.find(request.body().id)
// If the station does not exist, return a 404 response

View File

@ -25,19 +25,19 @@ export default class UsersController {
async store({ request, response }: HttpContext) {
try {
const data = request.only(['full_name', 'email', 'password'])
const data = request.only(['user_name', 'email', 'password'])
// Check if email already exists
const existingUser = await User.findBy('email', data.email)
const existingUser = await User.findBy('user_name', data.user_name)
if (existingUser) {
return response.conflict({
status: false,
message: 'Email already exists',
message: 'Username already exists',
})
}
const user = await User.create({
fullName: data.full_name,
userName: data.user_name,
email: data.email,
password: data.password,
})
@ -55,16 +55,16 @@ export default class UsersController {
async update({ params, request, response }: HttpContext) {
try {
const user = await User.findOrFail(params.id)
const data = request.only(['full_name', 'email', 'password']) // Include password
const data = request.only(['user_name', 'email', 'password']) // Include password
// Check if email already exists for another user
if (data.email)
if (data.email !== user.email) {
const existingUser = await User.findBy('email', data.email)
const existingUser = await User.findBy('user_name', data.user_name)
if (existingUser) {
return response.conflict({
status: false,
message: 'Email already exists',
message: 'Username already exists',
})
}
}

View File

@ -6,7 +6,7 @@ export default class User extends BaseModel {
declare id: number
@column()
declare fullName: string | null
declare userName: string
@column()
declare email: string

View File

@ -23,6 +23,11 @@ interface LineConfig {
openCLI: boolean
userEmailOpenCLI: string
userOpenCLI: string
inventory?: string
latestScenario?: {
name: string
time: number
}
data: {
command: string
output: string
@ -184,6 +189,10 @@ export default class LineConnection {
this.config.lineNumber,
this.config.port
)
this.config.latestScenario = {
name: script?.title,
time: now,
}
const steps = typeof script?.body === 'string' ? JSON.parse(script?.body) : []
let stepIndex = 0
@ -228,10 +237,17 @@ export default class LineConnection {
if (err) return
const logScenarios = getLogWithTimeScenario(content, now) || ''
const data = await textfsmResults(logScenarios, '')
const data = textfsmResults(logScenarios, '')
try {
data.forEach((item) => {
if (item?.textfsm && isValidJson(item?.textfsm)) {
if (
['show inventory', 'sh inventory', 'show inv', 'sh inv'].includes(
item.command
)
) {
this.config.inventory = JSON.parse(item.textfsm)[0]
}
item.textfsm = JSON.parse(item.textfsm)
}
})
@ -240,6 +256,8 @@ export default class LineConnection {
stationId: this.config.stationId,
lineId: this.config.id,
data,
inventory: this.config.inventory || null,
latestScenario: this.config.latestScenario || null,
})
} catch (error) {
console.log(error)

View File

@ -0,0 +1,23 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'users'
public async up() {
this.schema.alterTable(this.tableName, (table) => {
table.renameColumn('full_name', 'user_name')
})
this.schema.alterTable(this.tableName, (table) => {
table.string('user_name').notNullable().unique().alter()
table.dropUnique(['email'])
})
}
public async down() {
this.schema.alterTable(this.tableName, (table) => {
table.unique(['email'])
table.string('user_name').nullable().alter()
table.renameColumn('user_name', 'full_name')
})
}
}

View File

@ -78,15 +78,19 @@ export class WebSocketIo {
this.userConnecting.set(userId, { userId, userName })
setTimeout(() => {
io.emit('user_connecting', Array.from(this.userConnecting.values()))
}, 200)
const listUser = Array.from(this.userConnecting.values())
if (!listUser.find((el) => el.userId === userId)) {
listUser.push({ userId, userName })
}
io.emit('user_connecting', listUser)
}, 500)
setTimeout(() => {
io.to(socket.id).emit(
'init',
Array.from(this.lineMap.values()).map((el) => el.config)
)
}, 200)
}, 500)
socket.on('disconnect', () => {
console.log(`FE disconnected: ${socket.id}`)

4
FRONTEND/.env.example Normal file
View File

@ -0,0 +1,4 @@
VITE_BACKEND_URL=http://localhost:3333/
VITE_SOCKET_SERVER=http://localhost:8989/
VITE_LOCALSTORAGE_VARIABLE=au_ma_te_da
VITE_DOMAIN=http://localhost:5173/

View File

@ -13,7 +13,6 @@ import {
MantineProvider,
Grid,
ScrollArea,
Button,
LoadingOverlay,
} from "@mantine/core";
import type {
@ -22,7 +21,6 @@ import type {
LineConfig,
ReceivedFile,
ResponseData,
TextFSM,
TLine,
TStation,
TUser,
@ -30,7 +28,13 @@ import type {
import axios from "axios";
import CardLine from "./components/CardLine";
import { SocketProvider, useSocket } from "./context/SocketContext";
import { ButtonDPELP, ButtonScenario } from "./components/ButtonAction";
import {
ButtonConnect,
ButtonCopy,
ButtonDPELP,
ButtonScenario,
ButtonSelect,
} from "./components/ButtonAction";
import StationSetting from "./components/FormAddEdit";
import DrawerScenario from "./components/DrawerScenario";
import { Notifications } from "@mantine/notifications";
@ -230,6 +234,8 @@ function App() {
setTimeout(() => {
updateValueLineStation(data.lineId, {
data: data.data,
inventory: data.inventory,
latestScenario: data.latestScenario,
});
}, 100);
});
@ -319,19 +325,19 @@ function App() {
if (!line.userEmailOpenCLI) {
data.cliOpened = true;
data.userEmailOpenCLI = user?.email;
data.userOpenCLI = user?.fullName;
data.userOpenCLI = user?.userName;
socket?.emit("open_cli", {
lineId: line.id,
stationId: line.station_id,
userEmail: user?.email,
userName: user?.fullName,
userName: user?.userName,
});
}
setSelectedLine(data);
};
return (
<Container py="md" w={"100%"} style={{ maxWidth: "100%" }}>
<Container w={"100%"} style={{ maxWidth: "100%" }}>
<DraggableTabs
socket={socket}
usersConnecting={usersConnecting}
@ -405,100 +411,36 @@ function App() {
<Flex
direction={"column"}
align={"center"}
gap={"xs"}
gap={"6px"}
wrap={"wrap"}
>
<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>
<Button
disabled={selectedLines.length === 0}
variant="outline"
style={{ height: "30px", width: "100px" }}
onClick={() => {
if (selectedLines?.length > 0) {
const value = selectedLines
?.map((el) => {
// Get data platform
const dataPlatform = el.data?.find(
(comm: TextFSM) =>
comm.command?.trim() === "show platform"
);
const DPELP =
dataPlatform &&
!dataPlatform?.output?.includes("Incomplete")
? true
: false;
// Get data license
const dataLicense = el.data?.find(
(comm: TextFSM) =>
comm.command?.trim() === "show license" ||
comm.command?.trim() === "sh license"
);
const listLicense =
dataLicense?.textfsm &&
Array.isArray(dataLicense?.textfsm)
? dataLicense?.textfsm
?.map(
(val: { FEATURE: string }) =>
val.FEATURE
)
.join(", ")
: "";
return `Line ${el.line_number ?? ""}: PID: ${
el.inventory?.pid ?? ""
}, SN: ${el.inventory?.sn ?? ""}, VID: ${
el.inventory?.vid ?? ""
}, Tested mode: ${
DPELP ? "DPELP" : "DPEL"
}, License: ${listLicense}`;
})
.join("\n");
navigator.clipboard.writeText(value);
setSelectedLines([]);
}
}}
>
Copy
</Button>
<hr style={{ width: "100%" }} />
<DrawerScenario
scenarios={scenarios}
setScenarios={setScenarios}
<ButtonSelect
selectedLines={selectedLines}
setSelectedLines={setSelectedLines}
station={station}
/>
<ButtonConnect
selectedLines={selectedLines}
setSelectedLines={setSelectedLines}
station={station}
socket={socket}
/>
<ButtonCopy
selectedLines={selectedLines}
setSelectedLines={setSelectedLines}
/>
<Flex
w={"100%"}
direction={"column"}
align={"center"}
wrap={"wrap"}
>
<hr style={{ width: "100%" }} />
<DrawerScenario
scenarios={scenarios}
setScenarios={setScenarios}
/>
</Flex>
<ButtonDPELP
socket={socket}
selectedLines={selectedLines}
@ -511,26 +453,35 @@ function App() {
}, 10000);
}}
/>
{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}
/>
))}
<ScrollArea h={"60vh"} style={{ paddingBottom: "12px" }}>
<Flex
w={"100%"}
direction={"column"}
wrap={"wrap"}
gap={"6px"}
>
{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>
</ScrollArea>
</Flex>
<DrawerLogs
socket={socket}
@ -554,6 +505,25 @@ function App() {
}}
setActive={setActiveTab}
active={activeTab}
onSendCommand={(value) => {
const listLine = selectedLines.length
? selectedLines
: stations.find((el) => el.id === Number(activeTab))?.lines;
if (listLine?.length) {
socket?.emit("write_command_line_from_web", {
lineIds: listLine.map((line) => line.id),
stationId: Number(activeTab),
command: value + "\n",
});
setTimeout(() => {
socket?.emit("write_command_line_from_web", {
lineIds: listLine.map((line) => line.id),
stationId: Number(activeTab),
command: " \n",
});
}, 1000);
}
}}
/>
<StationSetting

View File

@ -6,18 +6,18 @@ import axios from "axios";
const apiUrl = import.meta.env.VITE_BACKEND_URL;
type TLogin = {
email: string;
user_name: string;
password: string;
};
const Login = () => {
const formLogin = useForm<TLogin>({
initialValues: {
email: "",
user_name: "",
password: "",
},
validate: (values) => ({
email: values.email === "" ? "Email is required" : null,
user_name: values.user_name === "" ? "Email is required" : null,
password: values.password === "" ? "Password is required" : null,
}),
@ -25,10 +25,10 @@ const Login = () => {
const handleLogin = async () => {
try {
if (!formLogin.values.email) {
if (!formLogin.values.user_name) {
notifications.show({
title: "Error",
message: "Email is required",
message: "Username is required",
color: "red",
});
return;
@ -42,7 +42,7 @@ const Login = () => {
return;
}
const payload = {
email: formLogin.values.email,
user_name: formLogin.values.user_name,
password: formLogin.values.password,
};
const response = await axios.post(apiUrl + "api/auth/login", payload);
@ -69,12 +69,12 @@ const Login = () => {
onSubmit={formLogin.onSubmit(handleLogin)}
>
<TextInput
label={"Email address"}
placeholder="hello@gmail.com"
value={formLogin.values.email}
error={formLogin.errors.email}
label={"Username"}
placeholder="Your username"
value={formLogin.values.user_name}
error={formLogin.errors.user_name}
onChange={(e) => {
formLogin.setFieldValue("email", e.target.value!);
formLogin.setFieldValue("user_name", e.target.value!);
}}
required
size="md"

View File

@ -10,13 +10,13 @@ type TRegister = {
email: string;
password: string;
confirm_password: string;
full_name: string;
user_name: string;
};
function Register() {
const [formRegister, setFormRegister] = useState<TRegister>({
email: "",
full_name: "",
user_name: "",
password: "",
confirm_password: "",
});
@ -42,12 +42,12 @@ function Register() {
const payload = {
email: formRegister.email,
password: formRegister.password,
full_name: formRegister.full_name,
user_name: formRegister.user_name,
};
const response = await axios.post(apiUrl + "api/auth/register", payload);
if (response.data.user) {
const user = response.data.user;
user.fullName = user.full_name;
user.userName = user.user_name;
localStorage.setItem("user", JSON.stringify(user));
window.location.href = "/";
} else {
@ -86,18 +86,17 @@ function Register() {
onChange={(e) => {
setFormRegister({ ...formRegister, email: e.target.value });
}}
required
size="md"
mb="md"
/>
<TextInput
mb="md"
label="Full name"
label="Username"
placeholder="Bill Gates"
value={formRegister.full_name}
value={formRegister.user_name}
onChange={(e) => {
setFormRegister({ ...formRegister, full_name: e.target.value });
setFormRegister({ ...formRegister, user_name: e.target.value });
}}
required
size="md"

View File

@ -1,5 +1,5 @@
import type { Socket } from "socket.io-client";
import type { IScenario, TLine } from "../untils/types";
import type { IScenario, TextFSM, TLine, TStation } from "../untils/types";
import { Button } from "@mantine/core";
import classes from "./Component.module.css";
@ -27,6 +27,13 @@ export const ButtonDPELP = ({
onClick();
selectedLines?.forEach((el) => {
const body = [
{
expect: "",
send: " show inventory",
delay: "1000",
repeat: "1",
note: "",
},
{
expect: "",
send: " show diag",
@ -149,7 +156,7 @@ export const ButtonScenario = ({
<Button
disabled={isDisable}
miw={"100px"}
style={{ minHeight: "24px", height: "auto" }}
style={{ minHeight: "28px", height: "auto" }}
mr={"5px"}
variant="outline"
color="#00a164"
@ -170,3 +177,119 @@ export const ButtonScenario = ({
</Button>
);
};
export const ButtonCopy = ({
selectedLines,
setSelectedLines,
}: {
setSelectedLines: (value: React.SetStateAction<TLine[]>) => void;
selectedLines: TLine[];
}) => {
return (
<Button
disabled={selectedLines.length === 0}
variant="outline"
style={{ height: "30px", width: "100px" }}
onClick={() => {
if (selectedLines?.length > 0) {
const value = selectedLines
?.map((el) => {
// Get data platform
const dataPlatform = el.data?.find(
(comm: TextFSM) => comm.command?.trim() === "show platform"
);
const DPELP =
dataPlatform && !dataPlatform?.output?.includes("Incomplete")
? true
: false;
// Get data license
const dataLicense = el.data?.find(
(comm: TextFSM) =>
comm.command?.trim() === "show license" ||
comm.command?.trim() === "sh license"
);
const listLicense =
dataLicense?.textfsm && Array.isArray(dataLicense?.textfsm)
? dataLicense?.textfsm
?.map((val: { FEATURE: string }) => val.FEATURE)
.join(", ")
: "";
return `Line ${el.line_number ?? ""}: PID: ${
el.inventory?.pid ?? ""
}, SN: ${el.inventory?.sn ?? ""}, VID: ${
el.inventory?.vid ?? ""
}, Tested mode: ${
DPELP ? "DPELP" : "DPEL"
}, License: ${listLicense}`;
})
.join("\n");
navigator.clipboard.writeText(value);
setSelectedLines([]);
}
}}
>
Copy
</Button>
);
};
export const ButtonSelect = ({
selectedLines,
setSelectedLines,
station,
}: {
setSelectedLines: (value: React.SetStateAction<TLine[]>) => void;
selectedLines: TLine[];
station: TStation;
}) => {
return (
<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"}
</Button>
);
};
export const ButtonConnect = ({
selectedLines,
setSelectedLines,
station,
socket,
}: {
setSelectedLines: (value: React.SetStateAction<TLine[]>) => void;
selectedLines: TLine[];
station: TStation;
socket: Socket | null;
}) => {
return (
<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>
);
};

View File

@ -5,6 +5,7 @@ import TerminalCLI from "./TerminalXTerm";
import type { Socket } from "socket.io-client";
import { IconCircleCheckFilled } from "@tabler/icons-react";
import { memo, useMemo } from "react";
import { convertTimestampToDate } from "../untils/helper";
const CardLine = ({
line,
@ -62,7 +63,10 @@ const CardLine = ({
// align={"center"}
>
<Flex justify={"space-between"}>
<Text fw={600} style={{ display: "flex", gap: "4px" }}>
<Text
fw={600}
style={{ display: "flex", gap: "4px", fontSize: "15px" }}
>
Line: {line.lineNumber || line.line_number} - {line.port}{" "}
{line.status === "connected" && (
<IconCircleCheckFilled color="green" />
@ -80,9 +84,17 @@ const CardLine = ({
{line?.userOpenCLI ? line?.userOpenCLI + " is using" : ""}
</div>
</Flex>
{/* <Text className={classes.info_line}>PID: WS-C3560CG-8PC-S</Text>
<div className={classes.info_line}>SN: FGL2240307M</div>
<div className={classes.info_line}>VID: V01</div> */}
<Flex justify={"space-between"}>
<div className={classes.info_line}>
PID: {line?.inventory?.pid || ""}
</div>
<div className={classes.info_line}>
SN: {line?.inventory?.sn || ""}
</div>
<div className={classes.info_line} style={{ minWidth: "50px" }}>
VID: {line?.inventory?.vid || ""}
</div>
</Flex>
<Box
onClick={(e) => {
e.preventDefault();
@ -118,6 +130,16 @@ const CardLine = ({
}}
/>
</Box>
<Box>
<div className={classes.info_line}>
Latest: {line?.latestScenario?.name || ""}
<Text style={{ fontStyle: "italic", fontSize: "11px" }}>
{line?.latestScenario?.time
? "(" + convertTimestampToDate(line?.latestScenario?.time) + ")"
: ""}
</Text>
</div>
</Box>
</Flex>
</Card>
);

View File

@ -1,6 +1,6 @@
.card_line {
width: 320px;
height: 220px;
height: 250px;
padding: 8px;
gap: 8px;
cursor: pointer;
@ -8,10 +8,10 @@
.info_line {
color: dimgrey;
font-size: 12px;
font-size: 11px;
display: flex;
gap: 4px;
margin-top: 4px;
/* margin-top: 4px; */
height: 20px;
}

View File

@ -2,11 +2,15 @@ import {
ActionIcon,
Avatar,
Box,
Button,
CloseButton,
Flex,
Group,
Input,
Menu,
Tabs,
Text,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import {
DndContext,
@ -24,7 +28,13 @@ import {
} 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 {
IconChevronRight,
IconEdit,
IconLogout,
IconSettingsPlus,
IconUsersGroup,
} from "@tabler/icons-react";
import classes from "./Component.module.css";
import type { TStation, TUser } from "../untils/types";
import type { Socket } from "socket.io-client";
@ -43,6 +53,7 @@ interface DraggableTabsProps {
setStationEdit: (value: React.SetStateAction<TStation | undefined>) => void;
active: string;
setActive: (value: React.SetStateAction<string>) => void;
onSendCommand: (value: string) => void;
}
function SortableTab({
@ -103,6 +114,7 @@ export default function DraggableTabs({
setStationEdit,
active,
setActive,
onSendCommand,
}: DraggableTabsProps) {
const user = useMemo(() => {
return localStorage.getItem("user") &&
@ -113,6 +125,7 @@ export default function DraggableTabs({
const [tabs, setTabs] = useState<TStation[]>(tabsData);
const [isChangeTab, setIsChangeTab] = useState<boolean>(false);
const [isSetActive, setIsSetActive] = useState<boolean>(false);
const [valueInput, setValueInput] = useState<string>("");
// const [active, setActive] = useState<string | null>(
// tabsData?.length > 0 ? tabsData[0]?.id.toString() : null
// );
@ -219,14 +232,33 @@ export default function DraggableTabs({
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 style={{ width: "300px" }} align={"center"}>
<Input
style={{
width: "300px",
boxShadow: "0px 0px 10px rgba(0, 0, 0, 0.1)",
}}
placeholder={"Chat to Port/All"}
value={valueInput}
onChange={(event) => {
const newValue = event.currentTarget.value;
setValueInput(newValue);
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
onSendCommand(valueInput);
setValueInput("");
}
}}
rightSectionPointerEvents="all"
rightSection={
<CloseButton
aria-label="Clear input"
onClick={() => setValueInput("")}
style={{ display: valueInput ? undefined : "none" }}
/>
}
/>
</Flex>
<Tabs.List className={classes.list}>
<SortableContext
@ -280,20 +312,56 @@ export default function DraggableTabs({
</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();
}}
<Flex align={"center"}>
<Tooltip
withArrow
label={usersConnecting.map((el) => (
<Text key={el.userId}>{el.userName}</Text>
))}
>
Logout
</Button>
<Avatar radius="xl" me={"sm"}>
<IconUsersGroup color="green" />
</Avatar>
</Tooltip>
<Menu withArrow>
<Menu.Target>
<UnstyledButton
style={{
padding: "var(--mantine-spacing-md)",
color: "var(--mantine-color-text)",
borderRadius: "var(--mantine-radius-sm)",
}}
>
<Group>
<div style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{user?.userName || user?.user_name || ""}
</Text>
<Text c="dimmed" size="xs">
{user?.email}
</Text>
</div>
<IconChevronRight size={16} />
</Group>
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
style={{ width: "150px" }}
onClick={() => {
localStorage.removeItem("user");
window.location.href = "/";
socket?.disconnect();
}}
color="red"
leftSection={<IconLogout size={16} stroke={1.5} />}
>
Logout
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Flex>
</Flex>

View File

@ -1,7 +1,6 @@
import { useDisclosure } from "@mantine/hooks";
import {
Drawer,
ActionIcon,
Box,
ScrollArea,
Table,
@ -9,6 +8,7 @@ import {
TextInput,
Button,
Checkbox,
Text,
} from "@mantine/core";
import { IconSettingsPlus } from "@tabler/icons-react";
import TableRows from "./Scenario/TableRows";
@ -361,17 +361,25 @@ function DrawerScenario({
</Grid.Col>
</Grid>
</Drawer>
<ActionIcon
title="Add Scenario"
variant="outline"
color="green"
onClick={() => {
open();
<Text
fw={700}
c={"#747474"}
style={{
fontSize: "14px",
display: "flex",
alignItems: "center",
gap: "6px",
}}
>
<IconSettingsPlus />
</ActionIcon>
Scenarios
<IconSettingsPlus
color="green"
style={{ cursor: "pointer", width: "18px", height: "18px" }}
onClick={() => {
open();
}}
/>
</Text>
<DialogConfirm
opened={openConfirm}

View File

@ -315,7 +315,7 @@ const StationSetting = ({
)}
</div>
}
size={"60%"}
size={"80%"}
style={{ position: "absolute", left: 0 }}
centered
opened={isOpen}

View File

@ -1,5 +1,6 @@
import { Modal, Text } from "@mantine/core";
import classes from "./Component.module.css";
import { convertTimestampToDate } from "../untils/helper";
const ModalLog = ({
opened,
@ -25,8 +26,8 @@ const ModalLog = ({
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(/^---scenario---.*$/gm, "") // Remove split-point lines
.replace(/^---send-command---.*$/gm, "") // Remove split-point lines
.replace(
/^(---start-testing---|---end-testing---|---start-scenarios---|---end-scenarios---)(\d+)(---.*)?$/gm,
(_, prefix, timestamp, suffix = "") => {
@ -48,12 +49,6 @@ const ModalLog = ({
);
};
// 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 }}

View File

@ -71,7 +71,7 @@ const ModalTerminal = ({
lineId: line?.id,
stationId: line?.station_id,
userEmail: user?.email,
userName: user?.fullName,
userName: user?.userName,
});
socket?.emit("request_take_over", {
station_id: line?.station_id,
@ -202,7 +202,7 @@ const ModalTerminal = ({
socket?.emit("request_take_over", {
line_id: line?.id,
station_id: Number(line?.station_id),
userName: user?.fullName?.trim() || "",
userName: user?.userName?.trim() || "",
userEmail: user?.email || "",
});
setDisableRequestTakeOver(true);

View File

@ -2,7 +2,6 @@ import { useEffect, useRef, useState } from "react";
import { Terminal } from "xterm";
import "xterm/css/xterm.css";
import { FitAddon } from "@xterm/addon-fit";
import { SOCKET_EVENTS } from "../untils/constanst";
import type { Socket } from "socket.io-client";
interface TerminalCLIProps {
@ -76,7 +75,7 @@ const TerminalCLI: React.FC<TerminalCLIProps> = ({
// Gửi input từ người dùng lên server
terminal.current.onData((data) => {
socket?.emit(SOCKET_EVENTS.CLI.WRITE_COMMAND_FROM_WEB, {
socket?.emit("write_command_line_from_web", {
lineIds: [line_id],
stationId: station_id,
command: data,

View File

@ -34,7 +34,7 @@ export const SocketProvider: React.FC<{ children: React.ReactNode }> = ({
const newSocket = io(SOCKET_URL, {
auth: {
userId: user?.id,
userName: user?.fullName,
userName: user?.userName,
},
});

View File

@ -26,3 +26,9 @@ export function mergeArray(array: any[], key: string) {
.flat()
.filter((el) => Object.keys(el).length > 0);
}
// Function to convert timestamp to date
export const convertTimestampToDate = (timestamp: number) => {
const date = new Date(Number(timestamp));
return date.toLocaleString();
};

View File

@ -83,6 +83,10 @@ export type TLine = {
userOpenCLI?: string;
userEmailOpenCLI?: string;
statusTicket?: string;
latestScenario?: {
name: string;
time: number;
};
};
export type TUser = {