36 Commits

Author SHA1 Message Date
b2c6a29b19 add Brazil & Argentina plate format 2026-02-01 22:21:30 -03:00
5075a2440d feat: Implement service-to-service authentication, centralize environment configuration, and harden Docker security. 2026-01-29 19:41:59 -03:00
5d85dc0714 add image switch and image delete 2026-01-29 12:35:21 -03:00
a6243413a1 mod custom logo and min 2026-01-29 12:12:56 -03:00
ab3eb8b1c4 add landing page 2026-01-29 11:46:36 -03:00
d7be8d7036 Major optimizations: multi-worker OCR, caching, rate limiting, healthchecks, logging 2026-01-13 13:21:00 -03:00
9b15c7a480 Fix guardado de imagenes 2026-01-13 13:15:16 -03:00
eb19a557c3 Add pagination to dataset gallery (50 per page, newest first) 2026-01-13 12:48:37 -03:00
c2fb62ab7a add preview dataset 2026-01-12 23:16:10 -03:00
000009595d add websocket counter plate 2026-01-12 22:48:27 -03:00
a62acdc47d fix dataset: only full frame, 60s cooldown per plate 2026-01-12 22:20:07 -03:00
24b04f84ab add snapshots & counter 2026-01-12 21:59:59 -03:00
de399fe3f8 "Optimize ALPR: async OCR, better FPS, remove CPU limits" 2026-01-12 21:39:12 -03:00
b9409cc48c change cpu usage 2 to 4 cpu 2026-01-12 21:33:48 -03:00
634365a45d Update new cheap camara 2026-01-12 21:19:01 -03:00
db75d2964f fix domain connections 2026-01-05 16:30:56 -03:00
82fbc54349 add allow host 2026-01-05 16:24:30 -03:00
9ee325a294 fix minor bugs 2026-01-05 10:49:02 -03:00
7fdf30ee3b minor fix portal error 2026-01-04 21:56:44 -03:00
7df80ebcff Add Spanish/English languaje 2026-01-04 21:50:22 -03:00
f9f841e4f1 fix update status plate user 2026-01-03 12:56:47 -03:00
d6f90c19f1 Add from user rm aproved/denied plates/rut 2025-12-28 21:56:31 -03:00
fbcf7fb9c8 fix sync usr to admin & fix notification plate & Rut Validation 2025-12-28 21:16:59 -03:00
522b885dfe fix validation sync 2025-12-28 20:52:25 -03:00
445dddc1df fix Admin Dashboar / rm user notification 2025-12-28 20:20:32 -03:00
10eed4cd26 fix user dashboard 2025-12-28 20:10:28 -03:00
f6564ec2a6 Add RUT registration & Validation, bulk aprove 2025-12-28 19:57:42 -03:00
b91de06e29 Correccion de dashboar usuarios y admins 2025-12-28 19:27:51 -03:00
ad52d300ad cambio en package.json para usuarios y admins 2025-12-28 19:11:27 -03:00
690c4d6ad7 Intento de Creador 2025-12-28 18:41:58 -03:00
d598e61985 Cambio a 24fps y cooldown de 30s 2025-12-28 17:59:03 -03:00
f557a1b2e9 add Autostart all service 2025-12-26 15:51:34 -03:00
1b12d51cde add 5 Ultimas Horas 2025-12-26 15:01:26 -03:00
d5b32fb4a2 Add Historico 2025-12-26 14:16:25 -03:00
5c1681339c Optimización de ALPR para Raspberry Pi: ajuste de resolución y límites de CPU 2025-12-26 14:01:24 -03:00
a9687711fa Merge pull request 'Integracion-modelo-YOLO' (#1) from Integracion-modelo-YOLO into main
Reviewed-on: #1
2025-12-25 21:45:02 -03:00
24 changed files with 15820 additions and 429 deletions

24
.env Normal file
View 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
View 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

View File

@@ -69,17 +69,7 @@ Mantén los contenedores de Docker corriendo (Backend, DB, Frontend) y ejecuta e
El script pedirá permisos de cámara. Una vez otorgados, verás el video en el Dashboard.
#### Opción B: Ejecución en Windows (Híbrida)
1. Asegúrate de tener **Docker Desktop** corriendo.
2. Abre la carpeta `alpr-service`.
3. Haz doble clic en el archivo `run_windows.bat`.
* Este script instalará las dependencias automáticamente.
* Configurará las variables de entorno.
* Iniciará el reconocimiento de patentes.
#### Opción C: Ejecución Full Docker (Linux / Raspberry Pi)
#### Opción B: Ejecución Full Docker (Linux / Raspberry Pi)
En sistemas Linux nativos donde se pueden mapear dispositivos (ej. `/dev/video0`), simplemente descomenta la sección `devices` en `docker-compose.yml` y todo correrá dentro de Docker.

View File

@@ -4,156 +4,396 @@ import requests
import os
import time
import threading
import numpy as np
import re
from flask import Flask, Response
import numpy as np
from datetime import datetime
from queue import Queue
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 = 0.5 # Faster processing with YOLO (it's efficient)
CONFIDENCE_THRESHOLD = 0.4
MODEL_PATH = 'best.pt' # Expecting the model here
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)
# Global variables
# Shared state
outputFrame = None
lock = threading.Lock()
# Store latest detections for visualization
frame_lock = threading.Lock()
latest_detections = []
detection_lock = threading.Lock()
def send_plate(plate_number):
try:
url = f"{BACKEND_URL}/api/detect"
payload = {'plate_number': plate_number}
print(f"Sending plate: {plate_number} to {url}")
requests.post(url, json=payload, timeout=2)
except Exception as e:
print(f"Error sending plate: {e}")
# Cola para procesamiento OCR asíncrono
ocr_queue = Queue(maxsize=10)
def alpr_loop():
global outputFrame, lock, latest_detections
# Cooldown para evitar múltiples capturas de la misma patente
recent_captures = {} # {plate_number: timestamp}
captures_lock = threading.Lock()
print("Initializing EasyOCR...")
reader = easyocr.Reader(['en'], gpu=False)
print("EasyOCR initialized.")
# Cache para lista de dataset
dataset_cache = {'data': None, 'timestamp': 0, 'ttl': 5} # 5 segundos de cache
# Load YOLO Model
print(f"Loading YOLO model from {MODEL_PATH}...")
try:
model = YOLO(MODEL_PATH)
print("YOLO model loaded successfully!")
except Exception as e:
print(f"Error loading YOLO model: {e}")
print("CRITICAL: Please place the 'best.pt' file in the alpr-service directory.")
return
# 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()
cap = cv2.VideoCapture(CAMERA_ID)
time.sleep(2.0)
if not cap.isOpened():
print("Error: Could not open video device.")
return
last_process_time = 0
# 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:
ret, frame = cap.read()
if not ret:
print("Failed to grab frame")
time.sleep(1)
continue
# Resize for performance
frame = cv2.resize(frame, (640, 480))
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()
# Detection Processing
# 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"
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_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, worker_id):
"""Hilo dedicado para OCR - múltiples workers para mejor rendimiento"""
print(f"🔤 OCR Worker {worker_id} started")
while True:
try:
data = ocr_queue.get(timeout=1)
if data is None:
continue
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 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"""
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)
cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*'MJPG'))
cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)
cap.set(cv2.CAP_PROP_FPS, 30)
cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
print("🧠 Loading YOLO model...")
try:
model = YOLO(MODEL_PATH)
except Exception as e:
print(f"❌ Critical Error loading model: {e}")
return
print("📝 Initializing EasyOCR...")
reader = easyocr.Reader(['en'], gpu=False)
# 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:
cap.grab()
cap.grab()
ret, frame = cap.retrieve()
if not ret:
time.sleep(0.01)
continue
frame_count += 1
current_time = time.time()
# 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
# Run YOLO Inference
results = model(frame, verbose=False)
detections = []
results = model(frame, verbose=False, imgsz=320, conf=0.5)
new_detections = []
for r in results:
boxes = r.boxes
for box in boxes:
# Bounding Box
x1, y1, x2, y2 = box.xyxy[0]
x1, y1, x2, y2 = int(x1), int(y1), int(x2), int(y2)
for box in r.boxes:
x1, y1, x2, y2 = map(int, box.xyxy[0])
conf = float(box.conf[0])
new_detections.append((x1, y1, x2, y2, conf))
if conf > 0.5: # Valid plate detection
# Visualization data
detections.append((x1, y1, x2, y2, conf))
plate_img = frame[y1:y2, x1:x2].copy()
if plate_img.size > 0 and not ocr_queue.full():
ocr_queue.put((plate_img, frame.copy()))
# Crop Plate
plate_img = frame[y1:y2, x1:x2]
with detection_lock:
latest_detections = new_detections
# Run OCR on Crop
try:
ocr_results = reader.readtext(plate_img)
for (_, text, prob) in ocr_results:
if prob > CONFIDENCE_THRESHOLD:
clean_text = ''.join(e for e in text if e.isalnum()).upper()
validate_and_send(clean_text)
except Exception as e:
print(f"OCR Error on crop: {e}")
with lock:
latest_detections = detections
# Draw Detections on Frame for Stream
display_frame = frame.copy()
with lock:
display_frame = frame
with detection_lock:
for (x1, y1, x2, y2, conf) in latest_detections:
cv2.rectangle(display_frame, (x1, y1), (x2, y2), (0, 255, 0), 2)
cv2.putText(display_frame, f"Plate {conf:.2f}", (x1, y1-10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 2)
cv2.putText(display_frame, f"{conf:.0%}", (x1, y1-5),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1)
with frame_lock:
outputFrame = display_frame
time.sleep(0.01)
def validate_and_send(text):
# Chilean Plate Regex Patterns
is_valid = False
if re.match(r'^[A-Z]{4}\d{2}$', text): # BBBB11
is_valid = True
elif re.match(r'^[A-Z]{2}\d{4}$', text): # BB1111
is_valid = True
if is_valid:
print(f"Detected Valid Plate: {text}")
send_plate(text)
def generate():
global outputFrame, lock
"""Generador para streaming MJPEG"""
global outputFrame
while True:
with lock:
time.sleep(0.033)
with frame_lock:
if outputFrame is None:
continue
(flag, encodedImage) = cv2.imencode(".jpg", outputFrame)
if not flag:
continue
yield(b'--frame\r\n' b'Content-Type: image/jpeg\r\n\r\n' +
bytearray(encodedImage) + b'\r\n')
_, encoded = cv2.imencode(".jpg", outputFrame, [cv2.IMWRITE_JPEG_QUALITY, 75])
yield b'--frame\r\nContent-Type: image/jpeg\r\n\r\n' + encoded.tobytes() + b'\r\n'
@app.route("/video_feed")
def video_feed():
return Response(generate(), mimetype = "multipart/x-mixed-replace; boundary=frame")
return Response(generate(), mimetype="multipart/x-mixed-replace; boundary=frame")
@app.route("/health")
def health():
"""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=alpr_loop)
t.daemon = True
t = threading.Thread(target=camera_loop, daemon=True)
t.start()
print("Starting Video Stream on port 5001...")
app.run(host="0.0.0.0", port=5001, debug=False, threaded=True, use_reloader=False)

View File

@@ -1,33 +0,0 @@
@echo off
TITLE ControlPatente AI - ALPR Service
echo ========================================================
echo Inicializando Servicio de Reconocimiento de Patentes
echo Windows Launcher
echo ========================================================
echo.
cd /d "%~dp0"
echo [1/3] Verificando entorno Python...
python --version >nul 2>&1
if %errorlevel% neq 0 (
echo ERROR: Python no esta instalado o no esta en el PATH.
echo Por favor instala Python desde https://python.org
pause
exit /b
)
echo [2/3] Instalando dependencias (si faltan)...
pip install -r requirements.txt
pip install flask flask-cors ultralytics
echo.
echo [3/3] Iniciando Servicio...
echo.
set BACKEND_URL=http://localhost:3000
python main.py
pause

View File

@@ -5,7 +5,7 @@
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "nodemon src/index.js",
"dev": "npx prisma db push && nodemon src/index.js",
"migrate": "npx prisma migrate dev"
},
"dependencies": {
@@ -13,7 +13,9 @@
"express": "^4.18.2",
"pg": "^8.11.0",
"socket.io": "^4.6.1",
"@prisma/client": "^5.0.0"
"@prisma/client": "^5.0.0",
"bcryptjs": "^2.4.3",
"jsonwebtoken": "^9.0.2"
},
"devDependencies": {
"nodemon": "^2.0.22",

View File

@@ -8,12 +8,35 @@ datasource db {
url = env("DATABASE_URL")
}
model User {
id Int @id @default(autoincrement())
username String @unique
password String
role String @default("USER") // ADMIN, USER
plates Plate[]
people Person[]
}
model Person {
id Int @id @default(autoincrement())
rut String @unique
name String
status String @default("PENDING") // PENDING, APPROVED, DENIED
startDate DateTime
endDate DateTime
createdAt DateTime @default(now())
addedBy User? @relation(fields: [addedById], references: [id])
addedById Int?
}
model Plate {
id Int @id @default(autoincrement())
number String @unique
owner String?
status String @default("ALLOWED") // ALLOWED, DENIED
status String @default("PENDING") // PENDING, ALLOWED, DENIED
createdAt DateTime @default(now())
addedBy User? @relation(fields: [addedById], references: [id])
addedById Int?
}
model AccessLog {
@@ -21,4 +44,8 @@ model AccessLog {
plateNumber String
accessStatus String // GRANTED, DENIED, UNKNOWN
timestamp DateTime @default(now())
@@index([plateNumber])
@@index([timestamp])
@@index([plateNumber, timestamp])
}

View File

@@ -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
}
});
@@ -23,35 +65,268 @@ app.get('/', (req, res) => {
res.send('ALPR Backend Running');
});
const authRoutes = require('./routes/auth');
const { authenticateToken, isAdmin } = require('./middleware/auth');
app.use('/api/auth', authRoutes);
// Plates CRUD
app.get('/api/plates', async (req, res) => {
app.get('/api/plates', authenticateToken, async (req, res) => {
try {
const plates = await prisma.plate.findMany();
const where = req.user.role === 'ADMIN' ? {} : { addedById: req.user.id };
const plates = await prisma.plate.findMany({
where,
include: { addedBy: { select: { username: true } } }
});
res.json(plates);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.post('/api/plates', async (req, res) => {
const { number, owner, status } = req.body;
app.post('/api/plates', authenticateToken, async (req, res) => {
const { number, owner } = req.body;
const isAdm = req.user.role === 'ADMIN';
const status = isAdm ? 'ALLOWED' : 'PENDING';
try {
const plate = await prisma.plate.create({
data: { number, owner, status: status || 'ALLOWED' }
data: {
number,
owner,
status,
addedById: req.user.id
}
});
io.emit('new_plate_registered', plate);
res.json(plate);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Detection Endpoint (from Python)
app.post('/api/detect', 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;
if (!['ALLOWED', 'DENIED'].includes(status)) {
return res.status(400).json({ error: 'Invalid status' });
}
try {
const plate = await prisma.plate.update({
where: { id: parseInt(id) },
data: { status }
});
io.emit('plate_status_updated', plate);
res.json(plate);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Delete Plate (Admin or Owner)
app.delete('/api/plates/:id', authenticateToken, async (req, res) => {
const { id } = req.params;
try {
const plate = await prisma.plate.findUnique({ where: { id: parseInt(id) } });
if (!plate) return res.status(404).json({ error: 'Plate not found' });
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 });
}
});
// Delete Person (Admin or Owner)
app.delete('/api/people/:id', authenticateToken, async (req, res) => {
const { id } = req.params;
try {
const person = await prisma.person.findUnique({ where: { id: parseInt(id) } });
if (!person) return res.status(404).json({ error: 'Person not found' });
if (req.user.role !== 'ADMIN' && person.addedById !== req.user.id) {
return res.status(403).json({ error: 'Unauthorized' });
}
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 });
}
});
// History Endpoint
app.get('/api/history', async (req, res) => {
const { date } = req.query;
if (!date) {
return res.status(400).json({ error: 'Date is required' });
}
const startDate = new Date(date);
startDate.setHours(0, 0, 0, 0);
const endDate = new Date(date);
endDate.setHours(23, 59, 59, 999);
try {
const logs = await prisma.accessLog.findMany({
where: {
timestamp: {
gte: startDate,
lte: endDate
}
},
orderBy: { timestamp: 'desc' }
});
res.json(logs);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Recent Scans Endpoint (Last 5 Hours)
app.get('/api/recent', async (req, res) => {
try {
const fiveHoursAgo = new Date(Date.now() - 5 * 60 * 60 * 1000);
const logs = await prisma.accessLog.findMany({
where: {
timestamp: { gte: fiveHoursAgo }
},
orderBy: { timestamp: 'desc' }
});
res.json(logs);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Helper: RUT Validation
function validateRut(rut) {
if (!rut || !/^[0-9]+-[0-9kK]{1}$/.test(rut)) return false;
let [num, dv] = rut.split('-');
let total = 0;
let multiple = 2;
for (let i = num.length - 1; i >= 0; i--) {
total += parseInt(num.charAt(i)) * multiple;
multiple = (multiple + 1) % 8 || 2;
}
let res = 11 - (total % 11);
let finalDv = res === 11 ? '0' : res === 10 ? 'K' : res.toString();
return finalDv.toUpperCase() === dv.toUpperCase();
}
// People CRUD
app.get('/api/people', authenticateToken, async (req, res) => {
try {
const where = req.user.role === 'ADMIN' ? {} : { addedById: req.user.id };
const people = await prisma.person.findMany({
where,
include: { addedBy: { select: { username: true } } }
});
res.json(people);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.post('/api/people', authenticateToken, async (req, res) => {
const { rut, name, durationDays } = req.body;
if (!validateRut(rut)) {
return res.status(400).json({ error: 'Invalid RUT format (12345678-K)' });
}
const startDate = new Date();
const endDate = new Date();
endDate.setDate(endDate.getDate() + parseInt(durationDays || 1));
try {
const person = await prisma.person.create({
data: {
rut,
name,
startDate,
endDate,
status: 'PENDING',
addedById: req.user.id
}
});
io.emit('new_person_registered', person);
res.json(person);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Admin: Bulk Approve People by User
app.post('/api/people/bulk-approve', authenticateToken, isAdmin, async (req, res) => {
const { userId } = req.body;
try {
await prisma.person.updateMany({
where: { addedById: parseInt(userId), status: 'PENDING' },
data: { status: 'APPROVED' }
});
io.emit('people_updated', { userId });
res.json({ message: 'Bulk approval successful' });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 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;
try {
// Check if plate exists
const lastLog = await prisma.accessLog.findFirst({
where: { plateNumber: plate_number },
orderBy: { timestamp: 'desc' }
});
if (lastLog) {
const timeDiff = new Date() - new Date(lastLog.timestamp);
if (timeDiff < DUPLICATE_COOLDOWN_MS) {
console.log(`Duplicate detection ignored for ${plate_number} (${timeDiff}ms since last)`);
return res.json({ message: 'Duplicate detection ignored', ignored: true, accessStatus: lastLog.accessStatus });
}
}
let plate = await prisma.plate.findUnique({
where: { number: plate_number }
});
@@ -63,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,
@@ -77,7 +349,6 @@ app.post('/api/detect', async (req, res) => {
}
});
// Notify Frontend via WebSocket
io.emit('new_detection', {
plate: plate_number,
status: accessStatus,
@@ -92,7 +363,73 @@ app.post('/api/detect', async (req, res) => {
}
});
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
// 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 () => {
console.log(`Server running on port ${PORT}`);
// Seed Admin User if none exists
try {
const userCount = await prisma.user.count();
if (userCount === 0) {
console.log('No users found. Creating default admin user...');
// 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',
password: hashedPassword,
role: 'ADMIN'
}
});
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);
}
});

View File

@@ -0,0 +1,32 @@
const jwt = require('jsonwebtoken');
// 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'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) return res.sendStatus(401);
jwt.verify(token, JWT_SECRET, (err, user) => {
if (err) return res.sendStatus(403);
req.user = user;
next();
});
};
const isAdmin = (req, res, next) => {
if (req.user && req.user.role === 'ADMIN') {
next();
} else {
res.status(403).json({ error: 'Admin access required' });
}
};
module.exports = { authenticateToken, isAdmin, JWT_SECRET };

View File

@@ -0,0 +1,77 @@
const express = require('express');
const router = express.Router();
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const { PrismaClient } = require('@prisma/client');
const { JWT_SECRET, authenticateToken, isAdmin } = require('../middleware/auth');
const prisma = new PrismaClient();
// Register (Protected - Admin only or Open? Plan said Admin creates users)
// Let's allow open registration but default to USER role, or only Admin can create.
// Requirement: "administrador sea capaz de crear y borrar usuarios".
// So we will make register protected by isAdmin or just login.
// For initial setup we might need a seed or allow open registration for the first user.
// Let's implement a public login and a protected register for now.
router.post('/login', async (req, res) => {
const { username, password } = req.body;
try {
const user = await prisma.user.findUnique({ where: { username } });
if (!user) return res.status(400).json({ error: 'User not found' });
const validPassword = await bcrypt.compare(password, user.password);
if (!validPassword) return res.status(400).json({ error: 'Invalid password' });
const token = jwt.sign({ id: user.id, username: user.username, role: user.role }, JWT_SECRET, { expiresIn: '1h' });
res.json({ token, role: user.role, username: user.username });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Admin: Create User
router.post('/register', authenticateToken, isAdmin, async (req, res) => {
const { username, password, role } = req.body;
try {
const hashedPassword = await bcrypt.hash(password, 10);
const user = await prisma.user.create({
data: {
username,
password: hashedPassword,
role: role || 'USER'
}
});
res.json({ message: 'User created', userId: user.id });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Admin: Delete User
router.delete('/:id', authenticateToken, isAdmin, async (req, res) => {
const { id } = req.params;
try {
await prisma.user.delete({ where: { id: parseInt(id) } });
res.json({ message: 'User deleted' });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Admin: List Users
router.get('/', authenticateToken, isAdmin, async (req, res) => {
try {
const users = await prisma.user.findMany({
select: { id: true, username: true, role: true } // Don't return passwords
});
res.json(users);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
module.exports = router;

View File

@@ -11,16 +11,23 @@ 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
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
test: [ "CMD-SHELL", "pg_isready -U postgres" ]
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:
@@ -36,28 +47,61 @@ services:
condition: service_healthy
networks:
- backend-net
restart: unless-stopped
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"
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
# Add privilege for hardware access
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:
@@ -67,11 +111,18 @@ services:
- "5173:5173"
networks:
- backend-net
restart: unless-stopped
volumes:
- ./frontend:/app
- /app/node_modules
environment:
- VITE_API_URL=http://localhost:3000
- VITE_API_URL=
- VITE_ALPR_STREAM_URL=
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
networks:
backend-net:

View File

@@ -1,13 +1,193 @@
<!doctype html>
<html lang="en">
<head>
<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>
</head>
<body>
<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>
</body>
<!-- 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>

View File

@@ -14,7 +14,11 @@
"lucide-react": "^0.260.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"socket.io-client": "^4.7.1"
"socket.io-client": "^4.7.1",
"react-router-dom": "^6.14.2",
"jwt-decode": "^3.1.2",
"i18next": "^23.10.0",
"react-i18next": "^14.1.0"
},
"devDependencies": {
"@types/react": "^18.2.15",

BIN
frontend/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 638 KiB

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 641 KiB

View File

@@ -1,246 +1,87 @@
import { useState, useEffect } from 'react'
import io from 'socket.io-client'
import axios from 'axios'
import { Car, AlertCircle, CheckCircle, XCircle, Clock } from 'lucide-react'
// Env var logic for Vite
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
const socket = io(API_URL);
import { useState, useEffect } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import Login from './pages/Login';
import AdminDashboard from './pages/AdminDashboard';
import UserDashboard from './pages/UserDashboard';
import './i18n'; // Initialize translations
function App() {
const [plates, setPlates] = useState([]);
const [detections, setDetections] = useState([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [newPlate, setNewPlate] = useState({ number: '', owner: '' });
const [token, setToken] = useState(localStorage.getItem('token'));
const [userRole, setUserRole] = useState(localStorage.getItem('role'));
const [username, setUsername] = useState(localStorage.getItem('username'));
const handleRegister = async (e) => {
e.preventDefault();
try {
if (!newPlate.number) return;
await axios.post(`${API_URL}/api/plates`, {
number: newPlate.number.toUpperCase(),
owner: newPlate.owner
});
setNewPlate({ number: '', owner: '' });
setShowModal(false);
fetchPlates();
} catch (err) {
alert('Error adding plate: ' + err.message);
const setAuth = (newToken, newRole, newUser) => {
setToken(newToken);
setUserRole(newRole);
setUsername(newUser);
};
const handleLogout = () => {
localStorage.removeItem('token');
localStorage.removeItem('role');
localStorage.removeItem('username');
setToken(null);
setUserRole(null);
setUsername(null);
};
// Protected Route Component
const ProtectedRoute = ({ children, allowedRoles }) => {
if (!token) {
return <Navigate to="/login" replace />;
}
};
// Load initial data
useEffect(() => {
fetchPlates();
// Socket listeners
socket.on('new_detection', (data) => {
console.log('New detection:', data);
setDetections(prev => [data, ...prev].slice(0, 10)); // Keep last 10
});
return () => {
socket.off('new_detection');
};
}, []);
const fetchPlates = async () => {
try {
const res = await axios.get(`${API_URL}/api/plates`);
setPlates(res.data);
setLoading(false);
} catch (err) {
console.error(err);
setLoading(false);
if (allowedRoles && !allowedRoles.includes(userRole)) {
return <Navigate to={userRole === 'ADMIN' ? '/admin' : '/user'} replace />;
}
};
const StatusBadge = ({ status }) => {
const colors = {
GRANTED: 'bg-green-500/20 text-green-400 border-green-500/50',
DENIED: 'bg-red-500/20 text-red-400 border-red-500/50',
UNKNOWN: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/50',
ALLOWED: 'bg-green-500/20 text-green-400 border-green-500/50',
return children;
};
return (
<span className={`px-3 py-1 rounded-full text-xs font-medium border ${colors[status] || colors.UNKNOWN}`}>
{status}
</span>
);
};
return (
<div className="min-h-screen bg-slate-900 text-slate-100 p-8">
{/* Modal */}
{showModal && (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 backdrop-blur-sm">
<div className="bg-slate-800 p-6 rounded-2xl w-full max-w-md border border-slate-700 shadow-2xl transform transition-all scale-100">
<h3 className="text-xl font-bold mb-4">Register New Plate</h3>
<form onSubmit={handleRegister} className="space-y-4">
<div>
<label className="block text-sm text-slate-400 mb-1">Plate Number</label>
<input
autoFocus
type="text"
value={newPlate.number}
onChange={e => setNewPlate({ ...newPlate, number: e.target.value.toUpperCase() })}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 focus:ring-2 focus:ring-blue-500 outline-none font-mono uppercase"
placeholder="ABCD12"
<BrowserRouter>
<Routes>
<Route
path="/login"
element={
!token ? (
<Login
setToken={(t) => setToken(t)}
setUserRole={(r) => setUserRole(r)}
setUsername={(u) => setUsername(u)} // Adding this prop to Login might be needed
/>
</div>
<div>
<label className="block text-sm text-slate-400 mb-1">Owner Name</label>
<input
type="text"
value={newPlate.owner}
onChange={e => setNewPlate({ ...newPlate, owner: e.target.value })}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 focus:ring-2 focus:ring-blue-500 outline-none"
placeholder="John Doe"
/>
</div>
<div className="flex space-x-3 mt-6">
<button
type="button"
onClick={() => setShowModal(false)}
className="flex-1 py-2 bg-slate-700 hover:bg-slate-600 rounded-lg font-medium transition-colors"
>
Cancel
</button>
<button
type="submit"
className="flex-1 py-2 bg-blue-600 hover:bg-blue-500 rounded-lg font-medium transition-colors"
>
Register
</button>
</div>
</form>
</div>
</div>
)}
<div className="max-w-7xl mx-auto space-y-8">
{/* Header */}
<header className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="p-3 bg-blue-600 rounded-lg">
<Car size={32} />
</div>
<div>
<h1 className="text-3xl font-bold bg-gradient-to-r from-blue-400 to-cyan-300 bg-clip-text text-transparent">
Control Patente AI
</h1>
<p className="text-slate-400">Real-time ALPR Monitoring System</p>
</div>
</div>
<div className="flex items-center space-x-2 text-sm text-slate-400">
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse"></div>
<span>System Online</span>
</div>
</header>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Live Detections Feed */}
<div className="lg:col-span-2 space-y-6">
<h2 className="text-xl font-semibold flex items-center space-x-2">
<Clock className="text-blue-400" />
<span>Live Detections</span>
</h2>
<div className="bg-slate-800/50 rounded-2xl p-6 border border-slate-700/50 backdrop-blur-sm min-h-[400px]">
{/* Video Feed */}
<div className="mb-6 rounded-xl overflow-hidden bg-black aspect-video relative border border-slate-700 shadow-lg">
<img
src="http://localhost:5001/video_feed"
alt="Live Camera Feed"
className="w-full h-full object-cover"
onError={(e) => {
e.target.style.display = 'none';
e.target.nextSibling.style.display = 'flex';
}}
/>
<div className="absolute inset-0 flex items-center justify-center text-slate-500 hidden bg-slate-900">
<p>Camera Offline or Connecting...</p>
</div>
<div className="absolute top-4 right-4 animate-pulse">
<div className="px-2 py-1 bg-red-600 rounded text-xs font-bold text-white uppercase tracking-wider">
LIVE
</div>
</div>
</div>
{/* Detections List */}
<h3 className="text-sm font-semibold text-slate-400 uppercase tracking-wider mb-4">Recent Scans</h3>
{detections.length === 0 ? (
<div className="flex flex-col items-center justify-center text-slate-500 space-y-4 text-center py-8">
<p>No detections yet...</p>
</div>
) : (
<div className="space-y-4">
{detections.map((d, i) => (
<div key={i} className="flex items-center justify-between p-4 bg-slate-800 border border-slate-700 rounded-xl hover:bg-slate-750 transition-colors">
<div className="flex items-center space-x-4">
<div className="p-2 bg-slate-700 rounded-lg font-mono text-xl tracking-wider font-bold">
{d.plate}
</div>
<div className="text-sm text-slate-400">
{new Date(d.timestamp).toLocaleTimeString()}
</div>
</div>
<StatusBadge status={d.status} />
</div>
))}
</div>
)}
</div>
</div>
{/* Database / Stats */}
<div className="space-y-6">
<h2 className="text-xl font-semibold flex items-center space-x-2">
<CheckCircle className="text-green-400" />
<span>Registered Plates</span>
</h2>
<div className="bg-slate-800/50 rounded-2xl p-6 border border-slate-700/50 backdrop-blur-sm max-h-[600px] overflow-y-auto">
{loading ? (
<p className="text-center text-slate-500">Loading database...</p>
) : (
<div className="space-y-3">
{plates.map((p) => (
<div key={p.id} className="flex items-center justify-between p-3 bg-slate-800/80 rounded-lg border border-slate-700/50">
<div>
<div className="font-mono font-bold text-slate-200">{p.number}</div>
<div className="text-xs text-slate-400">{p.owner || 'Unknown Owner'}</div>
</div>
<span className="text-xs font-medium text-green-400 bg-green-500/10 px-2 py-1 rounded">
{p.status}
</span>
</div>
))}
{plates.length === 0 && (
<p className="text-center text-slate-500 text-sm">No plates registered.</p>
)}
</div>
)}
<button
className="w-full mt-4 py-2 bg-blue-600 hover:bg-blue-500 rounded-lg font-medium transition-colors text-sm"
onClick={() => setShowModal(true)}
>
+ Register New Plate
</button>
</div>
</div>
</div>
</div>
</div>
<Navigate to={userRole === 'ADMIN' ? '/admin' : '/user'} replace />
)
}
/>
<Route
path="/admin"
element={
<ProtectedRoute allowedRoles={['ADMIN']}>
<div className="relative">
<button onClick={handleLogout} className="absolute top-4 right-4 text-slate-400 hover:text-white z-50">Logout</button>
<AdminDashboard token={token} />
</div>
</ProtectedRoute>
}
/>
<Route
path="/user"
element={
<ProtectedRoute allowedRoles={['USER', 'ADMIN']}> {/* Admin can view user db too usually, but separate for now */}
<div className="relative">
<button onClick={handleLogout} className="absolute top-4 right-4 text-slate-400 hover:text-white z-50">Logout</button>
<UserDashboard token={token} username={username} />
</div>
</ProtectedRoute>
}
/>
<Route path="/" element={<Navigate to="/login" replace />} />
</Routes>
</BrowserRouter>
);
}
export default App
export default App;

View File

@@ -0,0 +1,24 @@
import { useTranslation } from 'react-i18next';
import { Globe } from 'lucide-react';
function LanguageSelector() {
const { i18n } = useTranslation();
const toggleLanguage = () => {
const newLang = i18n.language === 'es' ? 'en' : 'es';
i18n.changeLanguage(newLang);
};
return (
<button
onClick={toggleLanguage}
className="flex items-center gap-2 px-3 py-1 bg-slate-700 hover:bg-slate-600 rounded-md text-sm text-slate-200 transition-colors"
title="Switch Language / Cambiar Idioma"
>
<Globe size={16} />
<span className="font-bold uppercase">{i18n.language}</span>
</button>
);
}
export default LanguageSelector;

143
frontend/src/i18n.js Normal file
View File

@@ -0,0 +1,143 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
// Translations
const resources = {
en: {
translation: {
// General
"title": "VigIA Control",
"logout": "Logout",
"login_title": "Login",
"login_subtitle": "Sign in to access the control panel",
"username": "Username",
"password": "Password",
"sign_in": "Sign In",
"welcome": "Welcome",
// Admin Dashboard
"monitor_area": "Monitor Area",
"plate_approvals": "Plate Approvals",
"user_management": "User Management",
"visitor_approvals": "Visitor Approvals",
"pending_approvals": "Pending Approvals",
"approve": "Approve",
"deny": "Deny",
"all_plates": "All Registered Plates",
"create_user": "Create New User",
"user_list": "User List",
"delete": "Delete",
// User Dashboard
"my_plates_title": "My Registered Plates",
"register_plate": "Register New Plate",
"plate_number": "Plate Number (e.g. AA-BB-11)",
"owner_desc": "Owner / Description",
"submit_plate": "Register Plate",
"my_visitors_title": "My Visitors",
"register_visitor": "Register Visitor",
"visitor_rut": "RUT (12345678-9)",
"full_name": "Full Name",
"submit_visitor": "Register Visitor",
"access_days": "Access Duration",
"1_day": "1 Day Access",
"2_days": "2 Days Access",
"1_week": "1 Week Access",
// Status
"active": "ACTIVE",
"pending": "PENDING",
"denied": "DENIED",
"approved": "APPROVED",
// Messages
"confirm_delete_plate": "Are you sure you want to delete this plate?",
"confirm_delete_visitor": "Are you sure you want to delete this visitor?",
// Visitor Check
"visitor_check": "Visitor Check",
"check_status": "Check Status",
"enter_rut": "Enter RUT",
"visitor_found": "Visitor Found",
"visitor_not_found": "Visitor Not Found",
"access_granted_until": "Access granted until",
"registered_by": "Registered by",
"no_record": "No record found for this RUT."
}
},
es: {
translation: {
// General
"title": "Control VigIA",
"logout": "Cerrar sesión",
"login_title": "Iniciar Sesión",
"login_subtitle": "Ingresa para acceder al panel de control",
"username": "Nombre de usuario",
"password": "Contraseña",
"sign_in": "Ingresar",
"welcome": "Bienvenido",
// Admin Dashboard
"monitor_area": "Monitor",
"plate_approvals": "Aprobación Patentes",
"user_management": "Gestión Usuarios",
"visitor_approvals": "Aprobación Visitas",
"pending_approvals": "Aprobaciones Pendientes",
"approve": "Aprobar",
"deny": "Rechazar",
"all_plates": "Todas las Patentes",
"create_user": "Crear Usuario",
"user_list": "Lista de Usuarios",
"delete": "Eliminar",
// User Dashboard
"my_plates_title": "Mis Patentes Registradas",
"register_plate": "Registrar Nueva Patente",
"plate_number": "Patente (ej. AA-BB-11)",
"owner_desc": "Propietario / Descripción",
"submit_plate": "Registrar Patente",
"my_visitors_title": "Mis Visitas",
"register_visitor": "Registrar Visita",
"visitor_rut": "RUT (12345678-9)",
"full_name": "Nombre Completo",
"submit_visitor": "Registrar Visita",
"access_days": "Duración Acceso",
"1_day": "Acceso 1 Día",
"2_days": "Acceso 2 Días",
"1_week": "Acceso 1 Semana",
// Status
"active": "ACTIVO",
"pending": "PENDIENTE",
"denied": "DENEGADO",
"approved": "APROBADO",
// Messages
"confirm_delete_plate": "¿Estás seguro de que quieres eliminar esta patente?",
"confirm_delete_visitor": "¿Estás seguro de que quieres eliminar esta visita?",
// Visitor Check
"visitor_check": "Consultar Visita",
"check_status": "Verificar Estado",
"enter_rut": "Ingresar RUT",
"visitor_found": "Visita Encontrada",
"visitor_not_found": "Visita No Encontrada",
"access_granted_until": "Acceso permitido hasta",
"registered_by": "Registrado por",
"no_record": "No se encontró registro para este RUT."
}
}
};
i18n
.use(initReactI18next)
.init({
resources,
lng: "es", // Default language
fallbackLng: "en",
interpolation: {
escapeValue: false
}
});
export default i18n;

View File

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

View File

@@ -0,0 +1,745 @@
import { useState, useEffect } from 'react';
import axios from 'axios';
import io from 'socket.io-client';
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';
const API_URL = import.meta.env.VITE_API_URL || '';
const socket = io(API_URL);
function AdminDashboard({ token }) {
const { t } = useTranslation();
const [users, setUsers] = useState([]);
const [plates, setPlates] = useState([]);
const [newUser, setNewUser] = useState({ username: '', password: '', role: 'USER' });
const [activeTab, setActiveTab] = useState('monitor'); // 'monitor' | 'plates' | 'users' | 'visitors'
// Monitor State
const [detections, setDetections] = useState([]);
const [historyLogs, setHistoryLogs] = useState([]);
const [viewMode, setViewMode] = useState('live'); // 'live' | 'history'
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
// Visitor State
const [activePeople, setActivePeople] = useState([]);
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());
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');
socket.off('plate_deleted');
socket.off('person_deleted');
};
}, [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);
}
}, [viewMode, selectedDate, activeTab]);
const fetchData = async () => {
try {
const authHeader = { headers: { Authorization: `Bearer ${token}` } };
const [usersRes, platesRes, recentRes, peopleRes] = await Promise.all([
axios.get(`${API_URL}/api/auth`, authHeader).catch(err => ({ data: [] })),
axios.get(`${API_URL}/api/plates`, authHeader),
axios.get(`${API_URL}/api/recent`, authHeader),
axios.get(`${API_URL}/api/people`, authHeader)
]);
setUsers(usersRes.data);
setPlates(platesRes.data);
setDetections(recentRes.data.map(log => ({
plate: log.plateNumber,
status: log.accessStatus,
timestamp: log.timestamp
})));
setActivePeople(peopleRes.data);
} catch (err) {
console.error(err);
}
};
const fetchHistory = async (date) => {
try {
const res = await axios.get(`${API_URL}/api/history?date=${date}`);
setHistoryLogs(res.data);
} catch (err) {
console.error(err);
}
};
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();
const found = activePeople.find(p => p.rut.toUpperCase() === normalizedRut);
if (found) {
setSearchResult({ found: true, data: found });
} else {
setSearchResult({ found: false });
}
};
const handleCreateUser = async (e) => {
e.preventDefault();
try {
await axios.post(`${API_URL}/api/auth/register`, newUser, {
headers: { Authorization: `Bearer ${token}` }
});
setNewUser({ username: '', password: '', role: 'USER' });
fetchData();
alert('User created');
} catch (err) {
alert('Error: ' + err.message);
}
};
const handleDeleteUser = async (id) => {
if (!confirm('Area you sure?')) return;
try {
await axios.delete(`${API_URL}/api/auth/${id}`, {
headers: { Authorization: `Bearer ${token}` }
});
fetchData();
} catch (err) {
console.error(err);
}
};
const handleApprovePlate = async (id, status) => {
try {
await axios.put(`${API_URL}/api/plates/${id}/approve`, { status }, {
headers: { Authorization: `Bearer ${token}` }
});
fetchData();
} catch (err) {
console.error(err);
}
};
const handleBulkApprove = async (userId) => {
if (!confirm('Approve ALL pending visitors for this user?')) return;
try {
await axios.post(`${API_URL}/api/people/bulk-approve`, { userId }, {
headers: { Authorization: `Bearer ${token}` }
});
fetchData();
alert('All visitors approved');
} catch (err) {
alert(err.message);
}
};
const StatusBadge = ({ status }) => (
<span className={`px-2 py-1 rounded text-xs font-bold ${status === 'GRANTED' ? 'bg-green-600 text-white' :
status === 'DENIED' ? 'bg-red-600 text-white' :
'bg-yellow-600 text-white'
}`}>
{status}
</span>
);
return (
<div className="min-h-screen bg-slate-900 text-slate-100 p-8">
<div className="max-w-7xl mx-auto space-y-8">
<header className="flex items-center justify-between">
<h1 className="text-3xl font-bold flex items-center gap-3">
<Shield className="text-purple-500" />
{t('title')}
</h1>
<div className="flex items-center gap-4">
<div className="flex bg-slate-800 rounded-lg p-1">
<button
onClick={() => setActiveTab('monitor')}
className={`px-4 py-2 rounded-md transition-all ${activeTab === 'monitor' ? 'bg-purple-600' : 'hover:text-purple-400'}`}
>
{t('monitor_area')}
</button>
<button
onClick={() => setActiveTab('plates')}
className={`px-4 py-2 rounded-md transition-all ${activeTab === 'plates' ? 'bg-purple-600' : 'hover:text-purple-400'}`}
>
{t('plate_approvals')}
</button>
<button
onClick={() => setActiveTab('users')}
className={`px-4 py-2 rounded-md transition-all ${activeTab === 'users' ? 'bg-purple-600' : 'hover:text-purple-400'}`}
>
{t('user_management')}
</button>
<button onClick={() => setActiveTab('visitors')} className={`px-4 py-2 rounded-md transition-all ${activeTab === 'visitors' ? 'bg-purple-600' : 'hover:text-purple-400'}`}>
{t('visitor_approvals')}
</button>
</div>
<LanguageSelector />
</div>
</header>
{activeTab === 'monitor' && (
<div className="space-y-6">
<div className="flex justify-between items-center">
<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">
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2 text-slate-300">
<Shield size={20} /> {t('visitor_check')}
</h3>
<form onSubmit={handleSearchRut} className="flex gap-4">
<input
className="flex-1 bg-slate-900 border border-slate-600 rounded-lg px-4 py-3 font-mono text-lg tracking-wider uppercase focus:ring-2 focus:ring-blue-500 outline-none transition-all"
placeholder={t('enter_rut')}
value={searchRut}
onChange={e => setSearchRut(e.target.value)}
/>
<button className="bg-blue-600 hover:bg-blue-500 text-white px-8 rounded-lg font-bold transition-all shadow-lg shadow-blue-900/50">
{t('check_status')}
</button>
</form>
{searchResult && (
<div className={`mt-6 rounded-xl p-6 border relative animate-in fade-in slide-in-from-top-4 duration-300 ${searchResult.found ? 'bg-gradient-to-r from-blue-900/50 to-cyan-900/50 border-blue-500/50' : 'bg-gradient-to-r from-red-900/50 to-orange-900/50 border-red-500/50'}`}>
<button
onClick={() => { setSearchResult(null); setSearchRut(''); }}
className="absolute top-4 right-4 text-slate-400 hover:text-white transition-colors bg-slate-800/50 rounded-full p-1"
>
<XCircle size={24} />
</button>
{searchResult.found ? (
<div className="flex items-start gap-4">
<div className="bg-green-500/20 p-3 rounded-full text-green-400">
<CheckCircle size={32} />
</div>
<div>
<h4 className="text-xl font-bold text-white mb-1">{t('visitor_found')}</h4>
<div className="space-y-1 text-slate-300">
<p className="flex items-center gap-2"><Users size={16} className="text-blue-400" /> Visitor: <span className="font-semibold text-white">{searchResult.data.name}</span></p>
<p className="flex items-center gap-2"><Shield size={16} className="text-purple-400" /> Status: <span className="font-bold text-green-400">{searchResult.data.status}</span></p>
<p className="text-sm text-slate-500 mt-2 border-t border-slate-700/50 pt-2">
{t('registered_by')}: <span className="text-slate-300 font-mono">@{searchResult.data.addedBy?.username || 'Unknown'}</span>
</p>
</div>
</div>
</div>
) : (
<div className="flex items-center gap-4">
<div className="bg-red-500/20 p-3 rounded-full text-red-400">
<AlertCircle size={32} />
</div>
<div>
<h4 className="text-xl font-bold text-white mb-1">{t('visitor_not_found')}</h4>
<p className="text-slate-300">{t('no_record')}</p>
</div>
</div>
)}
</div>
)}
</div>
{viewMode === 'live' ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-black rounded-xl overflow-hidden aspect-video border border-slate-700 relative">
<img
src="/video_feed"
className="w-full h-full object-cover"
onError={(e) => e.target.style.display = 'none'}
/>
<div className="absolute top-4 left-4 bg-red-600 px-2 py-1 rounded text-xs font-bold animate-pulse">LIVE</div>
</div>
<div className="bg-slate-800 rounded-xl p-4 border border-slate-700 h-[400px] overflow-y-auto">
<h3 className="font-bold mb-4 text-slate-400">Recent Detections</h3>
<div className="space-y-2">
{detections.map((d, i) => (
<div key={i} className="flex justify-between items-center p-3 bg-slate-900 rounded border border-slate-700">
<span className="font-mono text-lg font-bold">{d.plate}</span>
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500">{new Date(d.timestamp).toLocaleTimeString()}</span>
<StatusBadge status={d.status} />
</div>
</div>
))}
</div>
</div>
</div>
) : (
<div className="bg-slate-800 rounded-xl p-6 border border-slate-700">
<div className="flex gap-4 mb-6">
<input
type="date"
value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)}
className="bg-slate-900 border border-slate-600 rounded px-4 py-2"
/>
</div>
<div className="space-y-2">
{historyLogs.map(log => (
<div key={log.id} className="flex justify-between items-center p-3 bg-slate-900 rounded border border-slate-700">
<div className="flex items-center gap-4">
<span className="font-mono text-lg font-bold">{log.plateNumber}</span>
<span className="text-slate-500">{new Date(log.timestamp).toLocaleString()}</span>
</div>
<StatusBadge status={log.accessStatus} />
</div>
))}
{historyLogs.length === 0 && <p className="text-slate-500 text-center py-8">No logs found for this date.</p>}
</div>
</div>
)}
</div>
)}
{activeTab === 'plates' && (
<div className="bg-slate-800 rounded-2xl p-6 border border-slate-700">
<h2 className="text-xl font-semibold mb-6">{t('pending_approvals')}</h2>
<div className="space-y-4">
{plates.filter(p => p.status === 'PENDING').length === 0 && (
<p className="text-slate-500">No pending plates.</p>
)}
{plates.filter(p => p.status === 'PENDING').map(plate => (
<div key={plate.id} className="flex items-center justify-between p-4 bg-slate-900 rounded-xl border border-slate-700">
<div>
<div className="font-mono text-xl font-bold">{plate.number}</div>
<div className="text-sm text-slate-400">Owner: {plate.owner} | Added by: {plate.addedBy?.username || 'Unknown'}</div>
</div>
<div className="flex gap-2">
<button
onClick={() => handleApprovePlate(plate.id, 'ALLOWED')}
className="px-4 py-2 bg-green-600 hover:bg-green-500 rounded-lg text-sm font-bold flex items-center gap-2"
>
<CheckCircle size={16} /> {t('approve')}
</button>
<button
onClick={() => handleApprovePlate(plate.id, 'DENIED')}
className="px-4 py-2 bg-red-600 hover:bg-red-500 rounded-lg text-sm font-bold flex items-center gap-2"
>
<XCircle size={16} /> {t('deny')}
</button>
</div>
</div>
))}
</div>
<h2 className="text-xl font-semibold mt-10 mb-6">{t('all_plates')}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{plates.filter(p => p.status !== 'PENDING').map(plate => (
<div key={plate.id} className="p-4 bg-slate-900 rounded-xl border border-slate-700 opacity-75 hover:opacity-100 transition-opacity">
<div className="flex justify-between items-start">
<div>
<div className="font-mono text-lg font-bold">{plate.number}</div>
<div className="text-xs text-slate-500">{plate.owner}</div>
<div className="text-xs text-slate-600 mt-1">Added by: {plate.addedBy?.username || 'System'}</div>
</div>
<span className={`text-xs px-2 py-1 rounded ${plate.status === 'ALLOWED' ? 'bg-green-900 text-green-300' : 'bg-red-900 text-red-300'}`}>
{t(plate.status.toLowerCase()) || plate.status}
</span>
</div>
</div>
))}
</div>
</div>
)}
{activeTab === 'users' && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<div className="bg-slate-800 rounded-2xl p-6 border border-slate-700">
<h3 className="text-xl font-bold mb-4">{t('create_user')}</h3>
<form onSubmit={handleCreateUser} className="space-y-4">
<input
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3"
placeholder={t('username')}
value={newUser.username}
onChange={e => setNewUser({ ...newUser, username: e.target.value })}
/>
<input
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3"
type="password"
placeholder={t('password')}
value={newUser.password}
onChange={e => setNewUser({ ...newUser, password: e.target.value })}
/>
<select
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3"
value={newUser.role}
onChange={e => setNewUser({ ...newUser, role: e.target.value })}
>
<option value="USER">User</option>
<option value="ADMIN">Admin</option>
</select>
<button className="w-full py-3 bg-purple-600 hover:bg-purple-500 rounded-lg font-bold">{t('create_user')}</button>
</form>
</div>
<div className="md:col-span-2 bg-slate-800 rounded-2xl p-6 border border-slate-700">
<h3 className="text-xl font-bold mb-4">{t('user_list')}</h3>
<div className="space-y-3">
{users.map(u => (
<div key={u.id} className="flex items-center justify-between p-4 bg-slate-900 rounded-xl">
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-slate-700 rounded-full flex items-center justify-center font-bold">
{u.username[0].toUpperCase()}
</div>
<div>
<div className="font-bold">{u.username}</div>
<div className="text-xs text-slate-500">{u.role}</div>
</div>
</div>
{u.username !== 'admin' && (
<button onClick={() => handleDeleteUser(u.id)} className="text-red-400 hover:text-red-300">
<Trash2 size={20} />
</button>
)}
</div>
))}
</div>
</div>
</div>
)}
{activeTab === 'visitors' && (
<div className="bg-slate-800 rounded-2xl p-6 border border-slate-700">
<h2 className="text-xl font-bold mb-6">{t('visitor_approvals')}</h2>
{Object.values(activePeople.filter(p => p.status === 'PENDING').reduce((acc, p) => {
const uId = p.addedById || 0;
if (!acc[uId]) acc[uId] = { user: p.addedBy?.username || 'Unknown', userId: uId, items: [] };
acc[uId].items.push(p);
return acc;
}, {})).map(group => (
<div key={group.userId} className="mb-6 bg-slate-900 p-4 rounded-xl border border-slate-700">
<div className="flex justify-between items-center mb-4 border-b border-slate-700 pb-2">
<h3 className="font-bold text-lg text-purple-400">User: {group.user}</h3>
<button onClick={() => handleBulkApprove(group.userId)} className="bg-green-600 hover:bg-green-500 px-4 py-1 rounded text-sm font-bold">
Approve All ({group.items.length})
</button>
</div>
<div className="space-y-2">
{group.items.map(p => (
<div key={p.id} className="flex justify-between text-sm text-slate-300">
<span>{p.name} ({p.rut})</span>
<span>{parseInt((new Date(p.endDate) - new Date(p.startDate)) / (1000 * 60 * 60 * 24))} Days</span>
</div>
))}
</div>
</div>
))}
{activePeople.filter(p => p.status === 'PENDING').length === 0 && <p className="text-slate-500">No pending visitors.</p>}
</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;

View File

@@ -0,0 +1,86 @@
import { useState } from 'react';
import axios from 'axios';
import { Lock, User } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import LanguageSelector from '../components/LanguageSelector';
const API_URL = import.meta.env.VITE_API_URL || '';
function Login({ setToken, setUserRole, setUsername }) {
const { t } = useTranslation();
const [formData, setFormData] = useState({ username: '', password: '' });
const [error, setError] = useState('');
const handleSubmit = async (e) => {
e.preventDefault();
try {
const res = await axios.post(`${API_URL}/api/auth/login`, formData);
localStorage.setItem('token', res.data.token);
localStorage.setItem('role', res.data.role);
localStorage.setItem('username', res.data.username);
setToken(res.data.token);
setUserRole(res.data.role);
setUsername(res.data.username);
} catch (err) {
setError(err.response?.data?.error || 'Login failed');
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-slate-950 relative">
<div className="absolute top-4 right-4">
<LanguageSelector />
</div>
<div className="bg-slate-900 p-8 rounded-2xl shadow-2xl w-96 border border-slate-800">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold bg-gradient-to-r from-blue-500 to-purple-500 bg-clip-text text-transparent">
{t('title')}
</h1>
<p className="text-slate-400 mt-2">{t('login_subtitle')}</p>
</div>
{error && (
<div className="bg-red-500/10 border border-red-500 text-red-500 p-3 rounded mb-4 text-center">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-sm font-medium text-slate-400 mb-1">{t('username')}</label>
<div className="relative">
<User className="absolute left-3 top-3 text-slate-500" size={20} />
<input
className="w-full bg-slate-950 border border-slate-700 rounded-lg py-2.5 pl-10 text-white focus:ring-2 focus:ring-blue-500 outline-none"
placeholder="admin"
value={formData.username}
onChange={e => setFormData({ ...formData, username: e.target.value })}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-400 mb-1">{t('password')}</label>
<div className="relative">
<Lock className="absolute left-3 top-3 text-slate-500" size={20} />
<input
type="password"
className="w-full bg-slate-950 border border-slate-700 rounded-lg py-2.5 pl-10 text-white focus:ring-2 focus:ring-blue-500 outline-none"
placeholder="••••••"
value={formData.password}
onChange={e => setFormData({ ...formData, password: e.target.value })}
/>
</div>
</div>
<button className="w-full bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-500 hover:to-purple-500 py-3 rounded-lg font-bold shadow-lg shadow-blue-500/20 transition-all">
{t('sign_in')}
</button>
</form>
</div>
</div>
);
}
export default Login;

View File

@@ -0,0 +1,272 @@
import { useState, useEffect } from 'react';
import axios from 'axios';
import { Car, Clock, CheckCircle, AlertCircle, Users, Trash2, PlusCircle, UserPlus, AlertTriangle } from 'lucide-react';
import io from 'socket.io-client';
import { useTranslation } from 'react-i18next';
import LanguageSelector from '../components/LanguageSelector';
const API_URL = import.meta.env.VITE_API_URL || '';
const socket = io(API_URL);
function UserDashboard({ token, username }) {
const { t } = useTranslation();
const [plates, setPlates] = useState([]);
const [newPlate, setNewPlate] = useState({ number: '', owner: '' });
const [detections, setDetections] = useState([]);
const [people, setPeople] = useState([]);
const [newPerson, setNewPerson] = useState({ name: '', rut: '', durationDays: 1 });
useEffect(() => {
fetchPlates();
fetchPeople();
// Listen for live detections (optional, maybe user wants to see their plates detected?)
// For now, let's show all global detections but emphasize this is a "Portal"
socket.on('new_detection', (data) => {
setDetections(prev => [data, ...prev].slice(0, 5));
});
// Real-time status updates
socket.on('new_plate_registered', () => fetchPlates()); // Sync when plate added
socket.on('plate_status_updated', () => fetchPlates());
socket.on('people_updated', () => fetchPeople());
return () => {
socket.off('new_detection');
socket.off('new_plate_registered');
socket.off('plate_status_updated');
socket.off('people_updated');
};
}, [token]);
const fetchPlates = async () => {
try {
const res = await axios.get(`${API_URL}/api/plates`, {
headers: { Authorization: `Bearer ${token}` }
});
// Backend already filters by user, so use data directly
setPlates(res.data);
} catch (err) {
console.error(err);
}
};
const fetchPeople = async () => {
try {
const res = await axios.get(`${API_URL}/api/people`, {
headers: { Authorization: `Bearer ${token}` }
});
setPeople(res.data);
} catch (err) {
console.error(err);
}
};
const handleRegister = async (e) => {
e.preventDefault();
try {
await axios.post(`${API_URL}/api/plates`, {
number: newPlate.number.toUpperCase(),
owner: newPlate.owner
}, {
headers: { Authorization: `Bearer ${token}` }
});
setNewPlate({ number: '', owner: '' });
fetchPlates();
// Alert removed for better UX
} catch (err) {
alert('Error: ' + err.message);
}
};
const handleAddPerson = async (e) => {
e.preventDefault();
try {
await axios.post(`${API_URL}/api/people`, newPerson, {
headers: { Authorization: `Bearer ${token}` }
});
setNewPerson({ name: '', rut: '', durationDays: 1 });
fetchPeople();
// Alert removed for better UX
} catch (err) {
alert('Error: ' + (err.response?.data?.error || err.message));
}
};
const handleDeletePlate = async (id) => {
if (!confirm(t('confirm_delete_plate'))) return;
try {
await axios.delete(`${API_URL}/api/plates/${id}`, {
headers: { Authorization: `Bearer ${token}` }
});
fetchPlates();
} catch (err) {
alert(err.response?.data?.error || err.message);
}
};
const handleDeletePerson = async (id) => {
if (!confirm(t('confirm_delete_visitor'))) return;
try {
await axios.delete(`${API_URL}/api/people/${id}`, {
headers: { Authorization: `Bearer ${token}` }
});
fetchPeople();
} catch (err) {
alert(err.response?.data?.error || err.message);
}
};
return (
<div className="min-h-screen bg-slate-900 text-slate-100 p-8">
<div className="max-w-6xl mx-auto space-y-8">
<header className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold bg-gradient-to-r from-blue-400 to-cyan-300 bg-clip-text text-transparent mb-2">
{t('welcome')}, {username}
</h1>
<p className="text-slate-400">Manage your vehicles and visitors.</p>
</div>
<LanguageSelector />
</header>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Plate Registration Form (Existing) */}
<div className="bg-slate-800 rounded-2xl p-6 border border-slate-700">
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
<PlusCircle className="text-blue-500" /> {t('register_plate')}
</h2>
<form onSubmit={handleRegister} className="space-y-4">
<input
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 font-mono text-lg"
placeholder={t('plate_number')}
value={newPlate.number}
onChange={e => setNewPlate({ ...newPlate, number: e.target.value.toUpperCase() })}
required
/>
<input
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3"
placeholder={t('owner_desc')}
value={newPlate.owner}
onChange={e => setNewPlate({ ...newPlate, owner: e.target.value })}
required
/>
<button className="w-full py-3 bg-blue-600 hover:bg-blue-500 rounded-lg font-bold">{t('submit_plate')}</button>
</form>
</div>
{/* Visitor Registration Form (Existing) */}
<div className="bg-slate-800 rounded-2xl p-6 border border-slate-700">
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
<UserPlus className="text-purple-500" /> {t('register_visitor')}
</h2>
<form onSubmit={handleAddPerson} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<input
className="bg-slate-900 border border-slate-700 rounded-lg p-3"
placeholder={t('visitor_rut')}
value={newPerson.rut}
onChange={e => setNewPerson({ ...newPerson, rut: e.target.value })}
required
/>
<select
className="bg-slate-900 border border-slate-700 rounded-lg p-3"
value={newPerson.durationDays}
onChange={e => setNewPerson({ ...newPerson, durationDays: parseInt(e.target.value) })}
required
>
<option value="1">{t('1_day')}</option>
<option value="2">{t('2_days')}</option>
<option value="7">{t('1_week')}</option>
</select>
</div>
<input
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3"
placeholder={t('full_name')}
value={newPerson.name}
onChange={e => setNewPerson({ ...newPerson, name: e.target.value })}
required
/>
<button className="w-full py-3 bg-purple-600 hover:bg-purple-500 rounded-lg font-bold">{t('submit_visitor')}</button>
</form>
</div>
</div>
{/* My Plates List */}
<div>
<h2 className="text-2xl font-bold mb-4">{t('my_plates_title')}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{plates.length === 0 && <p className="text-slate-500 col-span-3">No plates registered.</p>}
{plates.map(plate => (
<div key={plate.id} className="flex items-center justify-between p-4 bg-slate-900 rounded-xl border border-slate-700 group">
<div>
<div className="font-mono text-xl font-bold tracking-wider">{plate.number}</div>
<div className="text-sm text-slate-400">{plate.owner}</div>
</div>
<div className="flex items-center gap-3">
{plate.status === 'ALLOWED' && (
<span className="flex items-center gap-1 text-green-400 bg-green-900/30 px-3 py-1 rounded-full text-xs font-bold">
<CheckCircle size={14} /> {t('active')}
</span>
)}
{plate.status === 'PENDING' && (
<span className="flex items-center gap-1 text-yellow-400 bg-yellow-900/30 px-3 py-1 rounded-full text-xs font-bold">
<Clock size={14} /> {t('pending')}
</span>
)}
{plate.status === 'DENIED' && (
<span className="flex items-center gap-1 text-red-400 bg-red-900/30 px-3 py-1 rounded-full text-xs font-bold">
<AlertTriangle size={14} /> {t('denied')}
</span>
)}
<button
onClick={() => handleDeletePlate(plate.id)}
className="text-slate-600 hover:text-red-500 transition-colors p-1"
title={t('delete')}
>
<Trash2 size={18} />
</button>
</div>
</div>
))}
</div>
</div>
{/* My Visitors List */}
<div>
<h2 className="text-2xl font-bold mb-4">{t('my_visitors_title')}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{people.length === 0 && <p className="text-slate-500 col-span-3">No visitors registered.</p>}
{people.map(p => (
<div key={p.id} className="flex items-center justify-between p-4 bg-slate-900 rounded-xl border border-slate-700">
<div>
<div className="font-bold">{p.name}</div>
<div className="text-sm text-slate-400 font-mono">{p.rut}</div>
<div className="text-xs text-slate-500 mt-1">Until: {new Date(p.endDate).toLocaleDateString()}</div>
</div>
<div className="flex items-center gap-3">
<span className={`px-2 py-1 rounded text-xs font-bold ${p.status === 'APPROVED' ? 'bg-green-900 text-green-300' :
p.status === 'DENIED' ? 'bg-red-900 text-red-300' : 'bg-yellow-900 text-yellow-300'
}`}>
{p.status === 'APPROVED' ? t('approved') : p.status === 'DENIED' ? t('denied') : t('pending')}
</span>
<button
onClick={() => handleDeletePerson(p.id)}
className="text-slate-600 hover:text-red-500 transition-colors p-1"
title={t('delete')}
>
<Trash2 size={18} />
</button>
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
}
export default UserDashboard;

View File

@@ -4,4 +4,25 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
allowedHosts: ['demo.v1ru5.cl'],
proxy: {
'/api': {
target: 'http://backend:3000',
changeOrigin: true
},
'/socket.io': {
target: 'http://backend:3000',
ws: true
},
'/video_feed': {
target: 'http://alpr-service:5001',
changeOrigin: true
},
'/dataset': {
target: 'http://alpr-service:5001',
changeOrigin: true
}
}
}
})

764
get-docker.sh Normal file
View File

@@ -0,0 +1,764 @@
#!/bin/sh
set -e
# Docker Engine for Linux installation script.
#
# This script is intended as a convenient way to configure docker's package
# repositories and to install Docker Engine, This script is not recommended
# for production environments. Before running this script, make yourself familiar
# with potential risks and limitations, and refer to the installation manual
# at https://docs.docker.com/engine/install/ for alternative installation methods.
#
# The script:
#
# - Requires `root` or `sudo` privileges to run.
# - Attempts to detect your Linux distribution and version and configure your
# package management system for you.
# - Doesn't allow you to customize most installation parameters.
# - Installs dependencies and recommendations without asking for confirmation.
# - Installs the latest stable release (by default) of Docker CLI, Docker Engine,
# Docker Buildx, Docker Compose, containerd, and runc. When using this script
# to provision a machine, this may result in unexpected major version upgrades
# of these packages. Always test upgrades in a test environment before
# deploying to your production systems.
# - Isn't designed to upgrade an existing Docker installation. When using the
# script to update an existing installation, dependencies may not be updated
# to the expected version, resulting in outdated versions.
#
# Source code is available at https://github.com/docker/docker-install/
#
# Usage
# ==============================================================================
#
# To install the latest stable versions of Docker CLI, Docker Engine, and their
# dependencies:
#
# 1. download the script
#
# $ curl -fsSL https://get.docker.com -o install-docker.sh
#
# 2. verify the script's content
#
# $ cat install-docker.sh
#
# 3. run the script with --dry-run to verify the steps it executes
#
# $ sh install-docker.sh --dry-run
#
# 4. run the script either as root, or using sudo to perform the installation.
#
# $ sudo sh install-docker.sh
#
# Command-line options
# ==============================================================================
#
# --version <VERSION>
# Use the --version option to install a specific version, for example:
#
# $ sudo sh install-docker.sh --version 23.0
#
# --channel <stable|test>
#
# Use the --channel option to install from an alternative installation channel.
# The following example installs the latest versions from the "test" channel,
# which includes pre-releases (alpha, beta, rc):
#
# $ sudo sh install-docker.sh --channel test
#
# Alternatively, use the script at https://test.docker.com, which uses the test
# channel as default.
#
# --mirror <Aliyun|AzureChinaCloud>
#
# Use the --mirror option to install from a mirror supported by this script.
# Available mirrors are "Aliyun" (https://mirrors.aliyun.com/docker-ce), and
# "AzureChinaCloud" (https://mirror.azure.cn/docker-ce), for example:
#
# $ sudo sh install-docker.sh --mirror AzureChinaCloud
#
# --setup-repo
#
# Use the --setup-repo option to configure Docker's package repositories without
# installing Docker packages. This is useful when you want to add the repository
# but install packages separately:
#
# $ sudo sh install-docker.sh --setup-repo
#
# Automatic Service Start
#
# By default, this script automatically starts the Docker daemon and enables the docker
# service after installation if systemd is used as init.
#
# If you prefer to start the service manually, use the --no-autostart option:
#
# $ sudo sh install-docker.sh --no-autostart
#
# Note: Starting the service requires appropriate privileges to manage system services.
#
# ==============================================================================
# Git commit from https://github.com/docker/docker-install when
# the script was uploaded (Should only be modified by upload job):
SCRIPT_COMMIT_SHA="8b33a64d28ec86a1121623f1d349801b48f2837b"
# strip "v" prefix if present
VERSION="${VERSION#v}"
# The channel to install from:
# * stable
# * test
DEFAULT_CHANNEL_VALUE="stable"
if [ -z "$CHANNEL" ]; then
CHANNEL=$DEFAULT_CHANNEL_VALUE
fi
DEFAULT_DOWNLOAD_URL="https://download.docker.com"
if [ -z "$DOWNLOAD_URL" ]; then
DOWNLOAD_URL=$DEFAULT_DOWNLOAD_URL
fi
DEFAULT_REPO_FILE="docker-ce.repo"
if [ -z "$REPO_FILE" ]; then
REPO_FILE="$DEFAULT_REPO_FILE"
# Automatically default to a staging repo fora
# a staging download url (download-stage.docker.com)
case "$DOWNLOAD_URL" in
*-stage*) REPO_FILE="docker-ce-staging.repo";;
esac
fi
mirror=''
DRY_RUN=${DRY_RUN:-}
REPO_ONLY=${REPO_ONLY:-0}
NO_AUTOSTART=${NO_AUTOSTART:-0}
while [ $# -gt 0 ]; do
case "$1" in
--channel)
CHANNEL="$2"
shift
;;
--dry-run)
DRY_RUN=1
;;
--mirror)
mirror="$2"
shift
;;
--version)
VERSION="${2#v}"
shift
;;
--setup-repo)
REPO_ONLY=1
shift
;;
--no-autostart)
NO_AUTOSTART=1
;;
--*)
echo "Illegal option $1"
;;
esac
shift $(( $# > 0 ? 1 : 0 ))
done
case "$mirror" in
Aliyun)
DOWNLOAD_URL="https://mirrors.aliyun.com/docker-ce"
;;
AzureChinaCloud)
DOWNLOAD_URL="https://mirror.azure.cn/docker-ce"
;;
"")
;;
*)
>&2 echo "unknown mirror '$mirror': use either 'Aliyun', or 'AzureChinaCloud'."
exit 1
;;
esac
case "$CHANNEL" in
stable|test)
;;
*)
>&2 echo "unknown CHANNEL '$CHANNEL': use either stable or test."
exit 1
;;
esac
command_exists() {
command -v "$@" > /dev/null 2>&1
}
# version_gte checks if the version specified in $VERSION is at least the given
# SemVer (Maj.Minor[.Patch]), or CalVer (YY.MM) version.It returns 0 (success)
# if $VERSION is either unset (=latest) or newer or equal than the specified
# version, or returns 1 (fail) otherwise.
#
# examples:
#
# VERSION=23.0
# version_gte 23.0 // 0 (success)
# version_gte 20.10 // 0 (success)
# version_gte 19.03 // 0 (success)
# version_gte 26.1 // 1 (fail)
version_gte() {
if [ -z "$VERSION" ]; then
return 0
fi
version_compare "$VERSION" "$1"
}
# version_compare compares two version strings (either SemVer (Major.Minor.Path),
# or CalVer (YY.MM) version strings. It returns 0 (success) if version A is newer
# or equal than version B, or 1 (fail) otherwise. Patch releases and pre-release
# (-alpha/-beta) are not taken into account
#
# examples:
#
# version_compare 23.0.0 20.10 // 0 (success)
# version_compare 23.0 20.10 // 0 (success)
# version_compare 20.10 19.03 // 0 (success)
# version_compare 20.10 20.10 // 0 (success)
# version_compare 19.03 20.10 // 1 (fail)
version_compare() (
set +x
yy_a="$(echo "$1" | cut -d'.' -f1)"
yy_b="$(echo "$2" | cut -d'.' -f1)"
if [ "$yy_a" -lt "$yy_b" ]; then
return 1
fi
if [ "$yy_a" -gt "$yy_b" ]; then
return 0
fi
mm_a="$(echo "$1" | cut -d'.' -f2)"
mm_b="$(echo "$2" | cut -d'.' -f2)"
# trim leading zeros to accommodate CalVer
mm_a="${mm_a#0}"
mm_b="${mm_b#0}"
if [ "${mm_a:-0}" -lt "${mm_b:-0}" ]; then
return 1
fi
return 0
)
is_dry_run() {
if [ -z "$DRY_RUN" ]; then
return 1
else
return 0
fi
}
is_wsl() {
case "$(uname -r)" in
*microsoft* ) true ;; # WSL 2
*Microsoft* ) true ;; # WSL 1
* ) false;;
esac
}
is_darwin() {
case "$(uname -s)" in
*darwin* ) true ;;
*Darwin* ) true ;;
* ) false;;
esac
}
deprecation_notice() {
distro=$1
distro_version=$2
echo
printf "\033[91;1mDEPRECATION WARNING\033[0m\n"
printf " This Linux distribution (\033[1m%s %s\033[0m) reached end-of-life and is no longer supported by this script.\n" "$distro" "$distro_version"
echo " No updates or security fixes will be released for this distribution, and users are recommended"
echo " to upgrade to a currently maintained version of $distro."
echo
printf "Press \033[1mCtrl+C\033[0m now to abort this script, or wait for the installation to continue."
echo
sleep 10
}
get_distribution() {
lsb_dist=""
# Every system that we officially support has /etc/os-release
if [ -r /etc/os-release ]; then
lsb_dist="$(. /etc/os-release && echo "$ID")"
fi
# Returning an empty string here should be alright since the
# case statements don't act unless you provide an actual value
echo "$lsb_dist"
}
start_docker_daemon() {
# Use systemctl if available (for systemd-based systems)
if command_exists systemctl; then
is_dry_run || >&2 echo "Using systemd to manage Docker service"
if (
is_dry_run || set -x
$sh_c systemctl enable --now docker.service 2>/dev/null
); then
is_dry_run || echo "INFO: Docker daemon enabled and started" >&2
else
is_dry_run || echo "WARNING: unable to enable the docker service" >&2
fi
else
# No service management available (container environment)
if ! is_dry_run; then
>&2 echo "Note: Running in a container environment without service management"
>&2 echo "Docker daemon cannot be started automatically in this environment"
>&2 echo "The Docker packages have been installed successfully"
fi
fi
>&2 echo
}
echo_docker_as_nonroot() {
if is_dry_run; then
return
fi
if command_exists docker && [ -e /var/run/docker.sock ]; then
(
set -x
$sh_c 'docker version'
) || true
fi
# intentionally mixed spaces and tabs here -- tabs are stripped by "<<-EOF", spaces are kept in the output
echo
echo "================================================================================"
echo
if version_gte "20.10"; then
echo "To run Docker as a non-privileged user, consider setting up the"
echo "Docker daemon in rootless mode for your user:"
echo
echo " dockerd-rootless-setuptool.sh install"
echo
echo "Visit https://docs.docker.com/go/rootless/ to learn about rootless mode."
echo
fi
echo
echo "To run the Docker daemon as a fully privileged service, but granting non-root"
echo "users access, refer to https://docs.docker.com/go/daemon-access/"
echo
echo "WARNING: Access to the remote API on a privileged Docker daemon is equivalent"
echo " to root access on the host. Refer to the 'Docker daemon attack surface'"
echo " documentation for details: https://docs.docker.com/go/attack-surface/"
echo
echo "================================================================================"
echo
}
# Check if this is a forked Linux distro
check_forked() {
# Check for lsb_release command existence, it usually exists in forked distros
if command_exists lsb_release; then
# Check if the `-u` option is supported
set +e
lsb_release -a -u > /dev/null 2>&1
lsb_release_exit_code=$?
set -e
# Check if the command has exited successfully, it means we're in a forked distro
if [ "$lsb_release_exit_code" = "0" ]; then
# Print info about current distro
cat <<-EOF
You're using '$lsb_dist' version '$dist_version'.
EOF
# Get the upstream release info
lsb_dist=$(lsb_release -a -u 2>&1 | tr '[:upper:]' '[:lower:]' | grep -E 'id' | cut -d ':' -f 2 | tr -d '[:space:]')
dist_version=$(lsb_release -a -u 2>&1 | tr '[:upper:]' '[:lower:]' | grep -E 'codename' | cut -d ':' -f 2 | tr -d '[:space:]')
# Print info about upstream distro
cat <<-EOF
Upstream release is '$lsb_dist' version '$dist_version'.
EOF
else
if [ -r /etc/debian_version ] && [ "$lsb_dist" != "ubuntu" ] && [ "$lsb_dist" != "raspbian" ]; then
if [ "$lsb_dist" = "osmc" ]; then
# OSMC runs Raspbian
lsb_dist=raspbian
else
# We're Debian and don't even know it!
lsb_dist=debian
fi
dist_version="$(sed 's/\/.*//' /etc/debian_version | sed 's/\..*//')"
case "$dist_version" in
13)
dist_version="trixie"
;;
12)
dist_version="bookworm"
;;
11)
dist_version="bullseye"
;;
10)
dist_version="buster"
;;
9)
dist_version="stretch"
;;
8)
dist_version="jessie"
;;
esac
fi
fi
fi
}
do_install() {
echo "# Executing docker install script, commit: $SCRIPT_COMMIT_SHA"
if command_exists docker; then
cat >&2 <<-'EOF'
Warning: the "docker" command appears to already exist on this system.
If you already have Docker installed, this script can cause trouble, which is
why we're displaying this warning and provide the opportunity to cancel the
installation.
If you installed the current Docker package using this script and are using it
again to update Docker, you can ignore this message, but be aware that the
script resets any custom changes in the deb and rpm repo configuration
files to match the parameters passed to the script.
You may press Ctrl+C now to abort this script.
EOF
( set -x; sleep 20 )
fi
user="$(id -un 2>/dev/null || true)"
sh_c='sh -c'
if [ "$user" != 'root' ]; then
if command_exists sudo; then
sh_c='sudo -E sh -c'
elif command_exists su; then
sh_c='su -c'
else
cat >&2 <<-'EOF'
Error: this installer needs the ability to run commands as root.
We are unable to find either "sudo" or "su" available to make this happen.
EOF
exit 1
fi
fi
if is_dry_run; then
sh_c="echo"
fi
# perform some very rudimentary platform detection
lsb_dist=$( get_distribution )
lsb_dist="$(echo "$lsb_dist" | tr '[:upper:]' '[:lower:]')"
if is_wsl; then
echo
echo "WSL DETECTED: We recommend using Docker Desktop for Windows."
echo "Please get Docker Desktop from https://www.docker.com/products/docker-desktop/"
echo
cat >&2 <<-'EOF'
You may press Ctrl+C now to abort this script.
EOF
( set -x; sleep 20 )
fi
case "$lsb_dist" in
ubuntu)
if command_exists lsb_release; then
dist_version="$(lsb_release --codename | cut -f2)"
fi
if [ -z "$dist_version" ] && [ -r /etc/lsb-release ]; then
dist_version="$(. /etc/lsb-release && echo "$DISTRIB_CODENAME")"
fi
;;
debian|raspbian)
dist_version="$(sed 's/\/.*//' /etc/debian_version | sed 's/\..*//')"
case "$dist_version" in
13)
dist_version="trixie"
;;
12)
dist_version="bookworm"
;;
11)
dist_version="bullseye"
;;
10)
dist_version="buster"
;;
9)
dist_version="stretch"
;;
8)
dist_version="jessie"
;;
esac
;;
centos|rhel)
if [ -z "$dist_version" ] && [ -r /etc/os-release ]; then
dist_version="$(. /etc/os-release && echo "$VERSION_ID")"
fi
;;
*)
if command_exists lsb_release; then
dist_version="$(lsb_release --release | cut -f2)"
fi
if [ -z "$dist_version" ] && [ -r /etc/os-release ]; then
dist_version="$(. /etc/os-release && echo "$VERSION_ID")"
fi
;;
esac
# Check if this is a forked Linux distro
check_forked
# Print deprecation warnings for distro versions that recently reached EOL,
# but may still be commonly used (especially LTS versions).
case "$lsb_dist.$dist_version" in
centos.8|centos.7|rhel.7)
deprecation_notice "$lsb_dist" "$dist_version"
;;
debian.buster|debian.stretch|debian.jessie)
deprecation_notice "$lsb_dist" "$dist_version"
;;
raspbian.buster|raspbian.stretch|raspbian.jessie)
deprecation_notice "$lsb_dist" "$dist_version"
;;
ubuntu.focal|ubuntu.bionic|ubuntu.xenial|ubuntu.trusty)
deprecation_notice "$lsb_dist" "$dist_version"
;;
ubuntu.oracular|ubuntu.mantic|ubuntu.lunar|ubuntu.kinetic|ubuntu.impish|ubuntu.hirsute|ubuntu.groovy|ubuntu.eoan|ubuntu.disco|ubuntu.cosmic)
deprecation_notice "$lsb_dist" "$dist_version"
;;
fedora.*)
if [ "$dist_version" -lt 41 ]; then
deprecation_notice "$lsb_dist" "$dist_version"
fi
;;
esac
# Run setup for each distro accordingly
case "$lsb_dist" in
ubuntu|debian|raspbian)
pre_reqs="ca-certificates curl"
apt_repo="deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] $DOWNLOAD_URL/linux/$lsb_dist $dist_version $CHANNEL"
(
if ! is_dry_run; then
set -x
fi
$sh_c 'apt-get -qq update >/dev/null'
$sh_c "DEBIAN_FRONTEND=noninteractive apt-get -y -qq install $pre_reqs >/dev/null"
$sh_c 'install -m 0755 -d /etc/apt/keyrings'
$sh_c "curl -fsSL \"$DOWNLOAD_URL/linux/$lsb_dist/gpg\" -o /etc/apt/keyrings/docker.asc"
$sh_c "chmod a+r /etc/apt/keyrings/docker.asc"
$sh_c "echo \"$apt_repo\" > /etc/apt/sources.list.d/docker.list"
$sh_c 'apt-get -qq update >/dev/null'
)
if [ "$REPO_ONLY" = "1" ]; then
exit 0
fi
pkg_version=""
if [ -n "$VERSION" ]; then
if is_dry_run; then
echo "# WARNING: VERSION pinning is not supported in DRY_RUN"
else
# Will work for incomplete versions IE (17.12), but may not actually grab the "latest" if in the test channel
pkg_pattern="$(echo "$VERSION" | sed 's/-ce-/~ce~.*/g' | sed 's/-/.*/g')"
search_command="apt-cache madison docker-ce | grep '$pkg_pattern' | head -1 | awk '{\$1=\$1};1' | cut -d' ' -f 3"
pkg_version="$($sh_c "$search_command")"
echo "INFO: Searching repository for VERSION '$VERSION'"
echo "INFO: $search_command"
if [ -z "$pkg_version" ]; then
echo
echo "ERROR: '$VERSION' not found amongst apt-cache madison results"
echo
exit 1
fi
if version_gte "18.09"; then
search_command="apt-cache madison docker-ce-cli | grep '$pkg_pattern' | head -1 | awk '{\$1=\$1};1' | cut -d' ' -f 3"
echo "INFO: $search_command"
cli_pkg_version="=$($sh_c "$search_command")"
fi
pkg_version="=$pkg_version"
fi
fi
(
pkgs="docker-ce${pkg_version%=}"
if version_gte "18.09"; then
# older versions didn't ship the cli and containerd as separate packages
pkgs="$pkgs docker-ce-cli${cli_pkg_version%=} containerd.io"
fi
if version_gte "20.10"; then
pkgs="$pkgs docker-compose-plugin docker-ce-rootless-extras$pkg_version"
fi
if version_gte "23.0"; then
pkgs="$pkgs docker-buildx-plugin"
fi
if version_gte "28.2"; then
pkgs="$pkgs docker-model-plugin"
fi
if ! is_dry_run; then
set -x
fi
$sh_c "DEBIAN_FRONTEND=noninteractive apt-get -y -qq install $pkgs >/dev/null"
)
if [ "$NO_AUTOSTART" != "1" ]; then
start_docker_daemon
fi
echo_docker_as_nonroot
exit 0
;;
centos|fedora|rhel)
if [ "$(uname -m)" = "s390x" ]; then
echo "Effective v27.5, please consult RHEL distro statement for s390x support."
exit 1
fi
repo_file_url="$DOWNLOAD_URL/linux/$lsb_dist/$REPO_FILE"
(
if ! is_dry_run; then
set -x
fi
if command_exists dnf5; then
$sh_c "dnf -y -q --setopt=install_weak_deps=False install dnf-plugins-core"
$sh_c "dnf5 config-manager addrepo --overwrite --save-filename=docker-ce.repo --from-repofile='$repo_file_url'"
if [ "$CHANNEL" != "stable" ]; then
$sh_c "dnf5 config-manager setopt \"docker-ce-*.enabled=0\""
$sh_c "dnf5 config-manager setopt \"docker-ce-$CHANNEL.enabled=1\""
fi
$sh_c "dnf makecache"
elif command_exists dnf; then
$sh_c "dnf -y -q --setopt=install_weak_deps=False install dnf-plugins-core"
$sh_c "rm -f /etc/yum.repos.d/docker-ce.repo /etc/yum.repos.d/docker-ce-staging.repo"
$sh_c "dnf config-manager --add-repo $repo_file_url"
if [ "$CHANNEL" != "stable" ]; then
$sh_c "dnf config-manager --set-disabled \"docker-ce-*\""
$sh_c "dnf config-manager --set-enabled \"docker-ce-$CHANNEL\""
fi
$sh_c "dnf makecache"
else
$sh_c "yum -y -q install yum-utils"
$sh_c "rm -f /etc/yum.repos.d/docker-ce.repo /etc/yum.repos.d/docker-ce-staging.repo"
$sh_c "yum-config-manager --add-repo $repo_file_url"
if [ "$CHANNEL" != "stable" ]; then
$sh_c "yum-config-manager --disable \"docker-ce-*\""
$sh_c "yum-config-manager --enable \"docker-ce-$CHANNEL\""
fi
$sh_c "yum makecache"
fi
)
if [ "$REPO_ONLY" = "1" ]; then
exit 0
fi
pkg_version=""
if command_exists dnf; then
pkg_manager="dnf"
pkg_manager_flags="-y -q --best"
else
pkg_manager="yum"
pkg_manager_flags="-y -q"
fi
if [ -n "$VERSION" ]; then
if is_dry_run; then
echo "# WARNING: VERSION pinning is not supported in DRY_RUN"
else
if [ "$lsb_dist" = "fedora" ]; then
pkg_suffix="fc$dist_version"
else
pkg_suffix="el"
fi
pkg_pattern="$(echo "$VERSION" | sed 's/-ce-/\\\\.ce.*/g' | sed 's/-/.*/g').*$pkg_suffix"
search_command="$pkg_manager list --showduplicates docker-ce | grep '$pkg_pattern' | tail -1 | awk '{print \$2}'"
pkg_version="$($sh_c "$search_command")"
echo "INFO: Searching repository for VERSION '$VERSION'"
echo "INFO: $search_command"
if [ -z "$pkg_version" ]; then
echo
echo "ERROR: '$VERSION' not found amongst $pkg_manager list results"
echo
exit 1
fi
if version_gte "18.09"; then
# older versions don't support a cli package
search_command="$pkg_manager list --showduplicates docker-ce-cli | grep '$pkg_pattern' | tail -1 | awk '{print \$2}'"
cli_pkg_version="$($sh_c "$search_command" | cut -d':' -f 2)"
fi
# Cut out the epoch and prefix with a '-'
pkg_version="-$(echo "$pkg_version" | cut -d':' -f 2)"
fi
fi
(
pkgs="docker-ce$pkg_version"
if version_gte "18.09"; then
# older versions didn't ship the cli and containerd as separate packages
if [ -n "$cli_pkg_version" ]; then
pkgs="$pkgs docker-ce-cli-$cli_pkg_version containerd.io"
else
pkgs="$pkgs docker-ce-cli containerd.io"
fi
fi
if version_gte "20.10"; then
pkgs="$pkgs docker-compose-plugin docker-ce-rootless-extras$pkg_version"
fi
if version_gte "23.0"; then
pkgs="$pkgs docker-buildx-plugin docker-model-plugin"
fi
if ! is_dry_run; then
set -x
fi
$sh_c "$pkg_manager $pkg_manager_flags install $pkgs"
)
if [ "$NO_AUTOSTART" != "1" ]; then
start_docker_daemon
fi
echo_docker_as_nonroot
exit 0
;;
sles)
echo "Effective v27.5, please consult SLES distro statement for s390x support."
exit 1
;;
*)
if [ -z "$lsb_dist" ]; then
if is_darwin; then
echo
echo "ERROR: Unsupported operating system 'macOS'"
echo "Please get Docker Desktop from https://www.docker.com/products/docker-desktop"
echo
exit 1
fi
fi
echo
echo "ERROR: Unsupported distribution '$lsb_dist'"
echo
exit 1
;;
esac
exit 1
}
# wrapped up in a function so that we have some protection against only getting
# half the file during "curl | sh"
do_install