Compare commits
12 Commits
main
...
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 time
|
||||||
import threading
|
import threading
|
||||||
import re
|
import re
|
||||||
|
import numpy as np
|
||||||
|
from datetime import datetime
|
||||||
from queue import Queue
|
from queue import Queue
|
||||||
from flask import Flask, Response
|
from flask import Flask, Response, request, send_from_directory
|
||||||
from flask_cors import CORS
|
from flask_cors import CORS
|
||||||
from ultralytics import YOLO
|
from ultralytics import YOLO
|
||||||
|
|
||||||
# Configuration
|
# Configuration (puede ser sobrescrito por variables de entorno)
|
||||||
BACKEND_URL = os.environ.get('BACKEND_URL', 'http://localhost:3000')
|
BACKEND_URL = os.environ.get('BACKEND_URL', 'http://localhost:3000')
|
||||||
CAMERA_ID = 0
|
CAMERA_ID = int(os.environ.get('CAMERA_ID', 0))
|
||||||
PROCESS_INTERVAL = 1.5 # Más reactivo
|
PROCESS_INTERVAL = float(os.environ.get('PROCESS_INTERVAL', 1.5))
|
||||||
MODEL_PATH = 'best.pt'
|
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__)
|
app = Flask(__name__)
|
||||||
CORS(app)
|
CORS(app)
|
||||||
@@ -26,50 +32,157 @@ latest_detections = []
|
|||||||
detection_lock = threading.Lock()
|
detection_lock = threading.Lock()
|
||||||
|
|
||||||
# Cola para procesamiento OCR asíncrono
|
# 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):
|
def send_plate(plate_number):
|
||||||
"""Envía la patente detectada al backend"""
|
"""Envía la patente detectada al backend"""
|
||||||
try:
|
try:
|
||||||
url = f"{BACKEND_URL}/api/detect"
|
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}")
|
print(f"✓ Plate sent: {plate_number}")
|
||||||
|
|
||||||
|
with metrics_lock:
|
||||||
|
metrics['total_detections'] += 1
|
||||||
|
metrics['last_detection'] = plate_number
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"✗ Error sending plate: {e}")
|
print(f"✗ Error sending plate: {e}")
|
||||||
|
|
||||||
def validate_and_send(text):
|
def validate_plate(text):
|
||||||
"""Valida formato chileno y envía"""
|
"""Valida formatos de patentes de Chile, Argentina y Brasil"""
|
||||||
# Formato nuevo: XXXX-00 | Formato antiguo: XX-0000
|
# Chile formato nuevo: XXXX00 (4 letras, 2 números)
|
||||||
if re.match(r'^[A-Z]{4}\d{2}$', text) or re.match(r'^[A-Z]{2}\d{4}$', text):
|
# Chile formato antiguo: XX0000 (2 letras, 4 números)
|
||||||
send_plate(text)
|
# Argentina Mercosur: AA000AA (2 letras, 3 números, 2 letras)
|
||||||
return True
|
# Brasil Mercosur: AAA0A00 (3 letras, 1 número, 1 letra, 2 números)
|
||||||
return False
|
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):
|
def ocr_worker(reader, worker_id):
|
||||||
"""Hilo dedicado para OCR - no bloquea el stream"""
|
"""Hilo dedicado para OCR - múltiples workers para mejor rendimiento"""
|
||||||
|
print(f"🔤 OCR Worker {worker_id} started")
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
plate_img = ocr_queue.get(timeout=1)
|
data = ocr_queue.get(timeout=1)
|
||||||
if plate_img is None:
|
if data is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Preprocesamiento para mejor OCR
|
plate_img, full_frame = data
|
||||||
|
|
||||||
gray = cv2.cvtColor(plate_img, cv2.COLOR_BGR2GRAY)
|
gray = cv2.cvtColor(plate_img, cv2.COLOR_BGR2GRAY)
|
||||||
|
|
||||||
ocr_results = reader.readtext(gray, detail=0, paragraph=False,
|
ocr_results = reader.readtext(gray, detail=0, paragraph=False,
|
||||||
allowlist='ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789')
|
allowlist='ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789')
|
||||||
for text in ocr_results:
|
for text in ocr_results:
|
||||||
clean_text = ''.join(e for e in text if e.isalnum()).upper()
|
clean_text = ''.join(e for e in text if e.isalnum()).upper()
|
||||||
if len(clean_text) >= 6:
|
if len(clean_text) >= 6 and validate_plate(clean_text):
|
||||||
validate_and_send(clean_text)
|
send_plate(clean_text)
|
||||||
|
save_plate_capture(clean_text, full_frame)
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def camera_loop():
|
def camera_loop():
|
||||||
"""Hilo principal de captura - mantiene FPS alto"""
|
"""Hilo principal de captura"""
|
||||||
global outputFrame, latest_detections
|
global outputFrame, latest_detections
|
||||||
|
|
||||||
print("🚀 Initializing ALPR System...")
|
print("🚀 Initializing ALPR System...")
|
||||||
|
print(f"⚙️ Config: PROCESS_INTERVAL={PROCESS_INTERVAL}s, OCR_WORKERS={OCR_WORKERS}")
|
||||||
print("📷 Loading camera...")
|
print("📷 Loading camera...")
|
||||||
|
|
||||||
cap = cv2.VideoCapture(CAMERA_ID)
|
cap = cv2.VideoCapture(CAMERA_ID)
|
||||||
@@ -89,17 +202,22 @@ def camera_loop():
|
|||||||
print("📝 Initializing EasyOCR...")
|
print("📝 Initializing EasyOCR...")
|
||||||
reader = easyocr.Reader(['en'], gpu=False)
|
reader = easyocr.Reader(['en'], gpu=False)
|
||||||
|
|
||||||
# Iniciar worker de OCR
|
# Iniciar múltiples workers de OCR
|
||||||
ocr_thread = threading.Thread(target=ocr_worker, args=(reader,), daemon=True)
|
for i in range(OCR_WORKERS):
|
||||||
ocr_thread.start()
|
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!")
|
print("✅ System ready!")
|
||||||
|
|
||||||
last_process_time = 0
|
last_process_time = 0
|
||||||
frame_count = 0
|
frame_count = 0
|
||||||
|
fps_start_time = time.time()
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
# Captura eficiente - solo 2 grabs
|
|
||||||
cap.grab()
|
cap.grab()
|
||||||
cap.grab()
|
cap.grab()
|
||||||
ret, frame = cap.retrieve()
|
ret, frame = cap.retrieve()
|
||||||
@@ -111,11 +229,18 @@ def camera_loop():
|
|||||||
frame_count += 1
|
frame_count += 1
|
||||||
current_time = time.time()
|
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:
|
if current_time - last_process_time > PROCESS_INTERVAL:
|
||||||
last_process_time = current_time
|
last_process_time = current_time
|
||||||
|
|
||||||
# YOLO detection - usar imgsz pequeño para velocidad
|
|
||||||
results = model(frame, verbose=False, imgsz=320, conf=0.5)
|
results = model(frame, verbose=False, imgsz=320, conf=0.5)
|
||||||
|
|
||||||
new_detections = []
|
new_detections = []
|
||||||
@@ -125,15 +250,13 @@ def camera_loop():
|
|||||||
conf = float(box.conf[0])
|
conf = float(box.conf[0])
|
||||||
new_detections.append((x1, y1, x2, y2, conf))
|
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()
|
plate_img = frame[y1:y2, x1:x2].copy()
|
||||||
if plate_img.size > 0 and not ocr_queue.full():
|
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:
|
with detection_lock:
|
||||||
latest_detections = new_detections
|
latest_detections = new_detections
|
||||||
|
|
||||||
# Actualizar frame para streaming (sin bloquear)
|
|
||||||
display_frame = frame
|
display_frame = frame
|
||||||
with detection_lock:
|
with detection_lock:
|
||||||
for (x1, y1, x2, y2, conf) in latest_detections:
|
for (x1, y1, x2, y2, conf) in latest_detections:
|
||||||
@@ -148,7 +271,7 @@ def generate():
|
|||||||
"""Generador para streaming MJPEG"""
|
"""Generador para streaming MJPEG"""
|
||||||
global outputFrame
|
global outputFrame
|
||||||
while True:
|
while True:
|
||||||
time.sleep(0.033) # ~30 FPS para el stream
|
time.sleep(0.033)
|
||||||
with frame_lock:
|
with frame_lock:
|
||||||
if outputFrame is None:
|
if outputFrame is None:
|
||||||
continue
|
continue
|
||||||
@@ -161,7 +284,114 @@ def video_feed():
|
|||||||
|
|
||||||
@app.route("/health")
|
@app.route("/health")
|
||||||
def 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__":
|
if __name__ == "__main__":
|
||||||
t = threading.Thread(target=camera_loop, daemon=True)
|
t = threading.Thread(target=camera_loop, daemon=True)
|
||||||
|
|||||||
@@ -44,4 +44,8 @@ model AccessLog {
|
|||||||
plateNumber String
|
plateNumber String
|
||||||
accessStatus String // GRANTED, DENIED, UNKNOWN
|
accessStatus String // GRANTED, DENIED, UNKNOWN
|
||||||
timestamp DateTime @default(now())
|
timestamp DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([plateNumber])
|
||||||
|
@@index([timestamp])
|
||||||
|
@@index([plateNumber, timestamp])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
|
const bcrypt = require('bcryptjs'); // Movido al inicio (E)
|
||||||
const { PrismaClient } = require('@prisma/client');
|
const { PrismaClient } = require('@prisma/client');
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
const { Server } = require('socket.io');
|
const { Server } = require('socket.io');
|
||||||
@@ -7,14 +8,55 @@ const { Server } = require('socket.io');
|
|||||||
const app = express();
|
const app = express();
|
||||||
const prisma = new PrismaClient();
|
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());
|
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 server = http.createServer(app);
|
||||||
const io = new Server(server, {
|
const io = new Server(server, {
|
||||||
cors: {
|
cors: {
|
||||||
origin: "*",
|
origin: ALLOWED_ORIGINS,
|
||||||
methods: ["GET", "POST"]
|
methods: ["GET", "POST"],
|
||||||
|
credentials: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -31,9 +73,7 @@ app.use('/api/auth', authRoutes);
|
|||||||
// Plates CRUD
|
// Plates CRUD
|
||||||
app.get('/api/plates', authenticateToken, async (req, res) => {
|
app.get('/api/plates', authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
// Filter based on role
|
|
||||||
const where = req.user.role === 'ADMIN' ? {} : { addedById: req.user.id };
|
const where = req.user.role === 'ADMIN' ? {} : { addedById: req.user.id };
|
||||||
|
|
||||||
const plates = await prisma.plate.findMany({
|
const plates = await prisma.plate.findMany({
|
||||||
where,
|
where,
|
||||||
include: { addedBy: { select: { username: true } } }
|
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) => {
|
app.post('/api/plates', authenticateToken, async (req, res) => {
|
||||||
const { number, owner } = req.body;
|
const { number, owner } = req.body;
|
||||||
const isAdm = req.user.role === 'ADMIN';
|
const isAdm = req.user.role === 'ADMIN';
|
||||||
// Admin -> ALLOWED, User -> PENDING
|
|
||||||
const status = isAdm ? 'ALLOWED' : 'PENDING';
|
const status = isAdm ? 'ALLOWED' : 'PENDING';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -59,10 +98,7 @@ app.post('/api/plates', authenticateToken, async (req, res) => {
|
|||||||
addedById: req.user.id
|
addedById: req.user.id
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Notify Admin via WebSocket
|
|
||||||
io.emit('new_plate_registered', plate);
|
io.emit('new_plate_registered', plate);
|
||||||
|
|
||||||
res.json(plate);
|
res.json(plate);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
@@ -72,7 +108,7 @@ app.post('/api/plates', authenticateToken, async (req, res) => {
|
|||||||
// Admin: Approve/Reject Plate
|
// Admin: Approve/Reject Plate
|
||||||
app.put('/api/plates/:id/approve', authenticateToken, isAdmin, async (req, res) => {
|
app.put('/api/plates/:id/approve', authenticateToken, isAdmin, async (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { status } = req.body; // ALLOWED or DENIED
|
const { status } = req.body;
|
||||||
|
|
||||||
if (!['ALLOWED', 'DENIED'].includes(status)) {
|
if (!['ALLOWED', 'DENIED'].includes(status)) {
|
||||||
return res.status(400).json({ error: 'Invalid 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) },
|
where: { id: parseInt(id) },
|
||||||
data: { status }
|
data: { status }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Notify Users via WebSocket
|
|
||||||
io.emit('plate_status_updated', plate);
|
io.emit('plate_status_updated', plate);
|
||||||
|
|
||||||
res.json(plate);
|
res.json(plate);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Admin: Delete Plate (Optional but good to have)
|
|
||||||
// Delete Plate (Admin or Owner)
|
// Delete Plate (Admin or Owner)
|
||||||
app.delete('/api/plates/:id', authenticateToken, async (req, res) => {
|
app.delete('/api/plates/:id', authenticateToken, async (req, res) => {
|
||||||
const { id } = req.params;
|
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) } });
|
const plate = await prisma.plate.findUnique({ where: { id: parseInt(id) } });
|
||||||
if (!plate) return res.status(404).json({ error: 'Plate not found' });
|
if (!plate) return res.status(404).json({ error: 'Plate not found' });
|
||||||
|
|
||||||
// Check permissions
|
|
||||||
if (req.user.role !== 'ADMIN' && plate.addedById !== req.user.id) {
|
if (req.user.role !== 'ADMIN' && plate.addedById !== req.user.id) {
|
||||||
return res.status(403).json({ error: 'Unauthorized' });
|
return res.status(403).json({ error: 'Unauthorized' });
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.plate.delete({ where: { id: parseInt(id) } });
|
await prisma.plate.delete({ where: { id: parseInt(id) } });
|
||||||
|
|
||||||
io.emit('plate_deleted', { id: parseInt(id) });
|
io.emit('plate_deleted', { id: parseInt(id) });
|
||||||
|
|
||||||
res.json({ message: 'Plate deleted' });
|
res.json({ message: 'Plate deleted' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: err.message });
|
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) } });
|
await prisma.person.delete({ where: { id: parseInt(id) } });
|
||||||
|
|
||||||
io.emit('person_deleted', { id: parseInt(id) });
|
io.emit('person_deleted', { id: parseInt(id) });
|
||||||
|
|
||||||
res.json({ message: 'Person deleted' });
|
res.json({ message: 'Person deleted' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
@@ -139,7 +166,7 @@ app.delete('/api/people/:id', authenticateToken, async (req, res) => {
|
|||||||
|
|
||||||
// History Endpoint
|
// History Endpoint
|
||||||
app.get('/api/history', async (req, res) => {
|
app.get('/api/history', async (req, res) => {
|
||||||
const { date } = req.query; // Format: YYYY-MM-DD
|
const { date } = req.query;
|
||||||
if (!date) {
|
if (!date) {
|
||||||
return res.status(400).json({ error: 'Date is required' });
|
return res.status(400).json({ error: 'Date is required' });
|
||||||
}
|
}
|
||||||
@@ -158,9 +185,7 @@ app.get('/api/history', async (req, res) => {
|
|||||||
lte: endDate
|
lte: endDate
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
orderBy: {
|
orderBy: { timestamp: 'desc' }
|
||||||
timestamp: 'desc'
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
res.json(logs);
|
res.json(logs);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -174,13 +199,9 @@ app.get('/api/recent', async (req, res) => {
|
|||||||
const fiveHoursAgo = new Date(Date.now() - 5 * 60 * 60 * 1000);
|
const fiveHoursAgo = new Date(Date.now() - 5 * 60 * 60 * 1000);
|
||||||
const logs = await prisma.accessLog.findMany({
|
const logs = await prisma.accessLog.findMany({
|
||||||
where: {
|
where: {
|
||||||
timestamp: {
|
timestamp: { gte: fiveHoursAgo }
|
||||||
gte: fiveHoursAgo
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
orderBy: {
|
orderBy: { timestamp: 'desc' }
|
||||||
timestamp: 'desc'
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
res.json(logs);
|
res.json(logs);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -238,10 +259,7 @@ app.post('/api/people', authenticateToken, async (req, res) => {
|
|||||||
addedById: req.user.id
|
addedById: req.user.id
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Notify Admin via WebSocket
|
|
||||||
io.emit('new_person_registered', person);
|
io.emit('new_person_registered', person);
|
||||||
|
|
||||||
res.json(person);
|
res.json(person);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: err.message });
|
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' },
|
where: { addedById: parseInt(userId), status: 'PENDING' },
|
||||||
data: { status: 'APPROVED' }
|
data: { status: 'APPROVED' }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Notify Users via WebSocket
|
|
||||||
io.emit('people_updated', { userId });
|
io.emit('people_updated', { userId });
|
||||||
|
|
||||||
res.json({ message: 'Bulk approval successful' });
|
res.json({ message: 'Bulk approval successful' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Detection Endpoint (from Python)
|
// Detection Endpoint (from Python) with Rate Limiting and Service Auth
|
||||||
app.post('/api/detect', async (req, res) => {
|
// 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;
|
const { plate_number } = req.body;
|
||||||
console.log(`Detected: ${plate_number}`);
|
console.log(`Detected: ${plate_number}`);
|
||||||
|
|
||||||
const DUPLICATE_COOLDOWN_MS = 30000; // 30 seconds
|
const DUPLICATE_COOLDOWN_MS = 30000;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check for recent duplicate
|
|
||||||
const lastLog = await prisma.accessLog.findFirst({
|
const lastLog = await prisma.accessLog.findFirst({
|
||||||
where: { plateNumber: plate_number },
|
where: { plateNumber: plate_number },
|
||||||
orderBy: { timestamp: 'desc' }
|
orderBy: { timestamp: 'desc' }
|
||||||
@@ -288,7 +327,6 @@ app.post('/api/detect', async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if plate exists
|
|
||||||
let plate = await prisma.plate.findUnique({
|
let plate = await prisma.plate.findUnique({
|
||||||
where: { number: plate_number }
|
where: { number: plate_number }
|
||||||
});
|
});
|
||||||
@@ -300,12 +338,9 @@ app.post('/api/detect', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!plate) {
|
if (!plate) {
|
||||||
// Optional: Auto-create unknown plates?
|
|
||||||
// For now, treat as UNKNOWN (Denied)
|
|
||||||
accessStatus = 'UNKNOWN';
|
accessStatus = 'UNKNOWN';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log the access attempt
|
|
||||||
const log = await prisma.accessLog.create({
|
const log = await prisma.accessLog.create({
|
||||||
data: {
|
data: {
|
||||||
plateNumber: plate_number,
|
plateNumber: plate_number,
|
||||||
@@ -314,7 +349,6 @@ app.post('/api/detect', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Notify Frontend via WebSocket
|
|
||||||
io.emit('new_detection', {
|
io.emit('new_detection', {
|
||||||
plate: plate_number,
|
plate: plate_number,
|
||||||
status: accessStatus,
|
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;
|
const PORT = process.env.PORT || 3000;
|
||||||
server.listen(PORT, async () => {
|
server.listen(PORT, async () => {
|
||||||
@@ -340,7 +397,18 @@ server.listen(PORT, async () => {
|
|||||||
const userCount = await prisma.user.count();
|
const userCount = await prisma.user.count();
|
||||||
if (userCount === 0) {
|
if (userCount === 0) {
|
||||||
console.log('No users found. Creating default admin user...');
|
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({
|
await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
username: 'admin',
|
username: 'admin',
|
||||||
@@ -348,7 +416,18 @@ server.listen(PORT, async () => {
|
|||||||
role: 'ADMIN'
|
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) {
|
} catch (err) {
|
||||||
console.error('Error seeding admin user:', err);
|
console.error('Error seeding admin user:', err);
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
const jwt = require('jsonwebtoken');
|
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 authenticateToken = (req, res, next) => {
|
||||||
const authHeader = req.headers['authorization'];
|
const authHeader = req.headers['authorization'];
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ services:
|
|||||||
POSTGRES_DB: ${DB_NAME:-controlpatente}
|
POSTGRES_DB: ${DB_NAME:-controlpatente}
|
||||||
volumes:
|
volumes:
|
||||||
- db_data:/var/lib/postgresql/data
|
- db_data:/var/lib/postgresql/data
|
||||||
ports:
|
# SECURITY: Port not exposed externally - only accessible within Docker network
|
||||||
- "5432:5432"
|
# Uncomment for local development debugging only
|
||||||
|
# ports:
|
||||||
|
# - "5432:5432"
|
||||||
networks:
|
networks:
|
||||||
- backend-net
|
- backend-net
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -21,6 +23,11 @@ services:
|
|||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
# Backend Service (Node.js)
|
# Backend Service (Node.js)
|
||||||
backend:
|
backend:
|
||||||
@@ -29,6 +36,10 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@db:5432/${DB_NAME:-controlpatente}
|
- DATABASE_URL=postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@db:5432/${DB_NAME:-controlpatente}
|
||||||
- PORT=3000
|
- 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:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -40,26 +51,57 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
- /app/node_modules
|
- /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 Component (Python + OpenCV)
|
||||||
alpr-service:
|
alpr-service:
|
||||||
build: ./alpr-service
|
build: ./alpr-service
|
||||||
container_name: controlpatente-alpr
|
container_name: controlpatente-alpr
|
||||||
ports:
|
ports:
|
||||||
- "5001:5001" # Permite acceder al stream de video desde el nave
|
- "5001:5001"
|
||||||
environment:
|
environment:
|
||||||
- BACKEND_URL=http://backend:3000
|
- BACKEND_URL=http://backend:3000
|
||||||
# On Mac, you usually cannot pass /dev/video0 directly.
|
- PROCESS_INTERVAL=1.5
|
||||||
# We might need to use a stream or just test with a file for now if direct access fails.
|
- DATASET_COOLDOWN=60
|
||||||
# For Linux/Raspberry Pi, the device mapping below is correct.
|
- OCR_WORKERS=2
|
||||||
|
- SERVICE_API_KEY=${SERVICE_API_KEY:-}
|
||||||
devices:
|
devices:
|
||||||
- "/dev/video0:/dev/video0"
|
- "/dev/video0:/dev/video0"
|
||||||
networks:
|
networks:
|
||||||
- backend-net
|
- backend-net
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
restart: unless-stopped
|
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 Service (React)
|
||||||
frontend:
|
frontend:
|
||||||
@@ -76,6 +118,12 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- VITE_API_URL=
|
- VITE_API_URL=
|
||||||
- VITE_ALPR_STREAM_URL=
|
- VITE_ALPR_STREAM_URL=
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
backend-net:
|
backend-net:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|||||||
@@ -1,13 +1,193 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="es">
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
<head>
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<link rel="icon" type="image/svg+xml" href="/logo.png" />
|
||||||
<title>frontend</title>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
</head>
|
<title>Control de Patentes - ALPR</title>
|
||||||
<body>
|
<meta name="description" content="Sistema de Control de Acceso mediante Reconocimiento Automático de Patentes" />
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
<!-- Loading Screen Styles (inline para carga inmediata) -->
|
||||||
</body>
|
<style>
|
||||||
</html>
|
/* 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 { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
import './i18n'
|
||||||
import App from './App.jsx'
|
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(
|
createRoot(document.getElementById('root')).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</StrictMode>,
|
</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 { useState, useEffect } from 'react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import io from 'socket.io-client';
|
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 { useTranslation } from 'react-i18next';
|
||||||
import LanguageSelector from '../components/LanguageSelector';
|
import LanguageSelector from '../components/LanguageSelector';
|
||||||
|
|
||||||
@@ -26,23 +26,41 @@ function AdminDashboard({ token }) {
|
|||||||
const [searchRut, setSearchRut] = useState('');
|
const [searchRut, setSearchRut] = useState('');
|
||||||
const [searchResult, setSearchResult] = useState(null); // null | { found: true, data: ... } | { found: false }
|
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(() => {
|
useEffect(() => {
|
||||||
fetchData();
|
fetchData();
|
||||||
|
fetchDatasetCount();
|
||||||
|
|
||||||
// Live detection listener
|
// Live detection listener
|
||||||
socket.on('new_detection', (data) => {
|
socket.on('new_detection', (data) => {
|
||||||
setDetections(prev => [data, ...prev].slice(0, 10));
|
setDetections(prev => [data, ...prev].slice(0, 10));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Real-time dataset updates
|
||||||
|
socket.on('dataset_updated', (data) => {
|
||||||
|
setDatasetCount(data.count);
|
||||||
|
});
|
||||||
|
|
||||||
// Real-time updates for approvals
|
// Real-time updates for approvals
|
||||||
socket.on('new_plate_registered', () => fetchData());
|
socket.on('new_plate_registered', () => fetchData());
|
||||||
socket.on('new_person_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('plate_deleted', () => fetchData());
|
||||||
socket.on('person_deleted', () => fetchData());
|
socket.on('person_deleted', () => fetchData());
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
socket.off('new_detection');
|
socket.off('new_detection');
|
||||||
|
socket.off('dataset_updated');
|
||||||
socket.off('new_plate_registered');
|
socket.off('new_plate_registered');
|
||||||
socket.off('new_person_registered');
|
socket.off('new_person_registered');
|
||||||
socket.off('plate_status_updated');
|
socket.off('plate_status_updated');
|
||||||
@@ -51,6 +69,35 @@ function AdminDashboard({ token }) {
|
|||||||
};
|
};
|
||||||
}, [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(() => {
|
useEffect(() => {
|
||||||
if (viewMode === 'history' && activeTab === 'monitor') {
|
if (viewMode === 'history' && activeTab === 'monitor') {
|
||||||
fetchHistory(selectedDate);
|
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) => {
|
const handleSearchRut = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const normalizedRut = searchRut.replace(/\./g, '').toUpperCase();
|
const normalizedRut = searchRut.replace(/\./g, '').toUpperCase();
|
||||||
@@ -201,9 +328,20 @@ function AdminDashboard({ token }) {
|
|||||||
<h2 className="text-xl font-bold flex items-center gap-2">
|
<h2 className="text-xl font-bold flex items-center gap-2">
|
||||||
<Camera /> {t('monitor_area')}
|
<Camera /> {t('monitor_area')}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex bg-slate-800 rounded p-1">
|
<div className="flex items-center gap-4">
|
||||||
<button onClick={() => setViewMode('live')} className={`px-3 py-1 rounded ${viewMode === 'live' ? 'bg-blue-600' : ''}`}>Live</button>
|
{/* Dataset Counter - Clickable */}
|
||||||
<button onClick={() => setViewMode('history')} className={`px-3 py-1 rounded ${viewMode === 'history' ? 'bg-blue-600' : ''}`}>History</button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -455,10 +593,153 @@ function AdminDashboard({ token }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default AdminDashboard;
|
export default AdminDashboard;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ export default defineConfig({
|
|||||||
'/video_feed': {
|
'/video_feed': {
|
||||||
target: 'http://alpr-service:5001',
|
target: 'http://alpr-service:5001',
|
||||||
changeOrigin: true
|
changeOrigin: true
|
||||||
|
},
|
||||||
|
'/dataset': {
|
||||||
|
target: 'http://alpr-service:5001',
|
||||||
|
changeOrigin: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user