devicetypeAPIGET1

This commit is contained in:
QuangMinh_123
2026-05-21 12:01:10 +07:00
parent 86383e7c03
commit 7aebcf9567
35 changed files with 2784 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

View File

View File

View File

View File

@@ -1,26 +1,30 @@
from flask import Flask, jsonify
# from flask_cors import CORS
import os
from dotenv import load_dotenv
from flask import Flask
load_dotenv() # Đọc file .env
from common.exceptions.handler import (
register_error_handlers
)
app = Flask(__name__) # ← Đây là dòng quan trọng nhất
# Cho phép React (localhost:5173) gọi API từ backend
CORS(app, origins=["http://localhost:5173", "http://127.0.0.1:5173"])
from modules.device_type.routes import (
device_type_bp
)
@app.route('/')
def home():
return jsonify({
"message": "✅ Backend NDMS đang chạy thành công!",
"status": "ok"
})
app = Flask(__name__)
@app.route('/health')
def health():
return jsonify({"status": "healthy"})
# Register Global Exception Handlers
register_error_handlers(app)
# Phần này bạn hay dùng
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8000, debug=True)
# Register Blueprints
app.register_blueprint(
device_type_bp,
url_prefix="/api"
)
# @app.route("/")
# def home():
# return {
# "message": "NDMS Backend Running"
# }
if __name__ == "__main__":
app.run(debug=True)

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

View File

@@ -0,0 +1 @@

View File

View File

@@ -0,0 +1,83 @@
# ============================================
# CONTROLLER: Nhận request → Validate → Gọi Service → Trả response
# ============================================
# Controller KHÔNG chứa business logic (check trùng tên, tính toán...)
# Controller CHỈ làm 3 việc:
# 1. Lấy dữ liệu từ request (body, params, query string)
# 2. Validate dữ liệu (bằng Marshmallow schema)
# 3. Gọi service → trả response
# ============================================
from flask import request
from common.response.api_response import success_response
from common.constants.status_code import HTTP_CREATED
from modules.device_type.service import (
get_device_types_service,
create_device_type_service
)
from modules.device_type.schemas import CreateDeviceTypeSchema
def get_device_types():
"""GET /api/device-types — Lấy danh sách tất cả"""
device_types = get_device_types_service()
return success_response(
data=device_types,
message="Device types retrieved successfully"
)
def get_device_type_by_id(device_type_id):
"""GET /api/device-types/<device_type_id> — Lấy chi tiết theo ID"""
# TODO: Triển khai sau
pass
def create_device_type():
"""
POST /api/device-types — Tạo mới device type
Luồng xử lý:
1. Lấy JSON body từ request
2. Validate bằng Marshmallow schema
- Nếu sai → Marshmallow tự throw ValidationError
- Global handler bắt → trả 400 kèm chi tiết lỗi
3. Gọi service (service sẽ check trùng tên + insert DB)
4. Trả 201 Created + data vừa tạo
"""
# Bước 1: Lấy JSON body từ request
body = request.get_json()
# request.get_json() parse body thành dict Python
# VD: {"name": "Router", "color": "#3B82F6"} → {'name': 'Router', 'color': '#3B82F6'}
# Bước 2: Validate bằng schema
schema = CreateDeviceTypeSchema()
data = schema.load(body)
# schema.load() làm 2 việc:
# a) Validate: check required, type, regex... → nếu sai throw ValidationError
# b) Deserialize: loại bỏ field thừa, áp dụng load_default
# VD: body = {"name": "Router", "color": "#3B82F6", "hack": "xss"}
# → data = {"name": "Router", "color": "#3B82F6", "sort_order": 0, "is_active": True}
# → "hack" bị loại bỏ, sort_order/is_active được thêm default
# Bước 3: Gọi service
new_device_type = create_device_type_service(data)
# Bước 4: Trả response 201 Created
return success_response(
data=new_device_type,
message="Device type created successfully",
status_code=HTTP_CREATED # 201 thay vì 200 — đúng chuẩn REST cho POST thành công
)
def update_device_type(device_type_id):
"""PUT /api/device-types/<device_type_id> — Cập nhật"""
# TODO: Triển khai sau
pass
def delete_device_type(device_type_id):
"""DELETE /api/device-types/<device_type_id> — Xóa"""
# TODO: Triển khai sau
pass

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,120 @@
from config.database import get_connection, release_connection
# ============================================
# Hàm chung: Chuyển 1 row (tuple) thành dict
# Tại sao tách riêng? Vì nhiều hàm đều cần chuyển row → dict,
# viết 1 lần dùng lại, tránh copy-paste
# ============================================
def _row_to_dict(row):
return {
"id": str(row[0]),
"name": row[1],
"description": row[2],
"icon_url": row[3],
"color": row[4],
"sort_order": row[5],
"is_active": row[6],
"created": row[7].isoformat() if row[7] else None,
"modified": row[8].isoformat() if row[8] else None
}
# ============================================
# GET ALL: Lấy danh sách tất cả device types
# ============================================
def find_all_device_types():
conn = get_connection()
cur = None
try:
cur = conn.cursor()
cur.execute("""
SELECT id, name, description, icon_url, color, sort_order, is_active, created, modified
FROM device_types
ORDER BY sort_order ASC, name ASC
""")
rows = cur.fetchall()
device_types = []
for row in rows:
device_types.append(_row_to_dict(row))
# ✅ FIX: return PHẢI nằm NGOÀI vòng for
# Trước đó return nằm trong for → chỉ trả về 1 row rồi dừng
return device_types
finally:
if cur:
cur.close()
# ✅ FIX: PHẢI trả connection về pool, không thì sau 10 request app sẽ đứng
release_connection(conn)
# ============================================
# FIND BY NAME: Tìm device type theo tên (để check trùng tên khi tạo mới)
# Trả về dict nếu tìm thấy, None nếu không
# ============================================
def find_device_type_by_name(name):
conn = get_connection()
cur = None
try:
cur = conn.cursor()
# Dùng LOWER() để so sánh không phân biệt hoa/thường
# VD: "Router" và "router" coi như trùng
cur.execute("""
SELECT id, name, description, icon_url, color, sort_order, is_active, created, modified
FROM device_types
WHERE LOWER(name) = LOWER(%s)
""", (name,))
# ⚠️ LƯU Ý: (name,) có dấu phẩy → tạo tuple 1 phần tử
# Nếu viết (name) thì Python hiểu là string, không phải tuple → lỗi
row = cur.fetchone() # fetchone() vì chỉ cần 1 kết quả
if row:
return _row_to_dict(row)
return None
finally:
if cur:
cur.close()
release_connection(conn)
# ============================================
# INSERT: Thêm mới 1 device type vào database
# Trả về dict của device type vừa tạo (có id, created, modified từ DB)
# ============================================
def insert_device_type(data):
conn = get_connection()
cur = None
try:
cur = conn.cursor()
cur.execute("""
INSERT INTO device_types (name, description, icon_url, color, sort_order, is_active)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING id, name, description, icon_url, color, sort_order, is_active, created, modified
""", (
data["name"],
data.get("description"), # .get() trả về None nếu không có key
data.get("icon_url"),
data["color"],
data.get("sort_order", 0), # default = 0 nếu không truyền
data.get("is_active", True) # default = True nếu không truyền
))
# RETURNING: PostgreSQL trả về row vừa INSERT (bao gồm id, created, modified do DB tự tạo)
# → Không cần SELECT lại sau khi INSERT
row = cur.fetchone()
conn.commit() # ⚠️ QUAN TRỌNG: INSERT/UPDATE/DELETE phải commit() để lưu vào DB
# Nếu không commit → dữ liệu chỉ nằm trong transaction, khi connection đóng → mất hết
return _row_to_dict(row)
except Exception:
conn.rollback() # Nếu có lỗi → rollback để hủy transaction, tránh dữ liệu bẩn
raise # raise lại exception để tầng trên (service/controller) xử lý
finally:
if cur:
cur.close()
release_connection(conn)

View File

@@ -0,0 +1,21 @@
from flask import Blueprint
from modules.device_type.controller import (
get_device_types,
get_device_type_by_id,
create_device_type,
update_device_type,
delete_device_type
)
device_type_bp = Blueprint(
"device_type",
__name__,
url_prefix="/device-types"
)
device_type_bp.route("", methods=["GET"])(get_device_types)
device_type_bp.route("/<device_type_id>", methods=["GET"])(get_device_type_by_id)
device_type_bp.route("", methods=["POST"])(create_device_type)
device_type_bp.route("/<device_type_id>", methods=["PUT"])(update_device_type)
device_type_bp.route("/<device_type_id>", methods=["DELETE"])(delete_device_type)

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,38 @@
# ============================================
# SERVICE LAYER: Chứa Business Logic
# ============================================
# Controller chỉ nhận request + trả response
# Repository chỉ truy vấn DB
# Service ở giữa: chứa LOGIC NGHIỆP VỤ (validate, check điều kiện, tính toán)
# ============================================
from modules.device_type.repository import (
find_all_device_types,
find_device_type_by_name,
insert_device_type
)
from modules.device_type.exceptions import DeviceTypeAlreadyExistsException
def get_device_types_service():
"""Lấy danh sách tất cả device types — hiện tại không có logic gì thêm"""
return find_all_device_types()
def create_device_type_service(data):
"""
Tạo mới device type.
Business logic:
1. Check xem tên đã tồn tại chưa (UNIQUE constraint)
2. Nếu trùng → throw exception
3. Nếu không trùng → insert vào DB
"""
# Bước 1: Check trùng tên
existing = find_device_type_by_name(data["name"])
if existing:
# Throw exception → global handler bắt → trả 409 Conflict
raise DeviceTypeAlreadyExistsException(data["name"])
# Bước 2: Insert vào DB
new_device_type = insert_device_type(data)
return new_device_type

View File

View File

@@ -10,3 +10,5 @@ python-dotenv==1.2.2
SQLAlchemy==2.0.49
typing_extensions==4.15.0
Werkzeug==3.1.8
marshmallow==3.21.2
APScheduler==3.10.4

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}