This commit is contained in:
nguyentrungthat 2025-10-24 16:39:39 +07:00
parent 85c4bb9a26
commit 077a2ddc35
22 changed files with 1258 additions and 156 deletions

View File

@ -0,0 +1,42 @@
import type { HttpContext } from '@adonisjs/core/http'
import User from '../models/user.js'
export default class AuthController {
// Đăng ký
async register({ request, response }: HttpContext) {
const data = request.only(['email', 'password', 'full_name'])
const user = await User.create(data)
return response.json({ message: 'User created', user })
}
// Đă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()
if (!user) {
return response.status(401).json({ message: 'Invalid email or password' })
}
try {
// So sánh password
if (user.password !== password) {
return response.status(401).json({ message: 'Invalid email or password' })
}
// ✅ Nếu dùng token thủ công:
const token = Math.random().toString(36).substring(2) // hoặc JWT nếu bạn cài auth
return response.json({
message: 'Login successful',
user: { id: user.id, email: user.email, token },
})
} catch {
return response.status(401).json({ message: 'Invalid credentials' })
}
}
// Đăng xuất
async logout({ auth, response }: HttpContext) {
return response.json({ message: 'Logged out successfully' })
}
}

View File

@ -1,6 +1,5 @@
import User from '#models/user'
import type { HttpContext } from '@adonisjs/core/http'
import hash from '@adonisjs/core/services/hash'
export default class UsersController {
async index({ request, response }: HttpContext) {
@ -37,13 +36,10 @@ export default class UsersController {
})
}
// Hash the password before saving
const hashedPassword = await hash.make(data.password)
const user = await User.create({
fullName: data.full_name,
email: data.email,
password: hashedPassword,
password: data.password,
})
return response.created({
@ -73,11 +69,6 @@ export default class UsersController {
}
}
// Hash the password if it is provided
if (data.password) {
data.password = await hash.make(data.password)
}
user.merge(data)
await user.save()

View File

@ -22,4 +22,4 @@ export default class AuthMiddleware {
await ctx.auth.authenticateUsing(options.guards, { loginRoute: this.redirectTo })
return next()
}
}
}

View File

@ -12,16 +12,16 @@ export default class Line extends BaseModel {
declare port: number
@column()
declare line_number: number
declare lineNumber: number
@column()
declare line_clear: number
declare lineClear: number
@column()
declare stationId: number
@column()
declare apc_name: number
declare apcName: string
@column()
declare outlet: number

View File

@ -1,4 +1,5 @@
import net from 'node:net'
import { cleanData } from '../ultils/helper.js'
interface LineConfig {
id: number
@ -7,6 +8,8 @@ interface LineConfig {
ip: string
stationId: number
apcName?: string
output: string
status: string
}
export default class LineConnection {
@ -20,12 +23,19 @@ export default class LineConnection {
this.client = new net.Socket()
}
connect() {
connect(timeoutMs = 5000) {
return new Promise<void>((resolve, reject) => {
const { ip, port, lineNumber, id, stationId } = this.config
let resolvedOrRejected = false
// Set timeout
this.client.setTimeout(timeoutMs)
this.client.connect(port, ip, () => {
if (resolvedOrRejected) return
resolvedOrRejected = true
console.log(`✅ Connected to line ${lineNumber} (${ip}:${port})`)
this.config.status = 'connected'
this.socketIO.emit('line_connected', {
stationId,
lineId: id,
@ -36,8 +46,19 @@ export default class LineConnection {
})
this.client.on('data', (data) => {
const message = data.toString().trim()
console.log(`📨 [${this.config.apcName}] ${message}`)
let message = data.toString()
// let output = cleanData(message)
// console.log(`📨 [${this.config.port}] ${message}`)
// Handle netOutput with backspace support
for (const char of message) {
if (char === '\x7F' || char === '\x08') {
this.config.output = this.config.output.slice(0, -1)
// message = message.slice(0, -1)
} else {
this.config.output += cleanData(char)
}
}
this.config.output = this.config.output.slice(-15000)
this.socketIO.emit('line_output', {
stationId,
lineId: id,
@ -46,7 +67,10 @@ export default class LineConnection {
})
this.client.on('error', (err) => {
if (resolvedOrRejected) return
resolvedOrRejected = true
console.error(`❌ Error line ${lineNumber}:`, err.message)
this.config.output += err.message
this.socketIO.emit('line_error', {
stationId,
lineId: id,
@ -57,12 +81,23 @@ export default class LineConnection {
this.client.on('close', () => {
console.log(`🔌 Line ${lineNumber} disconnected`)
this.config.status = 'disconnected'
this.socketIO.emit('line_disconnected', {
stationId,
lineId: id,
lineNumber,
status: 'disconnected',
})
})
this.client.on('timeout', () => {
if (resolvedOrRejected) return
resolvedOrRejected = true
console.log(`⏳ Connection timeout line ${lineNumber}`)
this.client.destroy()
reject(new Error('Connection timeout'))
})
})
}
@ -71,13 +106,26 @@ export default class LineConnection {
console.log(`⚠️ Cannot send, line ${this.config.lineNumber} is closed`)
return
}
console.log(`➡️ [${this.config.apcName}] SEND:`, cmd)
// console.log(`➡️ [${this.config.apcName}] SEND:`, cmd)
this.client.write(`${cmd}\r\n`)
}
writeCommand(cmd: string) {
if (this.client.destroyed) {
console.log(`⚠️ Cannot send, line ${this.config.lineNumber} is closed`)
return
}
this.client.write(`${cmd}`)
}
disconnect() {
try {
this.client.destroy()
this.config.status = 'disconnected'
this.socketIO.emit('line_disconnected', {
...this.config,
status: 'disconnected',
})
console.log(`🔻 Closed connection to line ${this.config.lineNumber}`)
} catch (e) {
console.error('Error closing line:', e)

View File

@ -0,0 +1,14 @@
/**
* Function to clean up unwanted characters from the output data.
* @param {string} data - The raw data to be cleaned.
* @returns {string} - The cleaned data.
*/
export const cleanData = (data) => {
return data
.replace(/--More--\s*BS\s*BS\s*BS\s*BS\s*BS\s*BS/g, '')
.replace(/\s*--More--\s*/g, '')
.replace(/\x1b\[[0-9;]*m/g, '') // Remove ANSI escape codes
.replace(/\x08/g, '')
.replace(/[^\x20-\x7E\r\n]/g, '') // Remove non-printable characters
// .replace(/\r\n/g, '\n')
}

View File

@ -9,7 +9,7 @@
"version": "0.0.0",
"license": "UNLICENSED",
"dependencies": {
"@adonisjs/auth": "^9.4.0",
"@adonisjs/auth": "^9.5.1",
"@adonisjs/core": "^6.18.0",
"@adonisjs/cors": "^2.2.1",
"@adonisjs/lucid": "^21.6.1",

View File

@ -51,7 +51,7 @@
"typescript": "~5.8"
},
"dependencies": {
"@adonisjs/auth": "^9.4.0",
"@adonisjs/auth": "^9.5.1",
"@adonisjs/core": "^6.18.0",
"@adonisjs/cors": "^2.2.1",
"@adonisjs/lucid": "^21.6.1",

View File

@ -4,6 +4,7 @@ import LineConnection from '../app/services/line_connection.js'
import { ApplicationService } from '@adonisjs/core/types'
import env from '#start/env'
import { CustomServer, CustomSocket } from '../app/ultils/types.js'
import Line from '#models/line'
interface Station {
id: number
@ -77,24 +78,37 @@ export class WebSocketIo {
console.log('Socket connected:', socket.id)
socket.connectionTime = new Date()
const lineConnectionArray: LineConnection[] = Array.from(this.lineMap.values())
io.to(socket.id).emit(
'init',
lineConnectionArray.map((el) => el.config)
)
socket.on('disconnect', () => {
console.log(`🔴 FE disconnected: ${socket.id}`)
console.log(`FE disconnected: ${socket.id}`)
})
// FE gửi yêu cầu connect lines
socket.on('connect_lines', async (stationData: Station) => {
console.log('📡 Yêu cầu connect station:', stationData.name)
await this.connectStation(socket, stationData)
socket.on('connect_lines', async (data) => {
const { stationData, linesData } = data
await this.connectLine(io, linesData, stationData)
})
// FE gửi command đến line cụ thể
socket.on('send_command', (data) => {
const { lineId, command } = data
const line = this.lineMap.get(lineId)
if (line) {
line.sendCommand(command)
} else {
socket.emit('line_error', { lineId, error: 'Line not connected' })
socket.on('write_command_line_from_web', (data) => {
const { lineIds, stationId, command } = data
for (const lineId of lineIds) {
const line = this.lineMap.get(lineId)
if (line) {
this.setTimeoutConnect(lineId, line)
line.writeCommand(command)
} else {
io.emit('line_disconnected', {
stationId,
lineId,
status: 'disconnected',
})
io.emit('line_error', { lineId, error: 'Line not connected\r\n' })
}
}
})
@ -111,22 +125,30 @@ export class WebSocketIo {
return io
}
private async connectStation(socket, station: Station) {
this.stationMap.set(station.id, station)
for (const line of station.lines) {
const lineConn = new LineConnection(
{
id: line.id,
port: line.port,
ip: station.ip,
lineNumber: line.lineNumber,
stationId: station.id,
apcName: line.apcName,
},
this.io
)
await lineConn.connect()
this.lineMap.set(line.id, lineConn)
private async connectLine(socket: any, lines: Line[], station: Station) {
try {
this.stationMap.set(station.id, station)
for (const line of lines) {
const lineConn = new LineConnection(
{
id: line.id,
port: line.port,
ip: station.ip,
lineNumber: line.lineNumber,
stationId: station.id,
apcName: line.apcName,
output: '',
status: '',
},
socket
)
await lineConn.connect()
lineConn.writeCommand('\r\n\r\n')
this.lineMap.set(line.id, lineConn)
this.setTimeoutConnect(line.id, lineConn)
}
} catch (error) {
console.log(error)
}
}
@ -145,4 +167,17 @@ export class WebSocketIo {
this.stationMap.delete(stationId)
console.log(`🔻 Station ${station.name} disconnected`)
}
private setTimeoutConnect = (lineId: number, lineConn: LineConnection) => {
if (this.intervalMap[`${lineId}`]) {
clearInterval(this.intervalMap[`${lineId}`])
delete this.intervalMap[`${lineId}`]
}
const interval = setInterval(() => {
lineConn.disconnect()
this.lineMap.delete(lineId)
}, 120000)
this.intervalMap[`${lineId}`] = interval
}
}

View File

@ -63,3 +63,9 @@ router
router.post('delete', '#controllers/scenarios_controller.delete')
})
.prefix('api/scenarios')
router
.group(() => {
router.post('/login', '#controllers/auth_controller.login')
})
.prefix('api/auth')

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
<title>Automation Test</title>
</head>
<body>
<div id="root"></div>

View File

@ -12,10 +12,12 @@
"@mantine/dates": "^8.3.5",
"@mantine/notifications": "^8.3.5",
"@tabler/icons-react": "^3.35.0",
"@xterm/addon-fit": "^0.10.0",
"axios": "^1.12.2",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"socket.io-client": "^4.8.1"
"socket.io-client": "^4.8.1",
"xterm": "^5.3.0"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
@ -1913,6 +1915,22 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@xterm/addon-fit": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
},
"node_modules/@xterm/xterm": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
"license": "MIT",
"peer": true
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@ -4280,6 +4298,13 @@
"node": ">=0.4.0"
}
},
"node_modules/xterm": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz",
"integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==",
"deprecated": "This package is now deprecated. Move to @xterm/xterm instead.",
"license": "MIT"
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@ -14,10 +14,12 @@
"@mantine/dates": "^8.3.5",
"@mantine/notifications": "^8.3.5",
"@tabler/icons-react": "^3.35.0",
"@xterm/addon-fit": "^0.10.0",
"axios": "^1.12.2",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"socket.io-client": "^4.8.1"
"socket.io-client": "^4.8.1",
"xterm": "^5.3.0"
},
"devDependencies": {
"@eslint/js": "^9.36.0",

View File

@ -21,14 +21,16 @@ import type { TLine, TStation } from "./untils/types";
import axios from "axios";
import CardLine from "./components/CardLine";
import { IconEdit, IconSettingsPlus } from "@tabler/icons-react";
import { SocketProvider, useSocket } from "./context/SocketContext";
const apiUrl = import.meta.env.VITE_BACKEND_URL;
/**
* Main Component
*/
export default function App() {
export function App() {
document.title = "Automation Test";
const { socket } = useSocket();
const [stations, setStations] = useState<TStation[]>([]);
const [selectedLines, setSelectedLines] = useState<TLine[]>([]);
const [activeTab, setActiveTab] = useState("0");
@ -62,95 +64,162 @@ export default function App() {
getStation();
}, []);
return (
<MantineProvider>
<Container py="xl" w={"100%"} style={{ maxWidth: "100%" }}>
{/* Tabs (Top Bar) */}
<Tabs
value={activeTab}
onChange={(id) => setActiveTab(id?.toString() || "0")}
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>
))}
useEffect(() => {
if (!socket || !stations?.length) return;
<FloatingIndicator
target={activeTab ? controlsRefs[activeTab] : null}
parent={rootRef}
className={classes.indicator}
/>
<Flex gap={"sm"}>
<ActionIcon title="Add Station" variant="outline" color="green">
<IconSettingsPlus />
</ActionIcon>
const updateStatus = (data: any) => {
const line = getLine(data.lineId, data.stationId);
if (line) {
updateValueLineStation(line, "status", data.status);
}
};
socket.on("line_connected", updateStatus);
socket.on("line_disconnected", updateStatus);
socket?.on("init", (data) => {
if (Array.isArray(data)) {
data.forEach((value) => {
updateStatus(value);
});
}
});
// ✅ cleanup on unmount or when socket changes
return () => {
socket.off("line_connected");
socket.off("line_disconnected");
socket.off("line_disconnected");
};
}, [socket, stations]);
const updateValueLineStation = (
currentLine: TLine,
field: string,
value: any
) => {
setStations((el) =>
el?.map((station: TStation) =>
station.id.toString() === activeTab
? {
...station,
lines: (station?.lines || [])?.map((lineItem: TLine) => {
if (lineItem.id === currentLine.id) {
return {
...lineItem,
[field]: value,
};
}
return lineItem;
}),
}
: station
)
);
};
const getLine = (lineId: number, stationId: number) => {
const station = stations?.find((sta) => sta.id === stationId);
if (station) {
const line = station.lines?.find((li) => li.id === lineId);
return line;
} else return null;
};
return (
<Container py="xl" w={"100%"} style={{ maxWidth: "100%" }}>
{/* Tabs (Top Bar) */}
<Tabs
value={activeTab}
onChange={(id) => setActiveTab(id?.toString() || "0")}
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>
))}
<FloatingIndicator
target={activeTab ? controlsRefs[activeTab] : null}
parent={rootRef}
className={classes.indicator}
/>
<Flex gap={"sm"}>
<ActionIcon title="Add Station" variant="outline" color="green">
<IconSettingsPlus />
</ActionIcon>
{Number(activeTab) && (
<ActionIcon title="Edit Station" variant="outline">
<IconEdit />
</ActionIcon>
</Flex>
</Tabs.List>
)}
</Flex>
</Tabs.List>
{stations.map((station) => (
<Tabs.Panel
className={classes.content}
key={station.id}
value={station.id.toString()}
pt="md"
>
<Grid>
<Grid.Col
span={10}
style={{
boxShadow: showBottomShadow
? "inset 0 -12px 10px -10px rgba(0, 0, 0, 0.2)"
: "none",
borderRadius: 8,
{stations.map((station) => (
<Tabs.Panel
className={classes.content}
key={station.id}
value={station.id.toString()}
pt="md"
>
<Grid>
<Grid.Col
span={11}
style={{
boxShadow: showBottomShadow
? "inset 0 -12px 10px -10px rgba(0, 0, 0, 0.2)"
: "none",
borderRadius: 8,
}}
>
<ScrollArea
h={"84vh"}
onScrollPositionChange={({ y }) => {
const el = document.querySelector(
".mantine-ScrollArea-viewport"
);
if (!el) return;
const maxScroll = el.scrollHeight - el.clientHeight;
setShowBottomShadow(y < maxScroll - 2);
}}
>
<ScrollArea
h={"84vh"}
onScrollPositionChange={({ y }) => {
const el = document.querySelector(
".mantine-ScrollArea-viewport"
);
if (!el) return;
const maxScroll = el.scrollHeight - el.clientHeight;
setShowBottomShadow(y < maxScroll - 2);
}}
>
{station.lines.length > 0 ? (
<Flex wrap="wrap" gap="sm" justify={"center"}>
{[
...station.lines,
...station.lines,
...station.lines,
].map((line) => (
<CardLine
line={line}
selectedLines={selectedLines}
setSelectedLines={setSelectedLines}
/>
))}
</Flex>
) : (
<Text ta="center" c="dimmed" mt="lg">
No lines configured
</Text>
)}
</ScrollArea>
</Grid.Col>
<Grid.Col
span={2}
style={{ backgroundColor: "#f1f1f1", borderRadius: 8 }}
{station.lines.length > 0 ? (
<Flex wrap="wrap" gap="sm" justify={"center"}>
{station.lines.map((line, i) => (
<CardLine
key={i}
socket={socket}
stationItem={station}
line={line}
selectedLines={selectedLines}
setSelectedLines={setSelectedLines}
/>
))}
</Flex>
) : (
<Text ta="center" c="dimmed" mt="lg">
No lines configured
</Text>
)}
</ScrollArea>
</Grid.Col>
<Grid.Col
span={1}
style={{ backgroundColor: "#f1f1f1", borderRadius: 8 }}
>
<Flex
direction={"column"}
align={"center"}
gap={"xs"}
wrap={"wrap"}
>
<Button
variant="filled"
@ -165,12 +234,42 @@ export default function App() {
? "Select All"
: "Deselect All"}
</Button>
</Grid.Col>
</Grid>
</Tabs.Panel>
))}
</Tabs>
</Container>
<Button
disabled={
selectedLines.filter((el) => el.status !== "connected")
.length === 0
}
variant="outline"
style={{ height: "30px", width: "120px" }}
onClick={() => {
const lines = selectedLines.filter(
(el) => el.status !== "connected"
);
socket?.emit("connect_lines", {
stationData: station,
linesData: lines,
});
setSelectedLines([]);
}}
>
Connect
</Button>
</Flex>
</Grid.Col>
</Grid>
</Tabs.Panel>
))}
</Tabs>
</Container>
);
}
export default function Main() {
return (
<MantineProvider>
<SocketProvider>
<App />
</SocketProvider>
</MantineProvider>
);
}

View File

@ -0,0 +1,70 @@
.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;
}
.title::after {
content: "";
display: block;
width: 200px;
height: 2px;
background-color: #007bff; /* blue accent */
margin: 0.1rem auto 0;
border-radius: 3px;
}

View File

@ -0,0 +1,189 @@
import { useState } from 'react'
import { useSelector } from 'react-redux'
import { RootState } from '@/rtk/store'
import {
Box,
Button,
Modal,
PasswordInput,
Text,
TextInput,
} from '@mantine/core'
import { notifications } from '@mantine/notifications'
import PasswordRequirementInput from '../PasswordRequirementInput/PasswordRequirementInput'
import { requirementsPassword } from '@/rtk/helpers/variables'
import { passwordRegex } from '@/utils/formRegexs'
import { post } from '@/rtk/helpers/apiService'
import { changePassword } from '@/api/Auth'
type TChangePassword = {
opened: boolean
setOpened: React.Dispatch<React.SetStateAction<boolean>>
}
function ChangePassword({ opened, setOpened }: TChangePassword) {
const { user } = useSelector((state: RootState) => state.auth)
const [countSpam, setCountSpam] = useState(0)
const [loading, setLoading] = useState(false)
const [formData, setFormData] = useState({
current_password: '',
password: '',
confirm_password: '',
})
const handleChangePassword = async () => {
setLoading(true)
if (countSpam > 5) {
notifications.show({
title: 'Error',
message: 'Password error more than 5 times. Logout after 3s',
color: 'red',
})
setTimeout(() => {
localStorage.clear()
window.location.reload()
}, 3000)
return
}
try {
const res = await post(changePassword, {
email: user!.email,
current_password: formData.current_password,
password: formData.password,
confirm_password: formData.confirm_password,
})
if (res.status) {
notifications.show({
title: 'Success',
message: res.message,
color: 'green',
})
setOpened(false)
setFormData({
current_password: '',
password: '',
confirm_password: '',
})
return
}
setCountSpam(countSpam + 1)
} catch (error: any) {
console.log(error)
} finally {
setLoading(false)
}
}
const isFormValid =
formData.current_password &&
formData.password &&
passwordRegex.test(formData.password) &&
formData.password === formData.confirm_password
return (
<Modal
opened={opened}
onClose={() => {
setOpened(false)
setFormData({
current_password: '',
password: '',
confirm_password: '',
})
}}
title={
<Text fw={700} fz={'1.2rem'}>
Change password
</Text>
}
>
<Box p="sm">
<form
onSubmit={(e) => {
e.preventDefault()
handleChangePassword()
}}
>
<TextInput
label="E-mail"
value={user!.email}
disabled
mb="md"
required
/>
<PasswordInput
label="Current password"
required
placeholder="Current password"
maxLength={32}
value={formData.current_password}
onChange={(e) => {
setFormData({
...formData,
current_password: e.target.value,
})
}}
mb="md"
error={
formData.current_password.length < 8 &&
formData.current_password !== '' &&
'Length 8 characters or more'
}
/>
<PasswordRequirementInput
requirements={requirementsPassword}
value={formData}
setValue={setFormData}
label="New password"
placeholder="New password"
name="password"
mb="md"
/>
<PasswordInput
label="Confirm password"
required
placeholder="Confirm password"
maxLength={32}
value={formData.confirm_password}
onChange={(e) =>
setFormData({
...formData,
confirm_password: e.target.value,
})
}
error={
formData.password !== formData.confirm_password &&
formData.confirm_password !== '' &&
'Password do not match'
}
/>
<Button
type="submit"
style={{ float: 'right' }}
mb={'lg'}
mt={'lg'}
loading={loading}
disabled={!isFormValid}
>
Change Password
</Button>
</form>
</Box>
</Modal>
)
}
export default ChangePassword

View File

@ -0,0 +1,181 @@
import { useGoogleLogin } from '@react-oauth/google'
import { emailRegex } from '@/utils/formRegexs'
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'
type TLogin = {
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: '',
},
validate: (values) => ({
email:
values.email === ''
? 'Email is required'
: isLoginERP
? null
: emailRegex.test(values.email)
? null
: 'Invalid email',
password: values.password === '' ? 'Password is required' : null,
}),
})
const handleLogin = async (values: TLogin) => {
if (isLoginERP) {
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')
}
}
}
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',
}}
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'}
placeholder="hello@gmail.com"
value={formLogin.values.email}
error={formLogin.errors.email}
onChange={(e) => {
formLogin.setFieldValue('email', e.target.value!)
}}
required
size="md"
/>
<PasswordInput
label="Password"
placeholder="Your password"
value={formLogin.values.password}
error={formLogin.errors.password}
onChange={(e) => {
formLogin.setFieldValue('password', e.target.value!)
}}
required
mt="md"
size="md"
/>
<Button
fullWidth
mt="xl"
size="md"
type="submit"
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

View File

@ -0,0 +1,126 @@
import { useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { AppDispatch, RootState } from '@/rtk/store'
import { registerAsync } from '@/rtk/slices/authSlice'
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'
type TRegister = {
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: '',
})
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()
}
}
return (
<form
onSubmit={(e) => {
e.preventDefault()
handleRegister()
}}
>
<TextInput
label="Email address"
placeholder="hello@gmail.com"
value={formRegister.email}
error={
emailRegex.test(formRegister.email) || formRegister.email === ''
? null
: 'Invalid email'
}
onChange={(e) => {
setFormRegister({ ...formRegister, email: e.target.value })
}}
required
size="md"
mb="md"
/>
<TextInput
mb="md"
label="Full name"
placeholder="Bill Gates"
value={formRegister.full_name}
onChange={(e) => {
setFormRegister({ ...formRegister, full_name: e.target.value })
}}
required
size="md"
/>
<PasswordRequirementInput
requirements={requirementsPassword}
value={formRegister}
setValue={setFormRegister}
label="Password"
placeholder="Password"
name="password"
/>
<PasswordInput
mt="md"
label="Confirm password"
placeholder="Your password"
value={formRegister.confirm_password}
error={
formRegister.confirm_password === formRegister.password ||
formRegister.confirm_password === ''
? null
: 'Password do not match'
}
onChange={(e) => {
setFormRegister({ ...formRegister, confirm_password: e.target.value })
}}
required
size="md"
/>
<Box ta={'center'}>
<Button
type="submit"
m="15px auto"
fullWidth
size="md"
loading={status === 'loading'}
disabled={
formRegister.password !== '' &&
passwordRegex.test(formRegister.password) &&
formRegister.password === formRegister.confirm_password
? false
: true
}
>
Register
</Button>
</Box>
</form>
)
}
export default Register

View File

@ -0,0 +1,4 @@
.resetForm {
background-color: light-dark(#ffffffdb, var(--mantine-color-dark-5));
border-radius: 8px;
}

View File

@ -1,15 +1,23 @@
import { Card, Text, Box, Flex } from "@mantine/core";
import type { TLine } 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";
import { IconCircleCheckFilled } from "@tabler/icons-react";
import { memo } from "react";
const CardLine = ({
line,
selectedLines,
setSelectedLines,
socket,
stationItem,
}: {
line: TLine;
selectedLines: TLine[];
setSelectedLines: (lines: React.SetStateAction<TLine[]>) => void;
socket: Socket | null;
stationItem: TStation;
}) => {
return (
<Card
@ -31,24 +39,50 @@ const CardLine = ({
else setSelectedLines((pre) => [...pre, line]);
}}
>
<Flex justify={"space-between"}>
<Box>
<div>
<Text fw={600}>
Line {line.lineNumber} - {line.port}
</Text>
</div>
<Text className={classes.info_line}>PID: WS-C3560CG-8PC-S</Text>
<Flex
justify={"space-between"}
direction={"column"}
// gap={"md"}
align={"center"}
>
<div>
<Text fw={600} style={{ display: "flex", gap: "4px" }}>
Line {line.lineNumber} - {line.port}{" "}
{line.status === "connected" && (
<IconCircleCheckFilled color="green" />
)}
</Text>
</div>
{/* <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>
</Box>
<div className={classes.info_line}>VID: V01</div> */}
<Box
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
style={{ backgroundColor: "black", height: "130px", width: "220px" }}
></Box>
style={{ height: "175px", width: "300px" }}
>
<TerminalCLI
cliOpened={true}
socket={socket}
content={line.netOutput ?? ""}
line_id={Number(line?.id)}
station_id={Number(stationItem.id)}
isDisabled={false}
line_status={line?.status || ""}
fontSize={11}
miniSize={true}
customStyle={{
maxHeight: "175px",
height: "175px",
fontSize: "7px",
padding: "0px",
paddingBottom: "0px",
}}
onDoubleClick={() => {}}
/>
</Box>
</Flex>
{/* <Flex justify={"flex-end"}>
<Button variant="filled" style={{ height: "30px", width: "70px" }}>
@ -59,4 +93,4 @@ const CardLine = ({
);
};
export default CardLine;
export default memo(CardLine);

View File

@ -1,6 +1,6 @@
.card_line {
width: 400px;
height: 150px;
width: 320px;
height: 220px;
padding: 8px;
gap: 8px;
cursor: pointer;

View File

@ -0,0 +1,236 @@
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 {
socket: Socket | null;
content?: string;
line_id: number;
line_status: string;
station_id: number;
cliOpened: boolean;
isDisabled?: boolean;
customStyle?: {
fontSize?: string;
maxHeight?: string;
height?: string;
padding?: string;
paddingBottom?: string;
paddingLeft?: string;
};
onDoubleClick?: () => void;
fontSize?: number;
miniSize?: boolean;
}
const TerminalCLI: React.FC<TerminalCLIProps> = ({
socket,
content = "",
line_id,
station_id,
cliOpened = false,
isDisabled = false,
line_status = "",
customStyle = {},
onDoubleClick = () => {},
fontSize = 14,
miniSize = false,
}) => {
const xtermRef = useRef<HTMLDivElement>(null);
const terminal = useRef<Terminal>(null);
const fitRef = useRef<FitAddon>(null);
const [loading, setLoading] = useState<boolean>(true);
useEffect(() => {
if (!cliOpened || fitRef.current) return;
terminal.current = new Terminal({
disableStdin: isDisabled,
cursorBlink: true,
convertEol: true,
fontFamily: "Consolas, 'Courier New', monospace",
fontSize: fontSize,
theme: {
background: "#000000",
foreground: "#41ee4a",
cursor: "#ffffff",
},
// rows: 24,
// cols: 80,
});
const fitAddon = new FitAddon();
fitRef.current = fitAddon;
if (!miniSize) terminal.current.focus();
terminal.current.loadAddon(fitAddon);
terminal.current.open(xtermRef.current!);
setTimeout(() => {
fitAddon.fit();
}, 500);
// Gửi input từ người dùng lên server
terminal.current.onData((data) => {
socket?.emit(SOCKET_EVENTS.CLI.WRITE_COMMAND_FROM_WEB, {
lineIds: [line_id],
stationId: station_id,
command: data,
});
});
terminal.current.onSelectionChange(() => {
const selectedText = terminal?.current?.getSelection();
if (selectedText) {
navigator.clipboard
.writeText(selectedText)
.then(() => console.log("Copied to clipboard"))
.catch((err) => console.error("Clipboard copy failed", err));
}
});
terminal.current.attachCustomKeyEventHandler(
(e: KeyboardEvent): boolean => {
// Handle Ctrl+V (Paste)
if (e.ctrlKey && e.key.toLowerCase() === "v") return false;
// Handle Esc
if (e.key === "Escape") return false;
return true; // allow all other keys through
}
);
const handleContextMenu = async (e: MouseEvent) => {
e.preventDefault();
try {
const text = await navigator.clipboard.readText();
terminal?.current?.paste(text);
} catch (err) {
console.error("Clipboard read failed:", err);
}
};
if (xtermRef.current)
xtermRef.current?.addEventListener("contextmenu", handleContextMenu);
return () => {
xtermRef?.current?.removeEventListener("contextmenu", handleContextMenu);
};
}, [cliOpened]);
useEffect(() => {
if (cliOpened) {
if (terminal.current)
setTimeout(() => terminal.current?.write(content), 200);
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);
}
});
}
});
return () => {
socket?.off("init");
socket?.off("line_error");
socket?.off("line_output");
};
}, []);
useEffect(() => {
if (cliOpened) {
setTimeout(() => {
setLoading(false);
}, 200);
}
if (!cliOpened && terminal?.current) {
// console.log('Dispose terminal CLI')
terminal?.current.clear();
terminal?.current.dispose();
terminal.current = null;
setLoading(true);
}
}, [cliOpened]);
useEffect(() => {
if (!loading) {
if (terminal.current) {
terminal.current?.write(content);
if (!miniSize && !isDisabled) terminal.current?.focus();
terminal.current.scrollToBottom();
}
if (fitRef.current) fitRef.current?.fit();
setLoading(true);
}
}, [loading]);
useEffect(() => {
if (terminal.current) {
terminal.current.options.disableStdin = isDisabled;
}
}, [isDisabled]);
useEffect(() => {
return () => {
setLoading(true);
};
}, []);
return (
<>
<div
style={{
width: "100%",
height: "100%",
backgroundColor: "black",
paddingBottom: customStyle.paddingBottom ?? "10px",
minHeight: customStyle.maxHeight ?? "60vh",
}}
>
<div
ref={xtermRef}
style={{
width: "100%",
paddingLeft: customStyle.paddingLeft ?? "10px",
paddingBottom: customStyle.paddingBottom ?? "10px",
fontSize: customStyle.fontSize ?? "9px",
maxHeight: customStyle.maxHeight ?? "60vh",
height: customStyle.height ?? "60vh",
padding: customStyle.padding ?? "4px",
}}
onDoubleClick={(event) => {
event.preventDefault();
event.stopPropagation();
onDoubleClick();
}}
/>
</div>
</>
);
};
export default TerminalCLI;