Compare commits
12 Commits
de399fe3f8
...
implementa
| Author | SHA1 | Date | |
|---|---|---|---|
| b2c6a29b19 | |||
| 5075a2440d | |||
| 5d85dc0714 | |||
| a6243413a1 | |||
| ab3eb8b1c4 | |||
| d7be8d7036 | |||
| 9b15c7a480 | |||
| eb19a557c3 | |||
| c2fb62ab7a | |||
| 000009595d | |||
| a62acdc47d | |||
| 24b04f84ab |
24
.env
Normal file
24
.env
Normal file
@@ -0,0 +1,24 @@
|
||||
# ===================================================================
|
||||
# ControlPatente - Environment Configuration
|
||||
# ===================================================================
|
||||
# Generated on: 2026-01-29
|
||||
# ===================================================================
|
||||
|
||||
# --- Database Configuration ---
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=e0p0kcnMmG8kg2YylgQ0Mw
|
||||
DB_NAME=controlpatente
|
||||
|
||||
# --- Security Configuration ---
|
||||
|
||||
# JWT Secret (auto-generated)
|
||||
JWT_SECRET=d95810e29f700cb99d3ed6a891ace603522875069ec655887a01629891a38ce8
|
||||
|
||||
# Admin password (optional - if not set, a random password will be generated on first run)
|
||||
# ADMIN_PASSWORD=HnXlU0BtE5-PtQ8n
|
||||
|
||||
# Allowed origins for CORS (comma-separated)
|
||||
ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,https://demo.v1ru5.cl
|
||||
|
||||
# Service-to-service API key (auto-generated)
|
||||
SERVICE_API_KEY=a6b73ab722d2980cafb89836393266f96bf798209b6a4ce2
|
||||
43
.env.example
Normal file
43
.env.example
Normal file
@@ -0,0 +1,43 @@
|
||||
# ===================================================================
|
||||
# ControlPatente - Environment Configuration
|
||||
# ===================================================================
|
||||
# Copy this file to .env and configure the values before starting
|
||||
#
|
||||
# IMPORTANT: Never commit .env to version control!
|
||||
# ===================================================================
|
||||
|
||||
# --- Database Configuration ---
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=CHANGE_THIS_PASSWORD
|
||||
DB_NAME=controlpatente
|
||||
|
||||
# --- Security Configuration (REQUIRED) ---
|
||||
|
||||
# JWT Secret - REQUIRED for authentication
|
||||
# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
JWT_SECRET=GENERATE_A_SECURE_64_CHARACTER_HEX_STRING_HERE
|
||||
|
||||
# Admin password (optional - if not set, a random password will be generated)
|
||||
# ADMIN_PASSWORD=your_secure_admin_password
|
||||
|
||||
# Allowed origins for CORS (comma-separated)
|
||||
# Default: http://localhost:5173
|
||||
ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
||||
|
||||
# Service-to-service API key (for ALPR -> Backend communication)
|
||||
# Generate with: node -e "console.log(require('crypto').randomBytes(24).toString('hex'))"
|
||||
SERVICE_API_KEY=GENERATE_A_SECURE_48_CHARACTER_HEX_STRING_HERE
|
||||
|
||||
# --- Optional Configuration ---
|
||||
|
||||
# Backend port (default: 3000)
|
||||
# PORT=3000
|
||||
|
||||
# ALPR processing interval in seconds (default: 1.5)
|
||||
# PROCESS_INTERVAL=1.5
|
||||
|
||||
# Dataset capture cooldown in seconds (default: 60)
|
||||
# DATASET_COOLDOWN=60
|
||||
|
||||
# Number of OCR worker threads (default: 2)
|
||||
# OCR_WORKERS=2
|
||||
@@ -5,16 +5,22 @@ import os
|
||||
import time
|
||||
import threading
|
||||
import re
|
||||
import numpy as np
|
||||
from datetime import datetime
|
||||
from queue import Queue
|
||||
from flask import Flask, Response
|
||||
from flask import Flask, Response, request, send_from_directory
|
||||
from flask_cors import CORS
|
||||
from ultralytics import YOLO
|
||||
|
||||
# Configuration
|
||||
# Configuration (puede ser sobrescrito por variables de entorno)
|
||||
BACKEND_URL = os.environ.get('BACKEND_URL', 'http://localhost:3000')
|
||||
CAMERA_ID = 0
|
||||
PROCESS_INTERVAL = 1.5 # Más reactivo
|
||||
MODEL_PATH = 'best.pt'
|
||||
CAMERA_ID = int(os.environ.get('CAMERA_ID', 0))
|
||||
PROCESS_INTERVAL = float(os.environ.get('PROCESS_INTERVAL', 1.5))
|
||||
MODEL_PATH = os.environ.get('MODEL_PATH', 'best.pt')
|
||||
DATASET_DIR = os.environ.get('DATASET_DIR', '/app/dataset')
|
||||
DATASET_COOLDOWN = int(os.environ.get('DATASET_COOLDOWN', 60))
|
||||
OCR_WORKERS = int(os.environ.get('OCR_WORKERS', 2)) # Número de workers OCR
|
||||
SERVICE_API_KEY = os.environ.get('SERVICE_API_KEY', '') # For backend auth
|
||||
|
||||
app = Flask(__name__)
|
||||
CORS(app)
|
||||
@@ -26,50 +32,157 @@ latest_detections = []
|
||||
detection_lock = threading.Lock()
|
||||
|
||||
# Cola para procesamiento OCR asíncrono
|
||||
ocr_queue = Queue(maxsize=5)
|
||||
ocr_queue = Queue(maxsize=10)
|
||||
|
||||
# Cooldown para evitar múltiples capturas de la misma patente
|
||||
recent_captures = {} # {plate_number: timestamp}
|
||||
captures_lock = threading.Lock()
|
||||
|
||||
# Cache para lista de dataset
|
||||
dataset_cache = {'data': None, 'timestamp': 0, 'ttl': 5} # 5 segundos de cache
|
||||
|
||||
# Métricas para health check
|
||||
metrics = {
|
||||
'fps': 0,
|
||||
'ocr_queue_size': 0,
|
||||
'total_detections': 0,
|
||||
'total_captures': 0,
|
||||
'last_detection': None,
|
||||
'start_time': time.time()
|
||||
}
|
||||
metrics_lock = threading.Lock()
|
||||
|
||||
# Crear carpeta de dataset si no existe
|
||||
os.makedirs(DATASET_DIR, exist_ok=True)
|
||||
print(f"📁 Dataset directory: {DATASET_DIR}")
|
||||
|
||||
def cleanup_recent_captures():
|
||||
"""Limpia capturas antiguas para evitar memory leak - ejecuta cada 5 minutos"""
|
||||
while True:
|
||||
time.sleep(300) # 5 minutos
|
||||
current_time = time.time()
|
||||
with captures_lock:
|
||||
expired = [k for k, v in recent_captures.items() if current_time - v > DATASET_COOLDOWN * 2]
|
||||
for k in expired:
|
||||
del recent_captures[k]
|
||||
if expired:
|
||||
print(f"🧹 Cleaned {len(expired)} expired capture records")
|
||||
|
||||
def save_plate_capture(plate_number, full_frame):
|
||||
"""Guarda la captura de la patente para el dataset con cooldown"""
|
||||
current_time = time.time()
|
||||
|
||||
# Validar que el frame no esté vacío
|
||||
if full_frame is None or full_frame.size == 0:
|
||||
print(f"⚠️ Empty frame, skipping save for {plate_number}")
|
||||
return False
|
||||
|
||||
# Verificar cooldown
|
||||
with captures_lock:
|
||||
if plate_number in recent_captures:
|
||||
elapsed = current_time - recent_captures[plate_number]
|
||||
if elapsed < DATASET_COOLDOWN:
|
||||
return False
|
||||
recent_captures[plate_number] = current_time
|
||||
|
||||
try:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
frame_to_save = np.copy(full_frame)
|
||||
|
||||
filename = f"{plate_number}_{timestamp}.jpg"
|
||||
filepath = f"{DATASET_DIR}/{filename}"
|
||||
|
||||
success = cv2.imwrite(filepath, frame_to_save, [cv2.IMWRITE_JPEG_QUALITY, 95])
|
||||
|
||||
if not success or not os.path.exists(filepath) or os.path.getsize(filepath) == 0:
|
||||
print(f"❌ Failed to save image for {plate_number}")
|
||||
if os.path.exists(filepath):
|
||||
os.remove(filepath)
|
||||
return False
|
||||
|
||||
# Invalidar cache
|
||||
dataset_cache['timestamp'] = 0
|
||||
|
||||
# Actualizar métricas
|
||||
with metrics_lock:
|
||||
metrics['total_captures'] += 1
|
||||
|
||||
# Contar total de capturas
|
||||
total_count = len([f for f in os.listdir(DATASET_DIR) if f.endswith('.jpg')])
|
||||
|
||||
# Notificar al backend
|
||||
try:
|
||||
requests.post(f"{BACKEND_URL}/api/dataset/capture", json={
|
||||
'plate_number': plate_number,
|
||||
'filename': filename,
|
||||
'count': total_count
|
||||
}, timeout=2)
|
||||
except:
|
||||
pass
|
||||
|
||||
print(f"📸 Saved to dataset: {plate_number} (Total: {total_count})")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"❌ Error saving capture: {e}")
|
||||
return False
|
||||
|
||||
def send_plate(plate_number):
|
||||
"""Envía la patente detectada al backend"""
|
||||
try:
|
||||
url = f"{BACKEND_URL}/api/detect"
|
||||
requests.post(url, json={'plate_number': plate_number}, timeout=3)
|
||||
headers = {}
|
||||
if SERVICE_API_KEY:
|
||||
headers['X-Service-Key'] = SERVICE_API_KEY
|
||||
requests.post(url, json={'plate_number': plate_number}, headers=headers, timeout=3)
|
||||
print(f"✓ Plate sent: {plate_number}")
|
||||
|
||||
with metrics_lock:
|
||||
metrics['total_detections'] += 1
|
||||
metrics['last_detection'] = plate_number
|
||||
except Exception as e:
|
||||
print(f"✗ Error sending plate: {e}")
|
||||
|
||||
def validate_and_send(text):
|
||||
"""Valida formato chileno y envía"""
|
||||
# Formato nuevo: XXXX-00 | Formato antiguo: XX-0000
|
||||
if re.match(r'^[A-Z]{4}\d{2}$', text) or re.match(r'^[A-Z]{2}\d{4}$', text):
|
||||
send_plate(text)
|
||||
return True
|
||||
return False
|
||||
def validate_plate(text):
|
||||
"""Valida formatos de patentes de Chile, Argentina y Brasil"""
|
||||
# Chile formato nuevo: XXXX00 (4 letras, 2 números)
|
||||
# Chile formato antiguo: XX0000 (2 letras, 4 números)
|
||||
# Argentina Mercosur: AA000AA (2 letras, 3 números, 2 letras)
|
||||
# Brasil Mercosur: AAA0A00 (3 letras, 1 número, 1 letra, 2 números)
|
||||
chile_new = re.match(r'^[A-Z]{4}\d{2}$', text)
|
||||
chile_old = re.match(r'^[A-Z]{2}\d{4}$', text)
|
||||
argentina = re.match(r'^[A-Z]{2}\d{3}[A-Z]{2}$', text)
|
||||
brasil = re.match(r'^[A-Z]{3}\d[A-Z]\d{2}$', text)
|
||||
return bool(chile_new or chile_old or argentina or brasil)
|
||||
|
||||
def ocr_worker(reader):
|
||||
"""Hilo dedicado para OCR - no bloquea el stream"""
|
||||
def ocr_worker(reader, worker_id):
|
||||
"""Hilo dedicado para OCR - múltiples workers para mejor rendimiento"""
|
||||
print(f"🔤 OCR Worker {worker_id} started")
|
||||
while True:
|
||||
try:
|
||||
plate_img = ocr_queue.get(timeout=1)
|
||||
if plate_img is None:
|
||||
data = ocr_queue.get(timeout=1)
|
||||
if data is None:
|
||||
continue
|
||||
|
||||
# Preprocesamiento para mejor OCR
|
||||
plate_img, full_frame = data
|
||||
|
||||
gray = cv2.cvtColor(plate_img, cv2.COLOR_BGR2GRAY)
|
||||
|
||||
ocr_results = reader.readtext(gray, detail=0, paragraph=False,
|
||||
allowlist='ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789')
|
||||
for text in ocr_results:
|
||||
clean_text = ''.join(e for e in text if e.isalnum()).upper()
|
||||
if len(clean_text) >= 6:
|
||||
validate_and_send(clean_text)
|
||||
if len(clean_text) >= 6 and validate_plate(clean_text):
|
||||
send_plate(clean_text)
|
||||
save_plate_capture(clean_text, full_frame)
|
||||
except:
|
||||
pass
|
||||
|
||||
def camera_loop():
|
||||
"""Hilo principal de captura - mantiene FPS alto"""
|
||||
"""Hilo principal de captura"""
|
||||
global outputFrame, latest_detections
|
||||
|
||||
print("🚀 Initializing ALPR System...")
|
||||
print(f"⚙️ Config: PROCESS_INTERVAL={PROCESS_INTERVAL}s, OCR_WORKERS={OCR_WORKERS}")
|
||||
print("📷 Loading camera...")
|
||||
|
||||
cap = cv2.VideoCapture(CAMERA_ID)
|
||||
@@ -89,17 +202,22 @@ def camera_loop():
|
||||
print("📝 Initializing EasyOCR...")
|
||||
reader = easyocr.Reader(['en'], gpu=False)
|
||||
|
||||
# Iniciar worker de OCR
|
||||
ocr_thread = threading.Thread(target=ocr_worker, args=(reader,), daemon=True)
|
||||
ocr_thread.start()
|
||||
# Iniciar múltiples workers de OCR
|
||||
for i in range(OCR_WORKERS):
|
||||
t = threading.Thread(target=ocr_worker, args=(reader, i+1), daemon=True)
|
||||
t.start()
|
||||
|
||||
# Iniciar limpiador de cache
|
||||
cleanup_thread = threading.Thread(target=cleanup_recent_captures, daemon=True)
|
||||
cleanup_thread.start()
|
||||
|
||||
print("✅ System ready!")
|
||||
|
||||
last_process_time = 0
|
||||
frame_count = 0
|
||||
fps_start_time = time.time()
|
||||
|
||||
while True:
|
||||
# Captura eficiente - solo 2 grabs
|
||||
cap.grab()
|
||||
cap.grab()
|
||||
ret, frame = cap.retrieve()
|
||||
@@ -111,11 +229,18 @@ def camera_loop():
|
||||
frame_count += 1
|
||||
current_time = time.time()
|
||||
|
||||
# Procesar ALPR cada PROCESS_INTERVAL segundos
|
||||
# Calcular FPS cada segundo
|
||||
if current_time - fps_start_time >= 1.0:
|
||||
with metrics_lock:
|
||||
metrics['fps'] = frame_count
|
||||
metrics['ocr_queue_size'] = ocr_queue.qsize()
|
||||
frame_count = 0
|
||||
fps_start_time = current_time
|
||||
|
||||
# Procesar ALPR
|
||||
if current_time - last_process_time > PROCESS_INTERVAL:
|
||||
last_process_time = current_time
|
||||
|
||||
# YOLO detection - usar imgsz pequeño para velocidad
|
||||
results = model(frame, verbose=False, imgsz=320, conf=0.5)
|
||||
|
||||
new_detections = []
|
||||
@@ -125,15 +250,13 @@ def camera_loop():
|
||||
conf = float(box.conf[0])
|
||||
new_detections.append((x1, y1, x2, y2, conf))
|
||||
|
||||
# Extraer imagen de placa y enviar a cola OCR
|
||||
plate_img = frame[y1:y2, x1:x2].copy()
|
||||
if plate_img.size > 0 and not ocr_queue.full():
|
||||
ocr_queue.put(plate_img)
|
||||
ocr_queue.put((plate_img, frame.copy()))
|
||||
|
||||
with detection_lock:
|
||||
latest_detections = new_detections
|
||||
|
||||
# Actualizar frame para streaming (sin bloquear)
|
||||
display_frame = frame
|
||||
with detection_lock:
|
||||
for (x1, y1, x2, y2, conf) in latest_detections:
|
||||
@@ -148,7 +271,7 @@ def generate():
|
||||
"""Generador para streaming MJPEG"""
|
||||
global outputFrame
|
||||
while True:
|
||||
time.sleep(0.033) # ~30 FPS para el stream
|
||||
time.sleep(0.033)
|
||||
with frame_lock:
|
||||
if outputFrame is None:
|
||||
continue
|
||||
@@ -161,7 +284,114 @@ def video_feed():
|
||||
|
||||
@app.route("/health")
|
||||
def health():
|
||||
return {"status": "ok", "service": "alpr"}
|
||||
"""Health check completo con métricas"""
|
||||
with metrics_lock:
|
||||
uptime = time.time() - metrics['start_time']
|
||||
return {
|
||||
"status": "ok",
|
||||
"service": "alpr",
|
||||
"uptime_seconds": int(uptime),
|
||||
"fps": metrics['fps'],
|
||||
"ocr_queue_size": metrics['ocr_queue_size'],
|
||||
"ocr_workers": OCR_WORKERS,
|
||||
"total_detections": metrics['total_detections'],
|
||||
"total_captures": metrics['total_captures'],
|
||||
"last_detection": metrics['last_detection'],
|
||||
"dataset_size": len([f for f in os.listdir(DATASET_DIR) if f.endswith('.jpg')])
|
||||
}
|
||||
|
||||
@app.route("/dataset/count")
|
||||
def dataset_count():
|
||||
try:
|
||||
files = [f for f in os.listdir(DATASET_DIR) if f.endswith('.jpg')]
|
||||
return {"plates_captured": len(files), "total_files": len(files)}
|
||||
except:
|
||||
return {"plates_captured": 0, "total_files": 0}
|
||||
|
||||
@app.route("/dataset/list")
|
||||
def dataset_list():
|
||||
"""Lista las imágenes del dataset con paginación y cache"""
|
||||
current_time = time.time()
|
||||
|
||||
# Usar cache si está vigente
|
||||
page = int(request.args.get('page', 1))
|
||||
per_page = int(request.args.get('per_page', 50))
|
||||
cache_key = f"{page}_{per_page}"
|
||||
|
||||
try:
|
||||
# Obtener lista de archivos (con cache básico)
|
||||
if dataset_cache['timestamp'] == 0 or current_time - dataset_cache['timestamp'] > dataset_cache['ttl']:
|
||||
files = [f for f in os.listdir(DATASET_DIR) if f.endswith('.jpg')]
|
||||
files_with_time = [(f, os.path.getmtime(os.path.join(DATASET_DIR, f))) for f in files]
|
||||
files_with_time.sort(key=lambda x: x[1], reverse=True)
|
||||
dataset_cache['data'] = [f[0] for f in files_with_time]
|
||||
dataset_cache['timestamp'] = current_time
|
||||
|
||||
sorted_files = dataset_cache['data']
|
||||
|
||||
# Paginación
|
||||
total = len(sorted_files)
|
||||
total_pages = (total + per_page - 1) // per_page
|
||||
start = (page - 1) * per_page
|
||||
end = start + per_page
|
||||
page_files = sorted_files[start:end]
|
||||
|
||||
images = []
|
||||
for f in page_files:
|
||||
parts = f.replace('.jpg', '').split('_')
|
||||
plate = parts[0] if parts else 'Unknown'
|
||||
images.append({
|
||||
'filename': f,
|
||||
'plate': plate,
|
||||
'url': f'/dataset/images/{f}'
|
||||
})
|
||||
|
||||
return {
|
||||
"images": images,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"total_pages": total_pages
|
||||
}
|
||||
except Exception as e:
|
||||
return {"images": [], "total": 0, "error": str(e)}
|
||||
|
||||
@app.route("/dataset/images/<filename>")
|
||||
def dataset_image(filename):
|
||||
return send_from_directory(DATASET_DIR, filename)
|
||||
|
||||
# SECURITY: Auth decorator for destructive operations
|
||||
from functools import wraps
|
||||
def require_auth(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
if not SERVICE_API_KEY:
|
||||
# No key configured = dev mode, allow but warn
|
||||
print("⚠️ SERVICE_API_KEY not set - dataset DELETE unprotected!")
|
||||
return f(*args, **kwargs)
|
||||
|
||||
provided_key = request.headers.get('X-Service-Key', '')
|
||||
if provided_key != SERVICE_API_KEY:
|
||||
return {"error": "Unauthorized"}, 401
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
|
||||
@app.route("/dataset/images/<filename>", methods=['DELETE'])
|
||||
@require_auth
|
||||
def delete_dataset_image(filename):
|
||||
"""Elimina una imagen del dataset"""
|
||||
try:
|
||||
filepath = os.path.join(DATASET_DIR, filename)
|
||||
if os.path.exists(filepath):
|
||||
os.remove(filepath)
|
||||
# Invalidar cache
|
||||
dataset_cache['timestamp'] = 0
|
||||
print(f"🗑️ Deleted from dataset: {filename}")
|
||||
return {"success": True, "message": f"Deleted {filename}"}
|
||||
else:
|
||||
return {"success": False, "message": "File not found"}, 404
|
||||
except Exception as e:
|
||||
return {"success": False, "message": str(e)}, 500
|
||||
|
||||
if __name__ == "__main__":
|
||||
t = threading.Thread(target=camera_loop, daemon=True)
|
||||
|
||||
@@ -44,4 +44,8 @@ model AccessLog {
|
||||
plateNumber String
|
||||
accessStatus String // GRANTED, DENIED, UNKNOWN
|
||||
timestamp DateTime @default(now())
|
||||
|
||||
@@index([plateNumber])
|
||||
@@index([timestamp])
|
||||
@@index([plateNumber, timestamp])
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const bcrypt = require('bcryptjs'); // Movido al inicio (E)
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const http = require('http');
|
||||
const { Server } = require('socket.io');
|
||||
@@ -7,14 +8,55 @@ const { Server } = require('socket.io');
|
||||
const app = express();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
app.use(cors());
|
||||
// SECURITY: Configure CORS with specific origins
|
||||
const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS
|
||||
? process.env.ALLOWED_ORIGINS.split(',')
|
||||
: ['http://localhost:5173', 'http://127.0.0.1:5173'];
|
||||
|
||||
app.use(cors({
|
||||
origin: ALLOWED_ORIGINS,
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization', 'X-Service-Key']
|
||||
}));
|
||||
app.use(express.json());
|
||||
|
||||
// Rate limiting simple para /api/detect (G)
|
||||
const detectRateLimit = new Map();
|
||||
const RATE_LIMIT_WINDOW = 1000; // 1 segundo
|
||||
const RATE_LIMIT_MAX = 10; // máximo 10 requests por segundo
|
||||
|
||||
function checkRateLimit(ip) {
|
||||
const now = Date.now();
|
||||
const record = detectRateLimit.get(ip) || { count: 0, resetTime: now + RATE_LIMIT_WINDOW };
|
||||
|
||||
if (now > record.resetTime) {
|
||||
record.count = 1;
|
||||
record.resetTime = now + RATE_LIMIT_WINDOW;
|
||||
} else {
|
||||
record.count++;
|
||||
}
|
||||
|
||||
detectRateLimit.set(ip, record);
|
||||
return record.count <= RATE_LIMIT_MAX;
|
||||
}
|
||||
|
||||
// Limpiar rate limit map cada minuto
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [ip, record] of detectRateLimit.entries()) {
|
||||
if (now > record.resetTime + 60000) {
|
||||
detectRateLimit.delete(ip);
|
||||
}
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
const server = http.createServer(app);
|
||||
const io = new Server(server, {
|
||||
cors: {
|
||||
origin: "*",
|
||||
methods: ["GET", "POST"]
|
||||
origin: ALLOWED_ORIGINS,
|
||||
methods: ["GET", "POST"],
|
||||
credentials: true
|
||||
}
|
||||
});
|
||||
|
||||
@@ -31,9 +73,7 @@ app.use('/api/auth', authRoutes);
|
||||
// Plates CRUD
|
||||
app.get('/api/plates', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
// Filter based on role
|
||||
const where = req.user.role === 'ADMIN' ? {} : { addedById: req.user.id };
|
||||
|
||||
const plates = await prisma.plate.findMany({
|
||||
where,
|
||||
include: { addedBy: { select: { username: true } } }
|
||||
@@ -47,7 +87,6 @@ app.get('/api/plates', authenticateToken, async (req, res) => {
|
||||
app.post('/api/plates', authenticateToken, async (req, res) => {
|
||||
const { number, owner } = req.body;
|
||||
const isAdm = req.user.role === 'ADMIN';
|
||||
// Admin -> ALLOWED, User -> PENDING
|
||||
const status = isAdm ? 'ALLOWED' : 'PENDING';
|
||||
|
||||
try {
|
||||
@@ -59,10 +98,7 @@ app.post('/api/plates', authenticateToken, async (req, res) => {
|
||||
addedById: req.user.id
|
||||
}
|
||||
});
|
||||
|
||||
// Notify Admin via WebSocket
|
||||
io.emit('new_plate_registered', plate);
|
||||
|
||||
res.json(plate);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
@@ -72,7 +108,7 @@ app.post('/api/plates', authenticateToken, async (req, res) => {
|
||||
// Admin: Approve/Reject Plate
|
||||
app.put('/api/plates/:id/approve', authenticateToken, isAdmin, async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { status } = req.body; // ALLOWED or DENIED
|
||||
const { status } = req.body;
|
||||
|
||||
if (!['ALLOWED', 'DENIED'].includes(status)) {
|
||||
return res.status(400).json({ error: 'Invalid status' });
|
||||
@@ -83,17 +119,13 @@ app.put('/api/plates/:id/approve', authenticateToken, isAdmin, async (req, res)
|
||||
where: { id: parseInt(id) },
|
||||
data: { status }
|
||||
});
|
||||
|
||||
// Notify Users via WebSocket
|
||||
io.emit('plate_status_updated', plate);
|
||||
|
||||
res.json(plate);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Admin: Delete Plate (Optional but good to have)
|
||||
// Delete Plate (Admin or Owner)
|
||||
app.delete('/api/plates/:id', authenticateToken, async (req, res) => {
|
||||
const { id } = req.params;
|
||||
@@ -101,15 +133,12 @@ app.delete('/api/plates/:id', authenticateToken, async (req, res) => {
|
||||
const plate = await prisma.plate.findUnique({ where: { id: parseInt(id) } });
|
||||
if (!plate) return res.status(404).json({ error: 'Plate not found' });
|
||||
|
||||
// Check permissions
|
||||
if (req.user.role !== 'ADMIN' && plate.addedById !== req.user.id) {
|
||||
return res.status(403).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
await prisma.plate.delete({ where: { id: parseInt(id) } });
|
||||
|
||||
io.emit('plate_deleted', { id: parseInt(id) });
|
||||
|
||||
res.json({ message: 'Plate deleted' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
@@ -128,9 +157,7 @@ app.delete('/api/people/:id', authenticateToken, async (req, res) => {
|
||||
}
|
||||
|
||||
await prisma.person.delete({ where: { id: parseInt(id) } });
|
||||
|
||||
io.emit('person_deleted', { id: parseInt(id) });
|
||||
|
||||
res.json({ message: 'Person deleted' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
@@ -139,7 +166,7 @@ app.delete('/api/people/:id', authenticateToken, async (req, res) => {
|
||||
|
||||
// History Endpoint
|
||||
app.get('/api/history', async (req, res) => {
|
||||
const { date } = req.query; // Format: YYYY-MM-DD
|
||||
const { date } = req.query;
|
||||
if (!date) {
|
||||
return res.status(400).json({ error: 'Date is required' });
|
||||
}
|
||||
@@ -158,9 +185,7 @@ app.get('/api/history', async (req, res) => {
|
||||
lte: endDate
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
timestamp: 'desc'
|
||||
}
|
||||
orderBy: { timestamp: 'desc' }
|
||||
});
|
||||
res.json(logs);
|
||||
} catch (err) {
|
||||
@@ -174,13 +199,9 @@ app.get('/api/recent', async (req, res) => {
|
||||
const fiveHoursAgo = new Date(Date.now() - 5 * 60 * 60 * 1000);
|
||||
const logs = await prisma.accessLog.findMany({
|
||||
where: {
|
||||
timestamp: {
|
||||
gte: fiveHoursAgo
|
||||
}
|
||||
timestamp: { gte: fiveHoursAgo }
|
||||
},
|
||||
orderBy: {
|
||||
timestamp: 'desc'
|
||||
}
|
||||
orderBy: { timestamp: 'desc' }
|
||||
});
|
||||
res.json(logs);
|
||||
} catch (err) {
|
||||
@@ -238,10 +259,7 @@ app.post('/api/people', authenticateToken, async (req, res) => {
|
||||
addedById: req.user.id
|
||||
}
|
||||
});
|
||||
|
||||
// Notify Admin via WebSocket
|
||||
io.emit('new_person_registered', person);
|
||||
|
||||
res.json(person);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
@@ -256,25 +274,46 @@ app.post('/api/people/bulk-approve', authenticateToken, isAdmin, async (req, res
|
||||
where: { addedById: parseInt(userId), status: 'PENDING' },
|
||||
data: { status: 'APPROVED' }
|
||||
});
|
||||
|
||||
// Notify Users via WebSocket
|
||||
io.emit('people_updated', { userId });
|
||||
|
||||
res.json({ message: 'Bulk approval successful' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Detection Endpoint (from Python)
|
||||
app.post('/api/detect', async (req, res) => {
|
||||
// Detection Endpoint (from Python) with Rate Limiting and Service Auth
|
||||
// SECURITY: Requires X-Service-Key header for service-to-service auth
|
||||
const validateServiceKey = (req, res, next) => {
|
||||
const serviceKey = process.env.SERVICE_API_KEY;
|
||||
|
||||
// If no key configured, allow (development mode) but warn
|
||||
if (!serviceKey) {
|
||||
console.warn('⚠️ SERVICE_API_KEY not configured - /api/detect is unprotected!');
|
||||
return next();
|
||||
}
|
||||
|
||||
const providedKey = req.headers['x-service-key'];
|
||||
if (providedKey !== serviceKey) {
|
||||
console.warn(`🔒 Rejected /api/detect request - invalid service key from ${req.ip}`);
|
||||
return res.status(401).json({ error: 'Invalid service key' });
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
app.post('/api/detect', validateServiceKey, async (req, res) => {
|
||||
const clientIp = req.ip || req.connection.remoteAddress;
|
||||
|
||||
// Check rate limit
|
||||
if (!checkRateLimit(clientIp)) {
|
||||
return res.status(429).json({ error: 'Too many requests' });
|
||||
}
|
||||
|
||||
const { plate_number } = req.body;
|
||||
console.log(`Detected: ${plate_number}`);
|
||||
|
||||
const DUPLICATE_COOLDOWN_MS = 30000; // 30 seconds
|
||||
const DUPLICATE_COOLDOWN_MS = 30000;
|
||||
|
||||
try {
|
||||
// Check for recent duplicate
|
||||
const lastLog = await prisma.accessLog.findFirst({
|
||||
where: { plateNumber: plate_number },
|
||||
orderBy: { timestamp: 'desc' }
|
||||
@@ -288,7 +327,6 @@ app.post('/api/detect', async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if plate exists
|
||||
let plate = await prisma.plate.findUnique({
|
||||
where: { number: plate_number }
|
||||
});
|
||||
@@ -300,12 +338,9 @@ app.post('/api/detect', async (req, res) => {
|
||||
}
|
||||
|
||||
if (!plate) {
|
||||
// Optional: Auto-create unknown plates?
|
||||
// For now, treat as UNKNOWN (Denied)
|
||||
accessStatus = 'UNKNOWN';
|
||||
}
|
||||
|
||||
// Log the access attempt
|
||||
const log = await prisma.accessLog.create({
|
||||
data: {
|
||||
plateNumber: plate_number,
|
||||
@@ -314,7 +349,6 @@ app.post('/api/detect', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Notify Frontend via WebSocket
|
||||
io.emit('new_detection', {
|
||||
plate: plate_number,
|
||||
status: accessStatus,
|
||||
@@ -329,7 +363,30 @@ app.post('/api/detect', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
const bcrypt = require('bcryptjs');
|
||||
// Dataset Capture Notification (from ALPR Python)
|
||||
app.post('/api/dataset/capture', (req, res) => {
|
||||
const { plate_number, filename, count } = req.body;
|
||||
console.log(`📸 Dataset capture: ${plate_number} (Total: ${count})`);
|
||||
|
||||
io.emit('dataset_updated', {
|
||||
plate: plate_number,
|
||||
filename,
|
||||
count,
|
||||
timestamp: new Date()
|
||||
});
|
||||
|
||||
res.json({ message: 'Notification sent' });
|
||||
});
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/api/health', async (req, res) => {
|
||||
try {
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
res.json({ status: 'ok', database: 'connected' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ status: 'error', database: 'disconnected' });
|
||||
}
|
||||
});
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
server.listen(PORT, async () => {
|
||||
@@ -340,7 +397,18 @@ server.listen(PORT, async () => {
|
||||
const userCount = await prisma.user.count();
|
||||
if (userCount === 0) {
|
||||
console.log('No users found. Creating default admin user...');
|
||||
const hashedPassword = await bcrypt.hash('admin123', 10);
|
||||
|
||||
// SECURITY: Use env var or generate random password
|
||||
let adminPassword = process.env.ADMIN_PASSWORD;
|
||||
let isGenerated = false;
|
||||
|
||||
if (!adminPassword) {
|
||||
// Generate a secure random password
|
||||
adminPassword = require('crypto').randomBytes(12).toString('base64url');
|
||||
isGenerated = true;
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(adminPassword, 10);
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
username: 'admin',
|
||||
@@ -348,7 +416,18 @@ server.listen(PORT, async () => {
|
||||
role: 'ADMIN'
|
||||
}
|
||||
});
|
||||
console.log('Default admin created: admin / admin123');
|
||||
|
||||
console.log('═'.repeat(50));
|
||||
console.log('🔐 ADMIN USER CREATED');
|
||||
console.log(' Username: admin');
|
||||
if (isGenerated) {
|
||||
console.log(` Password: ${adminPassword}`);
|
||||
console.log(' ⚠️ SAVE THIS PASSWORD - it won\'t be shown again!');
|
||||
console.log(' 💡 Set ADMIN_PASSWORD env var to use a custom password');
|
||||
} else {
|
||||
console.log(' Password: [from ADMIN_PASSWORD env var]');
|
||||
}
|
||||
console.log('═'.repeat(50));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error seeding admin user:', err);
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-this';
|
||||
// SECURITY: JWT_SECRET must be configured via environment variable
|
||||
const JWT_SECRET = process.env.JWT_SECRET;
|
||||
if (!JWT_SECRET) {
|
||||
console.error('❌ FATAL: JWT_SECRET environment variable is required');
|
||||
console.error(' Generate one with: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const authenticateToken = (req, res, next) => {
|
||||
const authHeader = req.headers['authorization'];
|
||||
|
||||
@@ -11,8 +11,10 @@ services:
|
||||
POSTGRES_DB: ${DB_NAME:-controlpatente}
|
||||
volumes:
|
||||
- db_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
# SECURITY: Port not exposed externally - only accessible within Docker network
|
||||
# Uncomment for local development debugging only
|
||||
# ports:
|
||||
# - "5432:5432"
|
||||
networks:
|
||||
- backend-net
|
||||
restart: unless-stopped
|
||||
@@ -21,6 +23,11 @@ services:
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# Backend Service (Node.js)
|
||||
backend:
|
||||
@@ -29,6 +36,10 @@ services:
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@db:5432/${DB_NAME:-controlpatente}
|
||||
- PORT=3000
|
||||
- JWT_SECRET=${JWT_SECRET:?JWT_SECRET is required}
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-}
|
||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost:5173}
|
||||
- SERVICE_API_KEY=${SERVICE_API_KEY:-}
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
@@ -40,26 +51,57 @@ services:
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- /app/node_modules
|
||||
healthcheck:
|
||||
test: [ "CMD", "wget", "-q", "--spider", "http://localhost:3000/api/health" ]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ALPR Component (Python + OpenCV)
|
||||
alpr-service:
|
||||
build: ./alpr-service
|
||||
container_name: controlpatente-alpr
|
||||
ports:
|
||||
- "5001:5001" # Permite acceder al stream de video desde el nave
|
||||
- "5001:5001"
|
||||
environment:
|
||||
- BACKEND_URL=http://backend:3000
|
||||
# On Mac, you usually cannot pass /dev/video0 directly.
|
||||
# We might need to use a stream or just test with a file for now if direct access fails.
|
||||
# For Linux/Raspberry Pi, the device mapping below is correct.
|
||||
- PROCESS_INTERVAL=1.5
|
||||
- DATASET_COOLDOWN=60
|
||||
- OCR_WORKERS=2
|
||||
- SERVICE_API_KEY=${SERVICE_API_KEY:-}
|
||||
devices:
|
||||
- "/dev/video0:/dev/video0"
|
||||
networks:
|
||||
- backend-net
|
||||
depends_on:
|
||||
- backend
|
||||
backend:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
privileged: true
|
||||
# SECURITY: Use specific capabilities instead of privileged mode
|
||||
# privileged: true # REMOVED - security risk
|
||||
cap_add:
|
||||
- SYS_RAWIO
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
volumes:
|
||||
- ./alpr-service/dataset:/app/dataset
|
||||
healthcheck:
|
||||
test: [ "CMD", "wget", "-q", "--spider", "http://localhost:5001/health" ]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "20m"
|
||||
max-file: "5"
|
||||
|
||||
# Frontend Service (React)
|
||||
frontend:
|
||||
@@ -76,6 +118,12 @@ services:
|
||||
environment:
|
||||
- VITE_API_URL=
|
||||
- VITE_ALPR_STREAM_URL=
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
networks:
|
||||
backend-net:
|
||||
driver: bridge
|
||||
|
||||
@@ -1,13 +1,193 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="es">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
<title>Control de Patentes - ALPR</title>
|
||||
<meta name="description" content="Sistema de Control de Acceso mediante Reconocimiento Automático de Patentes" />
|
||||
|
||||
<!-- Loading Screen Styles (inline para carga inmediata) -->
|
||||
<style>
|
||||
/* Loading screen container */
|
||||
#loading-screen {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #0f172a 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
transition: opacity 0.5s ease-out;
|
||||
}
|
||||
|
||||
#loading-screen.fade-out {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Logo container with glow effect */
|
||||
.loading-logo {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
margin-bottom: 30px;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.loading-logo img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 0 20px rgba(59, 130, 246, 0.5));
|
||||
}
|
||||
|
||||
/* Spinner ring */
|
||||
.spinner-ring {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border: 3px solid rgba(59, 130, 246, 0.2);
|
||||
border-top-color: #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
/* Loading text */
|
||||
.loading-text {
|
||||
color: #94a3b8;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 16px;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.loading-subtext {
|
||||
color: #64748b;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Progress bar */
|
||||
.progress-container {
|
||||
width: 200px;
|
||||
height: 4px;
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #3b82f6, #8b5cf6, #3b82f6);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s ease-in-out infinite;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dots animation */
|
||||
.loading-dots::after {
|
||||
content: '';
|
||||
animation: dots 1.5s steps(4, end) infinite;
|
||||
}
|
||||
|
||||
@keyframes dots {
|
||||
|
||||
0%,
|
||||
20% {
|
||||
content: '';
|
||||
}
|
||||
|
||||
40% {
|
||||
content: '.';
|
||||
}
|
||||
|
||||
60% {
|
||||
content: '..';
|
||||
}
|
||||
|
||||
80%,
|
||||
100% {
|
||||
content: '...';
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- Loading Screen (se remueve cuando React carga) -->
|
||||
<div id="loading-screen">
|
||||
<!-- Logo personalizable - coloca tu imagen en /public/logo.png -->
|
||||
<div class="loading-logo">
|
||||
<img src="/logo.png" alt="Logo" onerror="this.parentElement.style.display='none'" />
|
||||
</div>
|
||||
|
||||
<!-- Spinner -->
|
||||
<div class="spinner-ring"></div>
|
||||
|
||||
<!-- Loading text -->
|
||||
<div class="loading-text">Control de Patentes</div>
|
||||
<div class="loading-subtext">Cargando sistema<span class="loading-dots"></span></div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div class="progress-container">
|
||||
<div class="progress-bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
|
||||
<!-- Script para remover loading screen cuando React está listo -->
|
||||
<script>
|
||||
// Fallback: remover loading screen después de 10 segundos máximo
|
||||
setTimeout(function () {
|
||||
var loadingScreen = document.getElementById('loading-screen');
|
||||
if (loadingScreen) {
|
||||
loadingScreen.classList.add('fade-out');
|
||||
setTimeout(function () {
|
||||
loadingScreen.remove();
|
||||
}, 500);
|
||||
}
|
||||
}, 10000);
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
BIN
frontend/public/logo.png
Normal file
BIN
frontend/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 638 KiB |
12506
frontend/public/vite.svg
12506
frontend/public/vite.svg
File diff suppressed because it is too large
Load Diff
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 641 KiB |
@@ -1,10 +1,27 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import './i18n'
|
||||
import App from './App.jsx'
|
||||
|
||||
// Función para remover la pantalla de carga
|
||||
const removeLoadingScreen = () => {
|
||||
const loadingScreen = document.getElementById('loading-screen');
|
||||
if (loadingScreen) {
|
||||
loadingScreen.classList.add('fade-out');
|
||||
setTimeout(() => {
|
||||
loadingScreen.remove();
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
|
||||
// Renderizar React y remover loading screen
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
// Remover loading screen cuando React esté montado
|
||||
// Pequeño delay para asegurar que la UI esté lista
|
||||
setTimeout(removeLoadingScreen, 100);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import io from 'socket.io-client';
|
||||
import { Users, CheckCircle, XCircle, Shield, Trash2, Camera, AlertCircle } from 'lucide-react';
|
||||
import { Users, CheckCircle, XCircle, Shield, Trash2, Camera, AlertCircle, Database, X, Image, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LanguageSelector from '../components/LanguageSelector';
|
||||
|
||||
@@ -26,23 +26,41 @@ function AdminDashboard({ token }) {
|
||||
const [searchRut, setSearchRut] = useState('');
|
||||
const [searchResult, setSearchResult] = useState(null); // null | { found: true, data: ... } | { found: false }
|
||||
|
||||
// Dataset State
|
||||
const [datasetCount, setDatasetCount] = useState(0);
|
||||
const [showDatasetModal, setShowDatasetModal] = useState(false);
|
||||
const [datasetImages, setDatasetImages] = useState([]);
|
||||
const [selectedImage, setSelectedImage] = useState(null);
|
||||
const [selectedImageIndex, setSelectedImageIndex] = useState(-1);
|
||||
const [datasetPage, setDatasetPage] = useState(1);
|
||||
const [datasetTotalPages, setDatasetTotalPages] = useState(1);
|
||||
const [datasetTotal, setDatasetTotal] = useState(0);
|
||||
const [deletingImage, setDeletingImage] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
fetchDatasetCount();
|
||||
|
||||
// Live detection listener
|
||||
socket.on('new_detection', (data) => {
|
||||
setDetections(prev => [data, ...prev].slice(0, 10));
|
||||
});
|
||||
|
||||
// Real-time dataset updates
|
||||
socket.on('dataset_updated', (data) => {
|
||||
setDatasetCount(data.count);
|
||||
});
|
||||
|
||||
// Real-time updates for approvals
|
||||
socket.on('new_plate_registered', () => fetchData());
|
||||
socket.on('new_person_registered', () => fetchData());
|
||||
socket.on('plate_status_updated', () => fetchData()); // Reused for consistency
|
||||
socket.on('plate_status_updated', () => fetchData());
|
||||
socket.on('plate_deleted', () => fetchData());
|
||||
socket.on('person_deleted', () => fetchData());
|
||||
|
||||
return () => {
|
||||
socket.off('new_detection');
|
||||
socket.off('dataset_updated');
|
||||
socket.off('new_plate_registered');
|
||||
socket.off('new_person_registered');
|
||||
socket.off('plate_status_updated');
|
||||
@@ -51,6 +69,35 @@ function AdminDashboard({ token }) {
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
// Keyboard navigation for dataset gallery
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e) => {
|
||||
if (!showDatasetModal || !selectedImage) return;
|
||||
|
||||
if (e.key === 'ArrowLeft' && selectedImageIndex > 0) {
|
||||
e.preventDefault();
|
||||
setSelectedImageIndex(prev => {
|
||||
const newIndex = prev - 1;
|
||||
setSelectedImage(datasetImages[newIndex]);
|
||||
return newIndex;
|
||||
});
|
||||
} else if (e.key === 'ArrowRight' && selectedImageIndex < datasetImages.length - 1) {
|
||||
e.preventDefault();
|
||||
setSelectedImageIndex(prev => {
|
||||
const newIndex = prev + 1;
|
||||
setSelectedImage(datasetImages[newIndex]);
|
||||
return newIndex;
|
||||
});
|
||||
} else if (e.key === 'Escape') {
|
||||
setSelectedImage(null);
|
||||
setSelectedImageIndex(-1);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [showDatasetModal, selectedImage, selectedImageIndex, datasetImages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewMode === 'history' && activeTab === 'monitor') {
|
||||
fetchHistory(selectedDate);
|
||||
@@ -88,6 +135,86 @@ function AdminDashboard({ token }) {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchDatasetCount = async () => {
|
||||
try {
|
||||
const res = await axios.get('/dataset/count');
|
||||
setDatasetCount(res.data.plates_captured || 0);
|
||||
} catch (err) {
|
||||
console.log('Dataset count not available');
|
||||
}
|
||||
};
|
||||
|
||||
const fetchDatasetImages = async (page = 1) => {
|
||||
try {
|
||||
const res = await axios.get(`/dataset/list?page=${page}&per_page=50`);
|
||||
setDatasetImages(res.data.images || []);
|
||||
setDatasetPage(res.data.page || 1);
|
||||
setDatasetTotalPages(res.data.total_pages || 1);
|
||||
setDatasetTotal(res.data.total || 0);
|
||||
} catch (err) {
|
||||
console.error('Error fetching dataset images');
|
||||
}
|
||||
};
|
||||
|
||||
const openDatasetModal = () => {
|
||||
setDatasetPage(1);
|
||||
fetchDatasetImages(1);
|
||||
setShowDatasetModal(true);
|
||||
};
|
||||
|
||||
const handleDatasetPageChange = (newPage) => {
|
||||
if (newPage >= 1 && newPage <= datasetTotalPages) {
|
||||
setDatasetPage(newPage);
|
||||
fetchDatasetImages(newPage);
|
||||
}
|
||||
};
|
||||
|
||||
const selectImageByIndex = (index) => {
|
||||
if (index >= 0 && index < datasetImages.length) {
|
||||
setSelectedImage(datasetImages[index]);
|
||||
setSelectedImageIndex(index);
|
||||
}
|
||||
};
|
||||
|
||||
const navigateImage = (direction) => {
|
||||
const newIndex = selectedImageIndex + direction;
|
||||
if (newIndex >= 0 && newIndex < datasetImages.length) {
|
||||
selectImageByIndex(newIndex);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteCurrentImage = async () => {
|
||||
if (!selectedImage || deletingImage) return;
|
||||
|
||||
if (!confirm(`¿Eliminar imagen de ${selectedImage.plate}?`)) return;
|
||||
|
||||
setDeletingImage(true);
|
||||
try {
|
||||
await axios.delete(`/dataset/images/${selectedImage.filename}`);
|
||||
|
||||
// Remover de la lista local
|
||||
const newImages = datasetImages.filter((_, idx) => idx !== selectedImageIndex);
|
||||
setDatasetImages(newImages);
|
||||
setDatasetTotal(prev => prev - 1);
|
||||
setDatasetCount(prev => prev - 1);
|
||||
|
||||
// Navegar a la siguiente imagen o cerrar si no hay más
|
||||
if (newImages.length === 0) {
|
||||
setSelectedImage(null);
|
||||
setSelectedImageIndex(-1);
|
||||
} else if (selectedImageIndex >= newImages.length) {
|
||||
selectImageByIndex(newImages.length - 1);
|
||||
} else {
|
||||
setSelectedImage(newImages[selectedImageIndex]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error deleting image:', err);
|
||||
alert('Error al eliminar la imagen');
|
||||
} finally {
|
||||
setDeletingImage(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearchRut = (e) => {
|
||||
e.preventDefault();
|
||||
const normalizedRut = searchRut.replace(/\./g, '').toUpperCase();
|
||||
@@ -201,11 +328,22 @@ function AdminDashboard({ token }) {
|
||||
<h2 className="text-xl font-bold flex items-center gap-2">
|
||||
<Camera /> {t('monitor_area')}
|
||||
</h2>
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Dataset Counter - Clickable */}
|
||||
<button
|
||||
onClick={openDatasetModal}
|
||||
className="flex items-center gap-2 bg-gradient-to-r from-emerald-900/50 to-teal-900/50 px-4 py-2 rounded-lg border border-emerald-700/50 hover:from-emerald-800/50 hover:to-teal-800/50 transition-all cursor-pointer"
|
||||
>
|
||||
<Database size={18} className="text-emerald-400" />
|
||||
<span className="text-emerald-300 font-mono font-bold">{datasetCount}</span>
|
||||
<span className="text-emerald-400/70 text-sm">capturas</span>
|
||||
</button>
|
||||
<div className="flex bg-slate-800 rounded p-1">
|
||||
<button onClick={() => setViewMode('live')} className={`px-3 py-1 rounded ${viewMode === 'live' ? 'bg-blue-600' : ''}`}>Live</button>
|
||||
<button onClick={() => setViewMode('history')} className={`px-3 py-1 rounded ${viewMode === 'history' ? 'bg-blue-600' : ''}`}>History</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Visitor Lookup Banner */}
|
||||
<div className="bg-slate-800 rounded-xl p-6 border border-slate-700">
|
||||
@@ -455,10 +593,153 @@ function AdminDashboard({ token }) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dataset Gallery Modal */}
|
||||
{showDatasetModal && (
|
||||
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-slate-900 rounded-2xl border border-slate-700 w-full max-w-6xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-slate-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<Database className="text-emerald-400" size={24} />
|
||||
<h2 className="text-xl font-bold text-white">Dataset de Capturas</h2>
|
||||
<span className="bg-emerald-600 px-2 py-1 rounded text-sm font-mono">{datasetTotal} imágenes</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setShowDatasetModal(false); setSelectedImage(null); }}
|
||||
className="text-slate-400 hover:text-white p-2 hover:bg-slate-800 rounded-lg transition-colors"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{selectedImage ? (
|
||||
/* Image Preview with Navigation */
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
{/* Top Controls */}
|
||||
<div className="w-full flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => { setSelectedImage(null); setSelectedImageIndex(-1); }}
|
||||
className="flex items-center gap-2 text-slate-400 hover:text-white transition-colors"
|
||||
>
|
||||
← Volver a galería
|
||||
</button>
|
||||
<div className="text-slate-400 text-sm">
|
||||
{selectedImageIndex + 1} de {datasetImages.length}
|
||||
</div>
|
||||
<button
|
||||
onClick={deleteCurrentImage}
|
||||
disabled={deletingImage}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-red-600 hover:bg-red-500 disabled:bg-red-800 disabled:cursor-not-allowed rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
{deletingImage ? 'Eliminando...' : 'Eliminar'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Image with Navigation Arrows */}
|
||||
<div className="relative flex items-center gap-4 w-full justify-center">
|
||||
{/* Previous Button */}
|
||||
<button
|
||||
onClick={() => navigateImage(-1)}
|
||||
disabled={selectedImageIndex <= 0}
|
||||
className="p-3 bg-slate-800 hover:bg-slate-700 disabled:opacity-30 disabled:cursor-not-allowed rounded-full transition-colors"
|
||||
>
|
||||
<ChevronLeft size={24} />
|
||||
</button>
|
||||
|
||||
{/* Image */}
|
||||
<img
|
||||
src={selectedImage.url}
|
||||
alt={selectedImage.plate}
|
||||
className="max-w-[70%] max-h-[55vh] rounded-lg border border-slate-700"
|
||||
/>
|
||||
|
||||
{/* Next Button */}
|
||||
<button
|
||||
onClick={() => navigateImage(1)}
|
||||
disabled={selectedImageIndex >= datasetImages.length - 1}
|
||||
className="p-3 bg-slate-800 hover:bg-slate-700 disabled:opacity-30 disabled:cursor-not-allowed rounded-full transition-colors"
|
||||
>
|
||||
<ChevronRight size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Plate Info */}
|
||||
<div className="bg-slate-800 px-4 py-2 rounded-lg">
|
||||
<span className="text-slate-400">Patente: </span>
|
||||
<span className="font-mono font-bold text-emerald-400 text-lg">{selectedImage.plate}</span>
|
||||
</div>
|
||||
|
||||
{/* Keyboard hint */}
|
||||
<div className="text-slate-500 text-xs">
|
||||
Usa ← → para navegar
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Image Grid */
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
{datasetImages.map((img, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
onClick={() => selectImageByIndex(idx)}
|
||||
className="cursor-pointer group relative aspect-video bg-slate-800 rounded-lg overflow-hidden border border-slate-700 hover:border-emerald-500 transition-all"
|
||||
>
|
||||
<img
|
||||
src={img.url}
|
||||
alt={img.plate}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-2">
|
||||
<span className="font-mono text-sm text-emerald-300 font-bold">{img.plate}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{datasetImages.length === 0 && (
|
||||
<div className="col-span-full text-center py-12 text-slate-500">
|
||||
<Image size={48} className="mx-auto mb-4 opacity-50" />
|
||||
<p>No hay capturas en el dataset</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination Footer */}
|
||||
{!selectedImage && datasetTotalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-4 p-4 border-t border-slate-700 bg-slate-800/50">
|
||||
<button
|
||||
onClick={() => handleDatasetPageChange(datasetPage - 1)}
|
||||
disabled={datasetPage === 1}
|
||||
className="flex items-center gap-1 px-3 py-2 rounded-lg bg-slate-700 hover:bg-slate-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
Anterior
|
||||
</button>
|
||||
<div className="flex items-center gap-2 text-slate-300">
|
||||
<span>Página</span>
|
||||
<span className="bg-emerald-600 px-3 py-1 rounded font-mono font-bold">{datasetPage}</span>
|
||||
<span>de</span>
|
||||
<span className="font-mono font-bold">{datasetTotalPages}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDatasetPageChange(datasetPage + 1)}
|
||||
disabled={datasetPage === datasetTotalPages}
|
||||
className="flex items-center gap-1 px-3 py-2 rounded-lg bg-slate-700 hover:bg-slate-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Siguiente
|
||||
<ChevronRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminDashboard;
|
||||
|
||||
|
||||
|
||||
@@ -18,6 +18,10 @@ export default defineConfig({
|
||||
'/video_feed': {
|
||||
target: 'http://alpr-service:5001',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/dataset': {
|
||||
target: 'http://alpr-service:5001',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user