1 Commits

Author SHA1 Message Date
349e5598a0 .bat de integracion on windows alpr-service 2025-12-24 10:40:55 -03:00
24 changed files with 425 additions and 15816 deletions

24
.env
View File

@@ -1,24 +0,0 @@
# ===================================================================
# 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

View File

@@ -1,43 +0,0 @@
# ===================================================================
# 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,7 +69,17 @@ 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. El script pedirá permisos de cámara. Una vez otorgados, verás el video en el Dashboard.
#### Opción B: Ejecución Full Docker (Linux / Raspberry Pi)
#### 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)
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. 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,396 +4,156 @@ import requests
import os import os
import time import time
import threading import threading
import re
import numpy as np import numpy as np
from datetime import datetime import re
from queue import Queue from flask import Flask, Response
from flask import Flask, Response, request, send_from_directory
from flask_cors import CORS from flask_cors import CORS
from ultralytics import YOLO from ultralytics import YOLO
# Configuration (puede ser sobrescrito por variables de entorno) # Configuration
BACKEND_URL = os.environ.get('BACKEND_URL', 'http://localhost:3000') BACKEND_URL = os.environ.get('BACKEND_URL', 'http://localhost:3000')
CAMERA_ID = int(os.environ.get('CAMERA_ID', 0)) CAMERA_ID = 0
PROCESS_INTERVAL = float(os.environ.get('PROCESS_INTERVAL', 1.5)) PROCESS_INTERVAL = 0.5 # Faster processing with YOLO (it's efficient)
MODEL_PATH = os.environ.get('MODEL_PATH', 'best.pt') CONFIDENCE_THRESHOLD = 0.4
DATASET_DIR = os.environ.get('DATASET_DIR', '/app/dataset') MODEL_PATH = 'best.pt' # Expecting the model here
DATASET_COOLDOWN = int(os.environ.get('DATASET_COOLDOWN', 60))
OCR_WORKERS = int(os.environ.get('OCR_WORKERS', 2)) # Número de workers OCR
SERVICE_API_KEY = os.environ.get('SERVICE_API_KEY', '') # For backend auth
app = Flask(__name__) app = Flask(__name__)
CORS(app) CORS(app)
# Shared state # Global variables
outputFrame = None outputFrame = None
frame_lock = threading.Lock() lock = threading.Lock()
# Store latest detections for visualization
latest_detections = [] latest_detections = []
detection_lock = threading.Lock()
# Cola para procesamiento OCR asíncrono
ocr_queue = Queue(maxsize=10)
# Cooldown para evitar múltiples capturas de la misma patente
recent_captures = {} # {plate_number: timestamp}
captures_lock = threading.Lock()
# Cache para lista de dataset
dataset_cache = {'data': None, 'timestamp': 0, 'ttl': 5} # 5 segundos de cache
# Métricas para health check
metrics = {
'fps': 0,
'ocr_queue_size': 0,
'total_detections': 0,
'total_captures': 0,
'last_detection': None,
'start_time': time.time()
}
metrics_lock = threading.Lock()
# Crear carpeta de dataset si no existe
os.makedirs(DATASET_DIR, exist_ok=True)
print(f"📁 Dataset directory: {DATASET_DIR}")
def cleanup_recent_captures():
"""Limpia capturas antiguas para evitar memory leak - ejecuta cada 5 minutos"""
while True:
time.sleep(300) # 5 minutos
current_time = time.time()
with captures_lock:
expired = [k for k, v in recent_captures.items() if current_time - v > DATASET_COOLDOWN * 2]
for k in expired:
del recent_captures[k]
if expired:
print(f"🧹 Cleaned {len(expired)} expired capture records")
def save_plate_capture(plate_number, full_frame):
"""Guarda la captura de la patente para el dataset con cooldown"""
current_time = time.time()
# Validar que el frame no esté vacío
if full_frame is None or full_frame.size == 0:
print(f"⚠️ Empty frame, skipping save for {plate_number}")
return False
# Verificar cooldown
with captures_lock:
if plate_number in recent_captures:
elapsed = current_time - recent_captures[plate_number]
if elapsed < DATASET_COOLDOWN:
return False
recent_captures[plate_number] = current_time
try:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
frame_to_save = np.copy(full_frame)
filename = f"{plate_number}_{timestamp}.jpg"
filepath = f"{DATASET_DIR}/{filename}"
success = cv2.imwrite(filepath, frame_to_save, [cv2.IMWRITE_JPEG_QUALITY, 95])
if not success or not os.path.exists(filepath) or os.path.getsize(filepath) == 0:
print(f"❌ Failed to save image for {plate_number}")
if os.path.exists(filepath):
os.remove(filepath)
return False
# Invalidar cache
dataset_cache['timestamp'] = 0
# Actualizar métricas
with metrics_lock:
metrics['total_captures'] += 1
# Contar total de capturas
total_count = len([f for f in os.listdir(DATASET_DIR) if f.endswith('.jpg')])
# Notificar al backend
try:
requests.post(f"{BACKEND_URL}/api/dataset/capture", json={
'plate_number': plate_number,
'filename': filename,
'count': total_count
}, timeout=2)
except:
pass
print(f"📸 Saved to dataset: {plate_number} (Total: {total_count})")
return True
except Exception as e:
print(f"❌ Error saving capture: {e}")
return False
def send_plate(plate_number): def send_plate(plate_number):
"""Envía la patente detectada al backend"""
try: try:
url = f"{BACKEND_URL}/api/detect" url = f"{BACKEND_URL}/api/detect"
headers = {} payload = {'plate_number': plate_number}
if SERVICE_API_KEY: print(f"Sending plate: {plate_number} to {url}")
headers['X-Service-Key'] = SERVICE_API_KEY requests.post(url, json=payload, timeout=2)
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: except Exception as e:
print(f"Error sending plate: {e}") print(f"Error sending plate: {e}")
def validate_plate(text): def alpr_loop():
"""Valida formatos de patentes de Chile, Argentina y Brasil""" global outputFrame, lock, latest_detections
# 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): print("Initializing EasyOCR...")
"""Hilo dedicado para OCR - múltiples workers para mejor rendimiento""" reader = easyocr.Reader(['en'], gpu=False)
print(f"🔤 OCR Worker {worker_id} started") print("EasyOCR initialized.")
while True:
try:
data = ocr_queue.get(timeout=1)
if data is None:
continue
plate_img, full_frame = data # Load YOLO Model
print(f"Loading YOLO model from {MODEL_PATH}...")
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: try:
model = YOLO(MODEL_PATH) model = YOLO(MODEL_PATH)
print("YOLO model loaded successfully!")
except Exception as e: except Exception as e:
print(f"❌ Critical Error loading model: {e}") print(f"Error loading YOLO model: {e}")
print("CRITICAL: Please place the 'best.pt' file in the alpr-service directory.")
return return
print("📝 Initializing EasyOCR...") cap = cv2.VideoCapture(CAMERA_ID)
reader = easyocr.Reader(['en'], gpu=False) time.sleep(2.0)
# Iniciar múltiples workers de OCR if not cap.isOpened():
for i in range(OCR_WORKERS): print("Error: Could not open video device.")
t = threading.Thread(target=ocr_worker, args=(reader, i+1), daemon=True) return
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 last_process_time = 0
frame_count = 0
fps_start_time = time.time()
while True: while True:
cap.grab() ret, frame = cap.read()
cap.grab()
ret, frame = cap.retrieve()
if not ret: if not ret:
time.sleep(0.01) print("Failed to grab frame")
time.sleep(1)
continue continue
frame_count += 1 # Resize for performance
frame = cv2.resize(frame, (640, 480))
current_time = time.time() current_time = time.time()
# Calcular FPS cada segundo # Detection Processing
if current_time - fps_start_time >= 1.0:
with metrics_lock:
metrics['fps'] = frame_count
metrics['ocr_queue_size'] = ocr_queue.qsize()
frame_count = 0
fps_start_time = current_time
# Procesar ALPR
if current_time - last_process_time > PROCESS_INTERVAL: if current_time - last_process_time > PROCESS_INTERVAL:
last_process_time = current_time last_process_time = current_time
results = model(frame, verbose=False, imgsz=320, conf=0.5) # Run YOLO Inference
results = model(frame, verbose=False)
detections = []
new_detections = []
for r in results: for r in results:
for box in r.boxes: boxes = r.boxes
x1, y1, x2, y2 = map(int, box.xyxy[0]) 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)
conf = float(box.conf[0]) conf = float(box.conf[0])
new_detections.append((x1, y1, x2, y2, conf))
plate_img = frame[y1:y2, x1:x2].copy() if conf > 0.5: # Valid plate detection
if plate_img.size > 0 and not ocr_queue.full(): # Visualization data
ocr_queue.put((plate_img, frame.copy())) detections.append((x1, y1, x2, y2, conf))
with detection_lock: # Crop Plate
latest_detections = new_detections plate_img = frame[y1:y2, x1:x2]
display_frame = frame # Run OCR on Crop
with detection_lock: 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:
for (x1, y1, x2, y2, conf) in latest_detections: for (x1, y1, x2, y2, conf) in latest_detections:
cv2.rectangle(display_frame, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.rectangle(display_frame, (x1, y1), (x2, y2), (0, 255, 0), 2)
cv2.putText(display_frame, f"{conf:.0%}", (x1, y1-5), cv2.putText(display_frame, f"Plate {conf:.2f}", (x1, y1-10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 2)
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1)
with frame_lock:
outputFrame = display_frame 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(): def generate():
"""Generador para streaming MJPEG""" global outputFrame, lock
global outputFrame
while True: while True:
time.sleep(0.033) with lock:
with frame_lock:
if outputFrame is None: if outputFrame is None:
continue continue
_, encoded = cv2.imencode(".jpg", outputFrame, [cv2.IMWRITE_JPEG_QUALITY, 75]) (flag, encodedImage) = cv2.imencode(".jpg", outputFrame)
yield b'--frame\r\nContent-Type: image/jpeg\r\n\r\n' + encoded.tobytes() + b'\r\n' if not flag:
continue
yield(b'--frame\r\n' b'Content-Type: image/jpeg\r\n\r\n' +
bytearray(encodedImage) + b'\r\n')
@app.route("/video_feed") @app.route("/video_feed")
def 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__": if __name__ == "__main__":
t = threading.Thread(target=camera_loop, daemon=True) t = threading.Thread(target=alpr_loop)
t.daemon = True
t.start() 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) app.run(host="0.0.0.0", port=5001, debug=False, threaded=True, use_reloader=False)

View File

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

View File

@@ -8,35 +8,12 @@ datasource db {
url = env("DATABASE_URL") 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 { model Plate {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
number String @unique number String @unique
owner String? owner String?
status String @default("PENDING") // PENDING, ALLOWED, DENIED status String @default("ALLOWED") // ALLOWED, DENIED
createdAt DateTime @default(now()) createdAt DateTime @default(now())
addedBy User? @relation(fields: [addedById], references: [id])
addedById Int?
} }
model AccessLog { model AccessLog {
@@ -44,8 +21,4 @@ model AccessLog {
plateNumber String plateNumber String
accessStatus String // GRANTED, DENIED, UNKNOWN accessStatus String // GRANTED, DENIED, UNKNOWN
timestamp DateTime @default(now()) timestamp DateTime @default(now())
@@index([plateNumber])
@@index([timestamp])
@@index([plateNumber, timestamp])
} }

View File

@@ -1,6 +1,5 @@
const express = require('express'); const express = require('express');
const cors = require('cors'); const cors = require('cors');
const bcrypt = require('bcryptjs'); // Movido al inicio (E)
const { PrismaClient } = require('@prisma/client'); const { PrismaClient } = require('@prisma/client');
const http = require('http'); const http = require('http');
const { Server } = require('socket.io'); const { Server } = require('socket.io');
@@ -8,55 +7,14 @@ const { Server } = require('socket.io');
const app = express(); const app = express();
const prisma = new PrismaClient(); const prisma = new PrismaClient();
// SECURITY: Configure CORS with specific origins app.use(cors());
const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS
? process.env.ALLOWED_ORIGINS.split(',')
: ['http://localhost:5173', 'http://127.0.0.1:5173'];
app.use(cors({
origin: ALLOWED_ORIGINS,
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Service-Key']
}));
app.use(express.json()); app.use(express.json());
// Rate limiting simple para /api/detect (G)
const detectRateLimit = new Map();
const RATE_LIMIT_WINDOW = 1000; // 1 segundo
const RATE_LIMIT_MAX = 10; // máximo 10 requests por segundo
function checkRateLimit(ip) {
const now = Date.now();
const record = detectRateLimit.get(ip) || { count: 0, resetTime: now + RATE_LIMIT_WINDOW };
if (now > record.resetTime) {
record.count = 1;
record.resetTime = now + RATE_LIMIT_WINDOW;
} else {
record.count++;
}
detectRateLimit.set(ip, record);
return record.count <= RATE_LIMIT_MAX;
}
// Limpiar rate limit map cada minuto
setInterval(() => {
const now = Date.now();
for (const [ip, record] of detectRateLimit.entries()) {
if (now > record.resetTime + 60000) {
detectRateLimit.delete(ip);
}
}
}, 60000);
const server = http.createServer(app); const server = http.createServer(app);
const io = new Server(server, { const io = new Server(server, {
cors: { cors: {
origin: ALLOWED_ORIGINS, origin: "*",
methods: ["GET", "POST"], methods: ["GET", "POST"]
credentials: true
} }
}); });
@@ -65,268 +23,35 @@ app.get('/', (req, res) => {
res.send('ALPR Backend Running'); res.send('ALPR Backend Running');
}); });
const authRoutes = require('./routes/auth');
const { authenticateToken, isAdmin } = require('./middleware/auth');
app.use('/api/auth', authRoutes);
// Plates CRUD // Plates CRUD
app.get('/api/plates', authenticateToken, async (req, res) => { app.get('/api/plates', async (req, res) => {
try { try {
const where = req.user.role === 'ADMIN' ? {} : { addedById: req.user.id }; const plates = await prisma.plate.findMany();
const plates = await prisma.plate.findMany({
where,
include: { addedBy: { select: { username: true } } }
});
res.json(plates); res.json(plates);
} catch (err) { } catch (err) {
res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });
} }
}); });
app.post('/api/plates', authenticateToken, async (req, res) => { app.post('/api/plates', async (req, res) => {
const { number, owner } = req.body; const { number, owner, status } = req.body;
const isAdm = req.user.role === 'ADMIN';
const status = isAdm ? 'ALLOWED' : 'PENDING';
try { try {
const plate = await prisma.plate.create({ const plate = await prisma.plate.create({
data: { data: { number, owner, status: status || 'ALLOWED' }
number,
owner,
status,
addedById: req.user.id
}
}); });
io.emit('new_plate_registered', plate);
res.json(plate); res.json(plate);
} catch (err) { } catch (err) {
res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });
} }
}); });
// Admin: Approve/Reject Plate // Detection Endpoint (from Python)
app.put('/api/plates/:id/approve', authenticateToken, isAdmin, async (req, res) => { app.post('/api/detect', 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; const { plate_number } = req.body;
console.log(`Detected: ${plate_number}`); console.log(`Detected: ${plate_number}`);
const DUPLICATE_COOLDOWN_MS = 30000;
try { try {
const lastLog = await prisma.accessLog.findFirst({ // Check if plate exists
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({ let plate = await prisma.plate.findUnique({
where: { number: plate_number } where: { number: plate_number }
}); });
@@ -338,9 +63,12 @@ app.post('/api/detect', validateServiceKey, async (req, res) => {
} }
if (!plate) { if (!plate) {
// Optional: Auto-create unknown plates?
// For now, treat as UNKNOWN (Denied)
accessStatus = 'UNKNOWN'; accessStatus = 'UNKNOWN';
} }
// Log the access attempt
const log = await prisma.accessLog.create({ const log = await prisma.accessLog.create({
data: { data: {
plateNumber: plate_number, plateNumber: plate_number,
@@ -349,6 +77,7 @@ app.post('/api/detect', validateServiceKey, async (req, res) => {
} }
}); });
// Notify Frontend via WebSocket
io.emit('new_detection', { io.emit('new_detection', {
plate: plate_number, plate: plate_number,
status: accessStatus, status: accessStatus,
@@ -363,73 +92,7 @@ app.post('/api/detect', validateServiceKey, async (req, res) => {
} }
}); });
// Dataset Capture Notification (from ALPR Python)
app.post('/api/dataset/capture', (req, res) => {
const { plate_number, filename, count } = req.body;
console.log(`📸 Dataset capture: ${plate_number} (Total: ${count})`);
io.emit('dataset_updated', {
plate: plate_number,
filename,
count,
timestamp: new Date()
});
res.json({ message: 'Notification sent' });
});
// Health check endpoint
app.get('/api/health', async (req, res) => {
try {
await prisma.$queryRaw`SELECT 1`;
res.json({ status: 'ok', database: 'connected' });
} catch (err) {
res.status(500).json({ status: 'error', database: 'disconnected' });
}
});
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
server.listen(PORT, async () => { server.listen(PORT, () => {
console.log(`Server running on port ${PORT}`); 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

@@ -1,32 +0,0 @@
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

@@ -1,77 +0,0 @@
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,10 +11,8 @@ services:
POSTGRES_DB: ${DB_NAME:-controlpatente} POSTGRES_DB: ${DB_NAME:-controlpatente}
volumes: volumes:
- db_data:/var/lib/postgresql/data - db_data:/var/lib/postgresql/data
# SECURITY: Port not exposed externally - only accessible within Docker network ports:
# Uncomment for local development debugging only - "5432:5432"
# ports:
# - "5432:5432"
networks: networks:
- backend-net - backend-net
restart: unless-stopped restart: unless-stopped
@@ -23,11 +21,6 @@ services:
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 5 retries: 5
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# Backend Service (Node.js) # Backend Service (Node.js)
backend: backend:
@@ -36,10 +29,6 @@ services:
environment: environment:
- DATABASE_URL=postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@db:5432/${DB_NAME:-controlpatente} - DATABASE_URL=postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@db:5432/${DB_NAME:-controlpatente}
- PORT=3000 - PORT=3000
- JWT_SECRET=${JWT_SECRET:?JWT_SECRET is required}
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-}
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost:5173}
- SERVICE_API_KEY=${SERVICE_API_KEY:-}
ports: ports:
- "3000:3000" - "3000:3000"
depends_on: depends_on:
@@ -47,61 +36,28 @@ services:
condition: service_healthy condition: service_healthy
networks: networks:
- backend-net - backend-net
restart: unless-stopped
volumes: volumes:
- ./backend:/app - ./backend:/app
- /app/node_modules - /app/node_modules
healthcheck:
test: [ "CMD", "wget", "-q", "--spider", "http://localhost:3000/api/health" ]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# ALPR Component (Python + OpenCV) # ALPR Component (Python + OpenCV)
alpr-service: alpr-service:
build: ./alpr-service build: ./alpr-service
container_name: controlpatente-alpr container_name: controlpatente-alpr
ports:
- "5001:5001"
environment: environment:
- BACKEND_URL=http://backend:3000 - BACKEND_URL=http://backend:3000
- PROCESS_INTERVAL=1.5 # On Mac, you usually cannot pass /dev/video0 directly.
- DATASET_COOLDOWN=60 # We might need to use a stream or just test with a file for now if direct access fails.
- OCR_WORKERS=2 # For Linux/Raspberry Pi, the device mapping below is correct.
- SERVICE_API_KEY=${SERVICE_API_KEY:-}
devices: devices:
- "/dev/video0:/dev/video0" - "/dev/video0:/dev/video0"
networks: networks:
- backend-net - backend-net
depends_on: depends_on:
backend: - backend
condition: service_healthy
restart: unless-stopped restart: unless-stopped
# SECURITY: Use specific capabilities instead of privileged mode # Add privilege for hardware access
# privileged: true # REMOVED - security risk privileged: true
cap_add:
- SYS_RAWIO
security_opt:
- no-new-privileges:true
volumes:
- ./alpr-service/dataset:/app/dataset
healthcheck:
test: [ "CMD", "wget", "-q", "--spider", "http://localhost:5001/health" ]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
logging:
driver: "json-file"
options:
max-size: "20m"
max-file: "5"
# Frontend Service (React) # Frontend Service (React)
frontend: frontend:
@@ -111,18 +67,11 @@ services:
- "5173:5173" - "5173:5173"
networks: networks:
- backend-net - backend-net
restart: unless-stopped
volumes: volumes:
- ./frontend:/app - ./frontend:/app
- /app/node_modules - /app/node_modules
environment: environment:
- VITE_API_URL= - VITE_API_URL=http://localhost:3000
- VITE_ALPR_STREAM_URL=
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
networks: networks:
backend-net: backend-net:

View File

@@ -1,193 +1,13 @@
<!doctype html> <!doctype html>
<html lang="es"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/logo.png" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Control de Patentes - ALPR</title> <title>frontend</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> </head>
<body> <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> <div id="root"></div>
<script type="module" src="/src/main.jsx"></script> <script type="module" src="/src/main.jsx"></script>
<!-- Script para remover loading screen cuando React está listo -->
<script>
// Fallback: remover loading screen después de 10 segundos máximo
setTimeout(function () {
var loadingScreen = document.getElementById('loading-screen');
if (loadingScreen) {
loadingScreen.classList.add('fade-out');
setTimeout(function () {
loadingScreen.remove();
}, 500);
}
}, 10000);
</script>
</body> </body>
</html> </html>

View File

@@ -14,11 +14,7 @@
"lucide-react": "^0.260.0", "lucide-react": "^0.260.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^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": { "devDependencies": {
"@types/react": "^18.2.15", "@types/react": "^18.2.15",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 638 KiB

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 641 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,87 +1,246 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import io from 'socket.io-client'
import Login from './pages/Login'; import axios from 'axios'
import AdminDashboard from './pages/AdminDashboard'; import { Car, AlertCircle, CheckCircle, XCircle, Clock } from 'lucide-react'
import UserDashboard from './pages/UserDashboard';
import './i18n'; // Initialize translations // Env var logic for Vite
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
const socket = io(API_URL);
function App() { function App() {
const [token, setToken] = useState(localStorage.getItem('token')); const [plates, setPlates] = useState([]);
const [userRole, setUserRole] = useState(localStorage.getItem('role')); const [detections, setDetections] = useState([]);
const [username, setUsername] = useState(localStorage.getItem('username')); const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [newPlate, setNewPlate] = useState({ number: '', owner: '' });
const setAuth = (newToken, newRole, newUser) => { const handleRegister = async (e) => {
setToken(newToken); e.preventDefault();
setUserRole(newRole); try {
setUsername(newUser); 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 handleLogout = () => { // Load initial data
localStorage.removeItem('token'); useEffect(() => {
localStorage.removeItem('role'); fetchPlates();
localStorage.removeItem('username');
setToken(null); // Socket listeners
setUserRole(null); socket.on('new_detection', (data) => {
setUsername(null); 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);
}
}; };
// Protected Route Component const StatusBadge = ({ status }) => {
const ProtectedRoute = ({ children, allowedRoles }) => { const colors = {
if (!token) { GRANTED: 'bg-green-500/20 text-green-400 border-green-500/50',
return <Navigate to="/login" replace />; DENIED: 'bg-red-500/20 text-red-400 border-red-500/50',
} UNKNOWN: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/50',
if (allowedRoles && !allowedRoles.includes(userRole)) { ALLOWED: 'bg-green-500/20 text-green-400 border-green-500/50',
return <Navigate to={userRole === 'ADMIN' ? '/admin' : '/user'} replace />;
}
return children;
}; };
return ( return (
<BrowserRouter> <span className={`px-3 py-1 rounded-full text-xs font-medium border ${colors[status] || colors.UNKNOWN}`}>
<Routes> {status}
<Route </span>
path="/login" );
element={ };
!token ? (
<Login return (
setToken={(t) => setToken(t)} <div className="min-h-screen bg-slate-900 text-slate-100 p-8">
setUserRole={(r) => setUserRole(r)} {/* Modal */}
setUsername={(u) => setUsername(u)} // Adding this prop to Login might be needed {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"
/> />
</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>
) : ( ) : (
<Navigate to={userRole === 'ADMIN' ? '/admin' : '/user'} replace /> <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>
) )
} }
/>
<Route export default App
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;

View File

@@ -1,24 +0,0 @@
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;

View File

@@ -1,143 +0,0 @@
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,27 +1,10 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import './index.css' import './index.css'
import './i18n'
import App from './App.jsx' import App from './App.jsx'
// Función para remover la pantalla de carga
const removeLoadingScreen = () => {
const loadingScreen = document.getElementById('loading-screen');
if (loadingScreen) {
loadingScreen.classList.add('fade-out');
setTimeout(() => {
loadingScreen.remove();
}, 500);
}
};
// Renderizar React y remover loading screen
createRoot(document.getElementById('root')).render( createRoot(document.getElementById('root')).render(
<StrictMode> <StrictMode>
<App /> <App />
</StrictMode>, </StrictMode>,
) )
// Remover loading screen cuando React esté montado
// Pequeño delay para asegurar que la UI esté lista
setTimeout(removeLoadingScreen, 100);

View File

@@ -1,745 +0,0 @@
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

@@ -1,86 +0,0 @@
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

@@ -1,272 +0,0 @@
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,25 +4,4 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], 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
}
}
}
}) })

View File

@@ -1,764 +0,0 @@
#!/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