Compare commits

..

2 Commits

Author SHA1 Message Date
nguyentrungthat cbc8397ea8 Update 2025-10-28 16:57:15 +07:00
nguyentrungthat 0a0dd559f0 Update 2025-10-28 16:56:57 +07:00
24 changed files with 912 additions and 404 deletions

2
BACKEND/.gitignore vendored
View File

@ -23,3 +23,5 @@ yarn-error.log
# Platform specific # Platform specific
.DS_Store .DS_Store
storage/system_logs

View File

@ -4,9 +4,20 @@ import User from '../models/user.js'
export default class AuthController { export default class AuthController {
// Đăng ký // Đăng ký
async register({ request, response }: HttpContext) { async register({ request, response }: HttpContext) {
const data = request.only(['email', 'password', 'full_name']) try {
const user = await User.create(data) const data = request.only(['email', 'password', 'full_name'])
return response.json({ message: 'User created', user })
const user = await User.query().where('email', data.email).first()
if (user) {
return response.status(401).json({ status: false, message: 'Email is exist' })
}
const newUser = await User.create(data)
return response.json({ status: true, message: 'User created', user: newUser })
} catch (error) {
return response.status(401).json({ status: false, message: 'Invalid credentials' })
}
} }
// Đăng nhập // Đăng nhập
@ -24,11 +35,9 @@ export default class AuthController {
return response.status(401).json({ message: 'Invalid email or 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({ return response.json({
message: 'Login successful', message: 'Login successful',
user: { id: user.id, email: user.email, token }, user: { id: user.id, email: user.email, fullName: user.fullName },
}) })
} catch { } catch {
return response.status(401).json({ message: 'Invalid credentials' }) return response.status(401).json({ message: 'Invalid credentials' })

View File

@ -1,8 +1,6 @@
import Scenario from '#models/scenario' import Scenario from '#models/scenario'
import type { HttpContext } from '@adonisjs/core/http' import type { HttpContext } from '@adonisjs/core/http'
import { searchRequest } from '../utils/hasPaginationRequest.js'
import db from '@adonisjs/lucid/services/db' import db from '@adonisjs/lucid/services/db'
import UserScenarios from '#models/user_scenario'
export default class ScenariosController { export default class ScenariosController {
/** /**
@ -36,7 +34,6 @@ export default class ScenariosController {
async create({ request, response, auth }: HttpContext) { async create({ request, response, auth }: HttpContext) {
try { try {
const payload = await request.all() const payload = await request.all()
const trx = await db.transaction() const trx = await db.transaction()
try { try {
const scenario = await Scenario.create( const scenario = await Scenario.create(
@ -44,7 +41,7 @@ export default class ScenariosController {
title: payload.title.trim(), title: payload.title.trim(),
body: JSON.stringify(payload.body), body: JSON.stringify(payload.body),
timeout: payload.timeout, timeout: payload.timeout,
isReboot: payload.is_reboot, isReboot: payload.isReboot,
}, },
{ client: trx } { client: trx }
) )

View File

@ -1,5 +1,5 @@
import net from 'node:net' import net from 'node:net'
import { cleanData, sleep } from '../ultils/helper.js' import { appendLog, cleanData, sleep } from '../ultils/helper.js'
import Scenario from '#models/scenario' import Scenario from '#models/scenario'
interface LineConfig { interface LineConfig {
@ -11,6 +11,14 @@ interface LineConfig {
apcName?: string apcName?: string
output: string output: string
status: string status: string
openCLI: boolean
userEmailOpenCLI: string
userOpenCLI: string
}
interface User {
userEmail: string
userName: string
} }
export default class LineConnection { export default class LineConnection {
@ -77,6 +85,7 @@ export default class LineConnection {
lineId: id, lineId: id,
data: message, data: message,
}) })
appendLog(cleanData(message), this.config.stationId, this.config.id)
}) })
this.client.on('error', (err) => { this.client.on('error', (err) => {
@ -149,6 +158,11 @@ export default class LineConnection {
} }
this.isRunningScript = true this.isRunningScript = true
appendLog(
`\n\n---start-scenarios---${Date.now()}---\n---scenario---${script?.title}---${Date.now()}---\n`,
this.config.stationId,
this.config.id
)
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
@ -156,6 +170,13 @@ export default class LineConnection {
const timeoutTimer = setTimeout(() => { const timeoutTimer = setTimeout(() => {
this.isRunningScript = false this.isRunningScript = false
this.outputBuffer = '' this.outputBuffer = ''
this.config.output += 'Timeout run scenario'
this.socketIO.emit('line_output', {
stationId: this.config.stationId,
lineId: this.config.id,
data: 'Timeout run scenario',
})
appendLog(`\n---end-scenarios---${Date.now()}---\n`, this.config.stationId, this.config.id)
// reject(new Error('Script timeout')) // reject(new Error('Script timeout'))
}, script.timeout || 300000) }, script.timeout || 300000)
@ -164,11 +185,21 @@ export default class LineConnection {
clearTimeout(timeoutTimer) clearTimeout(timeoutTimer)
this.isRunningScript = false this.isRunningScript = false
this.outputBuffer = '' this.outputBuffer = ''
appendLog(
`\n---end-scenarios---${Date.now()}---\n`,
this.config.stationId,
this.config.id
)
resolve(true) resolve(true)
return return
} }
const step = steps[index] const step = steps[index]
appendLog(
`\n---send-command---"${step?.send ?? ''}"---${Date.now()}---\n`,
this.config.stationId,
this.config.id
)
let repeatCount = Number(step.repeat) || 1 let repeatCount = Number(step.repeat) || 1
const sendCommand = () => { const sendCommand = () => {
if (repeatCount <= 0) { if (repeatCount <= 0) {
@ -203,4 +234,27 @@ export default class LineConnection {
runStep(stepIndex) runStep(stepIndex)
}) })
} }
userOpenCLI(user: User) {
this.config.openCLI = true
this.config.userEmailOpenCLI = user.userEmail
this.config.userOpenCLI = user.userName
this.socketIO.emit('user_open_cli', {
stationId: this.config.stationId,
lineId: this.config.id,
userEmailOpenCLI: user.userEmail,
userOpenCLI: user.userName,
})
}
userCloseCLI() {
this.config.openCLI = false
this.config.userEmailOpenCLI = ''
this.config.userOpenCLI = ''
this.socketIO.emit('user_close_cli', {
stationId: this.config.stationId,
lineId: this.config.id,
userEmailOpenCLI: '',
})
}
} }

View File

@ -1,3 +1,6 @@
import fs from 'node:fs'
import path from 'node:path'
/** /**
* Function to clean up unwanted characters from the output data. * Function to clean up unwanted characters from the output data.
* @param {string} data - The raw data to be cleaned. * @param {string} data - The raw data to be cleaned.
@ -16,3 +19,20 @@ export const cleanData = (data: string) => {
export function sleep(ms: number) { export function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms)) return new Promise((resolve) => setTimeout(resolve, ms))
} }
export function appendLog(output: string, stationId: number, lineId: number) {
const date = new Date().toISOString().slice(0, 10).replace(/-/g, '') // YYYYMMDD
const logDir = path.join('storage', 'system_logs')
const logFile = path.join(logDir, `${date}-Station_${stationId}-Line_${lineId}.log`)
// Ensure folder exists
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir, { recursive: true })
}
fs.appendFile(logFile, output, (err) => {
if (err) {
console.error('❌ Failed to write log:', err.message)
}
})
}

View File

@ -9,7 +9,7 @@ export default class extends BaseSchema {
table.string('title').notNullable() table.string('title').notNullable()
table.text('body').notNullable() table.text('body').notNullable()
table.integer('timeout').notNullable() table.integer('timeout').notNullable()
table.boolean('isReboot').defaultTo(false) table.boolean('is_reboot').defaultTo(false)
table.timestamps() table.timestamps()
}) })
} }

View File

@ -51,6 +51,7 @@ export class WebSocketIo {
stationMap: Map<number, Station> = new Map() stationMap: Map<number, Station> = new Map()
lineMap: Map<number, LineConnection> = new Map() // key = lineId lineMap: Map<number, LineConnection> = new Map() // key = lineId
lineConnecting: number[] = [] // key = lineId lineConnecting: number[] = [] // key = lineId
userConnecting: Map<number, { userId: number; userName: string }> = new Map()
constructor(protected app: ApplicationService) {} constructor(protected app: ApplicationService) {}
@ -70,8 +71,14 @@ export class WebSocketIo {
}) })
io.on('connection', (socket: CustomSocket) => { io.on('connection', (socket: CustomSocket) => {
const { userId, userName } = socket.handshake.auth
console.log('Socket connected:', socket.id) console.log('Socket connected:', socket.id)
socket.connectionTime = new Date() socket.connectionTime = new Date()
this.userConnecting.set(userId, { userId, userName })
setTimeout(() => {
io.emit('user_connecting', Array.from(this.userConnecting.values()))
}, 200)
setTimeout(() => { setTimeout(() => {
io.to(socket.id).emit( io.to(socket.id).emit(
@ -82,6 +89,10 @@ export class WebSocketIo {
socket.on('disconnect', () => { socket.on('disconnect', () => {
console.log(`FE disconnected: ${socket.id}`) console.log(`FE disconnected: ${socket.id}`)
this.userConnecting.delete(userId)
setTimeout(() => {
io.emit('user_connecting', Array.from(this.userConnecting.values()))
}, 200)
}) })
// FE gửi yêu cầu connect lines // FE gửi yêu cầu connect lines
@ -94,8 +105,8 @@ export class WebSocketIo {
const { lineIds, stationId, command } = data const { lineIds, stationId, command } = data
for (const lineId of lineIds) { for (const lineId of lineIds) {
const line = this.lineMap.get(lineId) const line = this.lineMap.get(lineId)
if (line) { if (line && line.config.status === 'connected') {
this.lineConnecting.filter((el) => el !== lineId) this.lineConnecting = this.lineConnecting.filter((el) => el !== lineId)
this.setTimeoutConnect(lineId, line) this.setTimeoutConnect(lineId, line)
line.writeCommand(command) line.writeCommand(command)
} else { } else {
@ -107,7 +118,7 @@ export class WebSocketIo {
await this.connectLine(io, [linesData], stationData) await this.connectLine(io, [linesData], stationData)
const lineReconnect = this.lineMap.get(lineId) const lineReconnect = this.lineMap.get(lineId)
if (lineReconnect) { if (lineReconnect) {
this.lineConnecting.filter((el) => el !== lineId) this.lineConnecting = this.lineConnecting.filter((el) => el !== lineId)
this.setTimeoutConnect(lineId, lineReconnect) this.setTimeoutConnect(lineId, lineReconnect)
lineReconnect.writeCommand(command) lineReconnect.writeCommand(command)
} }
@ -127,7 +138,7 @@ export class WebSocketIo {
const lineId = data.id const lineId = data.id
const scenario = data.scenario const scenario = data.scenario
const line = this.lineMap.get(lineId) const line = this.lineMap.get(lineId)
if (line) { if (line && line.config.status === 'connected') {
this.setTimeoutConnect( this.setTimeoutConnect(
lineId, lineId,
line, line,
@ -155,9 +166,44 @@ export class WebSocketIo {
} }
}) })
// FE yêu cầu ngắt kết nối 1 station socket.on('open_cli', async (data) => {
socket.on('disconnect_station', (stationId) => { const { lineId, userEmail, userName: name, stationId } = data
this.disconnectStation(stationId) const line = this.lineMap.get(lineId)
if (line) {
line.userOpenCLI({ userEmail, userName: name })
} else {
if (this.lineConnecting.includes(lineId)) return
const linesData = await Line.findBy('id', lineId)
const stationData = await Station.findBy('id', stationId)
if (linesData && stationData) {
this.lineConnecting.push(lineId)
await this.connectLine(io, [linesData], stationData)
const lineReconnect = this.lineMap.get(lineId)
if (lineReconnect) {
lineReconnect.userOpenCLI({ userEmail, userName: name })
}
}
}
})
socket.on('close_cli', async (data) => {
const { lineId, stationId } = data
const line = this.lineMap.get(lineId)
if (line) {
line.userCloseCLI()
} else {
if (this.lineConnecting.includes(lineId)) return
const linesData = await Line.findBy('id', lineId)
const stationData = await Station.findBy('id', stationId)
if (linesData && stationData) {
this.lineConnecting.push(lineId)
await this.connectLine(io, [linesData], stationData)
const lineReconnect = this.lineMap.get(lineId)
if (lineReconnect) {
lineReconnect.userCloseCLI()
}
}
}
}) })
}) })
@ -182,6 +228,9 @@ export class WebSocketIo {
apcName: line.apcName, apcName: line.apcName,
output: '', output: '',
status: '', status: '',
openCLI: false,
userEmailOpenCLI: '',
userOpenCLI: '',
}, },
socket socket
) )
@ -195,22 +244,6 @@ export class WebSocketIo {
} }
} }
private disconnectStation(stationId: number) {
const station = this.stationMap.get(stationId)
if (!station) return
for (const line of station.lines) {
const conn = this.lineMap.get(line.id)
if (conn) {
conn.disconnect()
this.lineMap.delete(line.id)
}
}
this.stationMap.delete(stationId)
console.log(`🔻 Station ${station.name} disconnected`)
}
private setTimeoutConnect = (lineId: number, lineConn: LineConnection, timeout = 120000) => { private setTimeoutConnect = (lineId: number, lineConn: LineConnection, timeout = 120000) => {
if (this.intervalMap[`${lineId}`]) { if (this.intervalMap[`${lineId}`]) {
clearInterval(this.intervalMap[`${lineId}`]) clearInterval(this.intervalMap[`${lineId}`])
@ -218,7 +251,7 @@ export class WebSocketIo {
} }
const interval = setInterval(() => { const interval = setInterval(() => {
lineConn.disconnect() lineConn.disconnect()
this.lineMap.delete(lineId) // this.lineMap.delete(lineId)
if (this.intervalMap[`${lineId}`]) { if (this.intervalMap[`${lineId}`]) {
clearInterval(this.intervalMap[`${lineId}`]) clearInterval(this.intervalMap[`${lineId}`])
delete this.intervalMap[`${lineId}`] delete this.intervalMap[`${lineId}`]

View File

@ -67,5 +67,6 @@ router
router router
.group(() => { .group(() => {
router.post('/login', '#controllers/auth_controller.login') router.post('/login', '#controllers/auth_controller.login')
router.post('/register', '#controllers/auth_controller.register')
}) })
.prefix('api/auth') .prefix('api/auth')

View File

@ -18,6 +18,7 @@
"axios": "^1.12.2", "axios": "^1.12.2",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-router-dom": "^7.9.4",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
"xterm": "^5.3.0" "xterm": "^5.3.0"
}, },
@ -2216,6 +2217,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "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": { "node_modules/react-style-singleton": {
"version": "2.2.3", "version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
@ -3753,6 +3801,12 @@
"semver": "bin/semver.js" "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": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

View File

@ -3,7 +3,7 @@
} }
body { body {
font-family: 'Mulish', sans-serif; font-family: "Mulish", sans-serif;
} }
.list { .list {
@ -48,7 +48,25 @@ body {
} }
} }
.content{ .content {
width: 100%; width: 100%;
border-top: 1px #ccc solid; 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 "./App.css";
import classes from "./App.module.css"; import classes from "./App.module.css";
import { Suspense, useEffect, useState } from "react"; import { Suspense, useEffect, useMemo, useState } from "react";
import { import {
Tabs, Tabs,
Text, Text,
@ -17,8 +17,16 @@ import {
Button, Button,
ActionIcon, ActionIcon,
LoadingOverlay, LoadingOverlay,
Avatar,
Tooltip,
} from "@mantine/core"; } 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 axios from "axios";
import CardLine from "./components/CardLine"; import CardLine from "./components/CardLine";
import { IconEdit, IconSettingsPlus } from "@tabler/icons-react"; import { IconEdit, IconSettingsPlus } from "@tabler/icons-react";
@ -27,6 +35,8 @@ import { ButtonDPELP, ButtonScenario } 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";
import ModalTerminal from "./components/ModalTerminal";
import PageLogin from "./components/Authentication/LoginPage";
const apiUrl = import.meta.env.VITE_BACKEND_URL; const apiUrl = import.meta.env.VITE_BACKEND_URL;
@ -34,6 +44,14 @@ const apiUrl = import.meta.env.VITE_BACKEND_URL;
* Main Component * Main Component
*/ */
function App() { 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"; document.title = "Automation Test";
const { socket } = useSocket(); const { socket } = useSocket();
const [stations, setStations] = useState<TStation[]>([]); const [stations, setStations] = useState<TStation[]>([]);
@ -53,6 +71,10 @@ function App() {
const [isEditStation, setIsEditStation] = useState(false); const [isEditStation, setIsEditStation] = useState(false);
const [stationEdit, setStationEdit] = useState<TStation | undefined>(); const [stationEdit, setStationEdit] = useState<TStation | undefined>();
const [scenarios, setScenarios] = useState<IScenario[]>([]); 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 // function get list station
const getStation = async () => { const getStation = async () => {
@ -93,24 +115,74 @@ function App() {
if (!socket || !stations?.length) return; if (!socket || !stations?.length) return;
socket.on("line_connected", updateStatus); socket.on("line_connected", updateStatus);
socket.on("line_disconnected", 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 // ✅ cleanup on unmount or when socket changes
return () => { return () => {
socket.off("init");
socket.off("line_output");
socket.off("line_error");
socket.off("line_connected"); socket.off("line_connected");
socket.off("line_disconnected"); socket.off("line_disconnected");
socket.off("user_connecting");
socket.off("user_open_cli");
socket.off("user_close_cli");
}; };
}, [socket, stations]); }, [socket, stations]);
const updateStatus = (data: LineConfig) => { const updateStatus = (data: LineConfig) => {
const line = getLine(data.lineId, data.stationId); const line = getLine(data.lineId, data.stationId);
if (line) { if (line?.id) {
updateValueLineStation(line, "status", data.status); updateValueLineStation(line.id, "status", data.status);
} }
}; };
const updateValueLineStation = <K extends keyof TLine>( const updateValueLineStation = <K extends keyof TLine>(
currentLine: TLine, lineId: number,
field: K, field: K,
value: TLine[K] value: TLine[K]
) => { ) => {
@ -120,10 +192,20 @@ function App() {
? { ? {
...station, ...station,
lines: (station?.lines || [])?.map((lineItem: TLine) => { lines: (station?.lines || [])?.map((lineItem: TLine) => {
if (lineItem.id === currentLine.id) { if (lineItem.id === lineId) {
return { return {
...lineItem, ...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; return lineItem;
@ -132,6 +214,24 @@ function App() {
: station : station
) )
); );
if (selectedLine) {
const line = {
...selectedLine,
[field]:
field === "netOutput"
? (selectedLine.netOutput || "") + value
: value,
output: field === "netOutput" ? value : selectedLine.output,
loadingOutput:
field === "netOutput"
? selectedLine.loadingOutput
? false
: true
: false,
};
setSelectedLine(line);
}
}; };
const getLine = (lineId: number, stationId: number) => { const getLine = (lineId: number, stationId: number) => {
@ -142,63 +242,106 @@ function App() {
} else return null; } 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 ( return (
<Container py="xl" w={"100%"} style={{ maxWidth: "100%" }}> <Container py="md" w={"100%"} style={{ maxWidth: "100%" }}>
<Tabs <Tabs
value={activeTab} value={activeTab}
onChange={(id) => setActiveTab(id?.toString() || "0")} onChange={(id) => {
setActiveTab(id?.toString() || "0");
setLoadingTerminal(false);
setTimeout(() => {
setLoadingTerminal(true);
}, 100);
}}
variant="none" variant="none"
keepMounted={false} keepMounted={false}
> >
<Tabs.List ref={setRootRef} className={classes.list}> <Flex justify={"space-between"}>
{stations.map((station) => ( <Flex wrap={"wrap"} style={{ maxWidth: "250px" }} gap={"4px"}>
<Tabs.Tab {usersConnecting.map((el) => (
ref={setControlRef(station.id.toString())} <Tooltip label={el.userName} key={el.userId}>
className={classes.tab} <Avatar color="cyan" radius="xl" size={"md"}>
key={station.id} {el.userName.slice(0, 2)}
value={station.id.toString()} </Avatar>
> </Tooltip>
{station.name} ))}
</Tabs.Tab> </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 <FloatingIndicator
target={activeTab ? controlsRefs[activeTab] : null} target={activeTab ? controlsRefs[activeTab] : null}
parent={rootRef} parent={rootRef}
className={classes.indicator} className={classes.indicator}
/> />
<Flex gap={"sm"}> <Flex gap={"sm"}>
{Number(activeTab) ? ( {Number(activeTab) ? (
<ActionIcon
title="Edit Station"
variant="outline"
onClick={() => {
setStationEdit(
stations.find((el) => el.id === Number(activeTab))
);
setIsOpenAddStation(true);
setIsEditStation(true);
}}
>
<IconEdit />
</ActionIcon>
) : (
""
)}
<ActionIcon <ActionIcon
title="Edit Station" title="Add Station"
variant="outline" variant="outline"
color="green"
onClick={() => { onClick={() => {
setStationEdit(
stations.find((el) => el.id === Number(activeTab))
);
setIsOpenAddStation(true); setIsOpenAddStation(true);
setIsEditStation(true); setIsEditStation(false);
setStationEdit(undefined);
}} }}
> >
<IconEdit /> <IconSettingsPlus />
</ActionIcon> </ActionIcon>
) : ( </Flex>
"" </Tabs.List>
)} <Flex gap={"sm"} align={"baseline"}>
<ActionIcon <Text className={classes.userName}>{user?.fullName}</Text>
title="Add Station" <Button
variant="outline" variant="outline"
color="green" color="red"
style={{ height: "30px", width: "100px" }}
onClick={() => { onClick={() => {
setIsOpenAddStation(true); localStorage.removeItem("user");
setIsEditStation(false); window.location.href = "/";
setStationEdit(undefined); socket?.disconnect();
}} }}
> >
<IconSettingsPlus /> Logout
</ActionIcon> </Button>
</Flex> </Flex>
</Tabs.List> </Flex>
{stations.map((station) => ( {stations.map((station) => (
<Tabs.Panel <Tabs.Panel
@ -238,7 +381,11 @@ function App() {
line={line} line={line}
selectedLines={selectedLines} selectedLines={selectedLines}
setSelectedLines={setSelectedLines} setSelectedLines={setSelectedLines}
updateStatus={updateStatus} openTerminal={openTerminal}
loadTerminal={
loadingTerminal &&
Number(station.id) === Number(activeTab)
}
/> />
))} ))}
</Flex> </Flex>
@ -309,8 +456,9 @@ function App() {
}, 10000); }, 10000);
}} }}
/> />
{scenarios.map((el) => ( {scenarios.map((el, i) => (
<ButtonScenario <ButtonScenario
key={i}
socket={socket} socket={socket}
selectedLines={selectedLines} selectedLines={selectedLines}
isDisable={isDisable || selectedLines.length === 0} isDisable={isDisable || selectedLines.length === 0}
@ -345,11 +493,24 @@ function App() {
setActiveTab(stations.length ? stations[0]?.id.toString() : "0") 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> </Container>
); );
} }
export default function Main() { export default function Main() {
const user = localStorage.getItem("user");
return ( return (
<MantineProvider> <MantineProvider>
<SocketProvider> <SocketProvider>
@ -363,7 +524,13 @@ export default function Main() {
} }
> >
<Notifications position="top-right" autoClose={5000} /> <Notifications position="top-right" autoClose={5000} />
<App /> {user ? (
<App />
) : (
<Container w={"100%"} style={{ maxWidth: "100%", padding: 0 }}>
<PageLogin />
</Container>
)}
</Suspense> </Suspense>
</SocketProvider> </SocketProvider>
</MantineProvider> </MantineProvider>

View File

@ -1,70 +1,22 @@
.wrapper { .wrapper {
min-height: rem(100vh); height: 100vh;
background-size: cover; background-size: cover;
background-image: url(https://images.unsplash.com/photo-1484242857719-4b9144542727?ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1280&q=80); 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;
.form { align-items: center;
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 { .form {
content: ""; border-right: rem(1px) solid
display: block; light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-7));
width: 200px; max-width: rem(450px);
height: 2px; padding-top: rem(80px);
background-color: #007bff; /* blue accent */
margin: 0.1rem auto 0; font-weight: 600;
border-radius: 3px; }
}
.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 { Button, PasswordInput, TextInput } from "@mantine/core";
import { emailRegex } from '@/utils/formRegexs' import { useForm } from "@mantine/form";
import { notifications } from "@mantine/notifications";
import axios from "axios";
import { useDispatch, useSelector } from 'react-redux' const apiUrl = import.meta.env.VITE_BACKEND_URL;
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 = { type TLogin = {
email: string email: string;
password: string password: string;
} };
const Login = () => { 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>({ const formLogin = useForm<TLogin>({
initialValues: { initialValues: {
email: '', email: "",
password: '', password: "",
}, },
validate: (values) => ({ validate: (values) => ({
email: email: values.email === "" ? "Email is required" : null,
values.email === ''
? 'Email is required'
: isLoginERP
? null
: emailRegex.test(values.email)
? null
: 'Invalid email',
password: values.password === '' ? 'Password is required' : null, password: values.password === "" ? "Password is required" : null,
}), }),
}) });
const handleLogin = async (values: TLogin) => { const handleLogin = async () => {
if (isLoginERP) { 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 = { const payload = {
userEmail: values.email, email: formLogin.values.email,
password: values.password, password: formLogin.values.password,
} };
const resultAction = await dispatch(loginERPAsync(payload)) const response = await axios.post(apiUrl + "api/auth/login", payload);
if (response.data.user) {
if (loginERPAsync.fulfilled.match(resultAction)) { const user = response.data.user;
// set interval to wait for localStorage to be set localStorage.setItem("user", JSON.stringify(user));
window.location.href = '/dashboard' window.location.href = "/";
}
} else {
const resultAction = await dispatch(loginAsync(values))
if (loginAsync.fulfilled.match(resultAction)) {
navigate('/dashboard')
} }
} 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 ( return (
<form <form
style={{ style={{
padding: '10px 20px', padding: "10px 20px",
}} }}
onSubmit={formLogin.onSubmit(handleLogin)} onSubmit={formLogin.onSubmit(handleLogin)}
> >
<div style={{ textAlign: 'center' }}>
<h3 className={classes.title}>
{isLoginERP ? 'Login with ERP account' : 'Login with ATC account'}
</h3>
</div>
<TextInput <TextInput
label={isLoginERP ? 'Username/email:' : 'Email address'} label={"Email address"}
placeholder="hello@gmail.com" placeholder="hello@gmail.com"
value={formLogin.values.email} value={formLogin.values.email}
error={formLogin.errors.email} error={formLogin.errors.email}
onChange={(e) => { onChange={(e) => {
formLogin.setFieldValue('email', e.target.value!) formLogin.setFieldValue("email", e.target.value!);
}} }}
required required
size="md" size="md"
@ -110,7 +85,7 @@ const Login = () => {
value={formLogin.values.password} value={formLogin.values.password}
error={formLogin.errors.password} error={formLogin.errors.password}
onChange={(e) => { onChange={(e) => {
formLogin.setFieldValue('password', e.target.value!) formLogin.setFieldValue("password", e.target.value!);
}} }}
required required
mt="md" mt="md"
@ -122,60 +97,12 @@ const Login = () => {
mt="xl" mt="xl"
size="md" size="md"
type="submit" type="submit"
loading={status === 'loading'} loading={status === "loading"}
> >
Sign in Sign in
</Button> </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> </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 { 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 { Box, Button, PasswordInput, TextInput } from "@mantine/core";
import { emailRegex, passwordRegex } from '@/utils/formRegexs' import { emailRegex } from "../../untils/helper";
import { requirementsPassword } from '@/rtk/helpers/variables' import { notifications } from "@mantine/notifications";
import axios from "axios";
import { Box, Button, PasswordInput, TextInput } from '@mantine/core' const apiUrl = import.meta.env.VITE_BACKEND_URL;
type TRegister = { type TRegister = {
email: string email: string;
password: string password: string;
confirm_password: string confirm_password: string;
full_name: string full_name: string;
} };
function Register() { function Register() {
const dispatch = useDispatch<AppDispatch>()
const { status } = useSelector((state: RootState) => state.auth)
const [formRegister, setFormRegister] = useState<TRegister>({ const [formRegister, setFormRegister] = useState<TRegister>({
email: '', email: "",
full_name: '', full_name: "",
password: '', password: "",
confirm_password: '', confirm_password: "",
}) });
const handleRegister = async () => { const handleRegister = async () => {
// Dispatch action registerAsync với dữ liệu form và đợi kết quả try {
const resultAction = await dispatch(registerAsync(formRegister)) if (!formRegister.email) {
notifications.show({
// Kiểm tra nếu action thành công title: "Error",
if (registerAsync.fulfilled.match(resultAction)) { message: "Email is required",
// Tải lại trang web color: "red",
// window.location.reload() });
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 ( return (
<form <form
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault() e.preventDefault();
handleRegister() handleRegister();
}} }}
> >
<TextInput <TextInput
@ -50,12 +79,12 @@ function Register() {
placeholder="hello@gmail.com" placeholder="hello@gmail.com"
value={formRegister.email} value={formRegister.email}
error={ error={
emailRegex.test(formRegister.email) || formRegister.email === '' emailRegex.test(formRegister.email) || formRegister.email === ""
? null ? null
: 'Invalid email' : "Invalid email"
} }
onChange={(e) => { onChange={(e) => {
setFormRegister({ ...formRegister, email: e.target.value }) setFormRegister({ ...formRegister, email: e.target.value });
}} }}
required required
size="md" size="md"
@ -68,19 +97,22 @@ function Register() {
placeholder="Bill Gates" placeholder="Bill Gates"
value={formRegister.full_name} value={formRegister.full_name}
onChange={(e) => { onChange={(e) => {
setFormRegister({ ...formRegister, full_name: e.target.value }) setFormRegister({ ...formRegister, full_name: e.target.value });
}} }}
required required
size="md" size="md"
/> />
<PasswordRequirementInput <PasswordInput
requirements={requirementsPassword} mt="md"
value={formRegister}
setValue={setFormRegister}
label="Password" label="Password"
placeholder="Password" placeholder="Your password"
name="password" value={formRegister.password}
onChange={(e) => {
setFormRegister({ ...formRegister, password: e.target.value });
}}
required
size="md"
/> />
<PasswordInput <PasswordInput
@ -90,27 +122,29 @@ function Register() {
value={formRegister.confirm_password} value={formRegister.confirm_password}
error={ error={
formRegister.confirm_password === formRegister.password || formRegister.confirm_password === formRegister.password ||
formRegister.confirm_password === '' formRegister.confirm_password === ""
? null ? null
: 'Password do not match' : "Password do not match"
} }
onChange={(e) => { onChange={(e) => {
setFormRegister({ ...formRegister, confirm_password: e.target.value }) setFormRegister({
...formRegister,
confirm_password: e.target.value,
});
}} }}
required required
size="md" size="md"
/> />
<Box ta={'center'}> <Box ta={"center"}>
<Button <Button
type="submit" type="submit"
m="15px auto" m="15px auto"
fullWidth fullWidth
size="md" size="md"
loading={status === 'loading'} loading={status === "loading"}
disabled={ disabled={
formRegister.password !== '' && formRegister.password !== "" &&
passwordRegex.test(formRegister.password) &&
formRegister.password === formRegister.confirm_password formRegister.password === formRegister.confirm_password
? false ? false
: true : true
@ -120,7 +154,7 @@ function Register() {
</Button> </Button>
</Box> </Box>
</form> </form>
) );
} }
export default Register export default Register;

View File

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

View File

@ -8,6 +8,7 @@ import {
Grid, Grid,
TextInput, TextInput,
Button, Button,
Checkbox,
} 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";
@ -42,11 +43,10 @@ function DrawerScenario({
send: "", send: "",
delay: "0", delay: "0",
repeat: "1", repeat: "1",
note: "",
}, },
] as IBodyScenario[], ] as IBodyScenario[],
timeout: "30000", timeout: "30000",
is_reboot: false, isReboot: false,
}, },
validate: { validate: {
title: (value) => { title: (value) => {
@ -72,7 +72,6 @@ function DrawerScenario({
send: "", send: "",
delay: "0", delay: "0",
repeat: "1", repeat: "1",
note: "",
}); });
form.setFieldValue("body", newBody); form.setFieldValue("body", newBody);
}; };
@ -84,6 +83,22 @@ function DrawerScenario({
}; };
const handleSave = async () => { 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); setIsSubmit(true);
try { try {
const body = form.values.body.map((el: IBodyScenario) => ({ const body = form.values.body.map((el: IBodyScenario) => ({
@ -93,7 +108,8 @@ function DrawerScenario({
})); }));
const payload = { const payload = {
...form.values, title: form.values.title,
isReboot: form.values.isReboot,
body: body, body: body,
timeout: Number(form.values.timeout), timeout: Number(form.values.timeout),
}; };
@ -111,15 +127,28 @@ function DrawerScenario({
) )
: [...pre, scenario] : [...pre, scenario]
); );
setIsEdit(true);
setDataScenario(scenario);
notifications.show({ notifications.show({
title: "Success", title: "Success",
message: res.data.message, message: res.data.message,
color: "green", color: "green",
}); });
return; return;
} else {
notifications.show({
title: "Error",
message: res.data.message,
color: "red",
});
} }
} catch (error) { } catch (error) {
console.log(error); console.log(error);
notifications.show({
title: "Error",
message: "Failed to create scenario, please try again!",
color: "red",
});
} finally { } finally {
setIsSubmit(false); setIsSubmit(false);
} }
@ -196,7 +225,7 @@ function DrawerScenario({
form.setFieldValue("title", scenario.title); form.setFieldValue("title", scenario.title);
form.setFieldValue("timeout", scenario.timeout.toString()); form.setFieldValue("timeout", scenario.timeout.toString());
form.setFieldValue("body", JSON.parse(scenario.body)); 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 required
/> />
</Grid.Col> </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 <div
style={{ style={{
display: "flex", display: "flex",
@ -329,7 +378,7 @@ function DrawerScenario({
close={() => { close={() => {
setOpenConfirm(false); setOpenConfirm(false);
}} }}
message={"Are you sure delete this station?"} message={"Are you sure delete this scenario?"}
handle={() => { handle={() => {
setOpenConfirm(false); setOpenConfirm(false);
handleDelete(); 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 { FitAddon } from "@xterm/addon-fit";
import { SOCKET_EVENTS } from "../untils/constanst"; import { SOCKET_EVENTS } from "../untils/constanst";
import type { Socket } from "socket.io-client"; import type { Socket } from "socket.io-client";
import type { LineConfig } from "../untils/types";
interface TerminalCLIProps { interface TerminalCLIProps {
socket: Socket | null; socket: Socket | null;
content?: string; content?: string;
initContent?: string;
line_id: number; line_id: number;
line_status: string; line_status: string;
station_id: number; station_id: number;
@ -25,7 +25,7 @@ interface TerminalCLIProps {
onDoubleClick?: () => void; onDoubleClick?: () => void;
fontSize?: number; fontSize?: number;
miniSize?: boolean; miniSize?: boolean;
updateStatus: (value: LineConfig) => void; loadingContent?: boolean;
} }
const TerminalCLI: React.FC<TerminalCLIProps> = ({ const TerminalCLI: React.FC<TerminalCLIProps> = ({
@ -39,7 +39,8 @@ const TerminalCLI: React.FC<TerminalCLIProps> = ({
onDoubleClick = () => {}, onDoubleClick = () => {},
fontSize = 14, fontSize = 14,
miniSize = false, miniSize = false,
updateStatus, initContent = "",
loadingContent = false,
}) => { }) => {
const xtermRef = useRef<HTMLDivElement>(null); const xtermRef = useRef<HTMLDivElement>(null);
const terminal = useRef<Terminal>(null); const terminal = useRef<Terminal>(null);
@ -128,40 +129,7 @@ const TerminalCLI: React.FC<TerminalCLIProps> = ({
if (fitRef.current) setTimeout(() => fitRef.current?.fit(), 500); if (fitRef.current) setTimeout(() => fitRef.current?.fit(), 500);
} }
}, [content]); }, [content, loadingContent]);
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");
};
}, []);
useEffect(() => { useEffect(() => {
if (cliOpened) { if (cliOpened) {
@ -182,7 +150,7 @@ const TerminalCLI: React.FC<TerminalCLIProps> = ({
useEffect(() => { useEffect(() => {
if (!loading) { if (!loading) {
if (terminal.current) { if (terminal.current) {
terminal.current?.write(content); terminal.current?.write(initContent);
if (!miniSize && !isDisabled) terminal.current?.focus(); if (!miniSize && !isDisabled) terminal.current?.focus();
terminal.current.scrollToBottom(); terminal.current.scrollToBottom();
} }
@ -211,7 +179,7 @@ const TerminalCLI: React.FC<TerminalCLIProps> = ({
height: "100%", height: "100%",
backgroundColor: "black", backgroundColor: "black",
paddingBottom: customStyle.paddingBottom ?? "10px", paddingBottom: customStyle.paddingBottom ?? "10px",
minHeight: customStyle.maxHeight ?? "60vh", minHeight: customStyle.maxHeight ?? "75vh",
}} }}
> >
<div <div
@ -221,8 +189,8 @@ const TerminalCLI: React.FC<TerminalCLIProps> = ({
paddingLeft: customStyle.paddingLeft ?? "10px", paddingLeft: customStyle.paddingLeft ?? "10px",
paddingBottom: customStyle.paddingBottom ?? "10px", paddingBottom: customStyle.paddingBottom ?? "10px",
fontSize: customStyle.fontSize ?? "9px", fontSize: customStyle.fontSize ?? "9px",
maxHeight: customStyle.maxHeight ?? "60vh", maxHeight: customStyle.maxHeight ?? "75vh",
height: customStyle.height ?? "60vh", height: customStyle.height ?? "75vh",
padding: customStyle.padding ?? "4px", padding: customStyle.padding ?? "4px",
}} }}
onDoubleClick={(event) => { 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 { io, Socket } from "socket.io-client";
import { SOCKET_EVENTS } from "../untils/constanst"; import { SOCKET_EVENTS } from "../untils/constanst";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
@ -15,9 +21,21 @@ export const SocketProvider: React.FC<{ children: React.ReactNode }> = ({
children, children,
}) => { }) => {
const [socket, setSocket] = useState<Socket | null>(null); 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(() => { useEffect(() => {
const newSocket = io(SOCKET_URL); if (!user) return;
const newSocket = io(SOCKET_URL, {
auth: {
userId: user?.id,
userName: user?.fullName,
},
});
setSocket(newSocket); setSocket(newSocket);
@ -46,7 +64,7 @@ export const SocketProvider: React.FC<{ children: React.ReactNode }> = ({
newSocket.disconnect(); newSocket.disconnect();
}; };
}, []); }, [user]);
return ( return (
<SocketContext.Provider value={{ socket }}> <SocketContext.Provider value={{ socket }}>

View File

@ -2,3 +2,8 @@ export const numberOnly = (value: string): string => {
const matched = value.match(/[\d.]+/g); const matched = value.match(/[\d.]+/g);
return matched ? matched.join("") : ""; 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; inventory?: any;
status?: string; status?: string;
netOutput?: string; netOutput?: string;
output?: string;
loadingOutput?: boolean;
outlet?: number; outlet?: number;
cliOpened?: boolean; cliOpened?: boolean;
systemLogUrl?: string; systemLogUrl?: string;
@ -84,14 +86,8 @@ export type TLine = {
}; };
export type TUser = { export type TUser = {
id: number; userId: number;
email: string; userName: string;
email_cc: string;
full_name: string;
package_id: string;
zulip: string;
token?: string;
name: string;
}; };
export type APCProps = { export type APCProps = {
@ -144,7 +140,7 @@ export type IScenario = {
title: string; title: string;
body: string; body: string;
timeout: number; timeout: number;
is_reboot: boolean; isReboot: boolean;
updated_at: string; updated_at: string;
}; };
@ -153,5 +149,4 @@ export type IBodyScenario = {
send: string; send: string;
delay: string; delay: string;
repeat: string; repeat: string;
note: string;
}; };