devicetypeAPIGET1
This commit is contained in:
9
.env
9
.env
@@ -1,9 +0,0 @@
|
||||
#Account Database configuration for PostgreSQL
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=postgres
|
||||
POSTGRES_DB=ndms_db
|
||||
|
||||
#Account Minio S3 configuration
|
||||
MINIO_ROOT_USER=minioadmin
|
||||
MINIO_ROOT_PASSWORD=minioadmin
|
||||
MINIO_BUCKET_NAME=ndms-bucket
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -6,7 +6,7 @@ __pycache__/
|
||||
.Python
|
||||
venv/
|
||||
.venv/
|
||||
env/
|
||||
.env
|
||||
ENV/
|
||||
|
||||
# Node.js
|
||||
|
||||
BIN
PHẦN MỀM QUẢN LÝ THIẾT BỊ MẠNG (1) copy.text
Normal file
BIN
PHẦN MỀM QUẢN LÝ THIẾT BỊ MẠNG (1) copy.text
Normal file
Binary file not shown.
0
backend/ scheduler/alert_worker.py
Normal file
0
backend/ scheduler/alert_worker.py
Normal file
0
backend/ scheduler/ping_worker.py
Normal file
0
backend/ scheduler/ping_worker.py
Normal file
0
backend/ scheduler/scheduler.py
Normal file
0
backend/ scheduler/scheduler.py
Normal file
0
backend/ scheduler/snmp_worker.py
Normal file
0
backend/ scheduler/snmp_worker.py
Normal file
@@ -1,26 +1,30 @@
|
||||
from flask import Flask, jsonify
|
||||
# from flask_cors import CORS
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
from flask import Flask
|
||||
|
||||
load_dotenv() # Đọc file .env
|
||||
from common.exceptions.handler import (
|
||||
register_error_handlers
|
||||
)
|
||||
|
||||
app = Flask(__name__) # ← Đây là dòng quan trọng nhất
|
||||
|
||||
# Cho phép React (localhost:5173) gọi API từ backend
|
||||
CORS(app, origins=["http://localhost:5173", "http://127.0.0.1:5173"])
|
||||
from modules.device_type.routes import (
|
||||
device_type_bp
|
||||
)
|
||||
|
||||
@app.route('/')
|
||||
def home():
|
||||
return jsonify({
|
||||
"message": "✅ Backend NDMS đang chạy thành công!",
|
||||
"status": "ok"
|
||||
})
|
||||
app = Flask(__name__)
|
||||
|
||||
@app.route('/health')
|
||||
def health():
|
||||
return jsonify({"status": "healthy"})
|
||||
# Register Global Exception Handlers
|
||||
register_error_handlers(app)
|
||||
|
||||
# Phần này bạn hay dùng
|
||||
if __name__ == '__main__':
|
||||
app.run(host='0.0.0.0', port=8000, debug=True)
|
||||
# Register Blueprints
|
||||
app.register_blueprint(
|
||||
device_type_bp,
|
||||
url_prefix="/api"
|
||||
)
|
||||
|
||||
# @app.route("/")
|
||||
# def home():
|
||||
# return {
|
||||
# "message": "NDMS Backend Running"
|
||||
# }
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(debug=True)
|
||||
9
backend/common/constants/status_code.py
Normal file
9
backend/common/constants/status_code.py
Normal file
@@ -0,0 +1,9 @@
|
||||
HTTP_OK = 200
|
||||
HTTP_CREATED = 201
|
||||
HTTP_BAD_REQUEST = 400
|
||||
HTTP_UNAUTHORIZED = 401
|
||||
HTTP_FORBIDDEN = 403
|
||||
HTTP_NOT_FOUND = 404
|
||||
HTTP_CONFLICT = 409
|
||||
HTTP_UNPROCESSABLE_ENTITY = 422
|
||||
HTTP_INTERNAL_SERVER_ERROR = 500
|
||||
62
backend/common/exceptions/app_exception.py
Normal file
62
backend/common/exceptions/app_exception.py
Normal file
@@ -0,0 +1,62 @@
|
||||
# Đây là exception chung cho toàn bộ dự án
|
||||
class AppException(Exception): # Tạo ra class AppException kế thừa lại Exception của thư viện
|
||||
def __init__(
|
||||
self,
|
||||
message,
|
||||
status_code=400,
|
||||
error_code="BAD_REQUEST",
|
||||
payload=None
|
||||
):
|
||||
super().__init__(message) #kế thừa thêm thuộc tính
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
self.error_code = error_code
|
||||
self.payload = payload or {}
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"success": False,
|
||||
"error": self.error_code,
|
||||
"message": self.message,
|
||||
"details": self.payload
|
||||
}
|
||||
|
||||
# Rồi từ thằng kế thừa lại kế thừa tiếp từ thằng AppException
|
||||
class BadRequestException(AppException):
|
||||
def __init__(self, message="Bad request", payload=None):
|
||||
super().__init__(
|
||||
message=message,
|
||||
status_code=400,
|
||||
error_code="BAD_REQUEST",
|
||||
payload=payload
|
||||
)
|
||||
|
||||
|
||||
class NotFoundException(AppException):
|
||||
def __init__(self, message="Resource not found", payload=None):
|
||||
super().__init__(
|
||||
message=message,
|
||||
status_code=404,
|
||||
error_code="NOT_FOUND",
|
||||
payload=payload
|
||||
)
|
||||
|
||||
|
||||
class ConflictException(AppException):
|
||||
def __init__(self, message="Resource already exists", payload=None):
|
||||
super().__init__(
|
||||
message=message,
|
||||
status_code=409,
|
||||
error_code="CONFLICT",
|
||||
payload=payload
|
||||
)
|
||||
|
||||
|
||||
class UnprocessableEntityException(AppException):
|
||||
def __init__(self, message="Unprocessable entity", payload=None):
|
||||
super().__init__(
|
||||
message=message,
|
||||
status_code=422,
|
||||
error_code="UNPROCESSABLE_ENTITY",
|
||||
payload=payload
|
||||
)
|
||||
50
backend/common/exceptions/handler.py
Normal file
50
backend/common/exceptions/handler.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from marshmallow import ValidationError
|
||||
|
||||
from common.exceptions.app_exception import AppException
|
||||
from common.response.api_response import error_response
|
||||
from common.constants.status_code import (
|
||||
HTTP_BAD_REQUEST,
|
||||
HTTP_NOT_FOUND,
|
||||
HTTP_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
# Phải tạo global error handler(ở project cũ là để phần này ra app.py nhưng bây giờ chỉ cần tạo file hander rồi đăng kí ở app.py để nó thành global bắt lỗi toàn dự án)
|
||||
# Ngoài ra để có được các lỗi của toàn dự án, thì phải custom exception theo dự án bằng cách kế thừa lại Exception để custom thêm thuộc tính mới
|
||||
|
||||
def register_error_handlers(app): # Đăng ký hàm này ở app.py để nó bắt lỗi toàn dự án
|
||||
|
||||
@app.errorhandler(AppException)
|
||||
def handle_app_exception(error):
|
||||
return error_response(
|
||||
message=error.message,
|
||||
error=error.error_code,
|
||||
status_code=error.status_code,
|
||||
details=getattr(error, "payload", None)
|
||||
)
|
||||
|
||||
|
||||
@app.errorhandler(ValidationError)
|
||||
def handle_validation_error(error):
|
||||
return error_response(
|
||||
message="Invalid request data",
|
||||
error="VALIDATION_ERROR",
|
||||
status_code=HTTP_BAD_REQUEST,
|
||||
details=error.messages
|
||||
)
|
||||
|
||||
|
||||
@app.errorhandler(404)
|
||||
def handle_not_found(error):
|
||||
return error_response(
|
||||
message="Route not found",
|
||||
error="NOT_FOUND",
|
||||
status_code=HTTP_NOT_FOUND
|
||||
)
|
||||
|
||||
|
||||
@app.errorhandler(500)
|
||||
def handle_internal_error(error):
|
||||
return error_response(
|
||||
message="Internal server error",
|
||||
error="INTERNAL_SERVER_ERROR",
|
||||
status_code=HTTP_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
28
backend/common/response/api_response.py
Normal file
28
backend/common/response/api_response.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from flask import jsonify
|
||||
from common.constants.status_code import HTTP_OK, HTTP_BAD_REQUEST
|
||||
|
||||
|
||||
def success_response(
|
||||
data=None,
|
||||
message="Success",
|
||||
status_code=HTTP_OK
|
||||
):
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"message": message,
|
||||
"data": data
|
||||
}), status_code
|
||||
|
||||
|
||||
def error_response(
|
||||
message="Error",
|
||||
error="ERROR",
|
||||
status_code=HTTP_BAD_REQUEST,
|
||||
details=None
|
||||
):
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": error,
|
||||
"message": message,
|
||||
"details": details
|
||||
}), status_code
|
||||
25
backend/config/database.py
Normal file
25
backend/config/database.py
Normal file
@@ -0,0 +1,25 @@
|
||||
#Connection to Database
|
||||
from psycopg2.pool import SimpleConnectionPool
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
db_pool = SimpleConnectionPool(
|
||||
minconn=1,
|
||||
maxconn=10,
|
||||
host=os.getenv("DB_HOST"),
|
||||
port=os.getenv("DB_PORT"),
|
||||
database=os.getenv("POSTGRES_DB"),
|
||||
user=os.getenv("POSTGRES_USER"),
|
||||
password=os.getenv("POSTGRES_PASSWORD")
|
||||
)
|
||||
# Tạo ra 10 conection khác nhau, mỗi lần dùng thì vào đây lấy 1 connection ra => Nó tạo sẵn một nhóm connection. Khi cần thì lấy ra, dùng xong trả lại.
|
||||
# khác với kiểu cũ là mỗi lần request khác nhau lại phải tạo mới connection nó sẽ bị nặng và lâu hơn
|
||||
|
||||
|
||||
def get_connection():
|
||||
return db_pool.getconn() # Lấy ra connection trong này
|
||||
|
||||
def release_connection(conn):
|
||||
db_pool.putconn(conn) # trả connection lại pool này
|
||||
0
backend/database/init.sql
Normal file
0
backend/database/init.sql
Normal file
1
backend/database/schema.sql
Normal file
1
backend/database/schema.sql
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
0
backend/database/seed.sql
Normal file
0
backend/database/seed.sql
Normal file
83
backend/modules/device_type/controller.py
Normal file
83
backend/modules/device_type/controller.py
Normal file
@@ -0,0 +1,83 @@
|
||||
# ============================================
|
||||
# CONTROLLER: Nhận request → Validate → Gọi Service → Trả response
|
||||
# ============================================
|
||||
# Controller KHÔNG chứa business logic (check trùng tên, tính toán...)
|
||||
# Controller CHỈ làm 3 việc:
|
||||
# 1. Lấy dữ liệu từ request (body, params, query string)
|
||||
# 2. Validate dữ liệu (bằng Marshmallow schema)
|
||||
# 3. Gọi service → trả response
|
||||
# ============================================
|
||||
|
||||
from flask import request
|
||||
from common.response.api_response import success_response
|
||||
from common.constants.status_code import HTTP_CREATED
|
||||
from modules.device_type.service import (
|
||||
get_device_types_service,
|
||||
create_device_type_service
|
||||
)
|
||||
from modules.device_type.schemas import CreateDeviceTypeSchema
|
||||
|
||||
|
||||
def get_device_types():
|
||||
"""GET /api/device-types — Lấy danh sách tất cả"""
|
||||
device_types = get_device_types_service()
|
||||
return success_response(
|
||||
data=device_types,
|
||||
message="Device types retrieved successfully"
|
||||
)
|
||||
|
||||
|
||||
def get_device_type_by_id(device_type_id):
|
||||
"""GET /api/device-types/<device_type_id> — Lấy chi tiết theo ID"""
|
||||
# TODO: Triển khai sau
|
||||
pass
|
||||
|
||||
|
||||
def create_device_type():
|
||||
"""
|
||||
POST /api/device-types — Tạo mới device type
|
||||
|
||||
Luồng xử lý:
|
||||
1. Lấy JSON body từ request
|
||||
2. Validate bằng Marshmallow schema
|
||||
- Nếu sai → Marshmallow tự throw ValidationError
|
||||
- Global handler bắt → trả 400 kèm chi tiết lỗi
|
||||
3. Gọi service (service sẽ check trùng tên + insert DB)
|
||||
4. Trả 201 Created + data vừa tạo
|
||||
"""
|
||||
# Bước 1: Lấy JSON body từ request
|
||||
body = request.get_json()
|
||||
# request.get_json() parse body thành dict Python
|
||||
# VD: {"name": "Router", "color": "#3B82F6"} → {'name': 'Router', 'color': '#3B82F6'}
|
||||
|
||||
# Bước 2: Validate bằng schema
|
||||
schema = CreateDeviceTypeSchema()
|
||||
data = schema.load(body)
|
||||
# schema.load() làm 2 việc:
|
||||
# a) Validate: check required, type, regex... → nếu sai throw ValidationError
|
||||
# b) Deserialize: loại bỏ field thừa, áp dụng load_default
|
||||
# VD: body = {"name": "Router", "color": "#3B82F6", "hack": "xss"}
|
||||
# → data = {"name": "Router", "color": "#3B82F6", "sort_order": 0, "is_active": True}
|
||||
# → "hack" bị loại bỏ, sort_order/is_active được thêm default
|
||||
|
||||
# Bước 3: Gọi service
|
||||
new_device_type = create_device_type_service(data)
|
||||
|
||||
# Bước 4: Trả response 201 Created
|
||||
return success_response(
|
||||
data=new_device_type,
|
||||
message="Device type created successfully",
|
||||
status_code=HTTP_CREATED # 201 thay vì 200 — đúng chuẩn REST cho POST thành công
|
||||
)
|
||||
|
||||
|
||||
def update_device_type(device_type_id):
|
||||
"""PUT /api/device-types/<device_type_id> — Cập nhật"""
|
||||
# TODO: Triển khai sau
|
||||
pass
|
||||
|
||||
|
||||
def delete_device_type(device_type_id):
|
||||
"""DELETE /api/device-types/<device_type_id> — Xóa"""
|
||||
# TODO: Triển khai sau
|
||||
pass
|
||||
30
backend/modules/device_type/exceptions.py
Normal file
30
backend/modules/device_type/exceptions.py
Normal file
@@ -0,0 +1,30 @@
|
||||
# Đây là exception cho riêng cho từng module
|
||||
from common.exceptions.app_exception import (
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
BadRequestException
|
||||
)
|
||||
|
||||
|
||||
class DeviceTypeNotFoundException(NotFoundException):
|
||||
def __init__(self, device_type_id):
|
||||
super().__init__(
|
||||
message=f"Device type not found with id={device_type_id}",
|
||||
payload={"device_type_id": device_type_id}
|
||||
)
|
||||
|
||||
|
||||
class DeviceTypeAlreadyExistsException(ConflictException):
|
||||
def __init__(self, name):
|
||||
super().__init__(
|
||||
message=f"Device type already exists with name={name}",
|
||||
payload={"name": name}
|
||||
)
|
||||
|
||||
|
||||
class DeviceTypeInUseException(BadRequestException):
|
||||
def __init__(self, device_type_id):
|
||||
super().__init__(
|
||||
message="Cannot delete device type because it is being used by devices",
|
||||
payload={"device_type_id": device_type_id}
|
||||
)
|
||||
120
backend/modules/device_type/repository.py
Normal file
120
backend/modules/device_type/repository.py
Normal file
@@ -0,0 +1,120 @@
|
||||
from config.database import get_connection, release_connection
|
||||
|
||||
# ============================================
|
||||
# Hàm chung: Chuyển 1 row (tuple) thành dict
|
||||
# Tại sao tách riêng? Vì nhiều hàm đều cần chuyển row → dict,
|
||||
# viết 1 lần dùng lại, tránh copy-paste
|
||||
# ============================================
|
||||
def _row_to_dict(row):
|
||||
return {
|
||||
"id": str(row[0]),
|
||||
"name": row[1],
|
||||
"description": row[2],
|
||||
"icon_url": row[3],
|
||||
"color": row[4],
|
||||
"sort_order": row[5],
|
||||
"is_active": row[6],
|
||||
"created": row[7].isoformat() if row[7] else None,
|
||||
"modified": row[8].isoformat() if row[8] else None
|
||||
}
|
||||
|
||||
|
||||
# ============================================
|
||||
# GET ALL: Lấy danh sách tất cả device types
|
||||
# ============================================
|
||||
def find_all_device_types():
|
||||
conn = get_connection()
|
||||
cur = None
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT id, name, description, icon_url, color, sort_order, is_active, created, modified
|
||||
FROM device_types
|
||||
ORDER BY sort_order ASC, name ASC
|
||||
""")
|
||||
|
||||
rows = cur.fetchall()
|
||||
|
||||
device_types = []
|
||||
for row in rows:
|
||||
device_types.append(_row_to_dict(row))
|
||||
|
||||
# ✅ FIX: return PHẢI nằm NGOÀI vòng for
|
||||
# Trước đó return nằm trong for → chỉ trả về 1 row rồi dừng
|
||||
return device_types
|
||||
|
||||
finally:
|
||||
if cur:
|
||||
cur.close()
|
||||
# ✅ FIX: PHẢI trả connection về pool, không thì sau 10 request app sẽ đứng
|
||||
release_connection(conn)
|
||||
|
||||
|
||||
# ============================================
|
||||
# FIND BY NAME: Tìm device type theo tên (để check trùng tên khi tạo mới)
|
||||
# Trả về dict nếu tìm thấy, None nếu không
|
||||
# ============================================
|
||||
def find_device_type_by_name(name):
|
||||
conn = get_connection()
|
||||
cur = None
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
# Dùng LOWER() để so sánh không phân biệt hoa/thường
|
||||
# VD: "Router" và "router" coi như trùng
|
||||
cur.execute("""
|
||||
SELECT id, name, description, icon_url, color, sort_order, is_active, created, modified
|
||||
FROM device_types
|
||||
WHERE LOWER(name) = LOWER(%s)
|
||||
""", (name,))
|
||||
# ⚠️ LƯU Ý: (name,) có dấu phẩy → tạo tuple 1 phần tử
|
||||
# Nếu viết (name) thì Python hiểu là string, không phải tuple → lỗi
|
||||
|
||||
row = cur.fetchone() # fetchone() vì chỉ cần 1 kết quả
|
||||
if row:
|
||||
return _row_to_dict(row)
|
||||
return None
|
||||
|
||||
finally:
|
||||
if cur:
|
||||
cur.close()
|
||||
release_connection(conn)
|
||||
|
||||
|
||||
# ============================================
|
||||
# INSERT: Thêm mới 1 device type vào database
|
||||
# Trả về dict của device type vừa tạo (có id, created, modified từ DB)
|
||||
# ============================================
|
||||
def insert_device_type(data):
|
||||
conn = get_connection()
|
||||
cur = None
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO device_types (name, description, icon_url, color, sort_order, is_active)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
RETURNING id, name, description, icon_url, color, sort_order, is_active, created, modified
|
||||
""", (
|
||||
data["name"],
|
||||
data.get("description"), # .get() trả về None nếu không có key
|
||||
data.get("icon_url"),
|
||||
data["color"],
|
||||
data.get("sort_order", 0), # default = 0 nếu không truyền
|
||||
data.get("is_active", True) # default = True nếu không truyền
|
||||
))
|
||||
# RETURNING: PostgreSQL trả về row vừa INSERT (bao gồm id, created, modified do DB tự tạo)
|
||||
# → Không cần SELECT lại sau khi INSERT
|
||||
|
||||
row = cur.fetchone()
|
||||
conn.commit() # ⚠️ QUAN TRỌNG: INSERT/UPDATE/DELETE phải commit() để lưu vào DB
|
||||
# Nếu không commit → dữ liệu chỉ nằm trong transaction, khi connection đóng → mất hết
|
||||
|
||||
return _row_to_dict(row)
|
||||
|
||||
except Exception:
|
||||
conn.rollback() # Nếu có lỗi → rollback để hủy transaction, tránh dữ liệu bẩn
|
||||
raise # raise lại exception để tầng trên (service/controller) xử lý
|
||||
|
||||
finally:
|
||||
if cur:
|
||||
cur.close()
|
||||
release_connection(conn)
|
||||
21
backend/modules/device_type/routes.py
Normal file
21
backend/modules/device_type/routes.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from flask import Blueprint
|
||||
|
||||
from modules.device_type.controller import (
|
||||
get_device_types,
|
||||
get_device_type_by_id,
|
||||
create_device_type,
|
||||
update_device_type,
|
||||
delete_device_type
|
||||
)
|
||||
|
||||
device_type_bp = Blueprint(
|
||||
"device_type",
|
||||
__name__,
|
||||
url_prefix="/device-types"
|
||||
)
|
||||
|
||||
device_type_bp.route("", methods=["GET"])(get_device_types)
|
||||
device_type_bp.route("/<device_type_id>", methods=["GET"])(get_device_type_by_id)
|
||||
device_type_bp.route("", methods=["POST"])(create_device_type)
|
||||
device_type_bp.route("/<device_type_id>", methods=["PUT"])(update_device_type)
|
||||
device_type_bp.route("/<device_type_id>", methods=["DELETE"])(delete_device_type)
|
||||
67
backend/modules/device_type/schemas.py
Normal file
67
backend/modules/device_type/schemas.py
Normal file
@@ -0,0 +1,67 @@
|
||||
from marshmallow import Schema, fields, validate
|
||||
|
||||
HEX_COLOR_REGEX = r"^#[0-9A-Fa-f]{6}$"
|
||||
|
||||
|
||||
class CreateDeviceTypeSchema(Schema):
|
||||
name = fields.String(
|
||||
required=True,
|
||||
validate=validate.Length(min=1, max=100)
|
||||
)
|
||||
|
||||
description = fields.String(
|
||||
required=False,
|
||||
allow_none=True
|
||||
)
|
||||
|
||||
icon_url = fields.String(
|
||||
required=False,
|
||||
allow_none=True,
|
||||
validate=validate.Length(max=512)
|
||||
)
|
||||
|
||||
color = fields.String(
|
||||
required=True,
|
||||
validate=validate.Regexp(HEX_COLOR_REGEX)
|
||||
)
|
||||
|
||||
sort_order = fields.Integer(
|
||||
required=False,
|
||||
load_default=0
|
||||
)
|
||||
|
||||
is_active = fields.Boolean(
|
||||
required=False,
|
||||
load_default=True
|
||||
)
|
||||
|
||||
|
||||
class UpdateDeviceTypeSchema(Schema):
|
||||
name = fields.String(
|
||||
required=False,
|
||||
validate=validate.Length(min=1, max=100)
|
||||
)
|
||||
|
||||
description = fields.String(
|
||||
required=False,
|
||||
allow_none=True
|
||||
)
|
||||
|
||||
icon_url = fields.String(
|
||||
required=False,
|
||||
allow_none=True,
|
||||
validate=validate.Length(max=512)
|
||||
)
|
||||
|
||||
color = fields.String(
|
||||
required=False,
|
||||
validate=validate.Regexp(HEX_COLOR_REGEX)
|
||||
)
|
||||
|
||||
sort_order = fields.Integer(
|
||||
required=False
|
||||
)
|
||||
|
||||
is_active = fields.Boolean(
|
||||
required=False
|
||||
)
|
||||
38
backend/modules/device_type/service.py
Normal file
38
backend/modules/device_type/service.py
Normal file
@@ -0,0 +1,38 @@
|
||||
# ============================================
|
||||
# SERVICE LAYER: Chứa Business Logic
|
||||
# ============================================
|
||||
# Controller chỉ nhận request + trả response
|
||||
# Repository chỉ truy vấn DB
|
||||
# Service ở giữa: chứa LOGIC NGHIỆP VỤ (validate, check điều kiện, tính toán)
|
||||
# ============================================
|
||||
|
||||
from modules.device_type.repository import (
|
||||
find_all_device_types,
|
||||
find_device_type_by_name,
|
||||
insert_device_type
|
||||
)
|
||||
from modules.device_type.exceptions import DeviceTypeAlreadyExistsException
|
||||
|
||||
|
||||
def get_device_types_service():
|
||||
"""Lấy danh sách tất cả device types — hiện tại không có logic gì thêm"""
|
||||
return find_all_device_types()
|
||||
|
||||
|
||||
def create_device_type_service(data):
|
||||
"""
|
||||
Tạo mới device type.
|
||||
Business logic:
|
||||
1. Check xem tên đã tồn tại chưa (UNIQUE constraint)
|
||||
2. Nếu trùng → throw exception
|
||||
3. Nếu không trùng → insert vào DB
|
||||
"""
|
||||
# Bước 1: Check trùng tên
|
||||
existing = find_device_type_by_name(data["name"])
|
||||
if existing:
|
||||
# Throw exception → global handler bắt → trả 409 Conflict
|
||||
raise DeviceTypeAlreadyExistsException(data["name"])
|
||||
|
||||
# Bước 2: Insert vào DB
|
||||
new_device_type = insert_device_type(data)
|
||||
return new_device_type
|
||||
0
backend/modules/device_type/validator.py
Normal file
0
backend/modules/device_type/validator.py
Normal file
@@ -10,3 +10,5 @@ python-dotenv==1.2.2
|
||||
SQLAlchemy==2.0.49
|
||||
typing_extensions==4.15.0
|
||||
Werkzeug==3.1.8
|
||||
marshmallow==3.21.2
|
||||
APScheduler==3.10.4
|
||||
@@ -1,13 +1,45 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<!DOCTYPE html>
|
||||
<html lang="vi">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>NDMS - Quản lý thiết bị mạng</title>
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
<body class="bg-gray-100">
|
||||
|
||||
<!-- THANH MENU -->
|
||||
<nav class="bg-white shadow-md px-6 py-4">
|
||||
<div class="flex gap-3 flex-wrap">
|
||||
<button data-page="dashboard"
|
||||
class="menu-btn bg-indigo-600 text-white px-5 py-2 rounded-lg font-medium">
|
||||
🏠 Trang chủ
|
||||
</button>
|
||||
<button data-page="device-type"
|
||||
class="menu-btn bg-gray-200 text-gray-700 px-5 py-2 rounded-lg font-medium">
|
||||
📦 Loại thiết bị
|
||||
</button>
|
||||
<button data-page="device"
|
||||
class="menu-btn bg-gray-200 text-gray-700 px-5 py-2 rounded-lg font-medium">
|
||||
🖥️ Thiết bị
|
||||
</button>
|
||||
<button data-page="alert"
|
||||
class="menu-btn bg-gray-200 text-gray-700 px-5 py-2 rounded-lg font-medium">
|
||||
🔔 Cảnh báo
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- NỘI DUNG CHÍNH -->
|
||||
<div id="app" class="p-6">
|
||||
<div class="text-center text-gray-500">Đang tải...</div>
|
||||
</div>
|
||||
|
||||
<!-- QUAN TRỌNG: Đường dẫn tới main.js phải đúng -->
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
979
frontend/package-lock.json
generated
979
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,8 @@
|
||||
"axios": "^1.16.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.14.2"
|
||||
"react-router-dom": "^7.14.2",
|
||||
"tailwindcss": "^4.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
@@ -25,6 +26,7 @@
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.5.0",
|
||||
"vite": "^8.0.9"
|
||||
"vite": "^8.0.12",
|
||||
"vite-plugin-html": "^3.2.2"
|
||||
}
|
||||
}
|
||||
|
||||
114
frontend/src/features/dashboard/dashboard.js
Normal file
114
frontend/src/features/dashboard/dashboard.js
Normal file
@@ -0,0 +1,114 @@
|
||||
// ============================================
|
||||
// FILE NÀY LÀ TRANG CHỦ (DASHBOARD)
|
||||
// Nhiệm vụ: Tạo ra giao diện và dữ liệu cho trang chủ
|
||||
// ============================================
|
||||
|
||||
// Hàm này được gọi từ main.js
|
||||
export async function showDashboard(container) {
|
||||
// 1. TẠO GIAO DIỆN HTML
|
||||
container.innerHTML = `
|
||||
<div class="space-y-6">
|
||||
<!-- Tiêu đề chào mừng -->
|
||||
<div class="bg-gradient-to-r from-indigo-600 to-purple-600 rounded-2xl p-6 text-white">
|
||||
<h1 class="text-2xl font-bold mb-2">Chào mừng đến với NDMS</h1>
|
||||
<p>Hệ thống quản lý thiết bị mạng tập trung</p>
|
||||
</div>
|
||||
|
||||
<!-- 4 Ô THỐNG KÊ -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<!-- Ô 1: Tổng thiết bị -->
|
||||
<div class="bg-white rounded-xl p-5 shadow-md">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<p class="text-gray-500 text-sm">Tổng thiết bị</p>
|
||||
<p class="text-3xl font-bold text-indigo-600" id="stat-total">0</p>
|
||||
</div>
|
||||
<i class="fas fa-server text-3xl text-indigo-400"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ô 2: Đang hoạt động -->
|
||||
<div class="bg-white rounded-xl p-5 shadow-md">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<p class="text-gray-500 text-sm">Đang hoạt động</p>
|
||||
<p class="text-3xl font-bold text-green-600" id="stat-up">0</p>
|
||||
</div>
|
||||
<i class="fas fa-check-circle text-3xl text-green-400"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ô 3: Đang tắt -->
|
||||
<div class="bg-white rounded-xl p-5 shadow-md">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<p class="text-gray-500 text-sm">Đang tắt</p>
|
||||
<p class="text-3xl font-bold text-red-600" id="stat-down">0</p>
|
||||
</div>
|
||||
<i class="fas fa-power-off text-3xl text-red-400"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ô 4: Cảnh báo -->
|
||||
<div class="bg-white rounded-xl p-5 shadow-md">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<p class="text-gray-500 text-sm">Cảnh báo</p>
|
||||
<p class="text-3xl font-bold text-yellow-600" id="stat-alert">0</p>
|
||||
</div>
|
||||
<i class="fas fa-bell text-3xl text-yellow-400"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DANH SÁCH HOẠT ĐỘNG GẦN ĐÂY -->
|
||||
<div class="bg-white rounded-xl shadow-md p-5">
|
||||
<h2 class="font-bold text-lg mb-4">
|
||||
<i class="fas fa-clock text-gray-500 mr-2"></i>Hoạt động gần đây
|
||||
</h2>
|
||||
<div id="activity-list" class="space-y-2">
|
||||
<!-- Dữ liệu sẽ được chèn vào đây -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 2. ĐỔ DỮ LIỆU VÀO CÁC Ô THỐNG KÊ
|
||||
// (Đây là dữ liệu mẫu - sau này sẽ gọi API từ backend)
|
||||
document.getElementById('stat-total').innerText = '24';
|
||||
document.getElementById('stat-up').innerText = '18';
|
||||
document.getElementById('stat-down').innerText = '6';
|
||||
document.getElementById('stat-alert').innerText = '3';
|
||||
|
||||
// 3. ĐỔ DỮ LIỆU VÀO DANH SÁCH HOẠT ĐỘNG
|
||||
const activities = [
|
||||
{ time: '10:30', action: 'Switch Tầng 1', status: 'down', message: 'Mất kết nối' },
|
||||
{ time: '09:15', action: 'Router Chính', status: 'up', message: 'Đã kết nối lại' },
|
||||
{ time: '08:00', action: 'Server DB', status: 'warning', message: 'CPU quá tải' },
|
||||
{ time: 'Hôm qua', action: 'Cập nhật hệ thống', status: 'info', message: 'Đã cập nhật cấu hình' },
|
||||
];
|
||||
|
||||
// Tạo HTML cho từng hoạt động
|
||||
let activityHtml = '';
|
||||
for (let act of activities) {
|
||||
let iconClass = '';
|
||||
if (act.status === 'down') iconClass = 'text-red-500 fa-exclamation-circle';
|
||||
else if (act.status === 'up') iconClass = 'text-green-500 fa-check-circle';
|
||||
else if (act.status === 'warning') iconClass = 'text-yellow-500 fa-exclamation-triangle';
|
||||
else iconClass = 'text-blue-500 fa-info-circle';
|
||||
|
||||
activityHtml += `
|
||||
<div class="flex items-center gap-3 p-3 border-b hover:bg-gray-50">
|
||||
<i class="fas ${iconClass} w-5"></i>
|
||||
<div class="flex-1">
|
||||
<div class="flex justify-between">
|
||||
<span class="font-medium">${act.action}</span>
|
||||
<span class="text-xs text-gray-400">${act.time}</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500">${act.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
document.getElementById('activity-list').innerHTML = activityHtml;
|
||||
}
|
||||
52
frontend/src/features/device-type/device-type-api.js
Normal file
52
frontend/src/features/device-type/device-type-api.js
Normal file
@@ -0,0 +1,52 @@
|
||||
// ============================================
|
||||
// API: KẾT NỐI VỚI BACK-END
|
||||
// ============================================
|
||||
|
||||
// Địa chỉ Back-end (chạy cổng 5000)
|
||||
const API_BASE_URL = 'http://localhost:5000/api';
|
||||
|
||||
// Hàm gọi API chung
|
||||
async function callAPI(endpoint, method = 'GET', body = null) {
|
||||
const options = {
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
};
|
||||
|
||||
if (body && (method === 'POST' || method === 'PUT')) {
|
||||
options.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, options);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || 'Có lỗi xảy ra');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('API Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Các hàm API cho Device Type
|
||||
export const deviceTypeAPI = {
|
||||
// Lấy danh sách tất cả loại thiết bị
|
||||
getAll: () => callAPI('/device-types'),
|
||||
|
||||
// Lấy chi tiết 1 loại thiết bị
|
||||
getById: (id) => callAPI(`/device-types/${id}`),
|
||||
|
||||
// Thêm mới loại thiết bị
|
||||
create: (data) => callAPI('/device-types', 'POST', data),
|
||||
|
||||
// Cập nhật loại thiết bị
|
||||
update: (id, data) => callAPI(`/device-types/${id}`, 'PUT', data),
|
||||
|
||||
// Xóa loại thiết bị
|
||||
delete: (id) => callAPI(`/device-types/${id}`, 'DELETE'),
|
||||
};
|
||||
474
frontend/src/features/device-type/device-type.js
Normal file
474
frontend/src/features/device-type/device-type.js
Normal file
@@ -0,0 +1,474 @@
|
||||
// // ============================================
|
||||
// // MODULE: QUẢN LÝ LOẠI THIẾT BỊ (DEVICE TYPE)
|
||||
// // ============================================
|
||||
// // Nhiệm vụ: Thêm, sửa, xóa, hiển thị danh sách loại thiết bị
|
||||
// // ============================================
|
||||
|
||||
// // DỮ LIỆU MẪU (lưu trong bộ nhớ tạm)
|
||||
// let deviceTypes = [
|
||||
// { id: '1', name: 'Switch', description: 'Thiết bị chuyển mạch mạng', color: '#10B981', isActive: true },
|
||||
// { id: '2', name: 'Router', description: 'Bộ định tuyến mạng', color: '#3B82F6', isActive: true },
|
||||
// { id: '3', name: 'WorkStation', description: 'Máy trạm làm việc', color: '#8B5CF6', isActive: true },
|
||||
// { id: '4', name: 'Server', description: 'Máy chủ', color: '#EF4444', isActive: true },
|
||||
// ];
|
||||
|
||||
// // HÀM HIỂN THỊ TRANG DEVICE TYPE
|
||||
// export async function showDeviceTypePage(container) {
|
||||
// // Tạo giao diện HTML
|
||||
// container.innerHTML = `
|
||||
// <div class="space-y-6">
|
||||
// <!-- Tiêu đề và nút thêm -->
|
||||
// <div class="flex justify-between items-center">
|
||||
// <div>
|
||||
// <h1 class="text-2xl font-bold text-gray-800">
|
||||
// <i class="fas fa-tags text-indigo-500 mr-2"></i>Quản lý loại thiết bị
|
||||
// </h1>
|
||||
// <p class="text-gray-500 text-sm mt-1">Danh mục các loại thiết bị mạng trong hệ thống</p>
|
||||
// </div>
|
||||
// <button id="addDeviceTypeBtn" class="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-lg shadow transition flex items-center gap-2">
|
||||
// <i class="fas fa-plus"></i>
|
||||
// <span>Thêm loại thiết bị</span>
|
||||
// </button>
|
||||
// </div>
|
||||
|
||||
// <!-- 2 ô thống kê nhanh -->
|
||||
// <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
// <div class="bg-blue-50 rounded-xl p-4 border border-blue-100">
|
||||
// <div class="flex justify-between items-center">
|
||||
// <div>
|
||||
// <p class="text-blue-600 text-sm font-medium">Tổng số loại</p>
|
||||
// <p class="text-2xl font-bold text-blue-700" id="totalCount">0</p>
|
||||
// </div>
|
||||
// <i class="fas fa-layer-group text-3xl text-blue-400"></i>
|
||||
// </div>
|
||||
// </div>
|
||||
// <div class="bg-green-50 rounded-xl p-4 border border-green-100">
|
||||
// <div class="flex justify-between items-center">
|
||||
// <div>
|
||||
// <p class="text-green-600 text-sm font-medium">Đang hoạt động</p>
|
||||
// <p class="text-2xl font-bold text-green-700" id="activeCount">0</p>
|
||||
// </div>
|
||||
// <i class="fas fa-check-circle text-3xl text-green-400"></i>
|
||||
// </div>
|
||||
// </div>
|
||||
// </div>
|
||||
|
||||
// <!-- Ô tìm kiếm -->
|
||||
// <div class="relative">
|
||||
// <i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"></i>
|
||||
// <input type="text" id="searchInput" placeholder="Tìm kiếm theo tên hoặc mô tả..." class="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500">
|
||||
// </div>
|
||||
|
||||
// <!-- Danh sách loại thiết bị -->
|
||||
// <div id="deviceTypeList" class="space-y-3">
|
||||
// <!-- Nội dung sẽ được render bằng JavaScript -->
|
||||
// <div class="text-center py-8 text-gray-400">Đang tải...</div>
|
||||
// </div>
|
||||
// </div>
|
||||
// `;
|
||||
|
||||
// // HIỂN THỊ DANH SÁCH
|
||||
// renderList();
|
||||
|
||||
// // CẬP NHẬT THỐNG KÊ
|
||||
// updateStats();
|
||||
|
||||
// // GẮN SỰ KIỆN CHO NÚT THÊM
|
||||
// document.getElementById('addDeviceTypeBtn').onclick = showAddForm;
|
||||
|
||||
// // GẮN SỰ KIỆN CHO Ô TÌM KIẾM
|
||||
// document.getElementById('searchInput').oninput = function(e) {
|
||||
// const keyword = e.target.value.toLowerCase();
|
||||
// const filtered = deviceTypes.filter(item =>
|
||||
// item.name.toLowerCase().includes(keyword) ||
|
||||
// (item.description && item.description.toLowerCase().includes(keyword))
|
||||
// );
|
||||
// renderListWithData(filtered);
|
||||
// };
|
||||
// }
|
||||
|
||||
// // HÀM HIỂN THỊ DANH SÁCH (lấy từ mảng deviceTypes)
|
||||
// function renderList() {
|
||||
// renderListWithData(deviceTypes);
|
||||
// }
|
||||
|
||||
// // HÀM HIỂN THỊ DANH SÁCH VỚI DỮ LIỆU TÙY CHỈNH
|
||||
// function renderListWithData(data) {
|
||||
// const container = document.getElementById('deviceTypeList');
|
||||
|
||||
// if (!container) return;
|
||||
|
||||
// if (data.length === 0) {
|
||||
// container.innerHTML = `
|
||||
// <div class="text-center py-12 bg-white rounded-xl border">
|
||||
// <i class="fas fa-inbox text-gray-300 text-5xl mb-3"></i>
|
||||
// <p class="text-gray-500">Chưa có loại thiết bị nào</p>
|
||||
// <button onclick="document.getElementById('addDeviceTypeBtn').click()" class="mt-3 text-indigo-600 hover:text-indigo-700 text-sm">
|
||||
// <i class="fas fa-plus mr-1"></i>Thêm loại đầu tiên
|
||||
// </button>
|
||||
// </div>
|
||||
// `;
|
||||
// return;
|
||||
// }
|
||||
|
||||
// let html = '';
|
||||
// for (let item of data) {
|
||||
// html += `
|
||||
// <div class="bg-white border border-gray-200 rounded-xl p-4 hover:shadow-md transition-shadow">
|
||||
// <div class="flex items-start justify-between">
|
||||
// <div class="flex items-center gap-4">
|
||||
// <!-- Icon màu sắc -->
|
||||
// <div class="w-12 h-12 rounded-xl flex items-center justify-center shadow-sm" style="background-color: ${item.color}20">
|
||||
// <i class="fas fa-network-wired text-xl" style="color: ${item.color}"></i>
|
||||
// </div>
|
||||
// <!-- Thông tin -->
|
||||
// <div>
|
||||
// <div class="flex items-center gap-2">
|
||||
// <h3 class="font-semibold text-gray-800 text-lg">${escapeHtml(item.name)}</h3>
|
||||
// <span class="text-xs px-2 py-0.5 rounded-full ${item.isActive ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}">
|
||||
// ${item.isActive ? '● Hoạt động' : '○ Dừng'}
|
||||
// </span>
|
||||
// </div>
|
||||
// <p class="text-sm text-gray-500 mt-0.5">${escapeHtml(item.description || 'Chưa có mô tả')}</p>
|
||||
// <div class="flex items-center gap-3 mt-2">
|
||||
// <span class="text-xs text-gray-400">ID: ${item.id}</span>
|
||||
// <span class="text-xs text-gray-400">Mã màu: ${item.color}</span>
|
||||
// </div>
|
||||
// </div>
|
||||
// </div>
|
||||
// <!-- Nút hành động -->
|
||||
// <div class="flex gap-1">
|
||||
// <button onclick="window.editDeviceType('${item.id}')" class="p-2 text-indigo-600 hover:bg-indigo-50 rounded-lg transition" title="Sửa">
|
||||
// <i class="fas fa-edit"></i>
|
||||
// </button>
|
||||
// <button onclick="window.deleteDeviceType('${item.id}')" class="p-2 text-red-600 hover:bg-red-50 rounded-lg transition" title="Xóa">
|
||||
// <i class="fas fa-trash"></i>
|
||||
// </button>
|
||||
// </div>
|
||||
// </div>
|
||||
// </div>
|
||||
// `;
|
||||
// }
|
||||
// container.innerHTML = html;
|
||||
// }
|
||||
|
||||
// // CẬP NHẬT SỐ LIỆU THỐNG KÊ
|
||||
// function updateStats() {
|
||||
// const totalEl = document.getElementById('totalCount');
|
||||
// const activeEl = document.getElementById('activeCount');
|
||||
|
||||
// if (totalEl) totalEl.innerText = deviceTypes.length;
|
||||
// if (activeEl) activeEl.innerText = deviceTypes.filter(item => item.isActive).length;
|
||||
// }
|
||||
|
||||
// // HIỂN THỊ FORM THÊM MỚI
|
||||
// function showAddForm() {
|
||||
// // Tạo modal đơn giản bằng prompt (dễ hiểu cho người mới)
|
||||
// const name = prompt('📝 Nhập tên loại thiết bị:', '');
|
||||
// if (!name) return;
|
||||
|
||||
// const description = prompt('📄 Nhập mô tả (không bắt buộc):', '');
|
||||
// const color = prompt('🎨 Nhập mã màu (ví dụ: #3B82F6, #10B981, #EF4444):', '#3B82F6');
|
||||
|
||||
// // Tạo mới
|
||||
// const newType = {
|
||||
// id: Date.now().toString(),
|
||||
// name: name,
|
||||
// description: description || '',
|
||||
// color: color || '#3B82F6',
|
||||
// isActive: true
|
||||
// };
|
||||
|
||||
// deviceTypes.push(newType);
|
||||
|
||||
// // Cập nhật lại giao diện
|
||||
// renderList();
|
||||
// updateStats();
|
||||
|
||||
// alert(`✅ Đã thêm loại thiết bị "${name}" thành công!`);
|
||||
// }
|
||||
|
||||
// // HIỂN THỊ FORM SỬA
|
||||
// window.editDeviceType = function(id) {
|
||||
// const item = deviceTypes.find(t => t.id === id);
|
||||
// if (!item) return;
|
||||
|
||||
// const newName = prompt('✏️ Sửa tên loại thiết bị:', item.name);
|
||||
// if (!newName) return;
|
||||
|
||||
// const newDesc = prompt('📄 Sửa mô tả:', item.description || '');
|
||||
// const newColor = prompt('🎨 Sửa mã màu:', item.color);
|
||||
// const isActive = confirm(`✅ Loại thiết bị này có đang hoạt động không?\nOK = Có, Cancel = Không`);
|
||||
|
||||
// // Cập nhật
|
||||
// item.name = newName;
|
||||
// item.description = newDesc || '';
|
||||
// item.color = newColor || '#3B82F6';
|
||||
// item.isActive = isActive;
|
||||
|
||||
// // Cập nhật lại giao diện
|
||||
// renderList();
|
||||
// updateStats();
|
||||
|
||||
// alert(`✅ Đã cập nhật "${newName}" thành công!`);
|
||||
// };
|
||||
|
||||
// // XÓA LOẠI THIẾT BỊ
|
||||
// window.deleteDeviceType = function(id) {
|
||||
// const item = deviceTypes.find(t => t.id === id);
|
||||
// if (!item) return;
|
||||
|
||||
// if (confirm(`⚠️ Bạn có chắc muốn xóa "${item.name}"?\nHành động này không thể hoàn tác!`)) {
|
||||
// deviceTypes = deviceTypes.filter(t => t.id !== id);
|
||||
|
||||
// // Cập nhật lại giao diện
|
||||
// renderList();
|
||||
// updateStats();
|
||||
|
||||
// alert(`🗑️ Đã xóa "${item.name}" thành công!`);
|
||||
// }
|
||||
// };
|
||||
|
||||
// // Hàm bảo vệ chống mã độc XSS
|
||||
// function escapeHtml(str) {
|
||||
// if (!str) return '';
|
||||
// return str
|
||||
// .replace(/&/g, '&')
|
||||
// .replace(/</g, '<')
|
||||
// .replace(/>/g, '>')
|
||||
// .replace(/"/g, '"')
|
||||
// .replace(/'/g, ''');
|
||||
// }
|
||||
|
||||
// // Export để dùng ở nơi khác (nếu cần)
|
||||
// export { deviceTypes, renderList, updateStats };
|
||||
|
||||
|
||||
// ============================================
|
||||
// UI: GIAO DIỆN QUẢN LÝ LOẠI THIẾT BỊ
|
||||
// ============================================
|
||||
|
||||
import { deviceTypeAPI } from './device-type-api.js';
|
||||
|
||||
let deviceTypes = [];
|
||||
|
||||
// HÀM HIỂN THỊ TRANG DEVICE TYPE
|
||||
export async function showDeviceTypePage(container) {
|
||||
// Tạo giao diện HTML
|
||||
container.innerHTML = `
|
||||
<div class="space-y-6">
|
||||
<!-- Tiêu đề và nút thêm -->
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800">
|
||||
<i class="fas fa-tags text-indigo-500 mr-2"></i>Quản lý loại thiết bị
|
||||
</h1>
|
||||
<p class="text-gray-500 text-sm mt-1">Danh mục các loại thiết bị mạng</p>
|
||||
</div>
|
||||
<button id="addDeviceTypeBtn" class="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-lg shadow transition flex items-center gap-2">
|
||||
<i class="fas fa-plus"></i>
|
||||
<span>Thêm loại thiết bị</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 2 ô thống kê -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="bg-blue-50 rounded-xl p-4">
|
||||
<p class="text-blue-600 text-sm">Tổng số loại</p>
|
||||
<p class="text-2xl font-bold text-blue-700" id="totalCount">0</p>
|
||||
</div>
|
||||
<div class="bg-green-50 rounded-xl p-4">
|
||||
<p class="text-green-600 text-sm">Đang hoạt động</p>
|
||||
<p class="text-2xl font-bold text-green-700" id="activeCount">0</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ô tìm kiếm -->
|
||||
<div class="relative">
|
||||
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"></i>
|
||||
<input type="text" id="searchInput" placeholder="Tìm kiếm..." class="w-full pl-10 pr-4 py-2 border rounded-lg">
|
||||
</div>
|
||||
|
||||
<!-- Danh sách -->
|
||||
<div id="deviceTypeList" class="space-y-3">
|
||||
<div class="text-center py-8 text-gray-400">Đang tải dữ liệu...</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Load dữ liệu từ Back-end
|
||||
await loadDataFromAPI();
|
||||
|
||||
// Gắn sự kiện
|
||||
document.getElementById('addDeviceTypeBtn').onclick = showAddForm;
|
||||
document.getElementById('searchInput').oninput = handleSearch;
|
||||
}
|
||||
|
||||
// LOAD DỮ LIỆU TỪ BACK-END
|
||||
async function loadDataFromAPI() {
|
||||
try {
|
||||
// Gọi API lấy danh sách
|
||||
const result = await deviceTypeAPI.getAll();
|
||||
deviceTypes = Array.isArray(result) ? result : result.data || [];
|
||||
|
||||
// Hiển thị lên giao diện
|
||||
renderList(deviceTypes);
|
||||
updateStats();
|
||||
|
||||
console.log('Đã tải', deviceTypes.length, 'loại thiết bị');
|
||||
} catch (error) {
|
||||
console.error('Lỗi tải dữ liệu:', error);
|
||||
document.getElementById('deviceTypeList').innerHTML = `
|
||||
<div class="text-center py-8 text-red-500">
|
||||
<i class="fas fa-exclamation-triangle text-3xl mb-2"></i>
|
||||
<p>Không thể kết nối đến Back-end!</p>
|
||||
<p class="text-sm mt-2">Vui lòng đảm bảo Back-end đang chạy tại http://localhost:5000</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// HIỂN THỊ DANH SÁCH
|
||||
function renderList(data) {
|
||||
const container = document.getElementById('deviceTypeList');
|
||||
|
||||
if (!container) return;
|
||||
|
||||
if (data.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="text-center py-12 bg-white rounded-xl border">
|
||||
<i class="fas fa-inbox text-gray-300 text-5xl mb-3"></i>
|
||||
<p class="text-gray-500">Chưa có loại thiết bị nào</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
for (let item of data) {
|
||||
html += `
|
||||
<div class="bg-white border rounded-xl p-4 hover:shadow-md">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 rounded-xl flex items-center justify-center" style="background-color: ${item.color}20">
|
||||
<i class="fas fa-network-wired text-xl" style="color: ${item.color}"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-800">${escapeHtml(item.name)}</h3>
|
||||
<p class="text-sm text-gray-500">${escapeHtml(item.description || 'Chưa có mô tả')}</p>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full ${item.isActive ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}">
|
||||
${item.isActive ? '● Hoạt động' : '○ Dừng'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<button onclick="window.editDeviceType('${item.id}')" class="p-2 text-indigo-600 hover:bg-indigo-50 rounded-lg">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
<button onclick="window.deleteDeviceType('${item.id}')" class="p-2 text-red-600 hover:bg-red-50 rounded-lg">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// CẬP NHẬT THỐNG KÊ
|
||||
function updateStats() {
|
||||
const totalEl = document.getElementById('totalCount');
|
||||
const activeEl = document.getElementById('activeCount');
|
||||
|
||||
if (totalEl) totalEl.innerText = deviceTypes.length;
|
||||
if (activeEl) activeEl.innerText = deviceTypes.filter(item => item.isActive).length;
|
||||
}
|
||||
|
||||
// TÌM KIẾM
|
||||
function handleSearch(e) {
|
||||
const keyword = e.target.value.toLowerCase();
|
||||
const filtered = deviceTypes.filter(item =>
|
||||
item.name.toLowerCase().includes(keyword) ||
|
||||
(item.description && item.description.toLowerCase().includes(keyword))
|
||||
);
|
||||
renderList(filtered);
|
||||
}
|
||||
|
||||
// THÊM MỚI (GỌI API)
|
||||
async function showAddForm() {
|
||||
const name = prompt('📝 Nhập tên loại thiết bị:');
|
||||
if (!name) return;
|
||||
|
||||
const description = prompt('📄 Nhập mô tả:');
|
||||
const color = prompt('🎨 Nhập mã màu (vd: #3B82F6):', '#3B82F6');
|
||||
|
||||
try {
|
||||
const newType = await deviceTypeAPI.create({
|
||||
name: name,
|
||||
description: description || '',
|
||||
color: color || '#3B82F6',
|
||||
isActive: true
|
||||
});
|
||||
|
||||
// Tải lại dữ liệu
|
||||
await loadDataFromAPI();
|
||||
alert(`✅ Đã thêm "${name}" thành công!`);
|
||||
} catch (error) {
|
||||
alert('❌ Lỗi: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// SỬA (GỌI API)
|
||||
window.editDeviceType = async function(id) {
|
||||
const item = deviceTypes.find(t => t.id === id);
|
||||
if (!item) return;
|
||||
|
||||
const newName = prompt('✏️ Sửa tên:', item.name);
|
||||
if (!newName) return;
|
||||
|
||||
const newDesc = prompt('📄 Sửa mô tả:', item.description || '');
|
||||
const newColor = prompt('🎨 Sửa màu:', item.color);
|
||||
const isActive = confirm('Có đang hoạt động không?');
|
||||
|
||||
try {
|
||||
await deviceTypeAPI.update(id, {
|
||||
...item,
|
||||
name: newName,
|
||||
description: newDesc || '',
|
||||
color: newColor || '#3B82F6',
|
||||
isActive: isActive
|
||||
});
|
||||
|
||||
await loadDataFromAPI();
|
||||
alert('✅ Cập nhật thành công!');
|
||||
} catch (error) {
|
||||
alert('❌ Lỗi: ' + error.message);
|
||||
}
|
||||
};
|
||||
|
||||
// XÓA (GỌI API)
|
||||
window.deleteDeviceType = async function(id) {
|
||||
const item = deviceTypes.find(t => t.id === id);
|
||||
if (!item) return;
|
||||
|
||||
if (confirm(`⚠️ Xóa "${item.name}"?`)) {
|
||||
try {
|
||||
await deviceTypeAPI.delete(id);
|
||||
await loadDataFromAPI();
|
||||
alert('🗑️ Xóa thành công!');
|
||||
} catch (error) {
|
||||
alert('❌ Lỗi: ' + error.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/[&<>]/g, function(m) {
|
||||
if (m === '&') return '&';
|
||||
if (m === '<') return '<';
|
||||
if (m === '>') return '>';
|
||||
return m;
|
||||
});
|
||||
}
|
||||
57
frontend/src/main.js
Normal file
57
frontend/src/main.js
Normal file
@@ -0,0 +1,57 @@
|
||||
// ============================================
|
||||
// FILE NÀY CÓ NHIỆM VỤ: ĐIỀU KHIỂN CHUYỂN TRANG
|
||||
// ============================================
|
||||
|
||||
// Import các module (tính năng) của ứng dụng
|
||||
import { showDashboard } from './features/dashboard/dashboard.js';
|
||||
import { showDeviceTypePage } from './features/device-type/device-type.js';
|
||||
|
||||
// Hàm chuyển đổi giữa các trang
|
||||
async function switchToPage(pageName) {
|
||||
// Lấy thẻ div chứa nội dung
|
||||
const appDiv = document.getElementById('app');
|
||||
|
||||
// Debug: In ra console để kiểm tra xem hàm có được gọi đúng không
|
||||
if (!appDiv) {
|
||||
console.error('Không tìm thấy element #app');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Chuyển đến trang:', pageName); // Debug
|
||||
|
||||
|
||||
// Kiểm tra xem người dùng bấm vào trang nào
|
||||
if (pageName === 'dashboard') {
|
||||
// Gọi hàm showDashboard từ file dashboard.js
|
||||
await showDashboard(appDiv);
|
||||
}
|
||||
else if (pageName === 'device-type') {
|
||||
await showDeviceTypePage(appDiv);
|
||||
}
|
||||
else if (pageName === 'device') {
|
||||
appDiv.innerHTML = '<div class="bg-white p-6 rounded-lg shadow">🖥️ Trang Thiết bị - Đang phát triển</div>';
|
||||
}
|
||||
else if (pageName === 'alert') {
|
||||
appDiv.innerHTML = '<div class="bg-white p-6 rounded-lg shadow">🔔 Trang Cảnh báo - Đang phát triển</div>';
|
||||
}
|
||||
|
||||
// Đổi màu nút menu đang được chọn
|
||||
document.querySelectorAll('.menu-btn').forEach(btn => {
|
||||
if (btn.dataset.page === pageName) {
|
||||
btn.className = 'menu-btn bg-indigo-600 text-white px-5 py-2 rounded-lg font-medium';
|
||||
} else {
|
||||
btn.className = 'menu-btn bg-gray-200 text-gray-700 px-5 py-2 rounded-lg font-medium';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// BẮT SỰ KIỆN KHI BẤM VÀO CÁC NÚT MENU
|
||||
document.querySelectorAll('.menu-btn').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
const pageName = button.dataset.page; // lấy tên trang từ thuộc tính data-page
|
||||
switchToPage(pageName);
|
||||
});
|
||||
});
|
||||
|
||||
// ⭐ QUAN TRỌNG: Khi load trang, mặc định hiển thị DASHBOARD (trang chủ)
|
||||
switchToPage('dashboard');
|
||||
@@ -1,7 +1,22 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from 'vite';
|
||||
import { createHtmlPlugin } from 'vite-plugin-html';
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
plugins: [
|
||||
createHtmlPlugin({
|
||||
minify: true,
|
||||
entry: '/src/main.js',
|
||||
template: 'index.html',
|
||||
}),
|
||||
],
|
||||
server: {
|
||||
port: 5173,
|
||||
open: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:5000', // Backend Flask của bạn
|
||||
changeOrigin: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
579
spec_output.txt
Normal file
579
spec_output.txt
Normal file
@@ -0,0 +1,579 @@
|
||||
|
||||
|
||||
PHẦN MỀM QUẢN LÝ THIẾT BỊ MẠNG
|
||||
Network Device Management System
|
||||
|
||||
ĐẶC TẢ PHẦN MỀM
|
||||
Software Specification Document
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Công Nghệ
|
||||
|
||||
|
||||
• Tổng Quan Hệ Thống
|
||||
Phần mềm Quản Lý Thiết Bị Mạng (Network Device Management System – NDMS) là hệ thống fullstack phụ việc kiểm tra, giám sát, quản lý tập trung các thiết bị mạng trong tổ chức. Hệ thống tích hợp bản đồ số để trực quan hóa vị trí và trạng thái thiết bị theo thời gian thực.
|
||||
• Tính năng cốt lõi:
|
||||
• Hiển thị thông tin và trạng thái thiết bị trự quan trên bản đồ
|
||||
• Tìm kiếm, lọc thiết bị theo thông tin không gian (Vùng Bản Đồ) và thuộc tính (loại, trạng thái, IP)
|
||||
• Giám sát trạng thái thiết bị thời gian thực qua Ping và SNMP (Hai phương thức)
|
||||
• Lưu trữ và hiển thị lịch sử trạng thái
|
||||
• Cảnh báo tự động khi thiết bị down qua giao diện web hoặc email
|
||||
• Công Nghệ và Kiến Trúc Tổng Thể
|
||||
|
||||
Postgresql
|
||||
Database
|
||||
Flask Python
|
||||
Backend APIs
|
||||
Reactjs
|
||||
Frontend Web
|
||||
|
||||
|
||||
|
||||
Kiến Trúc Tổng Thể:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
• Quản Lý Danh mục loại thiết bị (DeviceType)
|
||||
• Mô tả chức năng
|
||||
Module Device Type cho phép người dùng quản lý danh mục các loại thiết bị mạng được hỗ trợ trong hệ thống. Mỗi loại thiết bị có tên, mô tả, icon đại diện và màu sắc hiển thị trên bản đồ. Dữ liệu danh mục này là nền tảng cho module Device – Mỗi thiết bị muốn quản lý phải nằm trong danh mục loại thiết bị này.
|
||||
• Loại thiết bị hỗ trợ: Máy Trạm (WorkStation), Switch, Router, Máy Tính, Điện thoại, ….
|
||||
• Mỗi loại có icon và màu sắc riêng – Dùng để hiển thị trên bản đồ
|
||||
• Không được xóa loại thiết đang được gán cho thiết bị nào đó (FK)
|
||||
• Mô tả quy trình
|
||||
Luồng Quản lý danh mục thiết bị: + Truy cập trang quản lý -> Danh mục loại thiết bị
|
||||
+ Thêm loại thiết bị mới: Nhập Tên, Mô Tả, Chọn icon (Upload SVG/ PNG), chọn màu sắc mặc định
|
||||
+ Hệ thống validate: Tên không trùng lặp (UNIQUE), icon đúng định dạng
|
||||
|
||||
• Giải Pháp & Công Nghệ
|
||||
+ Lưu icon dưới dạng URL (Upload lên S3-compatible storage hoặc server static file)+ Api danh sách trả về không phân trang -> Hiển thị dưới dạng dropdown để người dùng có thể chọn
|
||||
|
||||
• CSDL
|
||||
DeviceType
|
||||
Name
|
||||
Type
|
||||
Nullable
|
||||
ID
|
||||
UUID
|
||||
False
|
||||
Name
|
||||
VARCHAR(100)
|
||||
False
|
||||
Description
|
||||
TEXT
|
||||
True
|
||||
IconUrl
|
||||
VARCHAR(512)
|
||||
True
|
||||
Color
|
||||
VARCHAR(7)
|
||||
False
|
||||
SortOrder
|
||||
SMALLINT
|
||||
False
|
||||
IsActive
|
||||
BOOLEAN
|
||||
False
|
||||
Created
|
||||
TIMESTAMPTZ
|
||||
False
|
||||
Modified
|
||||
TIMESTAMPTZ
|
||||
False
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
• API
|
||||
API
|
||||
METHOD
|
||||
ROUTES
|
||||
Danh Sách
|
||||
GET
|
||||
/device-types
|
||||
/device-types/{DeviceTypeId}
|
||||
Thêm Mới
|
||||
POST
|
||||
/device-types
|
||||
Cập Nhật
|
||||
PUT
|
||||
/device-types/{deviceTypeId}
|
||||
Xóa
|
||||
DELETE
|
||||
/device-types/{deviceTypeId}
|
||||
|
||||
*Không cho phép xóa khi còn thiết bị thuộc loại này
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
• Quản Lý Thiết Bị (Device) -> một device type có nhiều device khác nhau
|
||||
• Mô Tả Chức Năng
|
||||
Module Device là phần trung tâm của hệ thống – quản lý toàn bộ các thông tin các thiết bị mạng cần giám sát. Mỗi thiết bị có đầy đủ thông tin nhận dạng, vị trí địa lý (tọa độ cần hiển thị trên bản đồ, thông tin kết nối mạng(IP/Port), và giao thức giám sát được áp dụng.
|
||||
• Mỗi thiết bị bắt buộc thuộc một DeviceType đã được định nghĩa
|
||||
• Tọa độ Iat/Ing nhập bằng cách click trực tiếp trên bản đồ Leaflet min trong form thêm thiết bị
|
||||
• Sau khi tạo, APScheduler tự động thêm job Ping/SNMP cho thiết bị theo MonitorConfig
|
||||
• Trạng thái hiện tại (CurrentStatus) được tính từ bản ghi mới nhất trong bảng DeviceStatus
|
||||
• Hỗ trợ upload hình ảnh đại diện riêng cho từng thiết bị
|
||||
|
||||
|
||||
• Mô Tả Quy Trình
|
||||
Luồng thêm mới thiết bị:
|
||||
+ Người dùng truy cập danh sách -> Thêm Mới thiết bị
|
||||
+ Điền thông tin thiết bị: Device Type, Mô Tả, Ip Address, Màu Sắc, Avatar.
|
||||
+ Validate: IP hợp lệ (regex IPv4/IPv6), tên không trùng, Iat, Ing
|
||||
+ Lưu vào bảng devices
|
||||
+ APSscheduler.add_job() tự động được gọi – Thêm job Ping hoặc SNMP theo MonitorConfig mặc định
|
||||
+ Thiết bị xuất hiện ngay trên Leafet map với trạng thái Unknow
|
||||
+ Ping worker chạy lần đầu theo interval cấu hình -> Cập nhật trạng thái Up hoặc Down
|
||||
|
||||
|
||||
• Giải Pháp & Công Nghệ
|
||||
|
||||
• Tọa độ lưu FLOAT8
|
||||
• Khi thiết bị di rời vật lí: Bấm vào sửa Iat/Ing trên form sửa -> Marker tự dịch chuyển trên map
|
||||
• Avatar upload : Upload qua S3 cloud để lưu trữ
|
||||
|
||||
|
||||
• CSDL
|
||||
Device Type
|
||||
Name
|
||||
Type
|
||||
Nullable
|
||||
ID
|
||||
UUID
|
||||
False
|
||||
DeviceTypeId
|
||||
UUID
|
||||
False
|
||||
Name
|
||||
VARCHAR(200)
|
||||
False
|
||||
Description
|
||||
TEXT
|
||||
True
|
||||
IpAddress
|
||||
VARCHAR(45)
|
||||
False
|
||||
Port
|
||||
INTERGER
|
||||
True
|
||||
Latitude
|
||||
FLOAT8
|
||||
False
|
||||
Longitude
|
||||
FLOAT8
|
||||
False
|
||||
Color
|
||||
VARCHAR(7)
|
||||
False
|
||||
AvatarUrl
|
||||
VARCHAR(512)
|
||||
True
|
||||
IsActivc
|
||||
BOOLEAN
|
||||
True
|
||||
Created
|
||||
TIMESTAMPTZ
|
||||
False
|
||||
Modified
|
||||
TIMESTAMPTZ
|
||||
False
|
||||
|
||||
• API
|
||||
Device
|
||||
API
|
||||
Method
|
||||
Routes
|
||||
Danh sách
|
||||
GET
|
||||
/devices
|
||||
/devices/{DeviceId}
|
||||
Cập Nhật
|
||||
PUT
|
||||
/devices/{DeviceId}
|
||||
Xóa
|
||||
DELETE
|
||||
/devices/{DeviceId}
|
||||
Upload Avatar
|
||||
POST
|
||||
/devices/{DeviceId}/avatar
|
||||
|
||||
|
||||
• Cấu Hình Giám Sát (MonitorConfig)
|
||||
• Mô tả chức năng
|
||||
Module MonitorConfig quản lý cấu hình phương thức và tần suất kiểm tra trạng thái cho từng thiết bị. Mỗi thiết bị có một bản cấu hình riêng — xác định dùng Ping (ICMP qua icmplib) hay SNMP (qua pysnmp), tần suất kiểm tra (interval), timeout, và các tham số giao thức cụ thể. Toàn bộ xử lý diễn ra trong Python, không phụ thuộc service bên ngoài.
|
||||
|
||||
• Ping (ICMP): dùng icmplib — async_ping() — kiểm tra kết nối cơ bản, đo RTT và packet loss
|
||||
• SNMP: dùng pysnmp — get_cmd() — lấy thông tin chi tiết qua OID (sysName, sysUpTime, ifNumber,...)
|
||||
• Hai phương thức có thể bật đồng thời — APScheduler tạo 2 job độc lập cho cùng 1 thiết bị
|
||||
• Interval cấu hình per-device — thiết bị quan trọng: 30s/lần, thiết bị ít quan trọng: 5 phút/lần
|
||||
• Thay đổi config → APScheduler.reschedule_job() cập nhật ngay, không cần restart service
|
||||
|
||||
APSscheduler ở đây chính là thư viện Python chạy trong bộ nhớ (RAM) của backend. Dữ liệu để APScheduler biết "cần làm gì, bao lâu 1 lần" → lấy từ bảng MonitorConfig khi app khởi động.
|
||||
|
||||
• Mô tả quy trình
|
||||
Luồng cấu hình monitoring cho thiết bị:
|
||||
+ Vào trang chi tiết thiết bị -> Cấu hình giám sát
|
||||
+ Chọn phương thức: Ping, SNMP hoặc cả hai
|
||||
+ Cấu hình ping: Số packet(Count), timeout(giây), interval kiểm tra(giây)
|
||||
+ Cấu hình SNMP: Community String, SNMP Version(v2c/v3), PORT, Danh sách OID tùy chỉnh
|
||||
+ Lưu config vào DB -> Gọi APSscheduler.add_job() hoặc reschedule_job() tức thì
|
||||
+ Worker chạy theo interval mới từ chu kỳ tiếp theo
|
||||
|
||||
• Giải pháp và công nghệ
|
||||
• Ping Worker – icmplib: async_ping(address, count, timeout, privileged=False)
|
||||
•
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
• CSDL
|
||||
MonitorConfig
|
||||
Name
|
||||
Type
|
||||
Nullable
|
||||
ID
|
||||
UUID
|
||||
False
|
||||
DeviceId
|
||||
UUID
|
||||
False
|
||||
EnablePing
|
||||
BOOLEAN
|
||||
False
|
||||
PingCount
|
||||
SMALLINT
|
||||
False
|
||||
PingTimeout
|
||||
SMALLINT
|
||||
False
|
||||
PingInterval
|
||||
INTEGER
|
||||
False
|
||||
EnableSnmp
|
||||
BOOLEAN
|
||||
False
|
||||
SnmpVersion
|
||||
VARCHAR(5)
|
||||
True
|
||||
SnmpCommunity
|
||||
VARCHAR(256)
|
||||
True
|
||||
SnmpPort
|
||||
INTEGER
|
||||
True
|
||||
SnmpInterval
|
||||
INTEGER
|
||||
True
|
||||
SnmpTimeout
|
||||
SMALLINT
|
||||
True
|
||||
SnmpCustomOids
|
||||
JSONB
|
||||
True
|
||||
Created
|
||||
TIMESTAMPTZ
|
||||
False
|
||||
Modified
|
||||
TIMESTAMPTZ
|
||||
False
|
||||
|
||||
|
||||
• API
|
||||
API
|
||||
METHOD
|
||||
ROUTES
|
||||
Lấy config của thiết bị
|
||||
GET
|
||||
devices/{DeviceId}/monitor-config
|
||||
Tạo hoặc cập nhật config
|
||||
PUT
|
||||
devices/{DevicesId}/monitor-config
|
||||
TEST kết nối
|
||||
POST
|
||||
devices/{DevicesId}/monitor-config/test
|
||||
|
||||
• Lịch sử trạng thái thiết bị(DeviceStatus)
|
||||
• Mô tả chức năng
|
||||
Module DeviceStatus lưu trữ kết quả của từng lần Ping/SNMP Worker chạy. Đây là dữ liệu time-series — volume tăng nhanh, cần tối ưu lưu trữ và query. Dữ liệu phục vụ: hiển thị trạng thái realtime trên Leaflet map (bản ghi mới nhất), vẽ biểu đồ response time (Recharts), tính SLA uptime %, và là cơ sở để phát hiện ngưỡng cảnh báo.
|
||||
|
||||
• Mỗi lần Ping Worker chạy → 1 bản ghi mới với status, RTT, packet loss, timestamp
|
||||
• Mỗi lần SNMP Worker chạy → 1 bản ghi mới với status, dữ liệu OID dạng JSONB
|
||||
• WebSocket broadcast ngay sau khi lưu — frontend nhận status mới trong vòng < 1 giây
|
||||
• Chiến lược change-only: chỉ lưu khi status thay đổi so với lần trước — giảm volume đáng kể
|
||||
|
||||
|
||||
|
||||
|
||||
• Mô tả quy trình
|
||||
Luồng ghi lịch sử trạng thái – Chạy tự động bởi Worker:
|
||||
• APSscheduler kích hoạt job theo interval đã cấu hình trong MonitorConfig
|
||||
• Ping Worker: gọi async_ping(ip, count, timeout, privileged=False) → nhận kết quả
|
||||
• Đánh giá trạng thái: is_alive=True -> up, is_alive=False -> down
|
||||
• SNMP Worker: gọi get_cmd() cho từng OID trong SnmpCustomOids -> Nhận giá trị
|
||||
• Đánh giá: Lấy được sysName -> up; timeout/error -> down
|
||||
• Lưu bản ghi mới vào bảng device_status(Timescale DB) kèm CheckedAt = now(UTC)
|
||||
• WebSocket gọi ws_manager.broadcast ({type:'device_status', device_id, status, rtt_ms})
|
||||
• Kiểm tra AlertConfig: đếm fail liên tiếp -> Nếu vượt FailThreshold -> Gọi Alert Worker
|
||||
|
||||
• Giải pháp và công nghệ
|
||||
• TimeScaleDB hypertable trên cột CheckedAt – partition tự động theo thời gian, query time- range nhanh
|
||||
• Retention policy: tự động xóa dữ liệu cũ hơn 90 ngày ( Cấu hình qua TimescaleDb policy)
|
||||
• Composite index: (DeviceId, CheckedAt DESC) – query lịch sử theo thiết bị trang range
|
||||
• API time-bucket: group dữ liệu theo 5m/1h/1d tùy range request – dùng TimescaleDB time_bucket()
|
||||
• Change detection: so sánh với bản ghi gần nhất, chỉ lưu khi status thay đổi hoặc RTT thay đổi >20%
|
||||
• CSDL
|
||||
Name
|
||||
Type
|
||||
Nullable
|
||||
ID
|
||||
BIGSERIAL
|
||||
False
|
||||
DeviceId
|
||||
UUID
|
||||
False
|
||||
Status
|
||||
VARCHAR(20)
|
||||
False
|
||||
Method
|
||||
VARCHAR(10)
|
||||
False
|
||||
ResponseTimeMs
|
||||
FLOAT8
|
||||
True
|
||||
PacketLoss
|
||||
FLOAT8
|
||||
True
|
||||
SnmpData
|
||||
JSONB
|
||||
True
|
||||
Detail
|
||||
TEXT
|
||||
True
|
||||
CheckedAt
|
||||
TIMESTAMPTZ
|
||||
False
|
||||
|
||||
|
||||
• API
|
||||
API
|
||||
Method
|
||||
Routes
|
||||
Lịch sử theo thiết bị
|
||||
-Filter:
|
||||
+ DateFrom, DateTo(datetime)
|
||||
+ Status(up/down/degraded)
|
||||
+ Method(ping,snmp)
|
||||
+ Bucket(5m/1h/1d)
|
||||
-Paging: Page + Size
|
||||
GET
|
||||
- devices/{DeviceId}/status/history
|
||||
- devices/{DeviceId}/status/latest
|
||||
-> Trả về bản ghi status mới nhất
|
||||
Trạng thái tất cả các thiết bị
|
||||
GET
|
||||
-devices/status/current
|
||||
Trả về map: {deviceId: {status, checkedAt, responseMs}}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
• Cảnh báo (Alert)
|
||||
• Mô tả chức năng
|
||||
Module Alert gồm hai phần: AlertConfig (cấu hình ngưỡng và kênh cảnh báo device và AlertLog( Lịch sử các sự cố phát sinh). Hệ Thống tự động phát cảnh báo khi Ping/SNMP Worker phát hiện thiết bị vi phạm ngưỡng – thông báo qua WebSocket toast trên giao diện web và email HTML qua STMP ( thư viện của Python smtplib + Jinja2 )
|
||||
|
||||
• Mô tả quy trình
|
||||
Luồng phát hiện và gửi cảnh báo – được gọi bởi Ping/SNMP Worker:
|
||||
• Worker phát hiện thiết bị trả về status = down → gọi check_and_alert(device_id)
|
||||
• Đếm số lần fail liên tiếp: query bản ghi gần nhất trong DeviceStatus cho đến khi gặp status != down
|
||||
• So sánh với AlertConfig.FailThreshold — nếu fail_count < threshold → không làm gì
|
||||
• Kiểm tra cooldown: lấy AlertLog gần nhất, tính elapsed = now - triggered_at (phút)
|
||||
• Nếu elapsed < CooldownMinutes → bỏ qua, không gửi lại (tránh spam)
|
||||
• Tạo bản ghi AlertLog mới (status: active)
|
||||
• Nếu NotifyWeb=True: gọi ws_manager.broadcast() — tất cả client nhận toast đỏ
|
||||
• Nếu NotifyEmail=True và EmailRecipients không rỗng: gọi send_alert_email() qua smtplib
|
||||
• Khi thiết bị up lại: Worker tự động cập nhật AlertLog.ResolvedAt, AlertLog.Status = resolved
|
||||
|
||||
• Giải pháp và công nghệ
|
||||
• Email: smtplib chuẩn Python — hỗ trợ SMTP_SSL, cấu hình host/port/user/pass qua .env
|
||||
• HTML Email template: Jinja2 (pip install jinja2) — render template với context {device, alert}
|
||||
• Web alert payload: {type:'alert', device_id, device_name, ip, status, message, triggered_at}
|
||||
|
||||
• CSDL
|
||||
AlertConfig
|
||||
Name
|
||||
Type
|
||||
Nullable
|
||||
ID
|
||||
UUID
|
||||
False
|
||||
DeviceId
|
||||
UUID
|
||||
False
|
||||
IsEnabled
|
||||
BOOLEAN
|
||||
False
|
||||
FailThreshold
|
||||
SMALLINT
|
||||
False
|
||||
CooldownMinutes
|
||||
INTEGER
|
||||
False
|
||||
NotifyWeb
|
||||
BOOLEAN
|
||||
False
|
||||
NotifyEmail
|
||||
BOOLEAN
|
||||
False
|
||||
EmailRecipients
|
||||
TEXT[]
|
||||
True
|
||||
Created
|
||||
TIMESTAMPTZ
|
||||
False
|
||||
Modified
|
||||
TIMESTAMPTZ
|
||||
False
|
||||
|
||||
AlertLog
|
||||
Name
|
||||
Type
|
||||
Nullable
|
||||
ID
|
||||
BIGSERIAL
|
||||
False
|
||||
DeviceId
|
||||
UUID
|
||||
False
|
||||
AlertType
|
||||
VARCHAR(50)
|
||||
False
|
||||
Status
|
||||
VARCHAR(20)
|
||||
False
|
||||
Message
|
||||
TEXT
|
||||
False
|
||||
TriggeredAt
|
||||
TIMESTAMPTZ
|
||||
False
|
||||
ResolvedAt
|
||||
TIMESTAMPTZ
|
||||
True
|
||||
AckBy
|
||||
UUID
|
||||
True
|
||||
AckAt
|
||||
TIMESTAMPTZ
|
||||
True
|
||||
EmailSent
|
||||
BOOLEAN
|
||||
False
|
||||
EmailSentAt
|
||||
TIMESTAMPTZ
|
||||
True
|
||||
|
||||
|
||||
• API
|
||||
AlertConfig
|
||||
|
||||
API
|
||||
Method
|
||||
Routes
|
||||
Lấy cấu hình cảnh báo
|
||||
GET
|
||||
-devices/{DeviceId}/alert-config
|
||||
Tạo hoặc cập nhật cấu hình
|
||||
PUT
|
||||
- devices/{DeviceId}/alert-config
|
||||
Test gửi email thử
|
||||
POST
|
||||
- devices/{DeviceId}/alert-config/test-email
|
||||
|
||||
|
||||
AlertLog
|
||||
|
||||
API
|
||||
Method
|
||||
Routes
|
||||
Danh sách cảnh báo
|
||||
- Paging: Page + Size
|
||||
- SortBy:
|
||||
+ TriggeredAt
|
||||
+ Status
|
||||
- FilterBy:
|
||||
+ DeviceId
|
||||
+ AlertType
|
||||
+ Status (active/resolved)
|
||||
+ DateFrom, DateTo
|
||||
+ EmailSent
|
||||
GET
|
||||
• alerts
|
||||
• devices/{DeviceId}/alerts
|
||||
Acknowledge cảnh báo
|
||||
PATCH
|
||||
• alerts/{AlertId}/acknowledge
|
||||
Đánh dấu đã giải quyết
|
||||
PATCH
|
||||
• alerts/{AlertId}/resolve
|
||||
Thống kê
|
||||
GET
|
||||
• alerts/summary
|
||||
Trả về : {totalActive, resolvedToday, topDevices}
|
||||
|
||||
Reference in New Issue
Block a user