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ý // Đăng ký
async register({ request, response }: HttpContext) { async register({ request, response }: HttpContext) {
try { 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) { 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) const newUser = await User.create(data)
return response.json({ status: true, message: 'User created', user: newUser }) return response.json({ status: true, message: 'User created', user: newUser })
} catch (error) { } 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 // Đăng nhập
async login({ request, auth, response }: HttpContext) { async login({ request, auth, response }: HttpContext) {
const { email, password } = request.only(['email', 'password']) const { user_name: userName, password } = request.only(['user_name', 'password'])
const user = await User.query().where('email', email).first() const user = await User.query().where('user_name', userName).first()
if (!user) { if (!user) {
return response.status(401).json({ message: 'Invalid email or password' }) return response.status(401).json({ message: 'Invalid Username or password' })
} }
try { try {
// So sánh password // So sánh password
if (user.password !== 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({ return response.json({
message: 'Login successful', message: 'Login successful',
user: { id: user.id, email: user.email, fullName: user.fullName }, user: { id: user.id, email: user.email, userName: user.userName },
}) })
} catch { } catch {
return response.status(401).json({ message: 'Invalid credentials' }) return response.status(401).json({ message: 'Invalid credentials' })

View File

@ -71,13 +71,6 @@ export default class StationsController {
let lines: Line[] = request.body().lines || [] let lines: Line[] = request.body().lines || []
try { 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) const station = await Station.find(request.body().id)
// If the station does not exist, return a 404 response // 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) { async store({ request, response }: HttpContext) {
try { try {
const data = request.only(['full_name', 'email', 'password']) const data = request.only(['user_name', 'email', 'password'])
// Check if email already exists // 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) { if (existingUser) {
return response.conflict({ return response.conflict({
status: false, status: false,
message: 'Email already exists', message: 'Username already exists',
}) })
} }
const user = await User.create({ const user = await User.create({
fullName: data.full_name, userName: data.user_name,
email: data.email, email: data.email,
password: data.password, password: data.password,
}) })
@ -55,16 +55,16 @@ export default class UsersController {
async update({ params, request, response }: HttpContext) { async update({ params, request, response }: HttpContext) {
try { try {
const user = await User.findOrFail(params.id) 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 // Check if email already exists for another user
if (data.email) if (data.email)
if (data.email !== user.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) { if (existingUser) {
return response.conflict({ return response.conflict({
status: false, 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 declare id: number
@column() @column()
declare fullName: string | null declare userName: string
@column() @column()
declare email: string declare email: string

View File

@ -23,6 +23,11 @@ interface LineConfig {
openCLI: boolean openCLI: boolean
userEmailOpenCLI: string userEmailOpenCLI: string
userOpenCLI: string userOpenCLI: string
inventory?: string
latestScenario?: {
name: string
time: number
}
data: { data: {
command: string command: string
output: string output: string
@ -184,6 +189,10 @@ export default class LineConnection {
this.config.lineNumber, this.config.lineNumber,
this.config.port this.config.port
) )
this.config.latestScenario = {
name: script?.title,
time: now,
}
const steps = typeof script?.body === 'string' ? JSON.parse(script?.body) : [] const steps = typeof script?.body === 'string' ? JSON.parse(script?.body) : []
let stepIndex = 0 let stepIndex = 0
@ -228,10 +237,17 @@ export default class LineConnection {
if (err) return if (err) return
const logScenarios = getLogWithTimeScenario(content, now) || '' const logScenarios = getLogWithTimeScenario(content, now) || ''
const data = await textfsmResults(logScenarios, '') const data = textfsmResults(logScenarios, '')
try { try {
data.forEach((item) => { data.forEach((item) => {
if (item?.textfsm && isValidJson(item?.textfsm)) { 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) item.textfsm = JSON.parse(item.textfsm)
} }
}) })
@ -240,6 +256,8 @@ export default class LineConnection {
stationId: this.config.stationId, stationId: this.config.stationId,
lineId: this.config.id, lineId: this.config.id,
data, data,
inventory: this.config.inventory || null,
latestScenario: this.config.latestScenario || null,
}) })
} catch (error) { } catch (error) {
console.log(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 }) this.userConnecting.set(userId, { userId, userName })
setTimeout(() => { setTimeout(() => {
io.emit('user_connecting', Array.from(this.userConnecting.values())) const listUser = Array.from(this.userConnecting.values())
}, 200) if (!listUser.find((el) => el.userId === userId)) {
listUser.push({ userId, userName })
}
io.emit('user_connecting', listUser)
}, 500)
setTimeout(() => { setTimeout(() => {
io.to(socket.id).emit( io.to(socket.id).emit(
'init', 'init',
Array.from(this.lineMap.values()).map((el) => el.config) Array.from(this.lineMap.values()).map((el) => el.config)
) )
}, 200) }, 500)
socket.on('disconnect', () => { socket.on('disconnect', () => {
console.log(`FE disconnected: ${socket.id}`) 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, MantineProvider,
Grid, Grid,
ScrollArea, ScrollArea,
Button,
LoadingOverlay, LoadingOverlay,
} from "@mantine/core"; } from "@mantine/core";
import type { import type {
@ -22,7 +21,6 @@ import type {
LineConfig, LineConfig,
ReceivedFile, ReceivedFile,
ResponseData, ResponseData,
TextFSM,
TLine, TLine,
TStation, TStation,
TUser, TUser,
@ -30,7 +28,13 @@ import type {
import axios from "axios"; import axios from "axios";
import CardLine from "./components/CardLine"; import CardLine from "./components/CardLine";
import { SocketProvider, useSocket } from "./context/SocketContext"; 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 StationSetting from "./components/FormAddEdit";
import DrawerScenario from "./components/DrawerScenario"; import DrawerScenario from "./components/DrawerScenario";
import { Notifications } from "@mantine/notifications"; import { Notifications } from "@mantine/notifications";
@ -230,6 +234,8 @@ function App() {
setTimeout(() => { setTimeout(() => {
updateValueLineStation(data.lineId, { updateValueLineStation(data.lineId, {
data: data.data, data: data.data,
inventory: data.inventory,
latestScenario: data.latestScenario,
}); });
}, 100); }, 100);
}); });
@ -319,19 +325,19 @@ function App() {
if (!line.userEmailOpenCLI) { if (!line.userEmailOpenCLI) {
data.cliOpened = true; data.cliOpened = true;
data.userEmailOpenCLI = user?.email; data.userEmailOpenCLI = user?.email;
data.userOpenCLI = user?.fullName; data.userOpenCLI = user?.userName;
socket?.emit("open_cli", { socket?.emit("open_cli", {
lineId: line.id, lineId: line.id,
stationId: line.station_id, stationId: line.station_id,
userEmail: user?.email, userEmail: user?.email,
userName: user?.fullName, userName: user?.userName,
}); });
} }
setSelectedLine(data); setSelectedLine(data);
}; };
return ( return (
<Container py="md" w={"100%"} style={{ maxWidth: "100%" }}> <Container w={"100%"} style={{ maxWidth: "100%" }}>
<DraggableTabs <DraggableTabs
socket={socket} socket={socket}
usersConnecting={usersConnecting} usersConnecting={usersConnecting}
@ -405,100 +411,36 @@ function App() {
<Flex <Flex
direction={"column"} direction={"column"}
align={"center"} align={"center"}
gap={"xs"} gap={"6px"}
wrap={"wrap"} wrap={"wrap"}
> >
<Button <ButtonSelect
variant="filled" selectedLines={selectedLines}
style={{ height: "30px", width: "100px" }} setSelectedLines={setSelectedLines}
onClick={() => { station={station}
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}
/> />
<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 <ButtonDPELP
socket={socket} socket={socket}
selectedLines={selectedLines} selectedLines={selectedLines}
@ -511,26 +453,35 @@ function App() {
}, 10000); }, 10000);
}} }}
/> />
{scenarios.map((el, i) => ( <ScrollArea h={"60vh"} style={{ paddingBottom: "12px" }}>
<ButtonScenario <Flex
key={i} w={"100%"}
socket={socket} direction={"column"}
selectedLines={selectedLines.filter( wrap={"wrap"}
(el) => gap={"6px"}
typeof el?.userEmailOpenCLI === "undefined" || >
el?.userEmailOpenCLI === user?.email {scenarios.map((el, i) => (
)} <ButtonScenario
isDisable={isDisable || selectedLines.length === 0} key={i}
onClick={() => { socket={socket}
setSelectedLines([]); selectedLines={selectedLines.filter(
setIsDisable(true); (el) =>
setTimeout(() => { typeof el?.userEmailOpenCLI === "undefined" ||
setIsDisable(false); el?.userEmailOpenCLI === user?.email
}, 10000); )}
}} isDisable={isDisable || selectedLines.length === 0}
scenario={el} onClick={() => {
/> setSelectedLines([]);
))} setIsDisable(true);
setTimeout(() => {
setIsDisable(false);
}, 10000);
}}
scenario={el}
/>
))}
</Flex>
</ScrollArea>
</Flex> </Flex>
<DrawerLogs <DrawerLogs
socket={socket} socket={socket}
@ -554,6 +505,25 @@ function App() {
}} }}
setActive={setActiveTab} setActive={setActiveTab}
active={activeTab} 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 <StationSetting

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import type { Socket } from "socket.io-client"; 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 { Button } from "@mantine/core";
import classes from "./Component.module.css"; import classes from "./Component.module.css";
@ -27,6 +27,13 @@ export const ButtonDPELP = ({
onClick(); onClick();
selectedLines?.forEach((el) => { selectedLines?.forEach((el) => {
const body = [ const body = [
{
expect: "",
send: " show inventory",
delay: "1000",
repeat: "1",
note: "",
},
{ {
expect: "", expect: "",
send: " show diag", send: " show diag",
@ -149,7 +156,7 @@ export const ButtonScenario = ({
<Button <Button
disabled={isDisable} disabled={isDisable}
miw={"100px"} miw={"100px"}
style={{ minHeight: "24px", height: "auto" }} style={{ minHeight: "28px", height: "auto" }}
mr={"5px"} mr={"5px"}
variant="outline" variant="outline"
color="#00a164" color="#00a164"
@ -170,3 +177,119 @@ export const ButtonScenario = ({
</Button> </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 type { Socket } from "socket.io-client";
import { IconCircleCheckFilled } from "@tabler/icons-react"; import { IconCircleCheckFilled } from "@tabler/icons-react";
import { memo, useMemo } from "react"; import { memo, useMemo } from "react";
import { convertTimestampToDate } from "../untils/helper";
const CardLine = ({ const CardLine = ({
line, line,
@ -62,7 +63,10 @@ const CardLine = ({
// align={"center"} // align={"center"}
> >
<Flex justify={"space-between"}> <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: {line.lineNumber || line.line_number} - {line.port}{" "}
{line.status === "connected" && ( {line.status === "connected" && (
<IconCircleCheckFilled color="green" /> <IconCircleCheckFilled color="green" />
@ -80,9 +84,17 @@ const CardLine = ({
{line?.userOpenCLI ? line?.userOpenCLI + " is using" : ""} {line?.userOpenCLI ? line?.userOpenCLI + " is using" : ""}
</div> </div>
</Flex> </Flex>
{/* <Text className={classes.info_line}>PID: WS-C3560CG-8PC-S</Text> <Flex justify={"space-between"}>
<div className={classes.info_line}>SN: FGL2240307M</div> <div className={classes.info_line}>
<div className={classes.info_line}>VID: V01</div> */} 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 <Box
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
@ -118,6 +130,16 @@ const CardLine = ({
}} }}
/> />
</Box> </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> </Flex>
</Card> </Card>
); );

View File

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

View File

@ -2,11 +2,15 @@ import {
ActionIcon, ActionIcon,
Avatar, Avatar,
Box, Box,
Button, CloseButton,
Flex, Flex,
Group,
Input,
Menu,
Tabs, Tabs,
Text, Text,
Tooltip, Tooltip,
UnstyledButton,
} from "@mantine/core"; } from "@mantine/core";
import { import {
DndContext, DndContext,
@ -24,7 +28,13 @@ import {
} from "@dnd-kit/sortable"; } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import { useEffect, useMemo, useState, type JSX } from "react"; 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 classes from "./Component.module.css";
import type { TStation, TUser } from "../untils/types"; import type { TStation, TUser } from "../untils/types";
import type { Socket } from "socket.io-client"; import type { Socket } from "socket.io-client";
@ -43,6 +53,7 @@ interface DraggableTabsProps {
setStationEdit: (value: React.SetStateAction<TStation | undefined>) => void; setStationEdit: (value: React.SetStateAction<TStation | undefined>) => void;
active: string; active: string;
setActive: (value: React.SetStateAction<string>) => void; setActive: (value: React.SetStateAction<string>) => void;
onSendCommand: (value: string) => void;
} }
function SortableTab({ function SortableTab({
@ -103,6 +114,7 @@ export default function DraggableTabs({
setStationEdit, setStationEdit,
active, active,
setActive, setActive,
onSendCommand,
}: DraggableTabsProps) { }: DraggableTabsProps) {
const user = useMemo(() => { const user = useMemo(() => {
return localStorage.getItem("user") && return localStorage.getItem("user") &&
@ -113,6 +125,7 @@ export default function DraggableTabs({
const [tabs, setTabs] = useState<TStation[]>(tabsData); const [tabs, setTabs] = useState<TStation[]>(tabsData);
const [isChangeTab, setIsChangeTab] = useState<boolean>(false); const [isChangeTab, setIsChangeTab] = useState<boolean>(false);
const [isSetActive, setIsSetActive] = useState<boolean>(false); const [isSetActive, setIsSetActive] = useState<boolean>(false);
const [valueInput, setValueInput] = useState<string>("");
// const [active, setActive] = useState<string | null>( // const [active, setActive] = useState<string | null>(
// tabsData?.length > 0 ? tabsData[0]?.id.toString() : null // tabsData?.length > 0 ? tabsData[0]?.id.toString() : null
// ); // );
@ -219,14 +232,33 @@ export default function DraggableTabs({
w={w} w={w}
> >
<Flex justify={"space-between"}> <Flex justify={"space-between"}>
<Flex wrap={"wrap"} style={{ maxWidth: "250px" }} gap={"4px"}> <Flex style={{ width: "300px" }} align={"center"}>
{usersConnecting.map((el) => ( <Input
<Tooltip label={el.userName} key={el.userId}> style={{
<Avatar color="cyan" radius="xl" size={"md"}> width: "300px",
{el.userName.slice(0, 2)} boxShadow: "0px 0px 10px rgba(0, 0, 0, 0.1)",
</Avatar> }}
</Tooltip> 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> </Flex>
<Tabs.List className={classes.list}> <Tabs.List className={classes.list}>
<SortableContext <SortableContext
@ -280,20 +312,56 @@ export default function DraggableTabs({
</ActionIcon> </ActionIcon>
</Flex> </Flex>
</Tabs.List> </Tabs.List>
<Flex gap={"sm"} align={"baseline"}> <Flex align={"center"}>
<Text className={classes.userName}>{user?.fullName}</Text> <Tooltip
<Button withArrow
variant="outline" label={usersConnecting.map((el) => (
color="red" <Text key={el.userId}>{el.userName}</Text>
style={{ height: "30px", width: "100px" }} ))}
onClick={() => {
localStorage.removeItem("user");
window.location.href = "/";
socket?.disconnect();
}}
> >
Logout <Avatar radius="xl" me={"sm"}>
</Button> <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>
</Flex> </Flex>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -26,3 +26,9 @@ export function mergeArray(array: any[], key: string) {
.flat() .flat()
.filter((el) => Object.keys(el).length > 0); .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; userOpenCLI?: string;
userEmailOpenCLI?: string; userEmailOpenCLI?: string;
statusTicket?: string; statusTicket?: string;
latestScenario?: {
name: string;
time: number;
};
}; };
export type TUser = { export type TUser = {