Compare commits

...

2 Commits

Author SHA1 Message Date
QuangMinh_123
2683cdb882 Device 2026-05-27 13:50:27 +07:00
QuangMinh_123
7aebcf9567 devicetypeAPIGET1 2026-05-21 12:01:10 +07:00
53 changed files with 4858 additions and 145 deletions

9
.env
View File

@@ -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
View File

@@ -6,7 +6,7 @@ __pycache__/
.Python
venv/
.venv/
env/
.env
ENV/
# Node.js

View File

@@ -1,26 +1,49 @@
from flask import Flask, jsonify
# from flask_cors import CORS
import os
from dotenv import load_dotenv
# pyrefly: ignore [missing-import]
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"
})
from modules.uploads.upload_routes import(
upload_bp
)
@app.route('/health')
def health():
return jsonify({"status": "healthy"})
from modules.device.routes import (
device_bp
)
# Phần này bạn hay dùng
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8000, debug=True)
app = Flask(__name__)
# Register Global Exception Handlers
register_error_handlers(app)
# Register Blueprints
app.register_blueprint(
device_type_bp,
url_prefix="/api/device-types"
)
app.register_blueprint(
upload_bp,
url_prefix="/api/uploads"
)
app.register_blueprint(
device_bp,
url_prefix="/api/devices"
)
# @app.route("/")
# def home():
# return {
# "message": "NDMS Backend Running"
# }
if __name__ == "__main__":
app.run(debug=True)

View 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

View 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
)

View 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
)

View 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

View 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

View File

@@ -0,0 +1 @@

View File

View File

@@ -0,0 +1,109 @@
# pyrefly: ignore [missing-import]
from flask import request
from common.response.api_response import success_response
from common.constants.status_code import HTTP_CREATED
from modules.device.service import (
get_devices_service,
get_device_by_id_service,
create_device_service,
update_device_service,
delete_device_service
)
from modules.device.schemas import CreateDeviceSchema, UpdateDeviceSchema
def get_devices():
"""GET /api/devices — Lấy danh sách tất cả các thiết bị mạng"""
devices = get_devices_service()
return success_response(
data=devices,
message="Devices retrieved successfully"
)
def get_device_by_id(device_id):
"""GET /api/devices/<device_id> — Lấy chi tiết thiết bị theo ID"""
device = get_device_by_id_service(device_id)
return success_response(
data=device,
message="Device retrieved successfully"
)
def create_device():
"""
POST /api/devices — Thêm mới một thiết bị mạng
"""
body = request.get_json()
# Validate bằng schema
schema = CreateDeviceSchema()
data = schema.load(body)
# Gọi service xử lý logic nghiệp vụ
new_device = create_device_service(data)
return success_response(
data=new_device,
message="Device created successfully",
status_code=HTTP_CREATED
)
def update_device(device_id):
"""
PUT /api/devices/<device_id> — Cập nhật thông tin thiết bị mạng
"""
body = request.get_json()
# Validate bằng schema
schema = UpdateDeviceSchema()
data = schema.load(body)
# Gọi service để kiểm tra và cập nhật DB
updated_device = update_device_service(device_id, data)
return success_response(
data=updated_device,
message="Device updated successfully"
)
def delete_device(device_id):
"""
DELETE /api/devices/<device_id> — Xóa thiết bị mạng khỏi hệ thống
"""
delete_device_service(device_id)
return success_response(
message="Device deleted successfully"
)
# def upload_device_avatar_controller(device_id):
# """
# POST /api/devices/<device_id>/avatar — Upload ảnh đại diện thiết bị lên S3/MinIO
# và cập nhật URL vào database.
# """
# # 1. Kiểm tra tồn tại của thiết bị
# device = get_device_by_id_service(device_id)
# # 2. Lấy file ảnh từ request.files
# file = request.files.get("file")
# if not file:
# raise BadRequestException("Missing image file in request")
# try:
# # 3. Thực hiện upload lên S3/MinIO
# avatar_url = upload_device_avatar(file, device["name"])
# # 4. Cập nhật trường avatar_url của thiết bị trong DB
# updated_device = update_device_service(device_id, {"avatar_url": avatar_url})
# return success_response(
# data=updated_device,
# message="Device avatar uploaded successfully",
# status_code=HTTP_CREATED
# )
# except ValueError as e:
# # ValidationError trong quá trình validate loại file
# raise BadRequestException(str(e))

View File

@@ -0,0 +1,25 @@
from common.exceptions.app_exception import (
NotFoundException,
ConflictException
)
class DeviceNotFoundException(NotFoundException):
def __init__(self, device_id):
super().__init__(
message=f"Device not found with id={device_id}",
payload={"device_id": device_id}
)
class DeviceAlreadyExistsException(ConflictException):
def __init__(self, name):
super().__init__(
message=f"Device already exists with name={name}",
payload={"name": name}
)
class DeviceIPAlreadyExistsException(ConflictException):
def __init__(self, ip_address):
super().__init__(
message=f"Device already exists with IP address={ip_address}",
payload={"ip_address": ip_address}
)

View File

@@ -0,0 +1,293 @@
from config.database import get_connection, release_connection
def _row_to_dict(row):
"""
Chuyển đổi một dòng kết quả từ tuple (từ câu lệnh JOIN) thành dictionary.
"""
if not row:
return None
device_dict = {
"id": str(row[0]),
"device_type_id": str(row[1]),
"name": row[2],
"description": row[3],
"ip_address": row[4],
"port": row[5],
"latitude": row[6],
"longitude": row[7],
"color": row[8],
"avatar_url": row[9],
"is_active": row[10],
"created": row[11].isoformat() if row[11] else None,
"modified": row[12].isoformat() if row[12] else None
}
# Nếu câu truy vấn có JOIN với device_type
if len(row) > 13:
device_dict["device_type_name"] = row[13]
device_dict["device_type_icon"] = row[14]
return device_dict
def find_all_devices():
"""
Lấy danh sách tất cả các thiết bị trong database, JOIN với bảng device_type
để hiển thị tên loại thiết bị và icon đại diện.
"""
conn = get_connection()
cur = None
try:
cur = conn.cursor()
cur.execute("""
SELECT
d.id, d.device_type_id, d.name, d.description, d.ip_address, d.port,
d.latitude, d.longitude, d.color, d.avatar_url, d.is_active, d.created, d.modified,
dt.name as device_type_name, dt.icon_url as device_type_icon
FROM device d
JOIN device_type dt ON d.device_type_id = dt.id
ORDER BY d.created DESC
""")
rows = cur.fetchall()
devices = []
for row in rows:
devices.append(_row_to_dict(row))
return devices
finally:
if cur:
cur.close()
release_connection(conn)
def find_device_by_id(device_id):
"""
Tìm thiết bị theo ID, JOIN với device_type để lấy thông tin chi tiết.
"""
conn = get_connection()
cur = None
try:
cur = conn.cursor()
cur.execute("""
SELECT
d.id, d.device_type_id, d.name, d.description, d.ip_address, d.port,
d.latitude, d.longitude, d.color, d.avatar_url, d.is_active, d.created, d.modified,
dt.name as device_type_name, dt.icon_url as device_type_icon
FROM device d
JOIN device_type dt ON d.device_type_id = dt.id
WHERE d.id = %s
""", (device_id,))
row = cur.fetchone()
return _row_to_dict(row) if row else None
finally:
if cur:
cur.close()
release_connection(conn)
def find_device_by_name(name):
"""
Tìm thiết bị theo tên (không phân biệt hoa thường) để phục vụ validate trùng tên.
"""
conn = get_connection()
cur = None
try:
cur = conn.cursor()
cur.execute("""
SELECT id, device_type_id, name, description, ip_address, port, latitude, longitude, color, avatar_url, is_active, created, modified
FROM device
WHERE LOWER(name) = LOWER(%s)
""", (name,))
row = cur.fetchone()
return _row_to_dict(row) if row else None
finally:
if cur:
cur.close()
release_connection(conn)
def find_device_by_ip(ip_address):
"""
Tìm thiết bị theo địa chỉ IP để phục vụ validate tránh trùng lặp IP thiết bị.
"""
conn = get_connection()
cur = None
try:
cur = conn.cursor()
cur.execute("""
SELECT id, device_type_id, name, description, ip_address, port, latitude, longitude, color, avatar_url, is_active, created, modified
FROM device
WHERE ip_address = %s
""", (ip_address,))
row = cur.fetchone()
return _row_to_dict(row) if row else None
finally:
if cur:
cur.close()
release_connection(conn)
def insert_device(data):
"""
Tạo mới một thiết bị và cấu hình mặc định (MonitorConfig & AlertConfig)
trong cùng một database transaction.
"""
conn = get_connection()
cur = None
try:
cur = conn.cursor()
# 1. Thêm thiết bị vào bảng device
cur.execute("""
INSERT INTO device (device_type_id, name, description, ip_address, port, latitude, longitude, color, avatar_url, is_active)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id, device_type_id, name, description, ip_address, port, latitude, longitude, color, avatar_url, is_active, created, modified
""", (
data["device_type_id"],
data["name"],
data.get("description"),
data["ip_address"],
data.get("port"),
data["latitude"],
data["longitude"],
data["color"],
data.get("avatar_url"),
data.get("is_active", True)
))
device_row = cur.fetchone()
device_id = device_row[0]
# 2. Thêm cấu hình giám sát mặc định (MonitorConfig) cho thiết bị vừa tạo
# enable_ping mặc định bật True để thực hiện Ping giám sát
cur.execute("""
INSERT INTO monitor_config (device_id, enable_ping, ping_count, ping_timeout, ping_interval, enable_snmp)
VALUES (%s, %s, %s, %s, %s, %s)
""", (
device_id,
True, # enable_ping
3, # ping_count
5, # ping_timeout (giây)
60, # ping_interval (giây)
False # enable_snmp
))
# 3. Thêm cấu hình cảnh báo mặc định (AlertConfig) cho thiết bị vừa tạo
cur.execute("""
INSERT INTO alert_config (device_id, is_enabled, fail_threshold, cooldown_minutes, notify_web, notify_email)
VALUES (%s, %s, %s, %s, %s, %s)
""", (
device_id,
True, # is_enabled
3, # fail_threshold (số lần fail liên tiếp trước khi alert)
30, # cooldown_minutes (thời gian tránh spam alert)
True, # notify_web (hiển thị thông báo trên web)
False # notify_email (mặc định tắt gửi email)
))
# 4. Lấy lại thiết bị kèm thông tin DeviceType đã JOIN để trả về đầy đủ dữ liệu
cur.execute("""
SELECT
d.id, d.device_type_id, d.name, d.description, d.ip_address, d.port,
d.latitude, d.longitude, d.color, d.avatar_url, d.is_active, d.created, d.modified,
dt.name as device_type_name, dt.icon_url as device_type_icon
FROM device d
JOIN device_type dt ON d.device_type_id = dt.id
WHERE d.id = %s
""", (device_id,))
full_row = cur.fetchone()
# Commit toàn bộ transaction
conn.commit()
return _row_to_dict(full_row)
except Exception:
conn.rollback()
raise
finally:
if cur:
cur.close()
release_connection(conn)
def update_device_db(device_id, data):
"""
Cập nhật thông tin chi tiết của một thiết bị.
"""
conn = get_connection()
cur = None
try:
cur = conn.cursor()
update_fields = []
params = []
fields_list = [
"device_type_id", "name", "description",
"ip_address", "port", "latitude",
"longitude", "color", "avatar_url", "is_active"
]
for key in fields_list:
if key in data:
update_fields.append(f"{key} = %s")
params.append(data[key])
if not update_fields:
return find_device_by_id(device_id)
update_fields.append("modified = CURRENT_TIMESTAMP")
sql = f"""
UPDATE device
SET {', '.join(update_fields)}
WHERE id = %s
RETURNING id, device_type_id, name, description, ip_address, port, latitude, longitude, color, avatar_url, is_active, created, modified
"""
params.append(device_id)
cur.execute(sql, tuple(params))
row = cur.fetchone()
conn.commit()
if row:
# Lấy chi tiết có kèm JOIN device_type để trả về đầy đủ
return find_device_by_id(device_id)
return None
except Exception:
conn.rollback()
raise
finally:
if cur:
cur.close()
release_connection(conn)
def delete_device_db(device_id):
"""
Xóa thiết bị khỏi database. Do có khóa ngoại với Cascade Delete,
tất cả các dòng liên quan trong monitor_config, alert_config, device_status, alert_log sẽ tự động bị xóa.
"""
conn = get_connection()
cur = None
try:
cur = conn.cursor()
cur.execute("""
DELETE FROM device
WHERE id = %s
""", (device_id,))
conn.commit()
except Exception:
conn.rollback()
raise
finally:
if cur:
cur.close()
release_connection(conn)

View File

@@ -0,0 +1,20 @@
# pyrefly: ignore [missing-import]
from flask import Blueprint
from modules.device.controller import (
get_devices,
get_device_by_id,
create_device,
update_device,
delete_device
)
# Khởi tạo Blueprint cho Module Device
device_bp = Blueprint("device", __name__)
# Đăng ký các endpoints với controller tương ứng
device_bp.route("", methods=["GET"])(get_devices)
device_bp.route("/<device_id>", methods=["GET"])(get_device_by_id)
device_bp.route("", methods=["POST"])(create_device)
device_bp.route("/<device_id>", methods=["PUT"])(update_device)
device_bp.route("/<device_id>", methods=["DELETE"])(delete_device)

View File

@@ -0,0 +1,120 @@
import ipaddress
# pyrefly: ignore [missing-import]
from marshmallow import Schema, fields, validate, ValidationError
HEX_COLOR_REGEX = r"^#[0-9A-Fa-f]{6}$"
def validate_ip_address(val):
try:
ipaddress.ip_address(val)
except ValueError:
raise ValidationError("Invalid IP address format. Must be a valid IPv4 or IPv6 address.")
class CreateDeviceSchema(Schema):
device_type_id = fields.UUID(
required=True,
error_messages={"required": "Device type ID is required."}
)
name = fields.String(
required=True,
validate=validate.Length(min=1, max=200),
error_messages={"required": "Device name is required."}
)
description = fields.String(
required=False,
allow_none=True
)
ip_address = fields.String(
required=True,
validate=validate_ip_address,
error_messages={"required": "IP address is required."}
)
port = fields.Integer(
required=False,
allow_none=True,
validate=validate.Range(min=1, max=65535)
)
latitude = fields.Float(
required=True,
validate=validate.Range(min=-90.0, max=90.0),
error_messages={"required": "Latitude is required."}
)
longitude = fields.Float(
required=True,
validate=validate.Range(min=-180.0, max=180.0),
error_messages={"required": "Longitude is required."}
)
color = fields.String(
required=True,
validate=validate.Regexp(HEX_COLOR_REGEX),
error_messages={"required": "Color code (HEX) is required."}
)
avatar_url = fields.String(
required=False,
allow_none=True,
validate=validate.Length(max=512)
)
is_active = fields.Boolean(
required=False,
load_default=True
)
class UpdateDeviceSchema(Schema):
device_type_id = fields.UUID(
required=False
)
name = fields.String(
required=False,
validate=validate.Length(min=1, max=200)
)
description = fields.String(
required=False,
allow_none=True
)
ip_address = fields.String(
required=False,
validate=validate_ip_address
)
port = fields.Integer(
required=False,
allow_none=True,
validate=validate.Range(min=1, max=65535)
)
latitude = fields.Float(
required=False,
validate=validate.Range(min=-90.0, max=90.0)
)
longitude = fields.Float(
required=False,
validate=validate.Range(min=-180.0, max=180.0)
)
color = fields.String(
required=False,
validate=validate.Regexp(HEX_COLOR_REGEX)
)
avatar_url = fields.String(
required=False,
allow_none=True,
validate=validate.Length(max=512)
)
is_active = fields.Boolean(
required=False
)

View File

@@ -0,0 +1,138 @@
from modules.device.repository import (
find_all_devices,
find_device_by_id,
find_device_by_name,
find_device_by_ip,
insert_device,
update_device_db,
delete_device_db
)
from modules.device.exceptions import (
DeviceNotFoundException,
DeviceAlreadyExistsException,
DeviceIPAlreadyExistsException
)
from modules.device_type.repository import find_device_type_by_id
from modules.device_type.exceptions import DeviceTypeNotFoundException
from scheduler.scheduler import (
add_device_monitoring_job,
remove_device_monitoring_job,
reschedule_device_monitoring_job
)
def get_devices_service():
"""Lấy danh sách tất cả thiết bị"""
return find_all_devices()
def get_device_by_id_service(device_id):
"""
Lấy thông tin chi tiết một thiết bị theo ID.
Ném lỗi DeviceNotFoundException nếu không tồn tại.
"""
device = find_device_by_id(device_id)
if not device:
raise DeviceNotFoundException(device_id)
return device
def create_device_service(data):
"""
Tạo mới một thiết bị mạng.
Các bước xử lý:
1. Kiểm tra loại thiết bị (device_type_id) có tồn tại trong hệ thống hay không.
2. Kiểm tra trùng lặp tên thiết bị (case-insensitive).
3. Kiểm tra trùng lặp địa chỉ IP.
4. Thêm thiết bị và cấu hình mặc định (monitor & alert) vào database.
5. Đăng ký công việc giám sát vào Scheduler nền.
"""
# 1. Kiểm tra DeviceType
device_type = find_device_type_by_id(data["device_type_id"])
if not device_type:
raise DeviceTypeNotFoundException(data["device_type_id"])
# 2. Kiểm tra trùng tên
existing_name = find_device_by_name(data["name"])
if existing_name:
raise DeviceAlreadyExistsException(data["name"])
# 3. Kiểm tra trùng IP
existing_ip = find_device_by_ip(data["ip_address"])
if existing_ip:
raise DeviceIPAlreadyExistsException(data["ip_address"])
# 4. Insert DB
new_device = insert_device(data)
# 5. Kích hoạt Job giám sát trên Background Scheduler
# Truyền kèm thông tin cấu hình mặc định (enable_ping=True, v.v...)
add_device_monitoring_job(new_device["id"], None)
return new_device
def update_device_service(device_id, data):
"""
Cập nhật thông tin thiết bị mạng.
Các bước xử lý:
1. Kiểm tra sự tồn tại của thiết bị.
2. Nếu cập nhật device_type_id -> kiểm tra xem có tồn tại không.
3. Nếu cập nhật name -> kiểm tra xem có bị trùng với thiết bị khác không.
4. Nếu cập nhật ip_address -> kiểm tra xem có bị trùng với thiết bị khác không.
5. Cập nhật dữ liệu vào DB.
6. Cập nhật lại cấu hình giám sát trong Scheduler.
"""
# 1. Kiểm tra thiết bị có tồn tại hay không
existing = find_device_by_id(device_id)
if not existing:
raise DeviceNotFoundException(device_id)
# 2. Kiểm tra loại thiết bị nếu có truyền vào
new_device_type_id = data.get("device_type_id")
if new_device_type_id and new_device_type_id != existing["device_type_id"]:
device_type = find_device_type_by_id(new_device_type_id)
if not device_type:
raise DeviceTypeNotFoundException(new_device_type_id)
# 3. Kiểm tra trùng tên khi tên bị thay đổi
new_name = data.get("name")
if new_name and new_name.lower() != existing["name"].lower():
conflict_name = find_device_by_name(new_name)
if conflict_name and conflict_name["id"] != device_id:
raise DeviceAlreadyExistsException(new_name)
# 4. Kiểm tra trùng IP khi IP bị thay đổi
new_ip = data.get("ip_address")
if new_ip and new_ip != existing["ip_address"]:
conflict_ip = find_device_by_ip(new_ip)
if conflict_ip and conflict_ip["id"] != device_id:
raise DeviceIPAlreadyExistsException(new_ip)
# 5. Cập nhật DB
updated_device = update_device_db(device_id, data)
# 6. Cập nhật lại thông tin giám sát trong Scheduler
reschedule_device_monitoring_job(device_id, None)
return updated_device
def delete_device_service(device_id):
"""
Xóa thiết bị mạng.
Các bước xử lý:
1. Kiểm tra sự tồn tại của thiết bị.
2. Thực hiện xóa khỏi DB (tự động xóa cấu hình và lịch sử liên quan).
3. Hủy bỏ job giám sát khỏi Scheduler.
"""
# 1. Kiểm tra tồn tại
existing = find_device_by_id(device_id)
if not existing:
raise DeviceNotFoundException(device_id)
# 2. Xóa khỏi DB
delete_device_db(device_id)
# 3. Hủy công việc giám sát trong Scheduler
remove_device_monitoring_job(device_id)

View File

@@ -0,0 +1 @@
{"name":"Local: controller","url":"/Users/danghongquangminh/Downloads/NetworkDeviceManagementSystem_Project/backend/modules/device_type/controller.py","tests":[{"id":1779345290807,"input":"","output":""}],"interactive":false,"memoryLimit":1024,"timeLimit":3000,"srcPath":"/Users/danghongquangminh/Downloads/NetworkDeviceManagementSystem_Project/backend/modules/device_type/controller.py","group":"local","local":true}

View File

@@ -0,0 +1,100 @@
# ============================================
# 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
# ============================================
# pyrefly: ignore [missing-import]
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,
get_device_type_by_id_service,
update_device_type_service,
delete_device_type_service
)
from modules.device_type.schemas import CreateDeviceTypeSchema, UpdateDeviceTypeSchema
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"""
device_type = get_device_type_by_id_service(device_type_id)
return success_response(
data=device_type,
message="Device type retrieved successfully"
)
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"""
body = request.get_json()
schema = UpdateDeviceTypeSchema()
data = schema.load(body)
updated_device_type = update_device_type_service(device_type_id, data)
return success_response(
data=updated_device_type,
message="Device type updated successfully"
)
def delete_device_type(device_type_id):
"""DELETE /api/device-types/<device_type_id> — Xóa"""
delete_device_type_service(device_type_id)
return success_response(
message="Device type deleted successfully"
)

View 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}
)

View File

@@ -0,0 +1,243 @@
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_type
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_type
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_type (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)
# ============================================
# FIND BY ID: Tìm device type theo ID
# Trả về dict nếu tìm thấy, None nếu không
# ============================================
def find_device_type_by_id(device_type_id):
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_type
WHERE id = %s
""", (device_type_id,))
row = cur.fetchone()
if row:
return _row_to_dict(row)
return None
finally:
if cur:
cur.close()
release_connection(conn)
# ============================================
# CHECK IN USE: Kiểm tra xem device type có đang được dùng bởi thiết bị nào không ?
# Trả về True nếu có, False nếu không
# ============================================
def is_device_type_in_use(device_type_id):
conn = get_connection()
cur = None
try:
cur = conn.cursor()
# Kiểm tra sự tồn tại trong bảng devices
cur.execute("""
SELECT EXISTS(
SELECT 1 FROM device
WHERE device_type_id = %s
)
""", (device_type_id,))
row = cur.fetchone()
return row[0] if row else False
finally:
if cur:
cur.close()
release_connection(conn)
# ============================================
# UPDATE: Cập nhật thông tin device type
# Trả về dict của device type sau khi cập nhật
# ============================================
def update_device_type_db(device_type_id, data):
conn = get_connection()
cur = None
try:
cur = conn.cursor()
# Xây dựng câu lệnh UPDATE động tùy vào các trường có trong data
update_fields = []
params = []
for key in ["name", "description", "icon_url", "color", "sort_order", "is_active"]:
if key in data:
update_fields.append(f"{key} = %s")
params.append(data[key])
if not update_fields:
# Không có trường nào cần cập nhật
return find_device_type_by_id(device_type_id)
# Thêm cập nhật thời gian sửa đổi (modified)
update_fields.append("modified = CURRENT_TIMESTAMP")
sql = f"""
UPDATE device_type
SET {', '.join(update_fields)}
WHERE id = %s
RETURNING id, name, description, icon_url, color, sort_order, is_active, created, modified
"""
params.append(device_type_id)
cur.execute(sql, tuple(params))
row = cur.fetchone()
conn.commit()
if row:
return _row_to_dict(row)
return None
except Exception:
conn.rollback()
raise
finally:
if cur:
cur.close()
release_connection(conn)
# ============================================
# DELETE: Xóa 1 device type khỏi database
# ============================================
def delete_device_type_db(device_type_id):
conn = get_connection()
cur = None
try:
cur = conn.cursor()
cur.execute("""
DELETE FROM device_type
WHERE id = %s
""", (device_type_id,))
conn.commit()
except Exception:
conn.rollback()
raise
finally:
if cur:
cur.close()
release_connection(conn)

View File

@@ -0,0 +1,20 @@
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__,
)
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)

View 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
)

View File

@@ -0,0 +1,107 @@
# ============================================
# 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,
find_device_type_by_id,
is_device_type_in_use,
update_device_type_db,
delete_device_type_db
)
from modules.device_type.exceptions import (
DeviceTypeAlreadyExistsException,
DeviceTypeNotFoundException,
DeviceTypeInUseException
)
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 get_device_type_by_id_service(device_type_id):
"""
Lấy chi tiết device type theo ID.
Business logic:
1. Tìm device type theo ID
2. Nếu không thấy → throw NotFoundException
"""
device_type = find_device_type_by_id(device_type_id)
if not device_type:
raise DeviceTypeNotFoundException(device_type_id)
return device_type
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
def update_device_type_service(device_type_id, data):
"""
Cập nhật device type.
Business logic:
1. Kiểm tra sự tồn tại của device_type_id. Nếu không có → throw NotFoundException
2. Nếu có đổi tên (name) → kiểm tra xem tên mới đã tồn tại ở bản ghi khác chưa
- Nếu trùng → throw ConflictException
3. Cập nhật vào DB
"""
# Bước 1: Check sự tồn tại
existing = find_device_type_by_id(device_type_id)
if not existing:
raise DeviceTypeNotFoundException(device_type_id)
# Bước 2: Check trùng tên nếu tên thay đổi
new_name = data.get("name")
if new_name and new_name.lower() != existing["name"].lower():
name_conflict = find_device_type_by_name(new_name)
if name_conflict and name_conflict["id"] != device_type_id:
raise DeviceTypeAlreadyExistsException(new_name)
# Bước 3: Cập nhật DB
updated_device_type = update_device_type_db(device_type_id, data)
return updated_device_type
def delete_device_type_service(device_type_id):
"""
Xóa device type.
Business logic:
1. Kiểm tra sự tồn tại. Nếu không có → throw NotFoundException
2. Kiểm tra xem có đang được gán cho thiết bị nào không (FK constraint check)
- Nếu có → throw DeviceTypeInUseException
3. Thực hiện xóa khỏi DB
"""
# Bước 1: Check sự tồn tại
existing = find_device_type_by_id(device_type_id)
if not existing:
raise DeviceTypeNotFoundException(device_type_id)
# Bước 2: Check xem có đang được sử dụng không
if is_device_type_in_use(device_type_id):
raise DeviceTypeInUseException(device_type_id)
# Bước 3: Xóa
delete_device_type_db(device_type_id)

View File

View File

@@ -0,0 +1,149 @@
# pyrefly: ignore [missing-import]
from flask import request
from common.response.api_response import success_response
from modules.monitor_config.service import (
get_monitor_config_service,
update_monitor_config_service,
test_connection_service
)
from modules.monitor_config.schemas import (
UpdateMonitorConfigSchema,
TestConnectionSchema
)
# ============================================
# CONTROLLER: Nhận request → Validate → Gọi Service → Trả response
# ============================================
# Luồng đi tổng thể:
#
# Client (Frontend/Postman)
# │
# ├── GET /api/devices/{device_id}/monitor-config → get_monitor_config()
# ├── PUT /api/devices/{device_id}/monitor-config → update_monitor_config()
# └── POST /api/devices/{device_id}/monitor-config/test → test_connection()
# │
# ▼
# Controller (file này)
# │ 1. Lấy dữ liệu từ request (params, body)
# │ 2. Validate bằng Marshmallow Schema
# │ 3. Gọi Service xử lý logic
# │
# ▼
# Service → Repository → Database
#
# Controller KHÔNG chứa business logic (check tồn tại, tính toán...)
# Controller CHỈ là cầu nối giữa HTTP request và Service layer
# ============================================
def get_monitor_config(device_id):
"""
GET /api/devices/<device_id>/monitor-config
Lấy cấu hình giám sát hiện tại của thiết bị.
Luồng đi:
1. Nhận device_id từ URL path
2. Gọi service → service check thiết bị tồn tại → trả config
3. Trả response thành công kèm data
"""
config = get_monitor_config_service(device_id)
return success_response(
data=config,
message="Monitor config retrieved successfully"
)
def update_monitor_config(device_id):
"""
PUT /api/devices/<device_id>/monitor-config
Cập nhật cấu hình giám sát của thiết bị.
Luồng đi:
1. Nhận device_id từ URL path + JSON body từ request
2. Validate body bằng UpdateMonitorConfigSchema
→ Marshmallow kiểm tra: kiểu dữ liệu, range, enum...
→ Nếu sai → ném ValidationError → Global handler trả 400
→ Nếu đúng → loại bỏ trường thừa, áp dụng defaults
3. Gọi service → service check tồn tại → cập nhật DB → reschedule job
4. Trả response thành công kèm config đã cập nhật
Ví dụ request body:
{
"enable_ping": true,
"ping_interval": 30,
"ping_count": 5,
"enable_snmp": true,
"snmp_community": "public",
"snmp_version": "v2c",
"snmp_port": 161
}
"""
body = request.get_json()
# Validate bằng schema — loại bỏ trường không hợp lệ, check range/enum
schema = UpdateMonitorConfigSchema()
data = schema.load(body)
# Gọi service để xử lý logic nghiệp vụ
updated_config = update_monitor_config_service(device_id, data)
return success_response(
data=updated_config,
message="Monitor config updated successfully"
)
def test_connection(device_id):
"""
POST /api/devices/<device_id>/monitor-config/test
Kiểm tra kết nối tới thiết bị (Ping và/hoặc SNMP).
Luồng đi:
1. Nhận device_id từ URL path + JSON body chứa tham số test
2. Validate body bằng TestConnectionSchema
3. Gọi service → service lấy IP thiết bị → chạy Ping/SNMP test
4. Trả kết quả kiểm tra (Up/Down, RTT, chi tiết SNMP)
Ví dụ request body (test cả Ping và SNMP):
{
"test_ping": true,
"ping_count": 3,
"ping_timeout": 5,
"test_snmp": true,
"snmp_community": "public",
"snmp_version": "v2c",
"snmp_port": 161,
"snmp_timeout": 5
}
Ví dụ response:
{
"success": true,
"data": {
"ping_result": {
"status": "up",
"method": "icmplib",
"avg_rtt_ms": 12.5,
"packet_loss": 0.0
},
"snmp_result": {
"status": "up",
"method": "snmp",
"snmp_data": {"1.3.6.1.2.1.1.1.0": "Cisco IOS XR Software..."}
}
}
}
"""
body = request.get_json() or {}
# Validate tham số test
schema = TestConnectionSchema()
test_params = schema.load(body)
# Gọi service chạy test kết nối
results = test_connection_service(device_id, test_params)
return success_response(
data=results,
message="Connection test completed"
)

View File

@@ -0,0 +1,12 @@
from common.exceptions.app_exception import NotFoundException
class MonitorConfigNotFoundException(NotFoundException):
"""
Exception ném ra khi không tìm thấy cấu hình giám sát (MonitorConfig) của thiết bị.
Kế thừa từ NotFoundException để trả về HTTP status 404 cho client.
"""
def __init__(self, device_id):
super().__init__(
message=f"Monitor config not found for device with id={device_id}",
payload={"device_id": device_id}
)

View File

@@ -0,0 +1,147 @@
import json
from config.database import get_connection, release_connection
# ============================================
# REPOSITORY LAYER: Tương tác trực tiếp với bảng monitor_config trong PostgreSQL
# ============================================
# Luồng đi tổng thể:
# Controller → Service → Repository → Database
# Repository CHỈ chịu trách nhiệm thực thi SQL, KHÔNG chứa logic nghiệp vụ.
#
# Bảng monitor_config có quan hệ 1-1 với bảng device:
# - Mỗi device có đúng 1 bản ghi monitor_config (tạo tự động khi tạo device)
# - Khóa ngoại: device_id → device(id) ON DELETE CASCADE
# - Ràng buộc UNIQUE trên device_id → đảm bảo không trùng
# ============================================
def _row_to_dict(row):
"""
Chuyển đổi một dòng kết quả (tuple) từ câu query thành dictionary.
Thứ tự các cột phải khớp với SELECT trong các hàm bên dưới.
"""
if not row:
return None
return {
"id": str(row[0]),
"device_id": str(row[1]),
"enable_ping": row[2],
"ping_count": row[3],
"ping_timeout": row[4],
"ping_interval": row[5],
"enable_snmp": row[6],
"snmp_version": row[7],
"snmp_community": row[8],
"snmp_port": row[9],
"snmp_interval": row[10],
"snmp_timeout": row[11],
"snmp_custom_oids": row[12], # PostgreSQL JSONB → Python dict (tự động)
"created": row[13].isoformat() if row[13] else None,
"modified": row[14].isoformat() if row[14] else None
}
# ============================================
# FIND BY DEVICE ID: Lấy cấu hình giám sát của một thiết bị
# ============================================
# Luồng đi:
# GET /api/devices/{device_id}/monitor-config
# → Controller → Service gọi hàm này
# → Trả về dict nếu tìm thấy, None nếu không
# ============================================
def find_monitor_config_by_device_id(device_id):
conn = get_connection()
cur = None
try:
cur = conn.cursor()
cur.execute("""
SELECT id, device_id, enable_ping, ping_count, ping_timeout, ping_interval,
enable_snmp, snmp_version, snmp_community, snmp_port, snmp_interval,
snmp_timeout, snmp_custom_oids, created, modified
FROM monitor_config
WHERE device_id = %s
""", (device_id,))
row = cur.fetchone()
return _row_to_dict(row) if row else None
finally:
if cur:
cur.close()
release_connection(conn)
# ============================================
# UPDATE: Cập nhật cấu hình giám sát của thiết bị
# ============================================
# Luồng đi:
# PUT /api/devices/{device_id}/monitor-config
# → Controller validate body → Service kiểm tra tồn tại → Repository cập nhật DB
#
# Cách hoạt động:
# - Dùng Dynamic UPDATE: chỉ cập nhật các trường có trong data (không ghi đè toàn bộ)
# - Trường snmp_custom_oids cần chuyển sang JSON string trước khi lưu vào JSONB
# - Tự động cập nhật cột modified = CURRENT_TIMESTAMP
# - RETURNING: trả về toàn bộ row sau khi update (tránh phải SELECT lại)
# ============================================
def update_monitor_config_db(device_id, data):
conn = get_connection()
cur = None
try:
cur = conn.cursor()
# Xây dựng câu lệnh UPDATE động — chỉ SET các trường có trong data
update_fields = []
params = []
# Danh sách các trường được phép cập nhật
allowed_fields = [
"enable_ping", "ping_count", "ping_timeout", "ping_interval",
"enable_snmp", "snmp_version", "snmp_community", "snmp_port",
"snmp_interval", "snmp_timeout", "snmp_custom_oids"
]
for key in allowed_fields:
if key in data:
value = data[key]
# ⚠️ XỬ LÝ ĐẶC BIỆT: snmp_custom_oids là JSONB trong PostgreSQL
# Python dict cần chuyển sang JSON string trước khi INSERT/UPDATE
# Ví dụ: {"sysName": "1.3.6.1.2.1.1.5.0"} → '{"sysName": "1.3.6.1.2.1.1.5.0"}'
if key == "snmp_custom_oids" and isinstance(value, dict):
value = json.dumps(value)
update_fields.append(f"{key} = %s")
params.append(value)
if not update_fields:
# Không có trường nào cần cập nhật → trả về config hiện tại
return find_monitor_config_by_device_id(device_id)
# Thêm cập nhật thời gian modified
update_fields.append("modified = CURRENT_TIMESTAMP")
sql = f"""
UPDATE monitor_config
SET {', '.join(update_fields)}
WHERE device_id = %s
RETURNING id, device_id, enable_ping, ping_count, ping_timeout, ping_interval,
enable_snmp, snmp_version, snmp_community, snmp_port, snmp_interval,
snmp_timeout, snmp_custom_oids, created, modified
"""
params.append(device_id)
cur.execute(sql, tuple(params))
row = cur.fetchone()
conn.commit()
return _row_to_dict(row) if row else None
except Exception:
conn.rollback()
raise
finally:
if cur:
cur.close()
release_connection(conn)

View File

@@ -0,0 +1,164 @@
# pyrefly: ignore [missing-import]
from marshmallow import Schema, fields, validate
# ============================================
# SCHEMAS: Validate dữ liệu đầu vào cho Monitor Config
# ============================================
# Luồng đi: Client gửi JSON body → Controller nhận → Schema validate → Service xử lý
# Nếu dữ liệu không hợp lệ → Marshmallow ném ValidationError → Global handler trả 400
# ============================================
# Danh sách các SNMP version hệ thống hỗ trợ
VALID_SNMP_VERSIONS = ["v1", "v2c", "v3"]
class UpdateMonitorConfigSchema(Schema):
"""
Schema để validate dữ liệu khi cập nhật cấu hình giám sát.
Tất cả các trường đều optional vì người dùng có thể chỉ cập nhật một phần.
Ví dụ request body:
{
"enable_ping": true,
"ping_interval": 30,
"enable_snmp": true,
"snmp_community": "public",
"snmp_version": "v2c"
}
"""
# ─── Cấu hình Ping (ICMP) ────────────────────────
enable_ping = fields.Boolean(required=False)
ping_count = fields.Integer(
required=False,
validate=validate.Range(min=1, max=10)
# Số lượng gói tin ping gửi mỗi lần kiểm tra
# min=1: ít nhất 1 gói, max=10: tránh gửi quá nhiều gây tải mạng
)
ping_timeout = fields.Integer(
required=False,
validate=validate.Range(min=1, max=30)
# Thời gian chờ phản hồi tối đa (giây)
# Nếu thiết bị không phản hồi trong khoảng này → coi như timeout
)
ping_interval = fields.Integer(
required=False,
validate=validate.Range(min=5, max=86400)
# Tần suất kiểm tra (giây): min=5s (thiết bị quan trọng), max=86400s (1 ngày)
# APScheduler sẽ chạy job Ping theo interval này
)
# ─── Cấu hình SNMP ───────────────────────────────
enable_snmp = fields.Boolean(required=False)
snmp_version = fields.String(
required=False,
allow_none=True,
validate=validate.OneOf(VALID_SNMP_VERSIONS)
# Chỉ chấp nhận: "v1", "v2c", "v3"
)
snmp_community = fields.String(
required=False,
allow_none=True,
validate=validate.Length(max=256)
# Community string dùng để xác thực SNMP (ví dụ: "public", "private")
)
snmp_port = fields.Integer(
required=False,
allow_none=True,
validate=validate.Range(min=1, max=65535)
# Port SNMP mặc định là 161, nhưng cho phép tùy chỉnh
)
snmp_interval = fields.Integer(
required=False,
allow_none=True,
validate=validate.Range(min=5, max=86400)
# Tần suất kiểm tra SNMP (giây), tương tự ping_interval
)
snmp_timeout = fields.Integer(
required=False,
allow_none=True,
validate=validate.Range(min=1, max=30)
# Thời gian chờ phản hồi SNMP tối đa (giây)
)
snmp_custom_oids = fields.Dict(
required=False,
allow_none=True
# Danh sách OID tùy chỉnh dưới dạng JSON object
# Ví dụ: {"sysName": "1.3.6.1.2.1.1.5.0", "ifNumber": "1.3.6.1.2.1.2.1.0"}
# Lưu vào PostgreSQL dưới dạng JSONB
)
class TestConnectionSchema(Schema):
"""
Schema để validate dữ liệu khi người dùng bấm nút "Test kết nối".
Cho phép test trước khi lưu cấu hình — client truyền lên cấu hình tạm để test.
Luồng đi:
1. Người dùng nhập cấu hình trên giao diện (chưa bấm Lưu)
2. Bấm nút "Test" → Client gửi POST /api/devices/{id}/monitor-config/test
3. Server nhận cấu hình tạm → Chạy Ping/SNMP test ngay lập tức
4. Trả kết quả (Up/Down, RTT, chi tiết) → Hiển thị trên giao diện
5. Nếu OK → Người dùng mới bấm "Lưu" để PUT cập nhật cấu hình thực
"""
# Test ping hay snmp?
test_ping = fields.Boolean(
required=False,
load_default=True
# Mặc định test ping
)
test_snmp = fields.Boolean(
required=False,
load_default=False
)
# ─── Tham số Ping (dùng cho test) ─────────────────
ping_count = fields.Integer(
required=False,
load_default=3,
validate=validate.Range(min=1, max=10)
)
ping_timeout = fields.Integer(
required=False,
load_default=5,
validate=validate.Range(min=1, max=30)
)
# ─── Tham số SNMP (dùng cho test) ─────────────────
snmp_version = fields.String(
required=False,
allow_none=True,
validate=validate.OneOf(VALID_SNMP_VERSIONS)
)
snmp_community = fields.String(
required=False,
allow_none=True,
validate=validate.Length(max=256)
)
snmp_port = fields.Integer(
required=False,
allow_none=True,
load_default=161,
validate=validate.Range(min=1, max=65535)
)
snmp_timeout = fields.Integer(
required=False,
allow_none=True,
load_default=5,
validate=validate.Range(min=1, max=30)
)

View File

@@ -0,0 +1,323 @@
import subprocess
import platform
# ============================================
# SERVICE LAYER: Business Logic cho Module Monitor Config
# ============================================
# Luồng đi tổng thể của module này:
#
# ┌──────────┐ ┌──────────┐ ┌──────────────┐ ┌──────────┐
# │ Controller│ ──→ │ Service │ ──→ │ Repository │ ──→ │ Database │
# └──────────┘ └──────────┘ └──────────────┘ └──────────┘
# │
# ├──→ Device Repository (kiểm tra thiết bị tồn tại)
# ├──→ Scheduler (cập nhật job giám sát khi config thay đổi)
# └──→ ICMP/SNMP Test (kiểm tra kết nối trực tiếp)
#
# Service chứa 3 chức năng chính:
# 1. Lấy cấu hình giám sát (GET)
# 2. Cập nhật cấu hình giám sát (PUT) + reschedule job
# 3. Test kết nối trực tiếp (POST /test) — Ping hoặc SNMP
# ============================================
from modules.monitor_config.repository import (
find_monitor_config_by_device_id,
update_monitor_config_db
)
from modules.monitor_config.exceptions import MonitorConfigNotFoundException
from modules.device.repository import find_device_by_id
from modules.device.exceptions import DeviceNotFoundException
from scheduler.scheduler import reschedule_device_monitoring_job
# ============================================
# Dynamic Import: icmplib và pysnmp
# ============================================
# Tại sao cần Dynamic Import?
# - icmplib cần quyền root/sudo trên một số hệ điều hành để gửi ICMP raw socket
# - pysnmp có thể chưa được cài đặt trên máy phát triển
# - Nếu import thất bại → app vẫn chạy được, chỉ fallback sang cách khác
# ============================================
try:
from icmplib import ping as icmp_ping
HAS_ICMPLIB = True
except ImportError:
HAS_ICMPLIB = False
try:
from pysnmp.hlapi import (
SnmpEngine, CommunityData, UdpTransportTarget,
ContextData, ObjectType, ObjectIdentity, getCmd
)
HAS_PYSNMP = True
except ImportError:
HAS_PYSNMP = False
# ============================================
# 1. GET: Lấy cấu hình giám sát của thiết bị
# ============================================
# Luồng đi:
# GET /api/devices/{device_id}/monitor-config
# → Controller gọi hàm này
# → Kiểm tra device tồn tại (DeviceNotFoundException nếu không)
# → Kiểm tra config tồn tại (MonitorConfigNotFoundException nếu không)
# → Trả về dict cấu hình
# ============================================
def get_monitor_config_service(device_id):
# Bước 1: Kiểm tra thiết bị có tồn tại trong hệ thống không
device = find_device_by_id(device_id)
if not device:
raise DeviceNotFoundException(device_id)
# Bước 2: Lấy cấu hình giám sát từ DB
# (Bản ghi monitor_config được tạo tự động khi tạo device trong module device)
config = find_monitor_config_by_device_id(device_id)
if not config:
raise MonitorConfigNotFoundException(device_id)
return config
# ============================================
# 2. PUT: Cập nhật cấu hình giám sát
# ============================================
# Luồng đi:
# PUT /api/devices/{device_id}/monitor-config
# → Controller validate body bằng UpdateMonitorConfigSchema
# → Service gọi hàm này
# → Kiểm tra device + config tồn tại
# → Cập nhật DB (Repository)
# → Gọi Scheduler reschedule_job để áp dụng tần suất mới NGAY LẬP TỨC
# → Trả về config đã cập nhật
#
# Ví dụ: Người dùng đổi ping_interval từ 60s → 30s
# → DB được cập nhật
# → Scheduler reschedule job: lần kiểm tra tiếp theo sẽ chạy sau 30s thay vì 60s
# → KHÔNG cần restart server
# ============================================
def update_monitor_config_service(device_id, data):
# Bước 1: Kiểm tra thiết bị tồn tại
device = find_device_by_id(device_id)
if not device:
raise DeviceNotFoundException(device_id)
# Bước 2: Kiểm tra config tồn tại
existing_config = find_monitor_config_by_device_id(device_id)
if not existing_config:
raise MonitorConfigNotFoundException(device_id)
# Bước 3: Cập nhật vào DB
updated_config = update_monitor_config_db(device_id, data)
# Bước 4: Thông báo cho Scheduler cập nhật lại job
# reschedule_device_monitoring_job sẽ đọc config mới từ DB
# và áp dụng interval mới cho job ping/snmp
reschedule_device_monitoring_job(device_id, updated_config)
return updated_config
# ============================================
# 3. POST: Test kết nối (Ping hoặc SNMP)
# ============================================
# Luồng đi:
# POST /api/devices/{device_id}/monitor-config/test
# → Controller validate body bằng TestConnectionSchema
# → Service gọi hàm này
# → Lấy IP thiết bị từ DB
# → Chạy Ping test (nếu test_ping=True)
# → Chạy SNMP test (nếu test_snmp=True)
# → Trả về kết quả chi tiết {ping_result, snmp_result}
#
# Cơ chế Fallback cho Ping:
# Ưu tiên 1: icmplib (thư viện Python, cần cài pip install icmplib)
# → async_ping() hoặc ping() với privileged=False
# Ưu tiên 2: Lệnh ping hệ điều hành (subprocess)
# → Chạy "ping -c 3 <ip>" (Linux/Mac) hoặc "ping -n 3 <ip>" (Windows)
# → Parse kết quả từ stdout
# ============================================
def test_connection_service(device_id, test_params):
# Bước 1: Kiểm tra thiết bị tồn tại và lấy IP
device = find_device_by_id(device_id)
if not device:
raise DeviceNotFoundException(device_id)
ip_address = device["ip_address"]
results = {}
# Bước 2: Test Ping nếu được yêu cầu
if test_params.get("test_ping", True):
ping_count = test_params.get("ping_count", 3)
ping_timeout = test_params.get("ping_timeout", 5)
results["ping_result"] = _run_ping_test(ip_address, ping_count, ping_timeout)
# Bước 3: Test SNMP nếu được yêu cầu
if test_params.get("test_snmp", False):
snmp_params = {
"community": test_params.get("snmp_community", "public"),
"version": test_params.get("snmp_version", "v2c"),
"port": test_params.get("snmp_port", 161),
"timeout": test_params.get("snmp_timeout", 5)
}
results["snmp_result"] = _run_snmp_test(ip_address, snmp_params)
return results
# ============================================
# PRIVATE: Hàm chạy Ping test
# ============================================
# Cơ chế:
# 1. Thử dùng icmplib trước (nhanh, chính xác, có RTT)
# → privileged=False: không cần quyền root, dùng UDP probe
# 2. Nếu icmplib không có hoặc lỗi → Fallback sang subprocess
# → Chạy lệnh ping của hệ điều hành qua subprocess.run()
# → Parse returncode: 0 = thành công, != 0 = thất bại
# ============================================
def _run_ping_test(ip_address, count, timeout):
# ─── Ưu tiên 1: Dùng icmplib ─────────────────────
if HAS_ICMPLIB:
try:
# privileged=False: Không cần quyền root
# Trên macOS/Linux không root, icmplib sẽ dùng UDP probe
result = icmp_ping(
ip_address,
count=count,
timeout=timeout,
privileged=False
)
return {
"status": "up" if result.is_alive else "down",
"method": "icmplib",
"packets_sent": result.packets_sent,
"packets_received": result.packets_received,
"packet_loss": result.packet_loss, # 0.0 ~ 1.0
"avg_rtt_ms": round(result.avg_rtt, 2), # Thời gian phản hồi trung bình (ms)
"min_rtt_ms": round(result.min_rtt, 2),
"max_rtt_ms": round(result.max_rtt, 2)
}
except Exception as e:
# icmplib có nhưng gặp lỗi (ví dụ: permission denied)
# → Fallback sang subprocess
pass
# ─── Ưu tiên 2: Fallback sang lệnh ping hệ điều hành ───
try:
# Xác định tham số theo hệ điều hành
# Windows dùng -n (number), Linux/macOS dùng -c (count)
param = "-n" if platform.system().lower() == "windows" else "-c"
timeout_param = "-w" if platform.system().lower() == "windows" else "-W"
cmd = ["ping", param, str(count), timeout_param, str(timeout), ip_address]
result = subprocess.run(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=timeout * count + 5 # Timeout tổng = timeout mỗi gói × số gói + buffer
)
is_alive = result.returncode == 0
return {
"status": "up" if is_alive else "down",
"method": "system_ping",
"detail": result.stdout.decode("utf-8", errors="replace")[:500]
# Giới hạn 500 ký tự đầu ra để tránh response quá lớn
}
except subprocess.TimeoutExpired:
return {
"status": "down",
"method": "system_ping",
"detail": f"Ping timeout after {timeout * count + 5} seconds"
}
except Exception as e:
return {
"status": "error",
"method": "system_ping",
"detail": str(e)
}
# ============================================
# PRIVATE: Hàm chạy SNMP test
# ============================================
# Cơ chế:
# 1. Dùng pysnmp gửi SNMP GET request lấy sysDescr (OID: 1.3.6.1.2.1.1.1.0)
# → Đây là OID tiêu chuẩn mà MỌI thiết bị SNMP đều hỗ trợ
# → Nếu lấy được giá trị → thiết bị Up + hỗ trợ SNMP
# 2. Nếu pysnmp không có → trả về lỗi yêu cầu cài đặt
#
# Tham số SNMP:
# - community: chuỗi xác thực (mặc định "public")
# - version: phiên bản SNMP (v1, v2c, v3)
# - port: cổng SNMP trên thiết bị (mặc định 161)
# - timeout: thời gian chờ phản hồi (giây)
# ============================================
def _run_snmp_test(ip_address, snmp_params):
if not HAS_PYSNMP:
return {
"status": "error",
"method": "snmp",
"detail": "pysnmp library is not installed. Run: pip install pysnmp"
}
try:
community = snmp_params.get("community", "public")
port = snmp_params.get("port", 161)
timeout_val = snmp_params.get("timeout", 5)
# Xác định mpModel (SNMP version) cho pysnmp
# v1 → mpModel=0, v2c → mpModel=1
version = snmp_params.get("version", "v2c")
mp_model = 0 if version == "v1" else 1
# Gửi SNMP GET request để lấy sysDescr
# OID: 1.3.6.1.2.1.1.1.0 = iso.org.dod.internet.mgmt.mib-2.system.sysDescr.0
# Đây là mô tả hệ thống — mọi thiết bị SNMP đều phải trả lời OID này
error_indication, error_status, error_index, var_binds = next(
getCmd(
SnmpEngine(),
CommunityData(community, mpModel=mp_model),
UdpTransportTarget((ip_address, port), timeout=timeout_val, retries=1),
ContextData(),
ObjectType(ObjectIdentity("1.3.6.1.2.1.1.1.0")) # sysDescr
)
)
# Phân tích kết quả
if error_indication:
# Lỗi transport: timeout, không kết nối được, v.v.
return {
"status": "down",
"method": "snmp",
"detail": str(error_indication)
}
elif error_status:
# Lỗi SNMP protocol: OID không tồn tại, quyền truy cập bị từ chối, v.v.
return {
"status": "down",
"method": "snmp",
"detail": f"SNMP error: {error_status.prettyPrint()} at {error_index}"
}
else:
# Thành công! Thiết bị phản hồi SNMP
snmp_data = {}
for var_bind in var_binds:
oid = str(var_bind[0])
value = str(var_bind[1])
snmp_data[oid] = value
return {
"status": "up",
"method": "snmp",
"snmp_data": snmp_data,
"detail": f"SNMP {version} response received successfully"
}
except Exception as e:
return {
"status": "error",
"method": "snmp",
"detail": f"SNMP test failed: {str(e)}"
}

View File

@@ -0,0 +1,60 @@
from flask import request, jsonify
from storage.storage_service import (
upload_device_type_icon,
upload_device_avatar,
)
def upload_device_type_icon_controller():
try:
file = request.files.get("file")
name = request.form.get("name")
icon_url = upload_device_type_icon(file, name)
return jsonify({
"success": True,
"message": "Device type icon uploaded successfully",
"url": icon_url
}), 201
except ValueError as e:
return jsonify({
"success": False,
"message": str(e)
}), 400
except Exception as e:
return jsonify({
"success": False,
"message": "Failed to upload device type icon",
"error": str(e)
}), 500
def upload_device_avatar_controller():
try:
file = request.files.get("file")
name = request.form.get("name")
avatar_url = upload_device_avatar(file, name)
return jsonify({
"success": True,
"message": "Device avatar uploaded successfully",
"url": avatar_url
}), 201
except ValueError as e:
return jsonify({
"success": False,
"message": str(e)
}), 400
except Exception as e:
return jsonify({
"success": False,
"message": "Failed to upload device avatar",
"error": str(e)
}), 500

View File

@@ -0,0 +1,11 @@
from flask import Blueprint
from modules.uploads.upload_controller import (
upload_device_type_icon_controller,
upload_device_avatar_controller,
)
upload_bp = Blueprint("uploads", __name__)
upload_bp.route("/device-type-icons", methods=["POST"])(upload_device_type_icon_controller)
upload_bp.route("/device-avatars", methods=["POST"])(upload_device_avatar_controller)

View File

@@ -10,3 +10,8 @@ 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
boto3==2.0.0
icmplib==3.0.4
pysnmp==4.4.12

View File

@@ -0,0 +1 @@
# Init file for scheduler package

View File

View File

View File

View File

@@ -0,0 +1,32 @@
from apscheduler.schedulers.background import BackgroundScheduler
# Khởi tạo background scheduler
# Thư viện APScheduler sẽ chạy các job ping/snmp định kỳ trong nền của Flask app
scheduler = BackgroundScheduler()
def start_scheduler():
"""Khởi chạy scheduler nếu nó chưa chạy"""
if not scheduler.running:
scheduler.start()
print("⏰ Background scheduler started.")
def add_device_monitoring_job(device_id, monitor_config):
"""
Thêm các job Ping/SNMP cho thiết bị mới hoặc được kích hoạt.
Sẽ được triển khai chi tiết ở module monitor_config/device_status.
"""
print(f"⏰ Stub: Added monitoring job for device {device_id}")
def remove_device_monitoring_job(device_id):
"""
Xóa toàn bộ các job Ping/SNMP liên quan đến thiết bị.
Sẽ được triển khai chi tiết ở module monitor_config/device_status.
"""
print(f"⏰ Stub: Removed monitoring job for device {device_id}")
def reschedule_device_monitoring_job(device_id, monitor_config):
"""
Cập nhật lại tần suất chạy (interval) hoặc phương thức chạy khi cấu hình giám sát thay đổi.
Sẽ được triển khai chi tiết ở module monitor_config/device_status.
"""
print(f"⏰ Stub: Rescheduled monitoring job for device {device_id}")

View File

View File

@@ -0,0 +1,49 @@
import os
import boto3
from dotenv import load_dotenv
load_dotenv()
def get_s3_client():
"""
Tạo và trả về S3 client dùng để làm việc với MinIO/S3.
MinIO tương thích với S3 API, nên mình dùng boto3 như khi làm với AWS S3.
Hàm này chỉ chịu trách nhiệm tạo kết nối, không xử lý upload hay nghiệp vụ file.
"""
return boto3.client(
"s3",
endpoint_url=os.getenv("S3_ENDPOINT_URL"),
aws_access_key_id=os.getenv("S3_ACCESS_KEY"),
aws_secret_access_key=os.getenv("S3_SECRET_KEY"),
region_name=os.getenv("S3_REGION", "us-east-1"),
)
def get_bucket_name():
"""
Lấy tên bucket từ biến môi trường.
Project này nên dùng một bucket chung, ví dụ: ndms.
Sau đó phân loại file bằng object key:
- device-types/router-uuid.svg
- devices/avatar-uuid.png
"""
return os.getenv("S3_BUCKET_NAME")
def get_public_url():
"""
Lấy public URL của MinIO/S3.
Ví dụ khi chạy local:
S3_PUBLIC_URL=http://localhost:9000
URL cuối cùng sẽ có dạng:
http://localhost:9000/ndms/device-types/router-uuid.svg
"""
return os.getenv("S3_PUBLIC_URL")

View File

@@ -0,0 +1,141 @@
import re
import uuid
from werkzeug.utils import secure_filename
from storage.s3_client import (
get_s3_client,
get_bucket_name,
get_public_url,
)
from storage.validators import validate_image_file
def slugify(text):
"""
Chuyển text thành dạng an toàn để dùng trong tên file.
Ví dụ:
- "Router" -> "router"
- "Core Switch 01" -> "core-switch-01"
- "Thiết bị định tuyến" -> "thi-t-b-nh-tuy-n"
Lưu ý:
Hàm này đơn giản, chưa xử lý tiếng Việt hoàn hảo.
Nếu muốn slug tiếng Việt đẹp hơn, sau này có thể dùng thư viện python-slugify.
"""
if text is None or text.strip() == "":
return "file"
text = text.lower().strip()
# Thay toàn bộ ký tự không phải chữ/số bằng dấu gạch ngang.
text = re.sub(r"[^a-z0-9]+", "-", text)
# Xóa dấu gạch ngang thừa ở đầu/cuối.
text = text.strip("-")
return text or "file"
def build_object_key(folder, business_name, extension):
"""
Tạo object key để lưu file trong bucket.
MinIO/S3 không có folder thật như filesystem.
Folder ở đây chỉ là prefix trong object key.
Ví dụ:
folder = "device-types"
business_name = "Router"
extension = "svg"
Kết quả:
device-types/router-550e8400.svg
Dùng UUID để tránh trùng tên file.
"""
safe_name = slugify(business_name)
unique_id = uuid.uuid4()
return f"{folder}/{safe_name}-{unique_id}.{extension}"
def upload_image(file, folder, business_name): # Hàm Upload dùng chung cho device_types và devices
"""
Upload một file ảnh lên MinIO/S3 và trả về public URL.
Tham số:
- file: file lấy từ request.files
- folder: nhóm lưu trữ, ví dụ "device-types" hoặc "devices"
- business_name: tên nghiệp vụ để đặt tên file đẹp hơn
Ví dụ:
+ DeviceType name = Router
+ Device name = Switch tầng 2
Flow:
1. Validate file
2. Tạo object key
3. Upload file lên bucket
4. Trả về URL public để lưu vào database
"""
extension = validate_image_file(file)
object_key = build_object_key(
folder=folder,
business_name=business_name,
extension=extension,
)
s3_client = get_s3_client()
bucket_name = get_bucket_name()
public_url = get_public_url()
s3_client.upload_fileobj(
file,
bucket_name,
object_key,
ExtraArgs={
"ContentType": file.content_type
},
)
return f"{public_url}/{bucket_name}/{object_key}"
def upload_device_type_icon(file, device_type_name):
"""
Upload icon cho DeviceType.
File sẽ được lưu trong bucket với prefix:
device-types/
Ví dụ URL:
http://localhost:9000/ndms/device-types/router-uuid.svg
"""
return upload_image( # Ở đoạn này có thể trả về một hàm luôn á ? Mà không cần phải gọi hàm đó vào bên trong và truyền tham số à ?
file=file,
folder="device-types",
business_name=device_type_name,
)
def upload_device_avatar(file, device_name):
"""
Upload avatar cho Device.
File sẽ được lưu trong bucket với prefix:
devices/
Ví dụ URL:
http://localhost:9000/ndms/devices/switch-tang-2-uuid.png
"""
return upload_image(
file=file,
folder="devices",
business_name=device_name,
)

View File

@@ -0,0 +1,49 @@
ALLOWED_IMAGE_EXTENSIONS = {"svg", "png", "jpg", "jpeg", "webp"}
MAX_FILE_SIZE_MB = 5
MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024
def validate_image_file(file):
"""
Validate file ảnh trước khi upload lên MinIO/S3.
Hàm này kiểm tra:
1. Có file được gửi lên không
2. File có tên không
3. File có extension không
4. Extension có hợp lệ không
5. File có vượt quá dung lượng cho phép không
Trả về:
- extension của file nếu hợp lệ
Raise:
- ValueError nếu file không hợp lệ
"""
if file is None:
raise ValueError("No file uploaded")
if file.filename == "":
raise ValueError("Filename is empty")
if "." not in file.filename:
raise ValueError("File must have an extension")
extension = file.filename.rsplit(".", 1)[1].lower()
if extension not in ALLOWED_IMAGE_EXTENSIONS:
raise ValueError("Invalid image type. Allowed: svg, png, jpg, jpeg, webp")
# Di chuyển con trỏ file tới cuối để đo dung lượng file.
file.seek(0, 2)
file_size = file.tell()
# Đưa con trỏ file về đầu để lát nữa upload không bị mất dữ liệu.
file.seek(0)
if file_size > MAX_FILE_SIZE_BYTES:
raise ValueError(f"File size must be less than {MAX_FILE_SIZE_MB}MB")
return extension

View File

@@ -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>

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

View 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;
}

View 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'),
};

View 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, '&amp;')
// .replace(/</g, '&lt;')
// .replace(/>/g, '&gt;')
// .replace(/"/g, '&quot;')
// .replace(/'/g, '&#39;');
// }
// // 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 '&amp;';
if (m === '<') return '&lt;';
if (m === '>') return '&gt;';
return m;
});
}

57
frontend/src/main.js Normal file
View 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');

View File

@@ -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
View 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}