This commit is contained in:
nguyentrungthat 2025-10-28 16:56:57 +07:00
parent dea4d2b804
commit 0a0dd559f0
16 changed files with 760 additions and 368 deletions

View File

@ -18,6 +18,7 @@
"axios": "^1.12.2",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-router-dom": "^7.9.4",
"socket.io-client": "^4.8.1",
"xterm": "^5.3.0"
},
@ -2216,6 +2217,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -3595,6 +3605,44 @@
}
}
},
"node_modules/react-router": {
"version": "7.9.4",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.4.tgz",
"integrity": "sha512-SD3G8HKviFHg9xj7dNODUKDFgpG4xqD5nhyd0mYoB5iISepuZAvzSr8ywxgxKJ52yRzf/HWtVHc9AWwoTbljvA==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.9.4",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.4.tgz",
"integrity": "sha512-f30P6bIkmYvnHHa5Gcu65deIXoA2+r3Eb6PJIAddvsT9aGlchMatJ51GgpU470aSqRRbFX22T70yQNUGuW3DfA==",
"license": "MIT",
"dependencies": {
"react-router": "7.9.4"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/react-style-singleton": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
@ -3753,6 +3801,12 @@
"semver": "bin/semver.js"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",

View File

@ -20,6 +20,7 @@
"axios": "^1.12.2",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-router-dom": "^7.9.4",
"socket.io-client": "^4.8.1",
"xterm": "^5.3.0"
},

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

View File

@ -3,7 +3,7 @@
}
body {
font-family: 'Mulish', sans-serif;
font-family: "Mulish", sans-serif;
}
.list {
@ -48,7 +48,25 @@ body {
}
}
.content{
.content {
width: 100%;
border-top: 1px #ccc solid;
}
}
.userName {
position: relative;
font-size: 16px;
font-weight: 600;
color: #222;
text-align: center;
margin-right: 8px;
}
.userName::after {
content: "";
display: block;
height: 1px;
background-color: #007bff; /* blue accent */
margin: 0.1rem auto 0;
border-radius: 3px;
}

View File

@ -4,7 +4,7 @@ import "@mantine/notifications/styles.css";
import "./App.css";
import classes from "./App.module.css";
import { Suspense, useEffect, useState } from "react";
import { Suspense, useEffect, useMemo, useState } from "react";
import {
Tabs,
Text,
@ -17,8 +17,16 @@ import {
Button,
ActionIcon,
LoadingOverlay,
Avatar,
Tooltip,
} from "@mantine/core";
import type { IScenario, LineConfig, TLine, TStation } from "./untils/types";
import type {
IScenario,
LineConfig,
TLine,
TStation,
TUser,
} from "./untils/types";
import axios from "axios";
import CardLine from "./components/CardLine";
import { IconEdit, IconSettingsPlus } from "@tabler/icons-react";
@ -27,6 +35,8 @@ import { ButtonDPELP, ButtonScenario } from "./components/ButtonAction";
import StationSetting from "./components/FormAddEdit";
import DrawerScenario from "./components/DrawerScenario";
import { Notifications } from "@mantine/notifications";
import ModalTerminal from "./components/ModalTerminal";
import PageLogin from "./components/Authentication/LoginPage";
const apiUrl = import.meta.env.VITE_BACKEND_URL;
@ -34,6 +44,14 @@ const apiUrl = import.meta.env.VITE_BACKEND_URL;
* Main Component
*/
function App() {
const user = useMemo(() => {
return localStorage.getItem("user") &&
typeof localStorage.getItem("user") === "string"
? JSON.parse(localStorage.getItem("user") || "")
: null;
}, []);
if (!user) window.location.href = "/";
document.title = "Automation Test";
const { socket } = useSocket();
const [stations, setStations] = useState<TStation[]>([]);
@ -53,6 +71,10 @@ function App() {
const [isEditStation, setIsEditStation] = useState(false);
const [stationEdit, setStationEdit] = useState<TStation | undefined>();
const [scenarios, setScenarios] = useState<IScenario[]>([]);
const [openModalTerminal, setOpenModalTerminal] = useState(false);
const [selectedLine, setSelectedLine] = useState<TLine | undefined>();
const [loadingTerminal, setLoadingTerminal] = useState(true);
const [usersConnecting, setUsersConnecting] = useState<TUser[]>([]);
// function get list station
const getStation = async () => {
@ -93,24 +115,74 @@ function App() {
if (!socket || !stations?.length) return;
socket.on("line_connected", updateStatus);
socket.on("line_disconnected", updateStatus);
socket?.on("line_output", (data) => {
updateValueLineStation(data?.lineId, "netOutput", data.data);
});
socket?.on("line_error", (data) => {
updateValueLineStation(data?.lineId, "netOutput", data.error);
});
socket?.on("init", (data) => {
if (Array.isArray(data)) {
data.forEach((value) => {
updateValueLineStation(value?.id, "netOutput", value.output);
updateStatus({ ...value, lineId: value.id });
});
}
});
socket?.on("user_connecting", (data) => {
if (Array.isArray(data)) {
setUsersConnecting(data);
}
});
socket?.on("user_open_cli", (data) => {
setTimeout(() => {
updateValueLineStation(data?.lineId, "cliOpened", true);
updateValueLineStation(
data?.lineId,
"userEmailOpenCLI",
data?.userEmailOpenCLI
);
updateValueLineStation(data?.lineId, "userOpenCLI", data?.userOpenCLI);
}, 100);
});
socket?.on("user_close_cli", (data) => {
setTimeout(() => {
updateValueLineStation(data?.lineId, "cliOpened", false);
updateValueLineStation(data?.lineId, "userEmailOpenCLI", "");
updateValueLineStation(data?.lineId, "userOpenCLI", "");
}, 100);
});
// ✅ cleanup on unmount or when socket changes
return () => {
socket.off("init");
socket.off("line_output");
socket.off("line_error");
socket.off("line_connected");
socket.off("line_disconnected");
socket.off("user_connecting");
socket.off("user_open_cli");
socket.off("user_close_cli");
};
}, [socket, stations]);
const updateStatus = (data: LineConfig) => {
const line = getLine(data.lineId, data.stationId);
if (line) {
updateValueLineStation(line, "status", data.status);
if (line?.id) {
updateValueLineStation(line.id, "status", data.status);
}
};
const updateValueLineStation = <K extends keyof TLine>(
currentLine: TLine,
lineId: number,
field: K,
value: TLine[K]
) => {
@ -120,10 +192,20 @@ function App() {
? {
...station,
lines: (station?.lines || [])?.map((lineItem: TLine) => {
if (lineItem.id === currentLine.id) {
if (lineItem.id === lineId) {
return {
...lineItem,
[field]: value,
[field]:
field === "netOutput"
? (lineItem.netOutput || "") + value
: value,
output: field === "netOutput" ? value : lineItem.output,
loadingOutput:
field === "netOutput"
? lineItem.loadingOutput
? false
: true
: false,
};
}
return lineItem;
@ -132,6 +214,24 @@ function App() {
: 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);
}
};
const getLine = (lineId: number, stationId: number) => {
@ -142,63 +242,106 @@ function App() {
} else return null;
};
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,
});
};
return (
<Container py="xl" w={"100%"} style={{ maxWidth: "100%" }}>
<Container py="md" w={"100%"} style={{ maxWidth: "100%" }}>
<Tabs
value={activeTab}
onChange={(id) => setActiveTab(id?.toString() || "0")}
onChange={(id) => {
setActiveTab(id?.toString() || "0");
setLoadingTerminal(false);
setTimeout(() => {
setLoadingTerminal(true);
}, 100);
}}
variant="none"
keepMounted={false}
>
<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>
))}
<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) ? (
<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="Edit Station"
title="Add Station"
variant="outline"
color="green"
onClick={() => {
setStationEdit(
stations.find((el) => el.id === Number(activeTab))
);
setIsOpenAddStation(true);
setIsEditStation(true);
setIsEditStation(false);
setStationEdit(undefined);
}}
>
<IconEdit />
<IconSettingsPlus />
</ActionIcon>
) : (
""
)}
<ActionIcon
title="Add Station"
</Flex>
</Tabs.List>
<Flex gap={"sm"} align={"baseline"}>
<Text className={classes.userName}>{user?.fullName}</Text>
<Button
variant="outline"
color="green"
color="red"
style={{ height: "30px", width: "100px" }}
onClick={() => {
setIsOpenAddStation(true);
setIsEditStation(false);
setStationEdit(undefined);
localStorage.removeItem("user");
window.location.href = "/";
socket?.disconnect();
}}
>
<IconSettingsPlus />
</ActionIcon>
Logout
</Button>
</Flex>
</Tabs.List>
</Flex>
{stations.map((station) => (
<Tabs.Panel
@ -238,7 +381,11 @@ function App() {
line={line}
selectedLines={selectedLines}
setSelectedLines={setSelectedLines}
updateStatus={updateStatus}
openTerminal={openTerminal}
loadTerminal={
loadingTerminal &&
Number(station.id) === Number(activeTab)
}
/>
))}
</Flex>
@ -309,8 +456,9 @@ function App() {
}, 10000);
}}
/>
{scenarios.map((el) => (
{scenarios.map((el, i) => (
<ButtonScenario
key={i}
socket={socket}
selectedLines={selectedLines}
isDisable={isDisable || selectedLines.length === 0}
@ -345,11 +493,24 @@ function App() {
setActiveTab(stations.length ? stations[0]?.id.toString() : "0")
}
/>
<ModalTerminal
opened={openModalTerminal}
onClose={() => {
setOpenModalTerminal(false);
setSelectedLine(undefined);
}}
line={selectedLine}
socket={socket}
stationItem={stations.find((el) => el.id === Number(activeTab))}
scenarios={scenarios}
/>
</Container>
);
}
export default function Main() {
const user = localStorage.getItem("user");
return (
<MantineProvider>
<SocketProvider>
@ -363,7 +524,13 @@ export default function Main() {
}
>
<Notifications position="top-right" autoClose={5000} />
<App />
{user ? (
<App />
) : (
<Container w={"100%"} style={{ maxWidth: "100%", padding: 0 }}>
<PageLogin />
</Container>
)}
</Suspense>
</SocketProvider>
</MantineProvider>

View File

@ -1,70 +1,22 @@
.wrapper {
min-height: rem(100vh);
background-size: cover;
background-image: url(https://images.unsplash.com/photo-1484242857719-4b9144542727?ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1280&q=80);
}
.form {
border-right: rem(1px) solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-7));
min-height: rem(100vh);
max-width: rem(500px);
padding-top: rem(80px);
@media (max-width: var(--mantine-breakpoint-sm)) {
max-width: 100%;
};
font-weight: 600;
}
.title {
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
font-family:
Greycliff CF,
var(--mantine-font-family);
}
.google-btn {
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #4285f4;
border-radius: 4px;
background-color: white;
margin: 15px auto;
font-size: 16px;
font-weight: bold;
cursor: pointer;
transition: background-color 0.3s ease;
}
.google-btn:hover {
background-color: #f0f0f0;
}
.google-btn:active {
background-color: #e0e0e0;
}
.google-icon {
width: 20px;
height: 20px;
margin-right: 10px;
}
.title {
position: relative;
font-size: 1.5rem;
font-weight: 600;
color: #222;
text-align: center;
margin-bottom: 1.5rem;
height: 100vh;
background-size: cover;
background-image: url(https://images.unsplash.com/photo-1484242857719-4b9144542727?ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1280&q=80);
display: flex;
justify-content: center;
align-items: center;
}
.title::after {
content: "";
display: block;
width: 200px;
height: 2px;
background-color: #007bff; /* blue accent */
margin: 0.1rem auto 0;
border-radius: 3px;
}
.form {
border-right: rem(1px) solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-7));
max-width: rem(450px);
padding-top: rem(80px);
font-weight: 600;
}
.title {
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
font-family: Greycliff CF, var(--mantine-font-family);
}

View File

@ -1,105 +1,80 @@
import { useGoogleLogin } from '@react-oauth/google'
import { emailRegex } from '@/utils/formRegexs'
import { Button, PasswordInput, TextInput } from "@mantine/core";
import { useForm } from "@mantine/form";
import { notifications } from "@mantine/notifications";
import axios from "axios";
import { useDispatch, useSelector } from 'react-redux'
import { AppDispatch, RootState } from '@/rtk/store'
import {
loginAsync,
loginERPAsync,
loginWithGoogleAsync,
} from '@/rtk/slices/authSlice'
import { Box, Button, PasswordInput, TextInput } from '@mantine/core'
import { useForm } from '@mantine/form'
import classes from './AuthenticationImage.module.css'
import { useNavigate } from 'react-router-dom'
import ImgERP from '../../lib/images/erp.jpg'
import { useState } from 'react'
const apiUrl = import.meta.env.VITE_BACKEND_URL;
type TLogin = {
email: string
password: string
}
email: string;
password: string;
};
const Login = () => {
const navigate = useNavigate()
const dispatch = useDispatch<AppDispatch>()
const { status } = useSelector((state: RootState) => state.auth)
const [isLoginERP, setIsLoginERP] = useState(false)
const formLogin = useForm<TLogin>({
initialValues: {
email: '',
password: '',
email: "",
password: "",
},
validate: (values) => ({
email:
values.email === ''
? 'Email is required'
: isLoginERP
? null
: emailRegex.test(values.email)
? null
: 'Invalid email',
email: values.email === "" ? "Email is required" : null,
password: values.password === '' ? 'Password is required' : null,
password: values.password === "" ? "Password is required" : null,
}),
})
});
const handleLogin = async (values: TLogin) => {
if (isLoginERP) {
const handleLogin = async () => {
try {
if (!formLogin.values.email) {
notifications.show({
title: "Error",
message: "Email is required",
color: "red",
});
return;
}
if (!formLogin.values.password) {
notifications.show({
title: "Error",
message: "Password is required",
color: "red",
});
return;
}
const payload = {
userEmail: values.email,
password: values.password,
}
const resultAction = await dispatch(loginERPAsync(payload))
if (loginERPAsync.fulfilled.match(resultAction)) {
// set interval to wait for localStorage to be set
window.location.href = '/dashboard'
}
} else {
const resultAction = await dispatch(loginAsync(values))
if (loginAsync.fulfilled.match(resultAction)) {
navigate('/dashboard')
email: formLogin.values.email,
password: formLogin.values.password,
};
const response = await axios.post(apiUrl + "api/auth/login", payload);
if (response.data.user) {
const user = response.data.user;
localStorage.setItem("user", JSON.stringify(user));
window.location.href = "/";
}
} catch (error) {
console.log(error);
notifications.show({
title: "Error",
message: "Login fail, please try again!",
color: "red",
});
}
}
const handleLoginGG = useGoogleLogin({
onSuccess: async (codeResponse) => {
const accessToken = codeResponse.access_token
const resultAction = await dispatch(loginWithGoogleAsync(accessToken))
if (loginWithGoogleAsync.fulfilled.match(resultAction)) {
navigate('/dashboard')
}
},
onError: (error) => console.log('Login Failed:', error),
})
};
return (
<form
style={{
padding: '10px 20px',
padding: "10px 20px",
}}
onSubmit={formLogin.onSubmit(handleLogin)}
>
<div style={{ textAlign: 'center' }}>
<h3 className={classes.title}>
{isLoginERP ? 'Login with ERP account' : 'Login with ATC account'}
</h3>
</div>
<TextInput
label={isLoginERP ? 'Username/email:' : 'Email address'}
label={"Email address"}
placeholder="hello@gmail.com"
value={formLogin.values.email}
error={formLogin.errors.email}
onChange={(e) => {
formLogin.setFieldValue('email', e.target.value!)
formLogin.setFieldValue("email", e.target.value!);
}}
required
size="md"
@ -110,7 +85,7 @@ const Login = () => {
value={formLogin.values.password}
error={formLogin.errors.password}
onChange={(e) => {
formLogin.setFieldValue('password', e.target.value!)
formLogin.setFieldValue("password", e.target.value!);
}}
required
mt="md"
@ -122,60 +97,12 @@ const Login = () => {
mt="xl"
size="md"
type="submit"
loading={status === 'loading'}
loading={status === "loading"}
>
Sign in
</Button>
{!isLoginERP ? (
<Box ta={'center'}>
<Button
variant="outline"
color="#228be6"
radius={'5px'}
className={classes['google-btn']}
onClick={() => setIsLoginERP(true)}
>
<img
src={ImgERP}
alt="ERP logo"
className={classes['google-icon']}
/>
Sign in with ERP
</Button>
</Box>
) : (
<Box ta={'center'}>
<Button
variant="outline"
color="#228be6"
radius={'5px'}
className={classes['google-btn']}
onClick={() => setIsLoginERP(false)}
>
Sign in normally
</Button>
</Box>
)}
<Box ta={'center'}>
<Button
variant="outline"
color="#228be6"
radius={'5px'}
onClick={() => handleLoginGG()}
className={classes['google-btn']}
>
<img
src="https://upload.wikimedia.org/wikipedia/commons/thumb/c/c1/Google_%22G%22_logo.svg/480px-Google_%22G%22_logo.svg.png"
alt="Google logo"
className={classes['google-icon']}
/>
Sign in with Google
</Button>
</Box>
</form>
)
}
);
};
export default Login
export default Login;

View File

@ -0,0 +1,63 @@
import { useState } from "react";
import { Anchor, Image, Paper, Text } from "@mantine/core";
import Login from "./Login";
import Register from "./Register";
import classes from "./AuthenticationImage.module.css";
export const PageLogin = () => {
const [isRegister, setIsRegister] = useState(false);
return (
<div
style={{
height: "100vh",
}}
>
<div className={classes.wrapper}>
<Paper className={classes.form} radius={0} p={30}>
<Image
w={"45%"}
mt={"sm"}
mb={"xs"}
m={"0 auto"}
src={import.meta.env.VITE_DOMAIN + "logo-ATC-removebg-preview.png"}
/>
{isRegister ? (
<>
<Register />
<Text ta="center" mt="md">
You have an account?{" "}
<Anchor<"a">
href="#"
fw={700}
onClick={() => setIsRegister(false)}
>
Sign in
</Anchor>
</Text>
</>
) : (
<>
<Login />
<Text ta="center" mt="md">
Don&apos;t have an account?{" "}
<Anchor<"a">
href="#"
fw={700}
onClick={() => setIsRegister(true)}
>
Register
</Anchor>
</Text>
</>
)}
</Paper>
</div>
</div>
);
};
export default PageLogin;

View File

@ -1,48 +1,77 @@
import { useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { AppDispatch, RootState } from '@/rtk/store'
import { registerAsync } from '@/rtk/slices/authSlice'
import { useState } from "react";
import PasswordRequirementInput from '../PasswordRequirementInput/PasswordRequirementInput'
import { emailRegex, passwordRegex } from '@/utils/formRegexs'
import { requirementsPassword } from '@/rtk/helpers/variables'
import { Box, Button, PasswordInput, TextInput } from '@mantine/core'
import { Box, Button, PasswordInput, TextInput } from "@mantine/core";
import { emailRegex } from "../../untils/helper";
import { notifications } from "@mantine/notifications";
import axios from "axios";
const apiUrl = import.meta.env.VITE_BACKEND_URL;
type TRegister = {
email: string
password: string
confirm_password: string
full_name: string
}
email: string;
password: string;
confirm_password: string;
full_name: string;
};
function Register() {
const dispatch = useDispatch<AppDispatch>()
const { status } = useSelector((state: RootState) => state.auth)
const [formRegister, setFormRegister] = useState<TRegister>({
email: '',
full_name: '',
password: '',
confirm_password: '',
})
email: "",
full_name: "",
password: "",
confirm_password: "",
});
const handleRegister = async () => {
// Dispatch action registerAsync với dữ liệu form và đợi kết quả
const resultAction = await dispatch(registerAsync(formRegister))
// Kiểm tra nếu action thành công
if (registerAsync.fulfilled.match(resultAction)) {
// Tải lại trang web
// window.location.reload()
try {
if (!formRegister.email) {
notifications.show({
title: "Error",
message: "Email is required",
color: "red",
});
return;
}
if (!formRegister.password) {
notifications.show({
title: "Error",
message: "Password is required",
color: "red",
});
return;
}
const payload = {
email: formRegister.email,
password: formRegister.password,
full_name: formRegister.full_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;
localStorage.setItem("user", JSON.stringify(user));
window.location.href = "/";
} else {
notifications.show({
title: "Error",
message: response.data.message,
color: "red",
});
}
} catch (error) {
console.log(error);
notifications.show({
title: "Error",
message: "Register fail, please try again!",
color: "red",
});
}
}
};
return (
<form
onSubmit={(e) => {
e.preventDefault()
handleRegister()
e.preventDefault();
handleRegister();
}}
>
<TextInput
@ -50,12 +79,12 @@ function Register() {
placeholder="hello@gmail.com"
value={formRegister.email}
error={
emailRegex.test(formRegister.email) || formRegister.email === ''
emailRegex.test(formRegister.email) || formRegister.email === ""
? null
: 'Invalid email'
: "Invalid email"
}
onChange={(e) => {
setFormRegister({ ...formRegister, email: e.target.value })
setFormRegister({ ...formRegister, email: e.target.value });
}}
required
size="md"
@ -68,19 +97,22 @@ function Register() {
placeholder="Bill Gates"
value={formRegister.full_name}
onChange={(e) => {
setFormRegister({ ...formRegister, full_name: e.target.value })
setFormRegister({ ...formRegister, full_name: e.target.value });
}}
required
size="md"
/>
<PasswordRequirementInput
requirements={requirementsPassword}
value={formRegister}
setValue={setFormRegister}
<PasswordInput
mt="md"
label="Password"
placeholder="Password"
name="password"
placeholder="Your password"
value={formRegister.password}
onChange={(e) => {
setFormRegister({ ...formRegister, password: e.target.value });
}}
required
size="md"
/>
<PasswordInput
@ -90,27 +122,29 @@ function Register() {
value={formRegister.confirm_password}
error={
formRegister.confirm_password === formRegister.password ||
formRegister.confirm_password === ''
formRegister.confirm_password === ""
? null
: 'Password do not match'
: "Password do not match"
}
onChange={(e) => {
setFormRegister({ ...formRegister, confirm_password: e.target.value })
setFormRegister({
...formRegister,
confirm_password: e.target.value,
});
}}
required
size="md"
/>
<Box ta={'center'}>
<Box ta={"center"}>
<Button
type="submit"
m="15px auto"
fullWidth
size="md"
loading={status === 'loading'}
loading={status === "loading"}
disabled={
formRegister.password !== '' &&
passwordRegex.test(formRegister.password) &&
formRegister.password !== "" &&
formRegister.password === formRegister.confirm_password
? false
: true
@ -120,7 +154,7 @@ function Register() {
</Button>
</Box>
</form>
)
);
}
export default Register
export default Register;

View File

@ -1,5 +1,5 @@
import { Card, Text, Box, Flex } from "@mantine/core";
import type { LineConfig, TLine, TStation } from "../untils/types";
import type { TLine, TStation } from "../untils/types";
import classes from "./Component.module.css";
import TerminalCLI from "./TerminalXTerm";
import type { Socket } from "socket.io-client";
@ -12,14 +12,16 @@ const CardLine = ({
setSelectedLines,
socket,
stationItem,
updateStatus,
openTerminal,
loadTerminal,
}: {
line: TLine;
selectedLines: TLine[];
setSelectedLines: (lines: React.SetStateAction<TLine[]>) => void;
socket: Socket | null;
stationItem: TStation;
updateStatus: (value: LineConfig) => void;
openTerminal: (value: TLine) => void;
loadTerminal: boolean;
}) => {
return (
<Card
@ -33,6 +35,11 @@ const CardLine = ({
? { backgroundColor: "#8bf55940" }
: {}
}
onDoubleClick={(e) => {
e.preventDefault();
e.stopPropagation();
openTerminal(line);
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
@ -45,16 +52,27 @@ const CardLine = ({
justify={"space-between"}
direction={"column"}
// gap={"md"}
align={"center"}
// align={"center"}
>
<div>
<Flex justify={"space-between"}>
<Text fw={600} style={{ display: "flex", gap: "4px" }}>
Line: {line.lineNumber || line.line_number} - {line.port}{" "}
{line.status === "connected" && (
<IconCircleCheckFilled color="green" />
)}
</Text>
</div>
<div
style={{
alignItems: "center",
marginLeft: "16px",
fontSize: "12px",
color: "red",
display: "flex",
}}
>
{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> */}
@ -66,9 +84,11 @@ const CardLine = ({
style={{ height: "175px", width: "300px" }}
>
<TerminalCLI
cliOpened={true}
cliOpened={loadTerminal}
socket={socket}
content={line.netOutput ?? ""}
content={line?.output ?? ""}
initContent={line?.netOutput ?? ""}
loadingContent={line?.loadingOutput}
line_id={Number(line?.id)}
station_id={Number(stationItem.id)}
isDisabled={false}
@ -82,8 +102,9 @@ const CardLine = ({
padding: "0px",
paddingBottom: "0px",
}}
onDoubleClick={() => {}}
updateStatus={updateStatus}
onDoubleClick={() => {
openTerminal(line);
}}
/>
</Box>
</Flex>

View File

@ -8,6 +8,7 @@ import {
Grid,
TextInput,
Button,
Checkbox,
} from "@mantine/core";
import { IconSettingsPlus } from "@tabler/icons-react";
import TableRows from "./Scenario/TableRows";
@ -42,11 +43,10 @@ function DrawerScenario({
send: "",
delay: "0",
repeat: "1",
note: "",
},
] as IBodyScenario[],
timeout: "30000",
is_reboot: false,
isReboot: false,
},
validate: {
title: (value) => {
@ -72,7 +72,6 @@ function DrawerScenario({
send: "",
delay: "0",
repeat: "1",
note: "",
});
form.setFieldValue("body", newBody);
};
@ -84,6 +83,22 @@ function DrawerScenario({
};
const handleSave = async () => {
if (!form.values.title) {
notifications.show({
title: "Error",
message: "Title is required",
color: "red",
});
return;
}
if (!form.values.timeout) {
notifications.show({
title: "Error",
message: "Timeout is required",
color: "red",
});
return;
}
setIsSubmit(true);
try {
const body = form.values.body.map((el: IBodyScenario) => ({
@ -93,7 +108,8 @@ function DrawerScenario({
}));
const payload = {
...form.values,
title: form.values.title,
isReboot: form.values.isReboot,
body: body,
timeout: Number(form.values.timeout),
};
@ -111,15 +127,28 @@ function DrawerScenario({
)
: [...pre, scenario]
);
setIsEdit(true);
setDataScenario(scenario);
notifications.show({
title: "Success",
message: res.data.message,
color: "green",
});
return;
} else {
notifications.show({
title: "Error",
message: res.data.message,
color: "red",
});
}
} catch (error) {
console.log(error);
notifications.show({
title: "Error",
message: "Failed to create scenario, please try again!",
color: "red",
});
} finally {
setIsSubmit(false);
}
@ -196,7 +225,7 @@ function DrawerScenario({
form.setFieldValue("title", scenario.title);
form.setFieldValue("timeout", scenario.timeout.toString());
form.setFieldValue("body", JSON.parse(scenario.body));
form.setFieldValue("is_reboot", scenario.is_reboot);
form.setFieldValue("isReboot", scenario.isReboot);
}
}}
>
@ -231,7 +260,27 @@ function DrawerScenario({
required
/>
</Grid.Col>
<Grid.Col span={6}>
<Grid.Col
span={3}
style={{
display: "flex",
alignItems: "end",
marginBottom: "8px",
}}
>
<Checkbox
label="Reboot"
style={{ color: "red" }}
checked={form.values.isReboot}
onChange={(event) =>
form.setFieldValue(
"isReboot",
event.currentTarget.checked
)
}
/>
</Grid.Col>
<Grid.Col span={3}>
<div
style={{
display: "flex",
@ -329,7 +378,7 @@ function DrawerScenario({
close={() => {
setOpenConfirm(false);
}}
message={"Are you sure delete this station?"}
message={"Are you sure delete this scenario?"}
handle={() => {
setOpenConfirm(false);
handleDelete();

View File

@ -0,0 +1,120 @@
import { Box, Button, Grid, Modal, Text } from "@mantine/core";
import type { 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 { IconCircleCheckFilled } from "@tabler/icons-react";
const ModalTerminal = ({
opened,
onClose,
line,
socket,
stationItem,
scenarios,
}: {
opened: boolean;
onClose: () => void;
line: TLine | undefined;
socket: Socket | null;
stationItem: TStation | undefined;
scenarios: IScenario[];
}) => {
const [isDisable, setIsDisable] = useState<boolean>(false);
// console.log(line);
return (
<Box>
<Modal
opened={opened}
onClose={() => {
onClose();
socket?.emit("close_cli", {
lineId: line?.id,
stationId: line?.station_id,
});
}}
size={"80%"}
style={{ position: "absolute", left: 0 }}
title={
<Box
style={{
display: "flex",
justifyContent: "center",
}}
>
<Text size="md" mr={10}>
Line number: <strong>{line?.line_number || ""}</strong>
</Text>
<Text size="md" mr={10}>
- <strong>{line?.port || ""}</strong>
</Text>
{line?.status === "connected" && (
<IconCircleCheckFilled color="green" />
)}
<div
style={{
alignItems: "center",
marginLeft: "16px",
fontSize: "12px",
color: "red",
display: "flex",
}}
>
{line?.userOpenCLI
? line?.userOpenCLI + " is using"
: "Terminal is used"}
</div>
</Box>
}
>
<Grid>
<Grid.Col span={10} style={{ borderRight: "1px solid #ccc" }}>
<TerminalCLI
cliOpened={opened}
socket={socket}
content={line?.output ?? ""}
initContent={line?.netOutput ?? ""}
loadingContent={line?.loadingOutput}
line_id={Number(line?.id)}
station_id={Number(stationItem?.id)}
isDisabled={false}
line_status={line?.status || ""}
/>
</Grid.Col>
<Grid.Col span={2}>
{scenarios.map((scenario) => (
<Button
disabled={isDisable}
className={classes.buttonScenario}
key={scenario.id}
miw={"100px"}
mb={"6px"}
style={{ minHeight: "24px" }}
mr={"5px"}
variant={"outline"}
onClick={async () => {
setIsDisable(true);
setTimeout(() => {
setIsDisable(false);
}, 10000);
if (line)
socket?.emit(
"run_scenario",
Object.assign(line, {
scenario: scenario,
})
);
}}
>
{scenario.title}
</Button>
))}
</Grid.Col>
</Grid>
</Modal>
</Box>
);
};
export default ModalTerminal;

View File

@ -4,11 +4,11 @@ import "xterm/css/xterm.css";
import { FitAddon } from "@xterm/addon-fit";
import { SOCKET_EVENTS } from "../untils/constanst";
import type { Socket } from "socket.io-client";
import type { LineConfig } from "../untils/types";
interface TerminalCLIProps {
socket: Socket | null;
content?: string;
initContent?: string;
line_id: number;
line_status: string;
station_id: number;
@ -25,7 +25,7 @@ interface TerminalCLIProps {
onDoubleClick?: () => void;
fontSize?: number;
miniSize?: boolean;
updateStatus: (value: LineConfig) => void;
loadingContent?: boolean;
}
const TerminalCLI: React.FC<TerminalCLIProps> = ({
@ -39,7 +39,8 @@ const TerminalCLI: React.FC<TerminalCLIProps> = ({
onDoubleClick = () => {},
fontSize = 14,
miniSize = false,
updateStatus,
initContent = "",
loadingContent = false,
}) => {
const xtermRef = useRef<HTMLDivElement>(null);
const terminal = useRef<Terminal>(null);
@ -128,40 +129,7 @@ const TerminalCLI: React.FC<TerminalCLIProps> = ({
if (fitRef.current) setTimeout(() => fitRef.current?.fit(), 500);
}
}, [content]);
useEffect(() => {
// Nhận output từ thiết bị và ghi vào terminal
socket?.on("line_output", (data) => {
if (data?.lineId === line_id && terminal.current) {
terminal.current?.write(data.data);
terminal.current?.focus();
}
});
socket?.on("line_error", (data) => {
if (data?.lineId === line_id && terminal.current) {
terminal.current?.write(data.error);
}
});
socket?.on("init", (data) => {
if (Array.isArray(data)) {
data.forEach((value) => {
if (value?.id === line_id && terminal.current) {
terminal.current?.write(value.output);
updateStatus({ ...value, lineId: value.id });
}
});
}
});
return () => {
socket?.off("init");
socket?.off("line_error");
socket?.off("line_output");
};
}, []);
}, [content, loadingContent]);
useEffect(() => {
if (cliOpened) {
@ -182,7 +150,7 @@ const TerminalCLI: React.FC<TerminalCLIProps> = ({
useEffect(() => {
if (!loading) {
if (terminal.current) {
terminal.current?.write(content);
terminal.current?.write(initContent);
if (!miniSize && !isDisabled) terminal.current?.focus();
terminal.current.scrollToBottom();
}
@ -211,7 +179,7 @@ const TerminalCLI: React.FC<TerminalCLIProps> = ({
height: "100%",
backgroundColor: "black",
paddingBottom: customStyle.paddingBottom ?? "10px",
minHeight: customStyle.maxHeight ?? "60vh",
minHeight: customStyle.maxHeight ?? "75vh",
}}
>
<div
@ -221,8 +189,8 @@ const TerminalCLI: React.FC<TerminalCLIProps> = ({
paddingLeft: customStyle.paddingLeft ?? "10px",
paddingBottom: customStyle.paddingBottom ?? "10px",
fontSize: customStyle.fontSize ?? "9px",
maxHeight: customStyle.maxHeight ?? "60vh",
height: customStyle.height ?? "60vh",
maxHeight: customStyle.maxHeight ?? "75vh",
height: customStyle.height ?? "75vh",
padding: customStyle.padding ?? "4px",
}}
onDoubleClick={(event) => {

View File

@ -1,4 +1,10 @@
import React, { createContext, useContext, useEffect, useState } from "react";
import React, {
createContext,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { io, Socket } from "socket.io-client";
import { SOCKET_EVENTS } from "../untils/constanst";
import { notifications } from "@mantine/notifications";
@ -15,9 +21,21 @@ export const SocketProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [socket, setSocket] = useState<Socket | null>(null);
const user = useMemo(() => {
return localStorage.getItem("user") &&
typeof localStorage.getItem("user") === "string"
? JSON.parse(localStorage.getItem("user") || "")
: null;
}, []);
useEffect(() => {
const newSocket = io(SOCKET_URL);
if (!user) return;
const newSocket = io(SOCKET_URL, {
auth: {
userId: user?.id,
userName: user?.fullName,
},
});
setSocket(newSocket);
@ -46,7 +64,7 @@ export const SocketProvider: React.FC<{ children: React.ReactNode }> = ({
newSocket.disconnect();
};
}, []);
}, [user]);
return (
<SocketContext.Provider value={{ socket }}>

View File

@ -2,3 +2,8 @@ export const numberOnly = (value: string): string => {
const matched = value.match(/[\d.]+/g);
return matched ? matched.join("") : "";
};
export const passwordRegex =
/^(?=.*[A-Z])(?=.*[a-z])(?=.*\d)(?=.*[@$!%*?&#])[A-Za-z\d@$!%*?&#]{8,}$/;
export const emailRegex = /^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/;

View File

@ -67,6 +67,8 @@ export type TLine = {
inventory?: any;
status?: string;
netOutput?: string;
output?: string;
loadingOutput?: boolean;
outlet?: number;
cliOpened?: boolean;
systemLogUrl?: string;
@ -84,14 +86,8 @@ export type TLine = {
};
export type TUser = {
id: number;
email: string;
email_cc: string;
full_name: string;
package_id: string;
zulip: string;
token?: string;
name: string;
userId: number;
userName: string;
};
export type APCProps = {
@ -144,7 +140,7 @@ export type IScenario = {
title: string;
body: string;
timeout: number;
is_reboot: boolean;
isReboot: boolean;
updated_at: string;
};
@ -153,5 +149,4 @@ export type IBodyScenario = {
send: string;
delay: string;
repeat: string;
note: string;
};