Initial commit

This commit is contained in:
nguyentrungthat 2025-10-23 17:00:58 +07:00
commit 85c4bb9a26
71 changed files with 15081 additions and 0 deletions

22
BACKEND/.editorconfig Normal file
View File

@ -0,0 +1,22 @@
# http://editorconfig.org
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.json]
insert_final_newline = unset
[**.min.js]
indent_style = unset
insert_final_newline = unset
[MakeFile]
indent_style = space
[*.md]
trim_trailing_whitespace = false

31
BACKEND/.env.example Normal file
View File

@ -0,0 +1,31 @@
TZ=UTC
PORT=3333
HOST=localhost
LOG_LEVEL=info
APP_KEY=55xt0BdfVHygJlHTvqJB3iCD8Z7PUPtb
NODE_ENV=development
DB_HOST=127.0.0.1
DB_PORT=3306
DB_USER=root
DB_PASSWORD=
DB_DATABASE=
DOMAIN_NAME=http://localhost
SOCKET_PORT=8989
FRONTEND_URL=http://localhost:5173
BACKEND_URL=http://localhost:3333
GOOGLE_CLIENT_ID=532287737140-2e3kb67raaac56u2uohnqveg6gt7vga9.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=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
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USERNAME=
SMTP_PASSWORD=
PAYPAL_MODE=sandbox
PAYPAL_BASE_URL=https://api-m.sandbox.paypal.com
PAYPAL_CLIENT_ID=ASV19JCbp2rUaaFLtfJ7TQtLFvaIFKMrze6kiK4LJYjcCRPjFWFjcAzFueofJKKzQ0iJTGle4qPUGwex
PAYPAL_CLIENT_SECRET=EDprE7q7sdsY2Lk869AnRI_mIc5VPtDLMfK4bsVlGd6Qswe4T2_By9anIi9mEKe-bNHosW9J2N_urTaH
PAYPAL_CURRENCY=USD
SEND_ZULIP=1
ZULIP_REALM="https://zulip.ipsupply.com.au"
ZULIP_USERNAME="networktool-bot@zulip.ipsupply.com.au"
ZULIP_API_KEY="0jMAmOuhfLvBqKJikv5oAkyNM4RIEoAM"

25
BACKEND/.gitignore vendored Normal file
View File

@ -0,0 +1,25 @@
# Dependencies and AdonisJS build
node_modules
build
tmp
# Secrets
.env
.env.local
.env.production.local
.env.development.local
# Frontend assets compiled code
public/assets
# Build tools specific
npm-debug.log
yarn-error.log
# Editors specific
.fleet
.idea
.vscode
# Platform specific
.DS_Store

27
BACKEND/ace.js Normal file
View File

@ -0,0 +1,27 @@
/*
|--------------------------------------------------------------------------
| JavaScript entrypoint for running ace commands
|--------------------------------------------------------------------------
|
| DO NOT MODIFY THIS FILE AS IT WILL BE OVERRIDDEN DURING THE BUILD
| PROCESS.
|
| See docs.adonisjs.com/guides/typescript-build-process#creating-production-build
|
| Since, we cannot run TypeScript source code using "node" binary, we need
| a JavaScript entrypoint to run ace commands.
|
| This file registers the "ts-node/esm" hook with the Node.js module system
| and then imports the "bin/console.ts" file.
|
*/
/**
* Register hook to process TypeScript files using ts-node
*/
import 'ts-node-maintained/register/esm'
/**
* Import ace console entrypoint
*/
await import('./bin/console.js')

87
BACKEND/adonisrc.ts Normal file
View File

@ -0,0 +1,87 @@
import { defineConfig } from '@adonisjs/core/app'
export default defineConfig({
/*
|--------------------------------------------------------------------------
| Experimental flags
|--------------------------------------------------------------------------
|
| The following features will be enabled by default in the next major release
| of AdonisJS. You can opt into them today to avoid any breaking changes
| during upgrade.
|
*/
experimental: {
mergeMultipartFieldsAndFiles: true,
shutdownInReverseOrder: true,
},
/*
|--------------------------------------------------------------------------
| Commands
|--------------------------------------------------------------------------
|
| List of ace commands to register from packages. The application commands
| will be scanned automatically from the "./commands" directory.
|
*/
commands: [() => import('@adonisjs/core/commands'), () => import('@adonisjs/lucid/commands')],
/*
|--------------------------------------------------------------------------
| Service providers
|--------------------------------------------------------------------------
|
| List of service providers to import and register when booting the
| application
|
*/
providers: [
() => import('@adonisjs/core/providers/app_provider'),
() => import('@adonisjs/core/providers/hash_provider'),
{
file: () => import('@adonisjs/core/providers/repl_provider'),
environment: ['repl', 'test'],
},
() => import('@adonisjs/core/providers/vinejs_provider'),
() => import('@adonisjs/cors/cors_provider'),
() => import('@adonisjs/lucid/database_provider'),
() => import('@adonisjs/auth/auth_provider'),
() => import('#providers/socket_io_provider')
],
/*
|--------------------------------------------------------------------------
| Preloads
|--------------------------------------------------------------------------
|
| List of modules to import before starting the application.
|
*/
preloads: [() => import('#start/routes'), () => import('#start/kernel')],
/*
|--------------------------------------------------------------------------
| Tests
|--------------------------------------------------------------------------
|
| List of test suites to organize tests by their type. Feel free to remove
| and add additional suites.
|
*/
tests: {
suites: [
{
files: ['tests/unit/**/*.spec(.ts|.js)'],
name: 'unit',
timeout: 2000,
},
{
files: ['tests/functional/**/*.spec(.ts|.js)'],
name: 'functional',
timeout: 30000,
},
],
forceExit: false,
},
})

View File

@ -0,0 +1,67 @@
import Line from '#models/line'
import type { HttpContext } from '@adonisjs/core/http'
import Log from '#models/log'
export default class LinesController {
/**
* Display a list of resource
*/
async get({}: HttpContext) {
const lines = await Line.all()
return { status: true, data: lines }
}
/**
* Display form to create a new record
*/
async create({ auth, request, response }: HttpContext) {
let payload = request.only([...Array.from(Line.$columnsDefinitions.keys()), 'station_id'])
try {
const line = await Line.create(payload)
return response.created({ status: true, message: 'Line created successfully', data: line })
} catch (error) {
return response.badRequest({ error: error, message: 'Line create failed', status: false })
}
}
async update({ request, response, auth }: HttpContext) {
let payload = request.only(
Array.from(Line.$columnsDefinitions.keys()).filter(
(f) => f !== 'created_at' && f !== 'updated_at'
)
)
try {
const line = await Line.find(request.body().id)
if (!line) {
return response.status(404).json({ message: 'Line not found' })
}
Object.assign(line, payload)
await line.save()
return response.ok({ status: true, message: 'Line update successfully', data: line })
} catch (error) {
return response.badRequest({ error: error, message: 'Line update failed', status: false })
}
}
/**
* Delete record
*/
async delete({ auth, request, response }: HttpContext) {
try {
const line = await Line.find(request.body().id)
if (!line) {
return response.status(404).json({ message: 'line not found' })
}
const logs = await Log.query().where('line_id', line.id)
for (const log of logs) {
await log.delete()
}
// Delete the line
await line.delete()
return response.ok({ status: true, message: 'Line delete successfully', data: line })
} catch (error) {
return response.badRequest({ error: error, message: 'Line delete failed', status: false })
}
}
}

View File

@ -0,0 +1,107 @@
import Log from '#models/log'
import type { HttpContext } from '@adonisjs/core/http'
import fs from 'node:fs'
import path, { dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const FILENAME = fileURLToPath(import.meta.url)
const DIRNAME = dirname(FILENAME)
export default class LogsController {
async list({ request, response, auth }: HttpContext) {
const search = request.input('search', '')
const perPage = request.input('per_page', 10)
const page = request.input('page', 1)
// Fetch logs that are associated with the user's stations with pagination
const query = Log.query()
.select('logs.*')
.preload('line', (lineQuery) => {
lineQuery.preload('station')
})
const logs = await query.paginate(page, perPage)
return response.ok({
status: true,
data: logs,
})
}
async viewLog({ request, response }: HttpContext) {
const logFilePath = request.input('path')
try {
const normalizedPath = path.normalize(logFilePath)
const fullPath = path.join(DIRNAME, '..', '..', normalizedPath)
if (!fs.existsSync(fullPath)) {
return response.notFound({
status: false,
message: 'Log file not found',
})
}
// Đọc file với encoding buffer trước
const buffer = fs.readFileSync(fullPath)
// Thử các encoding khác nhau
let logContent
try {
// Thử với utf-8 trước
logContent = buffer.toString('utf-8')
if (logContent.includes('\u0000')) {
// Nếu có null bytes, thử với encoding khác
logContent = buffer.toString('ascii')
}
} catch {
// Fallback to ascii if utf-8 fails
logContent = buffer.toString('ascii')
}
// Loại bỏ các null bytes
logContent = logContent.replace(/\u0000/g, '')
return response.ok({
status: true,
data: logContent,
})
} catch (error) {
return response.internalServerError({
status: false,
message: 'Failed to read log file',
error: error.message,
})
}
}
async downloadLog({ request, response }: HttpContext) {
try {
const logPath = request.input('path')
const fullPath = path.join(DIRNAME, '..', '..', logPath)
if (!fs.existsSync(fullPath)) {
return response.notFound({
status: false,
message: 'Log file not found',
})
}
// Lấy tên file từ đường dẫn
const fileName = path.basename(logPath)
// Set headers cho download
response.header('Content-Type', 'application/octet-stream')
response.header('Content-Disposition', `attachment; filename="${fileName}"`)
// Stream file về client
return response.stream(fs.createReadStream(fullPath))
} catch (error) {
return response.internalServerError({
status: false,
message: 'Failed to download log file',
error: error.message,
})
}
}
}

View File

@ -0,0 +1,38 @@
import type { HttpContext } from '@adonisjs/core/http'
import Model from '#models/model'
export default class ModelsController {
// GET /models
async index({}: HttpContext) {
return await Model.all()
}
// POST /models
async store({ request }: HttpContext) {
const data = request.only(['name'])
const model = await Model.create(data)
return model
}
// GET /models/:id
async show({ params }: HttpContext) {
const model = await Model.findOrFail(params.id)
return model
}
// PUT /models/:id
async update({ params, request }: HttpContext) {
const model = await Model.findOrFail(params.id)
const data = request.only(['name'])
model.merge(data)
await model.save()
return model
}
// DELETE /models/:id
async destroy({ params }: HttpContext) {
const model = await Model.findOrFail(params.id)
await model.delete()
return { success: true }
}
}

View File

@ -0,0 +1,134 @@
import Scenario from '#models/scenario'
import type { HttpContext } from '@adonisjs/core/http'
import { searchRequest } from '../utils/hasPaginationRequest.js'
import db from '@adonisjs/lucid/services/db'
import UserScenarios from '#models/user_scenario'
export default class ScenariosController {
/**
* List all scenarios
*/
async get({ request, response, auth }: HttpContext) {
try {
const search = request.input('search', '')
const perPage = request.input('per_page', 10)
const page = request.input('page', 1)
const query = Scenario.query()
const scenarios = await query.orderBy('scenarios.created_at', 'asc').paginate(page, perPage)
return response.ok({
status: true,
data: scenarios,
})
} catch (error) {
return response.internalServerError({
status: false,
message: 'Failed to fetch scenarios',
error,
})
}
}
/**
* Create a new scenario
*/
async create({ request, response, auth }: HttpContext) {
try {
const payload = await request.all()
const trx = await db.transaction()
try {
const scenario = await Scenario.create(
{
title: payload.title.trim(),
body: JSON.stringify(payload.body),
timeout: payload.timeout,
isReboot: payload.is_reboot,
},
{ client: trx }
)
await trx.commit()
return response.ok({
status: true,
message: 'Scenario created successfully',
data: scenario,
})
} catch (error) {
await trx.rollback()
return response.internalServerError({
status: false,
message: 'Failed to create scenario, please try again!',
error,
})
}
} catch (error) {
return response.internalServerError({
status: false,
message: 'Failed to create scenario',
error,
})
}
}
/**
* Get a single scenario by ID
*/
async show({ params, response }: HttpContext) {
try {
const scenario = await Scenario.findOrFail(params.id)
return response.ok({ status: true, data: scenario })
} catch (error) {
return response.notFound({ status: false, message: 'Scenario not found' })
}
}
/**
* Update a scenario
*/
async update({ request, response, auth }: HttpContext) {
try {
const scenarioId = request.param('id')
const payload = await request.all()
const scenario = await Scenario.findOrFail(scenarioId)
payload.body = JSON.stringify(payload.body)
scenario.merge(payload)
await scenario.save()
return response.ok({ status: true, message: 'Scenario updated successfully', data: scenario })
} catch (error) {
return response.internalServerError({
status: false,
message: 'Failed to update scenario',
error,
})
}
}
/**
* Delete a scenario
*/
async delete({ request, response }: HttpContext) {
try {
const scenarioId = request.param('id')
const scenario = await Scenario.findOrFail(scenarioId)
if (!scenario) {
return response.notFound({ message: 'Scenario not found' })
}
await scenario.delete()
return response.ok({ status: true, message: 'Scenario deleted successfully' })
} catch (error) {
return response.internalServerError({
status: false,
message: 'Failed to delete scenario',
error,
})
}
}
}

View File

@ -0,0 +1,76 @@
import type { HttpContext } from '@adonisjs/core/http'
import Station from '#models/station'
export default class StationsController {
public async index({}: HttpContext) {
return await Station.query().preload('lines')
}
public async store({ request, response }: HttpContext) {
let payload = request.only(Array.from(Station.$columnsDefinitions.keys()))
try {
const stationName = await Station.findBy('name', payload.name)
if (stationName) return response.status(400).json({ message: 'Station name exist!' })
const stationIP = await Station.findBy('ip', payload.ip)
if (stationIP) return response.status(400).json({ message: 'Ip of station is exist!' })
const station = await Station.create(payload)
return response.created({
status: true,
message: 'Station created successfully',
data: station,
})
} catch (error) {
return response.badRequest({ error: error, message: 'Station create failed', status: false })
}
}
public async show({ params }: HttpContext) {
return await Station.findOrFail(params.id)
}
public async update({ request, response }: HttpContext) {
let payload = request.only(
Array.from(Station.$columnsDefinitions.keys()).filter(
(f) => f !== 'created_at' && f !== 'updated_at'
)
)
try {
const station = await Station.find(request.body().id)
// If the station does not exist, return a 404 response
if (!station) {
return response.status(404).json({ message: 'Station not found' })
}
Object.assign(station, payload)
await station.save()
return response.ok({ status: true, message: 'Station update successfully', data: station })
} catch (error) {
return response.badRequest({ error: error, message: 'Station update failed', status: false })
}
}
public async destroy({ request, response }: HttpContext) {
try {
const station = await Station.find(request.body().id)
// If the station does not exist, return a 404 response
if (!station) {
return response.status(404).json({ message: 'Station not found' })
}
// Optionally, delete associated lines first
await station.related('lines').query().delete()
// Delete the station
await station.delete()
return response.ok({ status: true, message: 'Station delete successfully', data: station })
} catch (error) {
return response.badRequest({ error: error, message: 'Station delete failed', status: false })
}
}
}

View File

@ -0,0 +1,100 @@
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) {
const search = request.input('search', '')
const perPage = request.input('per_page', 10)
const page = request.input('page', 1)
const users = await User.query().orderBy('created_at', 'desc').paginate(page, perPage)
return response.ok({ status: true, data: users })
}
async get({ params, response }: HttpContext) {
const user = await User.query().where('id', params.id).firstOrFail() // Preload package data
return response.ok({ status: true, data: user })
}
async getByEmail({ request, response }: HttpContext) {
const data = request.only(['email'])
const user = await User.query().where('email', data.email).firstOrFail() // Preload package data
return response.ok({ status: true, data: user })
}
async store({ request, response }: HttpContext) {
try {
const data = request.only(['full_name', 'email', 'password'])
// Check if email already exists
const existingUser = await User.findBy('email', data.email)
if (existingUser) {
return response.conflict({
status: false,
message: 'Email already exists',
})
}
// 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,
})
return response.created({
status: true,
message: 'User created successfully',
data: user,
})
} catch (error) {
return response.badRequest({ error: error.message || 'User create failed', status: false })
}
}
async update({ params, request, response }: HttpContext) {
try {
const user = await User.findOrFail(params.id)
const data = request.only(['full_name', 'email', 'password']) // Include password
// Check if email already exists for another user
if (data.email)
if (data.email !== user.email) {
const existingUser = await User.findBy('email', data.email)
if (existingUser) {
return response.conflict({
status: false,
message: 'Email already exists',
})
}
}
// Hash the password if it is provided
if (data.password) {
data.password = await hash.make(data.password)
}
user.merge(data)
await user.save()
return response.ok({ status: true, message: 'User updated successfully', data: user })
} catch (error) {
return response.badRequest({ error: error.message || 'User update failed', status: false })
}
}
async destroy({ params, response }: HttpContext) {
try {
const user = await User.findOrFail(params.id)
await user.delete()
return response.ok({ status: true, message: 'User deleted successfully', data: user })
} catch (error) {
return response.badRequest({ error: error.message || 'User delete failed', status: false })
}
}
}

View File

@ -0,0 +1,28 @@
import app from '@adonisjs/core/services/app'
import { HttpContext, ExceptionHandler } from '@adonisjs/core/http'
export default class HttpExceptionHandler extends ExceptionHandler {
/**
* In debug mode, the exception handler will display verbose errors
* with pretty printed stack traces.
*/
protected debug = !app.inProduction
/**
* The method is used for handling errors and returning
* response to the client
*/
async handle(error: unknown, ctx: HttpContext) {
return super.handle(error, ctx)
}
/**
* The method is used to report error to the logging service or
* the third party error monitoring service.
*
* @note You should not attempt to send a response from this method.
*/
async report(error: unknown, ctx: HttpContext) {
return super.report(error, ctx)
}
}

View File

@ -0,0 +1,25 @@
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
import type { Authenticators } from '@adonisjs/auth/types'
/**
* Auth middleware is used authenticate HTTP requests and deny
* access to unauthenticated users.
*/
export default class AuthMiddleware {
/**
* The URL to redirect to, when authentication fails
*/
redirectTo = '/login'
async handle(
ctx: HttpContext,
next: NextFn,
options: {
guards?: (keyof Authenticators)[]
} = {}
) {
await ctx.auth.authenticateUsing(options.guards, { loginRoute: this.redirectTo })
return next()
}
}

View File

@ -0,0 +1,19 @@
import { Logger } from '@adonisjs/core/logger'
import { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
/**
* The container bindings middleware binds classes to their request
* specific value using the container resolver.
*
* - We bind "HttpContext" class to the "ctx" object
* - And bind "Logger" class to the "ctx.logger" object
*/
export default class ContainerBindingsMiddleware {
handle(ctx: HttpContext, next: NextFn) {
ctx.containerResolver.bindValue(HttpContext, ctx)
ctx.containerResolver.bindValue(Logger, ctx.logger)
return next()
}
}

View File

@ -0,0 +1,16 @@
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
/**
* Updating the "Accept" header to always accept "application/json" response
* from the server. This will force the internals of the framework like
* validator errors or auth errors to return a JSON response.
*/
export default class ForceJsonResponseMiddleware {
async handle({ request }: HttpContext, next: NextFn) {
const headers = request.headers()
headers.accept = 'application/json'
return next()
}
}

View File

@ -0,0 +1,42 @@
import { DateTime } from 'luxon'
import { BaseModel, belongsTo, column, hasMany } from '@adonisjs/lucid/orm'
import type { BelongsTo, HasMany } from '@adonisjs/lucid/types/relations'
import Station from './station.js'
import Log from './log.js'
export default class Line extends BaseModel {
@column({ isPrimary: true })
declare id: number
@column()
declare port: number
@column()
declare line_number: number
@column()
declare line_clear: number
@column()
declare stationId: number
@column()
declare apc_name: number
@column()
declare outlet: number
@belongsTo(() => Station)
declare station: BelongsTo<typeof Station>
@hasMany(() => Log, {
foreignKey: 'lineId',
})
declare logs: HasMany<typeof Log>
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime
}

33
BACKEND/app/models/log.ts Normal file
View File

@ -0,0 +1,33 @@
import { DateTime } from 'luxon'
import { BaseModel, belongsTo, column } from '@adonisjs/lucid/orm'
import Line from './line.js'
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
export default class Log extends BaseModel {
@column({ isPrimary: true })
declare id: number
@column()
declare path: string
@column()
declare lineId: number
@belongsTo(() => Line)
declare line: BelongsTo<typeof Line>
@column({ columnName: 'PID' })
declare PID: string
@column({ columnName: 'SN' })
declare SN: string
@column()
declare description: string
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime
}

View File

@ -0,0 +1,16 @@
import { DateTime } from 'luxon'
import { BaseModel, column } from '@adonisjs/lucid/orm'
export default class Model extends BaseModel {
@column({ isPrimary: true })
declare id: number
@column()
declare name: string
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime
}

View File

@ -0,0 +1,25 @@
import { BaseModel, column } from '@adonisjs/lucid/orm'
import { DateTime } from 'luxon'
export default class Scenario extends BaseModel {
@column({ isPrimary: true })
declare id: number
@column()
declare title: string
@column()
declare body: string
@column()
declare timeout: number
@column()
declare isReboot: boolean
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime
}

View File

@ -0,0 +1,82 @@
import { DateTime } from 'luxon'
import { BaseModel, column, hasMany, SnakeCaseNamingStrategy } from '@adonisjs/lucid/orm'
import type { HasMany } from '@adonisjs/lucid/types/relations'
import Line from './line.js'
BaseModel.namingStrategy = new SnakeCaseNamingStrategy()
export default class Station extends BaseModel {
@column({ isPrimary: true })
declare id: number
@column()
declare name: string
@column()
declare ip: string
@column()
declare netmask?: string
@column()
declare network?: string
@column()
declare gateway?: string
@column()
declare tftp_ip?: string
@column()
declare apc_1_ip?: string
@column()
declare apc_2_ip?: string
@column()
declare port: number
@column()
declare apc_1_port?: number
@column()
declare apc_2_port?: number
@column()
declare apc_1_username?: string
@column()
declare apc_1_password?: string
@column()
declare apc_2_username?: string
@column()
declare apc_2_password?: string
@column()
declare master_control: boolean
@column()
declare switch_control_ip?: string
@column()
declare switch_control_port?: number
@column()
declare switch_control_username?: string
@column()
declare switch_control_password?: string
@hasMany(() => Line, {
foreignKey: 'stationId',
})
declare lines: HasMany<typeof Line>
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime
}

View File

@ -0,0 +1,30 @@
import { DateTime } from 'luxon'
import hash from '@adonisjs/core/services/hash'
import { compose } from '@adonisjs/core/helpers'
import { BaseModel, column } from '@adonisjs/lucid/orm'
import { withAuthFinder } from '@adonisjs/auth/mixins/lucid'
const AuthFinder = withAuthFinder(() => hash.use('scrypt'), {
uids: ['email'],
passwordColumnName: 'password',
})
export default class User extends compose(BaseModel, AuthFinder) {
@column({ isPrimary: true })
declare id: number
@column()
declare fullName: string | null
@column()
declare email: string
@column({ serializeAs: null })
declare password: string
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null
}

View File

@ -0,0 +1,86 @@
import net from 'node:net'
interface LineConfig {
id: number
port: number
lineNumber: number
ip: string
stationId: number
apcName?: string
}
export default class LineConnection {
public client: net.Socket
public readonly config: LineConfig
public readonly socketIO: any
constructor(config: LineConfig, socketIO: any) {
this.config = config
this.socketIO = socketIO
this.client = new net.Socket()
}
connect() {
return new Promise<void>((resolve, reject) => {
const { ip, port, lineNumber, id, stationId } = this.config
this.client.connect(port, ip, () => {
console.log(`✅ Connected to line ${lineNumber} (${ip}:${port})`)
this.socketIO.emit('line_connected', {
stationId,
lineId: id,
lineNumber,
status: 'connected',
})
resolve()
})
this.client.on('data', (data) => {
const message = data.toString().trim()
console.log(`📨 [${this.config.apcName}] ${message}`)
this.socketIO.emit('line_output', {
stationId,
lineId: id,
data: message,
})
})
this.client.on('error', (err) => {
console.error(`❌ Error line ${lineNumber}:`, err.message)
this.socketIO.emit('line_error', {
stationId,
lineId: id,
error: err.message,
})
reject(err)
})
this.client.on('close', () => {
console.log(`🔌 Line ${lineNumber} disconnected`)
this.socketIO.emit('line_disconnected', {
stationId,
lineId: id,
lineNumber,
})
})
})
}
sendCommand(cmd: string) {
if (this.client.destroyed) {
console.log(`⚠️ Cannot send, line ${this.config.lineNumber} is closed`)
return
}
console.log(`➡️ [${this.config.apcName}] SEND:`, cmd)
this.client.write(`${cmd}\r\n`)
}
disconnect() {
try {
this.client.destroy()
console.log(`🔻 Closed connection to line ${this.config.lineNumber}`)
} catch (e) {
console.error('Error closing line:', e)
}
}
}

View File

@ -0,0 +1,9 @@
import { Server, Socket } from 'socket.io'
export interface CustomSocket extends Socket {
connectionTime?: Date
}
export interface CustomServer extends Server {
userKeys?: string[]
}

47
BACKEND/bin/console.ts Normal file
View File

@ -0,0 +1,47 @@
/*
|--------------------------------------------------------------------------
| Ace entry point
|--------------------------------------------------------------------------
|
| The "console.ts" file is the entrypoint for booting the AdonisJS
| command-line framework and executing commands.
|
| Commands do not boot the application, unless the currently running command
| has "options.startApp" flag set to true.
|
*/
import 'reflect-metadata'
import { Ignitor, prettyPrintError } from '@adonisjs/core'
/**
* URL to the application root. AdonisJS need it to resolve
* paths to file and directories for scaffolding commands
*/
const APP_ROOT = new URL('../', import.meta.url)
/**
* The importer is used to import files in context of the
* application.
*/
const IMPORTER = (filePath: string) => {
if (filePath.startsWith('./') || filePath.startsWith('../')) {
return import(new URL(filePath, APP_ROOT).href)
}
return import(filePath)
}
new Ignitor(APP_ROOT, { importer: IMPORTER })
.tap((app) => {
app.booting(async () => {
await import('#start/env')
})
app.listen('SIGTERM', () => app.terminate())
app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate())
})
.ace()
.handle(process.argv.splice(2))
.catch((error) => {
process.exitCode = 1
prettyPrintError(error)
})

45
BACKEND/bin/server.ts Normal file
View File

@ -0,0 +1,45 @@
/*
|--------------------------------------------------------------------------
| HTTP server entrypoint
|--------------------------------------------------------------------------
|
| The "server.ts" file is the entrypoint for starting the AdonisJS HTTP
| server. Either you can run this file directly or use the "serve"
| command to run this file and monitor file changes
|
*/
import 'reflect-metadata'
import { Ignitor, prettyPrintError } from '@adonisjs/core'
/**
* URL to the application root. AdonisJS need it to resolve
* paths to file and directories for scaffolding commands
*/
const APP_ROOT = new URL('../', import.meta.url)
/**
* The importer is used to import files in context of the
* application.
*/
const IMPORTER = (filePath: string) => {
if (filePath.startsWith('./') || filePath.startsWith('../')) {
return import(new URL(filePath, APP_ROOT).href)
}
return import(filePath)
}
new Ignitor(APP_ROOT, { importer: IMPORTER })
.tap((appBoot) => {
appBoot.booting(async () => {
await import('#start/env')
})
appBoot.listen('SIGTERM', () => appBoot.terminate())
appBoot.listenIf(appBoot.managedByPm2, 'SIGINT', () => appBoot.terminate())
})
.httpServer()
.start()
.catch((error) => {
process.exitCode = 1
prettyPrintError(error)
})

62
BACKEND/bin/test.ts Normal file
View File

@ -0,0 +1,62 @@
/*
|--------------------------------------------------------------------------
| Test runner entrypoint
|--------------------------------------------------------------------------
|
| The "test.ts" file is the entrypoint for running tests using Japa.
|
| Either you can run this file directly or use the "test"
| command to run this file and monitor file changes.
|
*/
process.env.NODE_ENV = 'test'
import 'reflect-metadata'
import { Ignitor, prettyPrintError } from '@adonisjs/core'
import { configure, processCLIArgs, run } from '@japa/runner'
/**
* URL to the application root. AdonisJS need it to resolve
* paths to file and directories for scaffolding commands
*/
const APP_ROOT = new URL('../', import.meta.url)
/**
* The importer is used to import files in context of the
* application.
*/
const IMPORTER = (filePath: string) => {
if (filePath.startsWith('./') || filePath.startsWith('../')) {
return import(new URL(filePath, APP_ROOT).href)
}
return import(filePath)
}
new Ignitor(APP_ROOT, { importer: IMPORTER })
.tap((app) => {
app.booting(async () => {
await import('#start/env')
})
app.listen('SIGTERM', () => app.terminate())
app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate())
})
.testRunner()
.configure(async (app) => {
const { runnerHooks, ...config } = await import('../tests/bootstrap.js')
processCLIArgs(process.argv.splice(2))
configure({
...app.rcFile.tests,
...config,
...{
setup: runnerHooks.setup,
teardown: runnerHooks.teardown.concat([() => app.terminate()]),
},
})
})
.run(() => run())
.catch((error) => {
process.exitCode = 1
prettyPrintError(error)
})

40
BACKEND/config/app.ts Normal file
View File

@ -0,0 +1,40 @@
import env from '#start/env'
import app from '@adonisjs/core/services/app'
import { Secret } from '@adonisjs/core/helpers'
import { defineConfig } from '@adonisjs/core/http'
/**
* The app key is used for encrypting cookies, generating signed URLs,
* and by the "encryption" module.
*
* The encryption module will fail to decrypt data if the key is lost or
* changed. Therefore it is recommended to keep the app key secure.
*/
export const appKey = new Secret(env.get('APP_KEY'))
/**
* The configuration settings used by the HTTP server
*/
export const http = defineConfig({
generateRequestId: true,
allowMethodSpoofing: false,
/**
* Enabling async local storage will let you access HTTP context
* from anywhere inside your application.
*/
useAsyncLocalStorage: false,
/**
* Manage cookies configuration. The settings for the session id cookie are
* defined inside the "config/session.ts" file.
*/
cookie: {
domain: '',
path: '/',
maxAge: '2h',
httpOnly: true,
secure: app.inProduction,
sameSite: 'lax',
},
})

27
BACKEND/config/auth.ts Normal file
View File

@ -0,0 +1,27 @@
import { defineConfig } from '@adonisjs/auth'
import { basicAuthGuard, basicAuthUserProvider } from '@adonisjs/auth/basic_auth'
import type { InferAuthenticators, InferAuthEvents, Authenticators } from '@adonisjs/auth/types'
const authConfig = defineConfig({
default: 'basicAuth',
guards: {
basicAuth: basicAuthGuard({
provider: basicAuthUserProvider({
model: () => import('#models/user')
}),
}),
},
})
export default authConfig
/**
* Inferring types from the configured auth
* guards.
*/
declare module '@adonisjs/auth/types' {
export interface Authenticators extends InferAuthenticators<typeof authConfig> {}
}
declare module '@adonisjs/core/types' {
interface EventsList extends InferAuthEvents<Authenticators> {}
}

View File

@ -0,0 +1,55 @@
import { defineConfig } from '@adonisjs/core/bodyparser'
const bodyParserConfig = defineConfig({
/**
* The bodyparser middleware will parse the request body
* for the following HTTP methods.
*/
allowedMethods: ['POST', 'PUT', 'PATCH', 'DELETE'],
/**
* Config for the "application/x-www-form-urlencoded"
* content-type parser
*/
form: {
convertEmptyStringsToNull: true,
types: ['application/x-www-form-urlencoded'],
},
/**
* Config for the JSON parser
*/
json: {
convertEmptyStringsToNull: true,
types: [
'application/json',
'application/json-patch+json',
'application/vnd.api+json',
'application/csp-report',
],
},
/**
* Config for the "multipart/form-data" content-type parser.
* File uploads are handled by the multipart parser.
*/
multipart: {
/**
* Enabling auto process allows bodyparser middleware to
* move all uploaded files inside the tmp folder of your
* operating system
*/
autoProcess: true,
convertEmptyStringsToNull: true,
processManually: [],
/**
* Maximum limit of data to parse including all files
* and fields
*/
limit: '20mb',
types: ['multipart/form-data'],
},
})
export default bodyParserConfig

19
BACKEND/config/cors.ts Normal file
View File

@ -0,0 +1,19 @@
import { defineConfig } from '@adonisjs/cors'
/**
* Configuration options to tweak the CORS policy. The following
* options are documented on the official documentation website.
*
* https://docs.adonisjs.com/guides/security/cors
*/
const corsConfig = defineConfig({
enabled: true,
origin: true,
methods: ['GET', 'HEAD', 'POST', 'PUT', 'DELETE'],
headers: true,
exposeHeaders: [],
credentials: true,
maxAge: 90,
})
export default corsConfig

View File

@ -0,0 +1,24 @@
import env from '#start/env'
import { defineConfig } from '@adonisjs/lucid'
const dbConfig = defineConfig({
connection: 'mysql',
connections: {
mysql: {
client: 'mysql2',
connection: {
host: env.get('DB_HOST'),
port: env.get('DB_PORT'),
user: env.get('DB_USER'),
password: env.get('DB_PASSWORD'),
database: env.get('DB_DATABASE'),
},
migrations: {
naturalSort: true,
paths: ['database/migrations'],
},
},
},
})
export default dbConfig

24
BACKEND/config/hash.ts Normal file
View File

@ -0,0 +1,24 @@
import { defineConfig, drivers } from '@adonisjs/core/hash'
const hashConfig = defineConfig({
default: 'scrypt',
list: {
scrypt: drivers.scrypt({
cost: 16384,
blockSize: 8,
parallelization: 1,
maxMemory: 33554432,
}),
},
})
export default hashConfig
/**
* Inferring types for the list of hashers you have configured
* in your application.
*/
declare module '@adonisjs/core/types' {
export interface HashersList extends InferHashers<typeof hashConfig> {}
}

35
BACKEND/config/logger.ts Normal file
View File

@ -0,0 +1,35 @@
import env from '#start/env'
import app from '@adonisjs/core/services/app'
import { defineConfig, targets } from '@adonisjs/core/logger'
const loggerConfig = defineConfig({
default: 'app',
/**
* The loggers object can be used to define multiple loggers.
* By default, we configure only one logger (named "app").
*/
loggers: {
app: {
enabled: true,
name: env.get('APP_NAME'),
level: env.get('LOG_LEVEL'),
transport: {
targets: targets()
.pushIf(!app.inProduction, targets.pretty())
.pushIf(app.inProduction, targets.file({ destination: 1 }))
.toArray(),
},
},
},
})
export default loggerConfig
/**
* Inferring types for the list of loggers you have configured
* in your application.
*/
declare module '@adonisjs/core/types' {
export interface LoggersList extends InferLoggers<typeof loggerConfig> {}
}

View File

@ -0,0 +1,20 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'users'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id').notNullable()
table.string('full_name').nullable()
table.string('email', 254).notNullable().unique()
table.string('password').notNullable()
table.timestamps()
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}

View File

@ -0,0 +1,35 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'stations'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id')
table.string('name').notNullable()
table.string('ip').notNullable()
table.integer('port').notNullable()
table.string('netmask')
table.string('network')
table.string('gateway')
table.string('tftp_ip')
table.string('apc_1_ip')
table.integer('apc_1_port')
table.string('apc_1_username')
table.string('apc_1_password')
table.string('apc_2_ip')
table.integer('apc_2_port')
table.string('apc_2_username')
table.string('apc_2_password')
table.string('switch_control_ip')
table.integer('switch_control_port')
table.string('switch_control_username')
table.string('switch_control_password')
table.timestamps()
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}

View File

@ -0,0 +1,22 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'lines'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id')
table.integer('port').notNullable()
table.integer('line_number').notNullable()
table.integer('line_clear')
table.integer('outlet')
table.integer('station_id').unsigned().references('id').inTable('stations')
table.string('apc_name').notNullable()
table.timestamps()
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}

View File

@ -0,0 +1,21 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'scenarios'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id')
table.string('title').notNullable()
table.string('name').notNullable()
table.text('body').notNullable()
table.integer('timeout').notNullable()
table.boolean('isReboot').defaultTo(false)
table.timestamps()
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}

View File

@ -0,0 +1,21 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'logs'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id')
table.string('path').notNullable()
table.integer('line_id').unsigned().references('id').inTable('lines')
table.string('PID')
table.string('SN')
table.string('description')
table.timestamps()
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}

View File

@ -0,0 +1,17 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'models'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id')
table.string('name').notNullable()
table.timestamps()
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}

2
BACKEND/eslint.config.js Normal file
View File

@ -0,0 +1,2 @@
import { configApp } from '@adonisjs/eslint-config'
export default configApp()

7655
BACKEND/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

72
BACKEND/package.json Normal file
View File

@ -0,0 +1,72 @@
{
"name": "BACKEND",
"version": "0.0.0",
"private": true,
"type": "module",
"license": "UNLICENSED",
"scripts": {
"start": "node bin/server.js",
"build": "node ace build",
"dev": "node ace serve --hmr",
"test": "node ace test",
"lint": "eslint .",
"format": "prettier --write .",
"typecheck": "tsc --noEmit"
},
"imports": {
"#controllers/*": "./app/controllers/*.js",
"#exceptions/*": "./app/exceptions/*.js",
"#models/*": "./app/models/*.js",
"#mails/*": "./app/mails/*.js",
"#services/*": "./app/services/*.js",
"#listeners/*": "./app/listeners/*.js",
"#events/*": "./app/events/*.js",
"#middleware/*": "./app/middleware/*.js",
"#validators/*": "./app/validators/*.js",
"#providers/*": "./providers/*.js",
"#policies/*": "./app/policies/*.js",
"#abilities/*": "./app/abilities/*.js",
"#database/*": "./database/*.js",
"#start/*": "./start/*.js",
"#tests/*": "./tests/*.js",
"#config/*": "./config/*.js"
},
"devDependencies": {
"@adonisjs/assembler": "^7.8.2",
"@adonisjs/eslint-config": "^2.0.0",
"@adonisjs/prettier-config": "^1.4.4",
"@adonisjs/tsconfig": "^1.4.0",
"@japa/api-client": "^3.1.0",
"@japa/assert": "^4.0.1",
"@japa/plugin-adonisjs": "^4.0.0",
"@japa/runner": "^4.2.0",
"@swc/core": "1.11.24",
"@types/luxon": "^3.7.1",
"@types/node": "^22.15.18",
"eslint": "^9.26.0",
"hot-hook": "^0.4.0",
"pino-pretty": "^13.0.0",
"prettier": "^3.5.3",
"ts-node-maintained": "^10.9.5",
"typescript": "~5.8"
},
"dependencies": {
"@adonisjs/auth": "^9.4.0",
"@adonisjs/core": "^6.18.0",
"@adonisjs/cors": "^2.2.1",
"@adonisjs/lucid": "^21.6.1",
"@vinejs/vine": "^3.0.1",
"luxon": "^3.7.2",
"mysql2": "^3.15.3",
"net": "^1.0.2",
"reflect-metadata": "^0.2.2",
"socket.io": "^4.8.1"
},
"hotHook": {
"boundaries": [
"./app/controllers/**/*.ts",
"./app/middleware/*.ts"
]
},
"prettier": "@adonisjs/prettier-config"
}

View File

@ -0,0 +1,148 @@
import { Server as SocketIOServer } from 'socket.io'
import http from 'node:http'
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'
interface Station {
id: number
name: string
ip: string
lines: any[]
}
export default class SocketIoProvider {
private static _io: CustomServer
constructor(protected app: ApplicationService) {}
/**
* Register bindings to the container
*/
register() {}
/**
* The container bindings have booted
*/
async boot() {}
/**
* The application has been booted
*/
async start() {}
/**
* The process has been started
*/
async ready() {
if (process.argv[1].includes('server.js')) {
const webSocket = new WebSocketIo(this.app)
SocketIoProvider._io = await webSocket.boot()
}
}
/**
* Preparing to shutdown the app
*/
async shutdown() {}
public static get io() {
return this._io
}
}
export class WebSocketIo {
intervalMap: { [key: string]: NodeJS.Timeout } = {}
stationMap: Map<number, Station> = new Map()
lineMap: Map<number, LineConnection> = new Map() // key = lineId
constructor(protected app: ApplicationService) {}
async boot() {
const SOCKET_IO_PORT = env.get('SOCKET_PORT') || 8989
const FRONTEND_URL = env.get('FRONTEND_URL') || 'http://localhost:5173'
const socketServer = http.createServer()
const io = new SocketIOServer(socketServer, {
pingInterval: 25000, // 25s server gửi ping
pingTimeout: 20000, // chờ 20s không có pong thì disconnect
cors: {
origin: [FRONTEND_URL],
methods: ['GET', 'POST'],
credentials: true,
},
})
io.on('connection', (socket: CustomSocket) => {
console.log('Socket connected:', socket.id)
socket.connectionTime = new Date()
socket.on('disconnect', () => {
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)
})
// 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' })
}
})
// FE yêu cầu ngắt kết nối 1 station
socket.on('disconnect_station', (stationId) => {
this.disconnectStation(stationId)
})
})
socketServer.listen(SOCKET_IO_PORT, () => {
console.log(`Socket server is running on port ${SOCKET_IO_PORT}`)
})
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 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`)
}
}

31
BACKEND/start/env.ts Normal file
View File

@ -0,0 +1,31 @@
/*
|--------------------------------------------------------------------------
| Environment variables service
|--------------------------------------------------------------------------
|
| The `Env.create` method creates an instance of the Env service. The
| service validates the environment variables and also cast values
| to JavaScript data types.
|
*/
import { Env } from '@adonisjs/core/env'
export default await Env.create(new URL('../', import.meta.url), {
NODE_ENV: Env.schema.enum(['development', 'production', 'test'] as const),
PORT: Env.schema.number(),
APP_KEY: Env.schema.string(),
HOST: Env.schema.string({ format: 'host' }),
LOG_LEVEL: Env.schema.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace', 'silent']),
/*
|----------------------------------------------------------
| Variables for configuring database connection
|----------------------------------------------------------
*/
DB_HOST: Env.schema.string({ format: 'host' }),
DB_PORT: Env.schema.number(),
DB_USER: Env.schema.string(),
DB_PASSWORD: Env.schema.string.optional(),
DB_DATABASE: Env.schema.string()
})

43
BACKEND/start/kernel.ts Normal file
View File

@ -0,0 +1,43 @@
/*
|--------------------------------------------------------------------------
| HTTP kernel file
|--------------------------------------------------------------------------
|
| The HTTP kernel file is used to register the middleware with the server
| or the router.
|
*/
import router from '@adonisjs/core/services/router'
import server from '@adonisjs/core/services/server'
/**
* The error handler is used to convert an exception
* to an HTTP response.
*/
server.errorHandler(() => import('#exceptions/handler'))
/**
* The server middleware stack runs middleware on all the HTTP
* requests, even if there is no route registered for
* the request URL.
*/
server.use([
() => import('#middleware/container_bindings_middleware'),
() => import('#middleware/force_json_response_middleware'),
() => import('@adonisjs/cors/cors_middleware'),
])
/**
* The router middleware stack runs middleware on all the HTTP
* requests with a registered route.
*/
router.use([() => import('@adonisjs/core/bodyparser_middleware'), () => import('@adonisjs/auth/initialize_auth_middleware')])
/**
* Named middleware collection must be explicitly assigned to
* the routes or the routes group.
*/
export const middleware = router.named({
auth: () => import('#middleware/auth_middleware')
})

65
BACKEND/start/routes.ts Normal file
View File

@ -0,0 +1,65 @@
/*
|--------------------------------------------------------------------------
| Routes file
|--------------------------------------------------------------------------
|
| The routes file is used for defining the HTTP routes.
|
*/
import router from '@adonisjs/core/services/router'
router
.group(() => {
router.get('/', '#controllers/stations_controller.index')
router.post('create', '#controllers/stations_controller.store')
router.post('update', '#controllers/stations_controller.update')
router.post('delete', '#controllers/stations_controller.destroy')
})
.prefix('api/stations')
router
.group(() => {
router.get('/', '#controllers/lines_controller.get')
router.post('create', '#controllers/lines_controller.create')
router.post('update', '#controllers/lines_controller.update')
router.post('delete', '#controllers/lines_controller.delete')
})
.prefix('api/lines')
router
.group(() => {
router.get('list', '#controllers/logs_controller.list')
router.post('viewLog', '#controllers/logs_controller.viewLog')
router.post('downloadLog', '#controllers/logs_controller.downloadLog')
})
.prefix('api/logs')
router
.group(() => {
router.get('/', '#controllers/users_controller.index')
router.get('/:id', '#controllers/users_controller.get')
router.post('create', '#controllers/users_controller.store')
router.post('update', '#controllers/users_controller.update')
router.post('delete', '#controllers/users_controller.destroy')
router.post('getByEmail', '#controllers/users_controller.getByEmail')
})
.prefix('api/users')
router
.group(() => {
router.get('/', '#controllers/models_controller.index')
router.post('create', '#controllers/models_controller.store')
router.post('update', '#controllers/models_controller.update')
router.post('delete', '#controllers/models_controller.destroy')
})
.prefix('api/models')
router
.group(() => {
router.get('/', '#controllers/scenarios_controller.get')
router.post('create', '#controllers/scenarios_controller.create')
router.post('update', '#controllers/scenarios_controller.update')
router.post('delete', '#controllers/scenarios_controller.delete')
})
.prefix('api/scenarios')

View File

@ -0,0 +1,38 @@
import { assert } from '@japa/assert'
import { apiClient } from '@japa/api-client'
import app from '@adonisjs/core/services/app'
import type { Config } from '@japa/runner/types'
import { pluginAdonisJS } from '@japa/plugin-adonisjs'
import testUtils from '@adonisjs/core/services/test_utils'
/**
* This file is imported by the "bin/test.ts" entrypoint file
*/
/**
* Configure Japa plugins in the plugins array.
* Learn more - https://japa.dev/docs/runner-config#plugins-optional
*/
export const plugins: Config['plugins'] = [assert(), apiClient(), pluginAdonisJS(app)]
/**
* Configure lifecycle function to run before and after all the
* tests.
*
* The setup functions are executed before all the tests
* The teardown functions are executed after all the tests
*/
export const runnerHooks: Required<Pick<Config, 'setup' | 'teardown'>> = {
setup: [],
teardown: [],
}
/**
* Configure suites by tapping into the test suite instance.
* Learn more - https://japa.dev/docs/test-suites#lifecycle-hooks
*/
export const configureSuite: Config['configureSuite'] = (suite) => {
if (['browser', 'functional', 'e2e'].includes(suite.name)) {
return suite.setup(() => testUtils.httpServer().start())
}
}

7
BACKEND/tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"extends": "@adonisjs/tsconfig/tsconfig.app.json",
"compilerOptions": {
"rootDir": "./",
"outDir": "./build"
}
}

5
FRONTEND/.env Normal file
View File

@ -0,0 +1,5 @@
VITE_BACKEND_URL=http://localhost:3333/
VITE_SOCKET_SERVER=http://localhost:8989/
VITE_GOOGLE_CLIEND_ID=532287737140-2e3kb67raaac56u2uohnqveg6gt7vga9.apps.googleusercontent.com
VITE_LOCALSTORAGE_VARIABLE=au_ma_te_da
VITE_DOMAIN=http://localhost:5173/

24
FRONTEND/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
FRONTEND/README.md Normal file
View File

@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
FRONTEND/eslint.config.js Normal file
View File

@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
FRONTEND/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<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>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4304
FRONTEND/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

36
FRONTEND/package.json Normal file
View File

@ -0,0 +1,36 @@
{
"name": "ATC",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@mantine/core": "^8.3.5",
"@mantine/dates": "^8.3.5",
"@mantine/notifications": "^8.3.5",
"@tabler/icons-react": "^3.35.0",
"axios": "^1.12.2",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"socket.io-client": "^4.8.1"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
"@types/node": "^24.9.1",
"@types/react": "^19.1.16",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.4",
"eslint": "^9.36.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.22",
"globals": "^16.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.45.0",
"vite": "^7.1.7"
}
}

1
FRONTEND/public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

9
FRONTEND/src/App.css Normal file
View File

@ -0,0 +1,9 @@
#root {
width: 100%;
height: 100vh;
/* background-color: aliceblue; */
}
button:focus{
outline: none;
}

View File

@ -0,0 +1,54 @@
.test {
/* background-color: red; */
}
body {
font-family: 'Mulish', sans-serif;
}
.list {
position: relative;
margin-bottom: var(--mantine-spacing-md);
gap: 10px;
justify-content: center;
}
.indicator {
background-color: var(--mantine-color-white);
border-radius: var(--mantine-radius-md);
border: 1px solid var(--mantine-color-gray-2);
box-shadow: var(--mantine-shadow-sm);
@mixin dark {
background-color: var(--mantine-color-dark-6);
border-color: var(--mantine-color-dark-4);
}
}
.tab {
z-index: 1;
font-weight: 500;
transition: color 100ms ease;
color: var(--mantine-color-gray-7);
background-color: var(--mantine-color-white);
outline: none;
font-size: 16px;
&[data-active] {
color: var(--mantine-color-black);
background-color: #a6ffe1 !important;
}
@mixin dark {
color: var(--mantine-color-dark-1);
&[data-active] {
color: var(--mantine-color-white);
}
}
}
.content{
width: 100%;
border-top: 1px #ccc solid;
}

176
FRONTEND/src/App.tsx Normal file
View File

@ -0,0 +1,176 @@
import "@mantine/core/styles.css";
import "@mantine/dates/styles.css";
import "@mantine/notifications/styles.css";
import "./App.css";
import classes from "./App.module.css";
import { useEffect, useState } from "react";
import {
Tabs,
Text,
Container,
Flex,
MantineProvider,
FloatingIndicator,
Grid,
ScrollArea,
Button,
ActionIcon,
} from "@mantine/core";
import type { TLine, TStation } from "./untils/types";
import axios from "axios";
import CardLine from "./components/CardLine";
import { IconEdit, IconSettingsPlus } from "@tabler/icons-react";
const apiUrl = import.meta.env.VITE_BACKEND_URL;
/**
* Main Component
*/
export default function App() {
document.title = "Automation Test";
const [stations, setStations] = useState<TStation[]>([]);
const [selectedLines, setSelectedLines] = useState<TLine[]>([]);
const [activeTab, setActiveTab] = useState("0");
const [controlsRefs, setControlsRefs] = useState<
Record<string, HTMLButtonElement | null>
>({});
const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null);
const setControlRef = (val: string) => (node: HTMLButtonElement) => {
controlsRefs[val] = node;
setControlsRefs(controlsRefs);
};
const [showBottomShadow, setShowBottomShadow] = useState(false);
// function get list station
const getStation = async () => {
try {
const response = await axios.get(apiUrl + "api/stations");
if (response.status) {
if (Array.isArray(response.data)) {
setStations(response.data);
if (response.data?.length > 0)
setActiveTab(response.data[0]?.id.toString());
}
}
} catch (error) {
console.log("Error get station", error);
}
};
useEffect(() => {
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>
))}
<FloatingIndicator
target={activeTab ? controlsRefs[activeTab] : null}
parent={rootRef}
className={classes.indicator}
/>
<Flex gap={"sm"}>
<ActionIcon title="Add Station" variant="outline" color="green">
<IconSettingsPlus />
</ActionIcon>
<ActionIcon title="Edit Station" variant="outline">
<IconEdit />
</ActionIcon>
</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,
}}
>
<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 }}
>
<Button
variant="filled"
style={{ height: "30px", width: "120px" }}
onClick={() => {
if (selectedLines.length !== station.lines.length)
setSelectedLines(station.lines);
else setSelectedLines([]);
}}
>
{selectedLines.length !== station.lines.length
? "Select All"
: "Deselect All"}
</Button>
</Grid.Col>
</Grid>
</Tabs.Panel>
))}
</Tabs>
</Container>
</MantineProvider>
);
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,62 @@
import { Card, Text, Box, Flex } from "@mantine/core";
import type { TLine } from "../untils/types";
import classes from "./Component.module.css";
const CardLine = ({
line,
selectedLines,
setSelectedLines,
}: {
line: TLine;
selectedLines: TLine[];
setSelectedLines: (lines: React.SetStateAction<TLine[]>) => void;
}) => {
return (
<Card
key={line.id}
shadow="sm"
radius="md"
withBorder
className={classes.card_line}
style={
selectedLines.find((val) => val.id === line.id)
? { backgroundColor: "#8bf55940" }
: {}
}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (selectedLines.find((val) => val.id === line.id))
setSelectedLines(selectedLines.filter((val) => val.id !== line.id));
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>
<div className={classes.info_line}>SN: FGL2240307M</div>
<div className={classes.info_line}>VID: V01</div>
</Box>
<Box
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
style={{ backgroundColor: "black", height: "130px", width: "220px" }}
></Box>
</Flex>
{/* <Flex justify={"flex-end"}>
<Button variant="filled" style={{ height: "30px", width: "70px" }}>
Take
</Button>
</Flex> */}
</Card>
);
};
export default CardLine;

View File

@ -0,0 +1,16 @@
.card_line {
width: 400px;
height: 150px;
padding: 8px;
gap: 8px;
cursor: pointer;
}
.info_line {
color: dimgrey;
font-size: 12px;
display: flex;
gap: 4px;
margin-top: 4px;
height: 20px;
}

View File

@ -0,0 +1,64 @@
import React, { createContext, useContext, useEffect, useState } from "react";
import { io, Socket } from "socket.io-client";
import { SOCKET_EVENTS } from "../untils/constanst";
import { notifications } from "@mantine/notifications";
interface ISocketContext {
socket: Socket | null;
}
const SocketContext = createContext<ISocketContext | undefined>(undefined);
const SOCKET_URL =
import.meta.env.VITE_SOCKET_SERVER || "http://localhost:8989/";
export const SocketProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [socket, setSocket] = useState<Socket | null>(null);
useEffect(() => {
const newSocket = io(SOCKET_URL);
setSocket(newSocket);
newSocket.on("connect", () => {
console.log("Connected to Socket Server");
newSocket.emit(SOCKET_EVENTS.ROOM.JOINED);
console.log(`Joined room`);
});
newSocket.on("disconnect", () => {
console.log("Disconnected from Socket Server");
});
newSocket.on("error", (error) => {
console.error("Connection failed:", error);
notifications.show({
title: "Error",
message: error.message,
color: "red",
});
});
return () => {
newSocket.emit(SOCKET_EVENTS.ROOM.LEFT);
newSocket.disconnect();
};
}, []);
return (
<SocketContext.Provider value={{ socket }}>
{children}
</SocketContext.Provider>
);
};
export const useSocket = () => {
const context = useContext(SocketContext);
if (!context) {
throw new Error("useSocket must be used within a SocketProvider");
}
return context;
};

68
FRONTEND/src/index.css Normal file
View File

@ -0,0 +1,68 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

10
FRONTEND/src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@ -0,0 +1,246 @@
import {
IconBan,
IconCrown,
IconDeviceDesktop,
IconFile,
IconHome,
IconRouter,
IconServer,
IconWebhook,
IconSettingsAutomation,
IconKey,
IconClipboardList,
} from '@tabler/icons-react'
export const SOCKET_EVENTS = {
ROOM: {
JOINED: 'room_joined',
LEFT: 'room_left',
},
APP_STATUS: { RECEIVED: 'app_status_received' },
APP_DATA: {
SENT: 'app_data_sent',
RECEIVED: 'app_data_received',
},
SCRIPT_TEST: {
SENT: 'script_test_sent',
TIME_RECEIVED: 'script_test_time_received',
},
DATA_OUTPUT: { RECEIVED: 'data_output_received' },
NOTIFICATION: {
FROM_APP: 'notification_send_from_app',
SEND_ALL: 'notification_send_to_all',
},
APC_CONTROL: {
FROM_WEB: 'apc_control_request_from_web',
TO_APP: 'apc_control_request_to_app',
FROM_WEB_ALL_APC: 'all_apc_control_request_from_web',
},
SYSTEM_LOG: {
FROM_APP: 'system_log_send_from_app',
GET_SYSTEM_LOG_FROM_WEB: 'get_system_log_from_web',
REQUEST_LIST_SYSTEM_LOG_FROM_WEB: 'request_list_system_log_from_web',
RESPONSE_LIST_SYSTEM_LOG_FROM_APP: 'response_list_system_log_from_app',
RESPONSE_SYSTEM_LOG_FROM_APP: 'response_system_log_from_app',
RESPONSE_SYSTEM_LOG_TO_WEB: 'response_system_log_to_web',
},
CLI: {
OPEN_CLI_LINE_FROM_WEB: 'open_cli_line_from_web',
CLOSE_CLI_LINE_FROM_WEB: 'close_cli_line_from_web',
OPEN_CLI_MULTI_LINE_FROM_WEB: 'open_cli_multi_line_from_web',
CLOSE_CLI_MULTI_LINE_FROM_WEB: 'close_cli_multi_line_from_web',
WRITE_COMMAND_FROM_WEB: 'write_command_line_from_web',
WRITE_COMMAND_TO_APP: 'write_command_line_to_app',
RECEIVE_COMMAND_DATA_FROM_APP: 'receive_command_data_from_app',
RECEIVE_COMMAND_DATA_TO_WEB: 'receive_command_data_to_web',
},
RESCAN: {
SEND_LIST_RESCAN_FROM_WEB: 'send_list_rescan_from_web',
SEND_LIST_RESCAN_TO_APP: 'send_list_rescan_to_app',
},
LOCK: {
SEND_LIST_LOCK_FROM_WEB: 'send_list_lock_from_web',
SEND_LIST_LOCK_TO_APP: 'send_list_lock_to_app',
},
CHANGE_STAGE: {
SEND_STAGE_FROM_WEB: 'send_stage_from_web',
SEND_STAGE_TO_APP: 'send_stage_to_app',
},
UPDATE_PROPERTY: {
UPDATE_PROPERTY_FROM_WEB: 'update_property_from_web',
UPDATE_PROPERTY_TO_APP: 'update_property_to_app',
},
RUN_SCENARIOS: {
RUN_SCENARIOS_FROM_WEB: 'run_scenarios_from_web',
RUN_SCENARIOS_TO_APP: 'run_scenarios_to_app',
},
SEND_BREAK: {
SEND_BREAK_FROM_WEB: 'send_break_from_web',
SEND_BREAK_TO_APP: 'send_break_to_app',
},
JOIN_MULTI_ROOM: {
JOIN_MULTI_ROOM_FROM_WEB: 'join_multi_room_from_web',
},
LEAVE_MULTI_ROOM: {
LEAVE_MULTI_ROOM_FROM_WEB: 'leave_multi_room_from_web',
},
TAKE_OVER: {
TAKE_OVER_FROM_WEB: 'take_over_from_web',
TAKE_OVER_TO_WEB: 'take_over_to_web',
},
CONNECT_APC: {
CONNECT_APC_FROM_WEB: 'connect_apc_from_web',
CONNECT_APC_TO_APP: 'connect_apc_to_app',
},
DATA_APC_RECEIVED: {
DATA_APC_RECEIVED_FROM_APP: 'data_apc_received_from_app',
DATA_APC_RECEIVED_TO_WEB: 'data_apc_received_to_web',
},
SEND_COMMAND_TO_APC: {
SEND_COMMAND_TO_APC_FROM_WEB: 'send_command_to_apc_from_web',
SEND_COMMAND_TO_APC_TO_APP: 'send_command_to_apc_to_app',
},
SEND_CLEAR_LINE: {
SEND_CLEAR_LINE_FROM_WEB: 'send_clear_line_from_web',
SEND_CLEAR_LINE_TO_APP: 'send_clear_line_to_app',
},
SEND_CLOSE_LINE: {
SEND_CLOSE_LINE_FROM_WEB: 'send_close_line_from_web',
SEND_CLOSE_LINE_TO_APP: 'send_close_line_to_app',
},
SEND_OPEN_LINE: {
SEND_OPEN_LINE_FROM_WEB: 'send_open_line_from_web',
SEND_OPEN_LINE_TO_APP: 'send_open_line_to_app',
},
CONTROL_APP: {
SEND_PAUSE_APP_FROM_WEB: 'send_pause_app_from_web',
SEND_RESUME_APP_FROM_WEB: 'send_resume_app_from_web',
SEND_RESTART_APP_FROM_WEB: 'send_restart_app_from_web',
SEND_QUIT_APP_FROM_WEB: 'send_quit_app_from_web',
},
CONNECT_SWITCH: {
CONNECT_SWITCH_FROM_WEB: 'connect_switch_from_web',
CONNECT_SWITCH_TO_APP: 'connect_switch_to_app',
},
DATA_SWITCH_RECEIVED: {
DATA_SWITCH_RECEIVED_FROM_APP: 'data_switch_received_from_app',
DATA_SWITCH_RECEIVED_TO_WEB: 'data_switch_received_to_web',
},
SEND_COMMAND_TO_SWITCH: {
SEND_COMMAND_TO_SWITCH_FROM_WEB: 'send_command_to_switch_from_web',
SEND_COMMAND_TO_SWITCH_TO_APP: 'send_command_to_switch_to_app',
SEND_COMMAND_TO_SWITCH_FROM_APP: 'send_command_to_switch_from_app',
},
RELOAD_TICKET: {
RELOAD_TICKET_FROM_WEB: 'reload_ticket_from_web',
RELOAD_TICKET_TO_WEB: 'reload_ticket_to_web',
},
}
export const LINE_STATUS = {
CHECK_INVENTORY: 'CHECK_INVENTORY',
STATUS_TEST: 'TESTING',
CONNECT_FAIL: 'CONNECT_FAIL',
CONNECTED: 'CONNECTED',
STATUS_READY: 'READY',
STATUS_DONE: 'DONE',
STATUS_CHECKING: 'CHECKING',
STATUS_LOCKED: 'LOCKED',
STATUS_CLOSED: 'CLOSED',
STATUS_TIMEOUT: 'TIMEOUT',
STATUS_PHYSICAL_TEST: 'STATUS_PHYSICAL_TEST',
STATUS_PHYSICAL_TEST_DONE: 'STATUS_PHYSICAL_TEST_DONE',
STATUS_UNDIFINED_INVEN: 'INVENTORY_UNIDENTIFIED',
STATUS_RUNNING_SCENARIOS: 'RUNNING_SCENARIOS',
APC_CONTROL: 'APC_CONTROL',
STATUS_STARTING: 'STARTING',
STATUS_TURN_OFF: 'TURN_OFF',
STATUS_RESTARTING: 'RESTARTING',
}
export const LIST_FAVORITE_COMMANDS = [
'sh inv',
'sh ver',
// 'sh diag',
// 'sh post',
// 'sh env',
// 'sh log',
// 'sh platform',
]
export const dataPermission = [
{
link: '/dashboard',
label: 'Dashboard',
icon: IconHome,
requiredPermissions: [],
},
{
link: '/station-setting',
label: 'Station Setting',
icon: IconServer,
requiredPermissions: ['station_activity'],
},
{
link: '/monitor',
label: 'Monitoring',
icon: IconDeviceDesktop,
requiredPermissions: [
'monitor_power',
'monitor_cli',
'monitor_other_items',
],
},
{
link: '/control-apc',
label: 'Control APC',
icon: IconSettingsAutomation,
requiredPermissions: ['control_apc_activity'],
},
{
link: '/group-model',
label: 'Group - Model',
icon: IconRouter,
requiredPermissions: ['group_model_activity'],
},
{
link: '/keyword',
label: 'Keyword',
icon: IconKey,
requiredPermissions: ['keyword_activity', 'keyword_limit'],
},
{
link: '/exclude-error',
label: 'Exclude Errors',
icon: IconBan,
requiredPermissions: ['exclude_error_activity', 'exclude_error_limit'],
},
{
link: '/list-logs',
label: 'List Logs',
icon: IconFile,
requiredPermissions: [],
},
{
link: '/webhooks',
label: 'Webhooks',
icon: IconWebhook,
requiredPermissions: ['webhook_activity', 'webhook_add_limit'],
},
{
link: '/scenario',
label: 'Scenario',
icon: IconClipboardList,
requiredPermissions: ['scenario_activity', 'scenario_add_limit'],
},
{
link: '/upgrade',
label: 'Upgrade now!',
icon: IconCrown,
requiredPermissions: [],
},
]

View File

@ -0,0 +1,128 @@
export type TGeneralSettingValues = {
page_title: string;
email: string;
phone: string;
meta_title: string;
meta_description: string;
meta_keyword: string;
facebook: string;
twitter: string;
linkedin: string;
favicon: File | null | string;
logo: File | null | string;
address: string;
license: string;
};
export type TStation = {
id: number;
name: string;
ip: string;
port: number;
netmask?: string; // Optional
network?: string; // Optional
gateway?: string; // Optional
tftp_ip?: string; // Optional
apc_1_ip?: string; // Optional
apc_1_port?: number; // Optional
apc_2_ip?: string; // Optional
apc_2_port?: number; // Optional
apc_1_username?: string; // Optional
apc_1_password?: string; // Optional
apc_2_username?: string; // Optional
apc_2_password?: string; // Optional
inventory?: {
pid: string;
vid: string;
sn: string;
};
lines: TLine[];
is_active: boolean;
data?: string | any;
type?: string;
status?: boolean;
created_at?: string; // or use Date if you're working with Date objects
updated_at?: string; // or use Date if you're working with Date objects
owner_id: number;
owner: TUser;
users: TUser[];
apcs?: APCProps[];
master_control?: boolean;
switch_control_ip?: string; // Optional
switch_control_port?: number; // Optional
switch_control_username?: string; // Optional
switch_control_password?: string; // Optional
switches?: SwitchesProps[];
};
export type TLine = {
id?: number;
port: number;
lineNumber: number;
lineClear: number;
station_id: number;
is_active: string | boolean;
data?: string | any;
type?: string;
inventory?: any;
status?: string;
netOutput?: string;
outlet?: number;
cliOpened?: boolean;
systemLogUrl?: string;
start_round_at: number;
apc_name: string;
created_at?: string; // or use Date if you're working with Date objects
updated_at?: string; // or use Date if you're working with Date objects
notes?: string;
noteScenario?: string;
physicalLog?: string;
checkPower?: boolean;
checkLed?: boolean;
userOpenCLI?: string;
userEmailOpenCLI?: string;
statusTicket?: string;
};
export type TUser = {
id: number;
email: string;
email_cc: string;
full_name: string;
package_id: string;
zulip: string;
token?: string;
name: string;
};
export type APCProps = {
apc_number?: number;
apc_ip?: string;
apc_port?: number;
apc_username?: string;
apc_password?: string;
outlets?: OutletProps[];
status: string;
output?: string;
keep_connect?: boolean;
};
export type OutletProps = {
name: string;
status: string;
};
export type SwitchesProps = {
host?: string;
port?: number;
username?: string;
password?: string;
status: string;
ports?: SwitchPortsProps[];
portGroups?: SwitchPortsProps[][];
};
export type SwitchPortsProps = {
name: string;
status: string;
poe: string;
};

View File

@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
FRONTEND/tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

7
FRONTEND/vite.config.ts Normal file
View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})