Compare commits
1 Commits
implementa
...
Integracio
| Author | SHA1 | Date | |
|---|---|---|---|
| 349e5598a0 |
24
.env
24
.env
@@ -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
|
||||
43
.env.example
43
.env.example
@@ -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
|
||||
12
README.md
12
README.md
@@ -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.
|
||||
|
||||
#### 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.
|
||||
|
||||
|
||||
@@ -4,396 +4,156 @@ import requests
|
||||
import os
|
||||
import time
|
||||
import threading
|
||||
import re
|
||||
import numpy as np
|
||||
from datetime import datetime
|
||||
from queue import Queue
|
||||
from flask import Flask, Response, request, send_from_directory
|
||||
import re
|
||||
from flask import Flask, Response
|
||||
from flask_cors import CORS
|
||||
from ultralytics import YOLO
|
||||
|
||||
# Configuration (puede ser sobrescrito por variables de entorno)
|
||||
# Configuration
|
||||
BACKEND_URL = os.environ.get('BACKEND_URL', 'http://localhost:3000')
|
||||
CAMERA_ID = int(os.environ.get('CAMERA_ID', 0))
|
||||
PROCESS_INTERVAL = float(os.environ.get('PROCESS_INTERVAL', 1.5))
|
||||
MODEL_PATH = os.environ.get('MODEL_PATH', 'best.pt')
|
||||
DATASET_DIR = os.environ.get('DATASET_DIR', '/app/dataset')
|
||||
DATASET_COOLDOWN = int(os.environ.get('DATASET_COOLDOWN', 60))
|
||||
OCR_WORKERS = int(os.environ.get('OCR_WORKERS', 2)) # Número de workers OCR
|
||||
SERVICE_API_KEY = os.environ.get('SERVICE_API_KEY', '') # For backend auth
|
||||
CAMERA_ID = 0
|
||||
PROCESS_INTERVAL = 0.5 # Faster processing with YOLO (it's efficient)
|
||||
CONFIDENCE_THRESHOLD = 0.4
|
||||
MODEL_PATH = 'best.pt' # Expecting the model here
|
||||
|
||||
app = Flask(__name__)
|
||||
CORS(app)
|
||||
|
||||
# Shared state
|
||||
# Global variables
|
||||
outputFrame = None
|
||||
frame_lock = threading.Lock()
|
||||
lock = threading.Lock()
|
||||
# Store latest detections for visualization
|
||||
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):
|
||||
"""Envía la patente detectada al backend"""
|
||||
try:
|
||||
url = f"{BACKEND_URL}/api/detect"
|
||||
headers = {}
|
||||
if SERVICE_API_KEY:
|
||||
headers['X-Service-Key'] = SERVICE_API_KEY
|
||||
requests.post(url, json={'plate_number': plate_number}, headers=headers, timeout=3)
|
||||
print(f"✓ Plate sent: {plate_number}")
|
||||
|
||||
with metrics_lock:
|
||||
metrics['total_detections'] += 1
|
||||
metrics['last_detection'] = plate_number
|
||||
payload = {'plate_number': plate_number}
|
||||
print(f"Sending plate: {plate_number} to {url}")
|
||||
requests.post(url, json=payload, timeout=2)
|
||||
except Exception as e:
|
||||
print(f"✗ Error sending plate: {e}")
|
||||
print(f"Error sending plate: {e}")
|
||||
|
||||
def validate_plate(text):
|
||||
"""Valida formatos de patentes de Chile, Argentina y Brasil"""
|
||||
# Chile formato nuevo: XXXX00 (4 letras, 2 números)
|
||||
# Chile formato antiguo: XX0000 (2 letras, 4 números)
|
||||
# Argentina Mercosur: AA000AA (2 letras, 3 números, 2 letras)
|
||||
# Brasil Mercosur: AAA0A00 (3 letras, 1 número, 1 letra, 2 números)
|
||||
chile_new = re.match(r'^[A-Z]{4}\d{2}$', text)
|
||||
chile_old = re.match(r'^[A-Z]{2}\d{4}$', text)
|
||||
argentina = re.match(r'^[A-Z]{2}\d{3}[A-Z]{2}$', text)
|
||||
brasil = re.match(r'^[A-Z]{3}\d[A-Z]\d{2}$', text)
|
||||
return bool(chile_new or chile_old or argentina or brasil)
|
||||
def alpr_loop():
|
||||
global outputFrame, lock, latest_detections
|
||||
|
||||
def ocr_worker(reader, worker_id):
|
||||
"""Hilo dedicado para OCR - múltiples workers para mejor rendimiento"""
|
||||
print(f"🔤 OCR Worker {worker_id} started")
|
||||
while True:
|
||||
try:
|
||||
data = ocr_queue.get(timeout=1)
|
||||
if data is None:
|
||||
continue
|
||||
print("Initializing EasyOCR...")
|
||||
reader = easyocr.Reader(['en'], gpu=False)
|
||||
print("EasyOCR initialized.")
|
||||
|
||||
plate_img, full_frame = data
|
||||
|
||||
gray = cv2.cvtColor(plate_img, cv2.COLOR_BGR2GRAY)
|
||||
|
||||
ocr_results = reader.readtext(gray, detail=0, paragraph=False,
|
||||
allowlist='ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789')
|
||||
for text in ocr_results:
|
||||
clean_text = ''.join(e for e in text if e.isalnum()).upper()
|
||||
if len(clean_text) >= 6 and validate_plate(clean_text):
|
||||
send_plate(clean_text)
|
||||
save_plate_capture(clean_text, full_frame)
|
||||
except:
|
||||
pass
|
||||
|
||||
def camera_loop():
|
||||
"""Hilo principal de captura"""
|
||||
global outputFrame, latest_detections
|
||||
|
||||
print("🚀 Initializing ALPR System...")
|
||||
print(f"⚙️ Config: PROCESS_INTERVAL={PROCESS_INTERVAL}s, OCR_WORKERS={OCR_WORKERS}")
|
||||
print("📷 Loading camera...")
|
||||
|
||||
cap = cv2.VideoCapture(CAMERA_ID)
|
||||
cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*'MJPG'))
|
||||
cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
|
||||
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)
|
||||
cap.set(cv2.CAP_PROP_FPS, 30)
|
||||
cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
|
||||
|
||||
print("🧠 Loading YOLO model...")
|
||||
# Load YOLO Model
|
||||
print(f"Loading YOLO model from {MODEL_PATH}...")
|
||||
try:
|
||||
model = YOLO(MODEL_PATH)
|
||||
print("YOLO model loaded successfully!")
|
||||
except Exception as e:
|
||||
print(f"❌ 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
|
||||
|
||||
print("📝 Initializing EasyOCR...")
|
||||
reader = easyocr.Reader(['en'], gpu=False)
|
||||
cap = cv2.VideoCapture(CAMERA_ID)
|
||||
time.sleep(2.0)
|
||||
|
||||
# Iniciar múltiples workers de OCR
|
||||
for i in range(OCR_WORKERS):
|
||||
t = threading.Thread(target=ocr_worker, args=(reader, i+1), daemon=True)
|
||||
t.start()
|
||||
|
||||
# Iniciar limpiador de cache
|
||||
cleanup_thread = threading.Thread(target=cleanup_recent_captures, daemon=True)
|
||||
cleanup_thread.start()
|
||||
|
||||
print("✅ System ready!")
|
||||
if not cap.isOpened():
|
||||
print("Error: Could not open video device.")
|
||||
return
|
||||
|
||||
last_process_time = 0
|
||||
frame_count = 0
|
||||
fps_start_time = time.time()
|
||||
|
||||
while True:
|
||||
cap.grab()
|
||||
cap.grab()
|
||||
ret, frame = cap.retrieve()
|
||||
|
||||
ret, frame = cap.read()
|
||||
if not ret:
|
||||
time.sleep(0.01)
|
||||
print("Failed to grab frame")
|
||||
time.sleep(1)
|
||||
continue
|
||||
|
||||
frame_count += 1
|
||||
# Resize for performance
|
||||
frame = cv2.resize(frame, (640, 480))
|
||||
|
||||
current_time = time.time()
|
||||
|
||||
# Calcular FPS cada segundo
|
||||
if current_time - fps_start_time >= 1.0:
|
||||
with metrics_lock:
|
||||
metrics['fps'] = frame_count
|
||||
metrics['ocr_queue_size'] = ocr_queue.qsize()
|
||||
frame_count = 0
|
||||
fps_start_time = current_time
|
||||
|
||||
# Procesar ALPR
|
||||
# Detection Processing
|
||||
if current_time - last_process_time > PROCESS_INTERVAL:
|
||||
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 box in r.boxes:
|
||||
x1, y1, x2, y2 = map(int, box.xyxy[0])
|
||||
boxes = r.boxes
|
||||
for box in boxes:
|
||||
# Bounding Box
|
||||
x1, y1, x2, y2 = box.xyxy[0]
|
||||
x1, y1, x2, y2 = int(x1), int(y1), int(x2), int(y2)
|
||||
conf = float(box.conf[0])
|
||||
new_detections.append((x1, y1, x2, y2, conf))
|
||||
|
||||
plate_img = frame[y1:y2, x1:x2].copy()
|
||||
if plate_img.size > 0 and not ocr_queue.full():
|
||||
ocr_queue.put((plate_img, frame.copy()))
|
||||
if conf > 0.5: # Valid plate detection
|
||||
# Visualization data
|
||||
detections.append((x1, y1, x2, y2, conf))
|
||||
|
||||
with detection_lock:
|
||||
latest_detections = new_detections
|
||||
# Crop Plate
|
||||
plate_img = frame[y1:y2, x1:x2]
|
||||
|
||||
display_frame = frame
|
||||
with detection_lock:
|
||||
# Run OCR on Crop
|
||||
try:
|
||||
ocr_results = reader.readtext(plate_img)
|
||||
for (_, text, prob) in ocr_results:
|
||||
if prob > CONFIDENCE_THRESHOLD:
|
||||
clean_text = ''.join(e for e in text if e.isalnum()).upper()
|
||||
validate_and_send(clean_text)
|
||||
except Exception as e:
|
||||
print(f"OCR Error on crop: {e}")
|
||||
|
||||
with lock:
|
||||
latest_detections = detections
|
||||
|
||||
# Draw Detections on Frame for Stream
|
||||
display_frame = frame.copy()
|
||||
with lock:
|
||||
for (x1, y1, x2, y2, conf) in latest_detections:
|
||||
cv2.rectangle(display_frame, (x1, y1), (x2, y2), (0, 255, 0), 2)
|
||||
cv2.putText(display_frame, f"{conf:.0%}", (x1, y1-5),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1)
|
||||
cv2.putText(display_frame, f"Plate {conf:.2f}", (x1, y1-10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 2)
|
||||
|
||||
with frame_lock:
|
||||
outputFrame = display_frame
|
||||
|
||||
time.sleep(0.01)
|
||||
|
||||
def validate_and_send(text):
|
||||
# Chilean Plate Regex Patterns
|
||||
is_valid = False
|
||||
if re.match(r'^[A-Z]{4}\d{2}$', text): # BBBB11
|
||||
is_valid = True
|
||||
elif re.match(r'^[A-Z]{2}\d{4}$', text): # BB1111
|
||||
is_valid = True
|
||||
|
||||
if is_valid:
|
||||
print(f"Detected Valid Plate: {text}")
|
||||
send_plate(text)
|
||||
|
||||
def generate():
|
||||
"""Generador para streaming MJPEG"""
|
||||
global outputFrame
|
||||
global outputFrame, lock
|
||||
while True:
|
||||
time.sleep(0.033)
|
||||
with frame_lock:
|
||||
with lock:
|
||||
if outputFrame is None:
|
||||
continue
|
||||
_, encoded = cv2.imencode(".jpg", outputFrame, [cv2.IMWRITE_JPEG_QUALITY, 75])
|
||||
yield b'--frame\r\nContent-Type: image/jpeg\r\n\r\n' + encoded.tobytes() + b'\r\n'
|
||||
(flag, encodedImage) = cv2.imencode(".jpg", outputFrame)
|
||||
if not flag:
|
||||
continue
|
||||
|
||||
yield(b'--frame\r\n' b'Content-Type: image/jpeg\r\n\r\n' +
|
||||
bytearray(encodedImage) + b'\r\n')
|
||||
|
||||
@app.route("/video_feed")
|
||||
def video_feed():
|
||||
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
|
||||
return Response(generate(), mimetype = "multipart/x-mixed-replace; boundary=frame")
|
||||
|
||||
if __name__ == "__main__":
|
||||
t = threading.Thread(target=camera_loop, daemon=True)
|
||||
t = threading.Thread(target=alpr_loop)
|
||||
t.daemon = True
|
||||
t.start()
|
||||
|
||||
print("Starting Video Stream on port 5001...")
|
||||
app.run(host="0.0.0.0", port=5001, debug=False, threaded=True, use_reloader=False)
|
||||
|
||||
33
alpr-service/run_windows.bat
Normal file
33
alpr-service/run_windows.bat
Normal 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
|
||||
@@ -5,7 +5,7 @@
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "npx prisma db push && nodemon src/index.js",
|
||||
"dev": "nodemon src/index.js",
|
||||
"migrate": "npx prisma migrate dev"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -13,9 +13,7 @@
|
||||
"express": "^4.18.2",
|
||||
"pg": "^8.11.0",
|
||||
"socket.io": "^4.6.1",
|
||||
"@prisma/client": "^5.0.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"jsonwebtoken": "^9.0.2"
|
||||
"@prisma/client": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^2.0.22",
|
||||
|
||||
@@ -8,35 +8,12 @@ datasource db {
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
username String @unique
|
||||
password String
|
||||
role String @default("USER") // ADMIN, USER
|
||||
plates Plate[]
|
||||
people Person[]
|
||||
}
|
||||
|
||||
model Person {
|
||||
id Int @id @default(autoincrement())
|
||||
rut String @unique
|
||||
name String
|
||||
status String @default("PENDING") // PENDING, APPROVED, DENIED
|
||||
startDate DateTime
|
||||
endDate DateTime
|
||||
createdAt DateTime @default(now())
|
||||
addedBy User? @relation(fields: [addedById], references: [id])
|
||||
addedById Int?
|
||||
}
|
||||
|
||||
model Plate {
|
||||
id Int @id @default(autoincrement())
|
||||
number String @unique
|
||||
owner String?
|
||||
status String @default("PENDING") // PENDING, ALLOWED, DENIED
|
||||
status String @default("ALLOWED") // ALLOWED, DENIED
|
||||
createdAt DateTime @default(now())
|
||||
addedBy User? @relation(fields: [addedById], references: [id])
|
||||
addedById Int?
|
||||
}
|
||||
|
||||
model AccessLog {
|
||||
@@ -44,8 +21,4 @@ model AccessLog {
|
||||
plateNumber String
|
||||
accessStatus String // GRANTED, DENIED, UNKNOWN
|
||||
timestamp DateTime @default(now())
|
||||
|
||||
@@index([plateNumber])
|
||||
@@index([timestamp])
|
||||
@@index([plateNumber, timestamp])
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const bcrypt = require('bcryptjs'); // Movido al inicio (E)
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const http = require('http');
|
||||
const { Server } = require('socket.io');
|
||||
@@ -8,55 +7,14 @@ const { Server } = require('socket.io');
|
||||
const app = express();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// SECURITY: Configure CORS with specific origins
|
||||
const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS
|
||||
? process.env.ALLOWED_ORIGINS.split(',')
|
||||
: ['http://localhost:5173', 'http://127.0.0.1:5173'];
|
||||
|
||||
app.use(cors({
|
||||
origin: ALLOWED_ORIGINS,
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization', 'X-Service-Key']
|
||||
}));
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// Rate limiting simple para /api/detect (G)
|
||||
const detectRateLimit = new Map();
|
||||
const RATE_LIMIT_WINDOW = 1000; // 1 segundo
|
||||
const RATE_LIMIT_MAX = 10; // máximo 10 requests por segundo
|
||||
|
||||
function checkRateLimit(ip) {
|
||||
const now = Date.now();
|
||||
const record = detectRateLimit.get(ip) || { count: 0, resetTime: now + RATE_LIMIT_WINDOW };
|
||||
|
||||
if (now > record.resetTime) {
|
||||
record.count = 1;
|
||||
record.resetTime = now + RATE_LIMIT_WINDOW;
|
||||
} else {
|
||||
record.count++;
|
||||
}
|
||||
|
||||
detectRateLimit.set(ip, record);
|
||||
return record.count <= RATE_LIMIT_MAX;
|
||||
}
|
||||
|
||||
// Limpiar rate limit map cada minuto
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [ip, record] of detectRateLimit.entries()) {
|
||||
if (now > record.resetTime + 60000) {
|
||||
detectRateLimit.delete(ip);
|
||||
}
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
const server = http.createServer(app);
|
||||
const io = new Server(server, {
|
||||
cors: {
|
||||
origin: ALLOWED_ORIGINS,
|
||||
methods: ["GET", "POST"],
|
||||
credentials: true
|
||||
origin: "*",
|
||||
methods: ["GET", "POST"]
|
||||
}
|
||||
});
|
||||
|
||||
@@ -65,268 +23,35 @@ app.get('/', (req, res) => {
|
||||
res.send('ALPR Backend Running');
|
||||
});
|
||||
|
||||
const authRoutes = require('./routes/auth');
|
||||
const { authenticateToken, isAdmin } = require('./middleware/auth');
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
|
||||
// Plates CRUD
|
||||
app.get('/api/plates', authenticateToken, async (req, res) => {
|
||||
app.get('/api/plates', async (req, res) => {
|
||||
try {
|
||||
const where = req.user.role === 'ADMIN' ? {} : { addedById: req.user.id };
|
||||
const plates = await prisma.plate.findMany({
|
||||
where,
|
||||
include: { addedBy: { select: { username: true } } }
|
||||
});
|
||||
const plates = await prisma.plate.findMany();
|
||||
res.json(plates);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/plates', authenticateToken, async (req, res) => {
|
||||
const { number, owner } = req.body;
|
||||
const isAdm = req.user.role === 'ADMIN';
|
||||
const status = isAdm ? 'ALLOWED' : 'PENDING';
|
||||
|
||||
app.post('/api/plates', async (req, res) => {
|
||||
const { number, owner, status } = req.body;
|
||||
try {
|
||||
const plate = await prisma.plate.create({
|
||||
data: {
|
||||
number,
|
||||
owner,
|
||||
status,
|
||||
addedById: req.user.id
|
||||
}
|
||||
data: { number, owner, status: status || 'ALLOWED' }
|
||||
});
|
||||
io.emit('new_plate_registered', plate);
|
||||
res.json(plate);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Admin: Approve/Reject Plate
|
||||
app.put('/api/plates/:id/approve', authenticateToken, isAdmin, async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { status } = req.body;
|
||||
|
||||
if (!['ALLOWED', 'DENIED'].includes(status)) {
|
||||
return res.status(400).json({ error: 'Invalid status' });
|
||||
}
|
||||
|
||||
try {
|
||||
const plate = await prisma.plate.update({
|
||||
where: { id: parseInt(id) },
|
||||
data: { status }
|
||||
});
|
||||
io.emit('plate_status_updated', plate);
|
||||
res.json(plate);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete Plate (Admin or Owner)
|
||||
app.delete('/api/plates/:id', authenticateToken, async (req, res) => {
|
||||
const { id } = req.params;
|
||||
try {
|
||||
const plate = await prisma.plate.findUnique({ where: { id: parseInt(id) } });
|
||||
if (!plate) return res.status(404).json({ error: 'Plate not found' });
|
||||
|
||||
if (req.user.role !== 'ADMIN' && plate.addedById !== req.user.id) {
|
||||
return res.status(403).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
await prisma.plate.delete({ where: { id: parseInt(id) } });
|
||||
io.emit('plate_deleted', { id: parseInt(id) });
|
||||
res.json({ message: 'Plate deleted' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete Person (Admin or Owner)
|
||||
app.delete('/api/people/:id', authenticateToken, async (req, res) => {
|
||||
const { id } = req.params;
|
||||
try {
|
||||
const person = await prisma.person.findUnique({ where: { id: parseInt(id) } });
|
||||
if (!person) return res.status(404).json({ error: 'Person not found' });
|
||||
|
||||
if (req.user.role !== 'ADMIN' && person.addedById !== req.user.id) {
|
||||
return res.status(403).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
await prisma.person.delete({ where: { id: parseInt(id) } });
|
||||
io.emit('person_deleted', { id: parseInt(id) });
|
||||
res.json({ message: 'Person deleted' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// History Endpoint
|
||||
app.get('/api/history', async (req, res) => {
|
||||
const { date } = req.query;
|
||||
if (!date) {
|
||||
return res.status(400).json({ error: 'Date is required' });
|
||||
}
|
||||
|
||||
const startDate = new Date(date);
|
||||
startDate.setHours(0, 0, 0, 0);
|
||||
|
||||
const endDate = new Date(date);
|
||||
endDate.setHours(23, 59, 59, 999);
|
||||
|
||||
try {
|
||||
const logs = await prisma.accessLog.findMany({
|
||||
where: {
|
||||
timestamp: {
|
||||
gte: startDate,
|
||||
lte: endDate
|
||||
}
|
||||
},
|
||||
orderBy: { timestamp: 'desc' }
|
||||
});
|
||||
res.json(logs);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Recent Scans Endpoint (Last 5 Hours)
|
||||
app.get('/api/recent', async (req, res) => {
|
||||
try {
|
||||
const fiveHoursAgo = new Date(Date.now() - 5 * 60 * 60 * 1000);
|
||||
const logs = await prisma.accessLog.findMany({
|
||||
where: {
|
||||
timestamp: { gte: fiveHoursAgo }
|
||||
},
|
||||
orderBy: { timestamp: 'desc' }
|
||||
});
|
||||
res.json(logs);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Helper: RUT Validation
|
||||
function validateRut(rut) {
|
||||
if (!rut || !/^[0-9]+-[0-9kK]{1}$/.test(rut)) return false;
|
||||
let [num, dv] = rut.split('-');
|
||||
let total = 0;
|
||||
let multiple = 2;
|
||||
for (let i = num.length - 1; i >= 0; i--) {
|
||||
total += parseInt(num.charAt(i)) * multiple;
|
||||
multiple = (multiple + 1) % 8 || 2;
|
||||
}
|
||||
let res = 11 - (total % 11);
|
||||
let finalDv = res === 11 ? '0' : res === 10 ? 'K' : res.toString();
|
||||
return finalDv.toUpperCase() === dv.toUpperCase();
|
||||
}
|
||||
|
||||
// People CRUD
|
||||
app.get('/api/people', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const where = req.user.role === 'ADMIN' ? {} : { addedById: req.user.id };
|
||||
const people = await prisma.person.findMany({
|
||||
where,
|
||||
include: { addedBy: { select: { username: true } } }
|
||||
});
|
||||
res.json(people);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/people', authenticateToken, async (req, res) => {
|
||||
const { rut, name, durationDays } = req.body;
|
||||
if (!validateRut(rut)) {
|
||||
return res.status(400).json({ error: 'Invalid RUT format (12345678-K)' });
|
||||
}
|
||||
|
||||
const startDate = new Date();
|
||||
const endDate = new Date();
|
||||
endDate.setDate(endDate.getDate() + parseInt(durationDays || 1));
|
||||
|
||||
try {
|
||||
const person = await prisma.person.create({
|
||||
data: {
|
||||
rut,
|
||||
name,
|
||||
startDate,
|
||||
endDate,
|
||||
status: 'PENDING',
|
||||
addedById: req.user.id
|
||||
}
|
||||
});
|
||||
io.emit('new_person_registered', person);
|
||||
res.json(person);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Admin: Bulk Approve People by User
|
||||
app.post('/api/people/bulk-approve', authenticateToken, isAdmin, async (req, res) => {
|
||||
const { userId } = req.body;
|
||||
try {
|
||||
await prisma.person.updateMany({
|
||||
where: { addedById: parseInt(userId), status: 'PENDING' },
|
||||
data: { status: 'APPROVED' }
|
||||
});
|
||||
io.emit('people_updated', { userId });
|
||||
res.json({ message: 'Bulk approval successful' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Detection Endpoint (from Python) with Rate Limiting and Service Auth
|
||||
// SECURITY: Requires X-Service-Key header for service-to-service auth
|
||||
const validateServiceKey = (req, res, next) => {
|
||||
const serviceKey = process.env.SERVICE_API_KEY;
|
||||
|
||||
// If no key configured, allow (development mode) but warn
|
||||
if (!serviceKey) {
|
||||
console.warn('⚠️ SERVICE_API_KEY not configured - /api/detect is unprotected!');
|
||||
return next();
|
||||
}
|
||||
|
||||
const providedKey = req.headers['x-service-key'];
|
||||
if (providedKey !== serviceKey) {
|
||||
console.warn(`🔒 Rejected /api/detect request - invalid service key from ${req.ip}`);
|
||||
return res.status(401).json({ error: 'Invalid service key' });
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
app.post('/api/detect', validateServiceKey, async (req, res) => {
|
||||
const clientIp = req.ip || req.connection.remoteAddress;
|
||||
|
||||
// Check rate limit
|
||||
if (!checkRateLimit(clientIp)) {
|
||||
return res.status(429).json({ error: 'Too many requests' });
|
||||
}
|
||||
|
||||
// Detection Endpoint (from Python)
|
||||
app.post('/api/detect', async (req, res) => {
|
||||
const { plate_number } = req.body;
|
||||
console.log(`Detected: ${plate_number}`);
|
||||
|
||||
const DUPLICATE_COOLDOWN_MS = 30000;
|
||||
|
||||
try {
|
||||
const lastLog = await prisma.accessLog.findFirst({
|
||||
where: { plateNumber: plate_number },
|
||||
orderBy: { timestamp: 'desc' }
|
||||
});
|
||||
|
||||
if (lastLog) {
|
||||
const timeDiff = new Date() - new Date(lastLog.timestamp);
|
||||
if (timeDiff < DUPLICATE_COOLDOWN_MS) {
|
||||
console.log(`Duplicate detection ignored for ${plate_number} (${timeDiff}ms since last)`);
|
||||
return res.json({ message: 'Duplicate detection ignored', ignored: true, accessStatus: lastLog.accessStatus });
|
||||
}
|
||||
}
|
||||
|
||||
// Check if plate exists
|
||||
let plate = await prisma.plate.findUnique({
|
||||
where: { number: plate_number }
|
||||
});
|
||||
@@ -338,9 +63,12 @@ app.post('/api/detect', validateServiceKey, async (req, res) => {
|
||||
}
|
||||
|
||||
if (!plate) {
|
||||
accessStatus = 'UNKNOWN';
|
||||
// Optional: Auto-create unknown plates?
|
||||
// For now, treat as UNKNOWN (Denied)
|
||||
accessStatus = 'UNKNOWN';
|
||||
}
|
||||
|
||||
// Log the access attempt
|
||||
const log = await prisma.accessLog.create({
|
||||
data: {
|
||||
plateNumber: plate_number,
|
||||
@@ -349,6 +77,7 @@ app.post('/api/detect', validateServiceKey, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Notify Frontend via WebSocket
|
||||
io.emit('new_detection', {
|
||||
plate: plate_number,
|
||||
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;
|
||||
server.listen(PORT, async () => {
|
||||
server.listen(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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
@@ -11,23 +11,16 @@ services:
|
||||
POSTGRES_DB: ${DB_NAME:-controlpatente}
|
||||
volumes:
|
||||
- db_data:/var/lib/postgresql/data
|
||||
# SECURITY: Port not exposed externally - only accessible within Docker network
|
||||
# Uncomment for local development debugging only
|
||||
# ports:
|
||||
# - "5432:5432"
|
||||
ports:
|
||||
- "5432:5432"
|
||||
networks:
|
||||
- backend-net
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "pg_isready -U postgres" ]
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# Backend Service (Node.js)
|
||||
backend:
|
||||
@@ -36,10 +29,6 @@ services:
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@db:5432/${DB_NAME:-controlpatente}
|
||||
- PORT=3000
|
||||
- JWT_SECRET=${JWT_SECRET:?JWT_SECRET is required}
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-}
|
||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost:5173}
|
||||
- SERVICE_API_KEY=${SERVICE_API_KEY:-}
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
@@ -47,61 +36,28 @@ services:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- backend-net
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- /app/node_modules
|
||||
healthcheck:
|
||||
test: [ "CMD", "wget", "-q", "--spider", "http://localhost:3000/api/health" ]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ALPR Component (Python + OpenCV)
|
||||
alpr-service:
|
||||
build: ./alpr-service
|
||||
container_name: controlpatente-alpr
|
||||
ports:
|
||||
- "5001:5001"
|
||||
environment:
|
||||
- BACKEND_URL=http://backend:3000
|
||||
- PROCESS_INTERVAL=1.5
|
||||
- DATASET_COOLDOWN=60
|
||||
- OCR_WORKERS=2
|
||||
- SERVICE_API_KEY=${SERVICE_API_KEY:-}
|
||||
# On Mac, you usually cannot pass /dev/video0 directly.
|
||||
# We might need to use a stream or just test with a file for now if direct access fails.
|
||||
# For Linux/Raspberry Pi, the device mapping below is correct.
|
||||
devices:
|
||||
- "/dev/video0:/dev/video0"
|
||||
networks:
|
||||
- backend-net
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
# SECURITY: Use specific capabilities instead of privileged mode
|
||||
# privileged: true # REMOVED - security risk
|
||||
cap_add:
|
||||
- SYS_RAWIO
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
volumes:
|
||||
- ./alpr-service/dataset:/app/dataset
|
||||
healthcheck:
|
||||
test: [ "CMD", "wget", "-q", "--spider", "http://localhost:5001/health" ]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "20m"
|
||||
max-file: "5"
|
||||
# Add privilege for hardware access
|
||||
privileged: true
|
||||
|
||||
# Frontend Service (React)
|
||||
frontend:
|
||||
@@ -111,18 +67,11 @@ services:
|
||||
- "5173:5173"
|
||||
networks:
|
||||
- backend-net
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
- /app/node_modules
|
||||
environment:
|
||||
- VITE_API_URL=
|
||||
- VITE_ALPR_STREAM_URL=
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
- VITE_API_URL=http://localhost:3000
|
||||
|
||||
networks:
|
||||
backend-net:
|
||||
|
||||
@@ -1,193 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Control de Patentes - ALPR</title>
|
||||
<meta name="description" content="Sistema de Control de Acceso mediante Reconocimiento Automático de Patentes" />
|
||||
|
||||
<!-- Loading Screen Styles (inline para carga inmediata) -->
|
||||
<style>
|
||||
/* Loading screen container */
|
||||
#loading-screen {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #0f172a 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
transition: opacity 0.5s ease-out;
|
||||
}
|
||||
|
||||
#loading-screen.fade-out {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Logo container with glow effect */
|
||||
.loading-logo {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
margin-bottom: 30px;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.loading-logo img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 0 20px rgba(59, 130, 246, 0.5));
|
||||
}
|
||||
|
||||
/* Spinner ring */
|
||||
.spinner-ring {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border: 3px solid rgba(59, 130, 246, 0.2);
|
||||
border-top-color: #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
/* Loading text */
|
||||
.loading-text {
|
||||
color: #94a3b8;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 16px;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.loading-subtext {
|
||||
color: #64748b;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Progress bar */
|
||||
.progress-container {
|
||||
width: 200px;
|
||||
height: 4px;
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #3b82f6, #8b5cf6, #3b82f6);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s ease-in-out infinite;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dots animation */
|
||||
.loading-dots::after {
|
||||
content: '';
|
||||
animation: dots 1.5s steps(4, end) infinite;
|
||||
}
|
||||
|
||||
@keyframes dots {
|
||||
|
||||
0%,
|
||||
20% {
|
||||
content: '';
|
||||
}
|
||||
|
||||
40% {
|
||||
content: '.';
|
||||
}
|
||||
|
||||
60% {
|
||||
content: '..';
|
||||
}
|
||||
|
||||
80%,
|
||||
100% {
|
||||
content: '...';
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- Loading Screen (se remueve cuando React carga) -->
|
||||
<div id="loading-screen">
|
||||
<!-- Logo personalizable - coloca tu imagen en /public/logo.png -->
|
||||
<div class="loading-logo">
|
||||
<img src="/logo.png" alt="Logo" onerror="this.parentElement.style.display='none'" />
|
||||
</div>
|
||||
|
||||
<!-- Spinner -->
|
||||
<div class="spinner-ring"></div>
|
||||
|
||||
<!-- Loading text -->
|
||||
<div class="loading-text">Control de Patentes</div>
|
||||
<div class="loading-subtext">Cargando sistema<span class="loading-dots"></span></div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div class="progress-container">
|
||||
<div class="progress-bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
|
||||
<!-- Script para remover loading screen cuando React está listo -->
|
||||
<script>
|
||||
// Fallback: remover loading screen después de 10 segundos máximo
|
||||
setTimeout(function () {
|
||||
var loadingScreen = document.getElementById('loading-screen');
|
||||
if (loadingScreen) {
|
||||
loadingScreen.classList.add('fade-out');
|
||||
setTimeout(function () {
|
||||
loadingScreen.remove();
|
||||
}, 500);
|
||||
}
|
||||
}, 10000);
|
||||
</script>
|
||||
</body>
|
||||
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -14,11 +14,7 @@
|
||||
"lucide-react": "^0.260.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"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"
|
||||
"socket.io-client": "^4.7.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.15",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 638 KiB |
12506
frontend/public/vite.svg
12506
frontend/public/vite.svg
File diff suppressed because it is too large
Load Diff
|
Before Width: | Height: | Size: 641 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -1,87 +1,246 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import Login from './pages/Login';
|
||||
import AdminDashboard from './pages/AdminDashboard';
|
||||
import UserDashboard from './pages/UserDashboard';
|
||||
import './i18n'; // Initialize translations
|
||||
import { useState, useEffect } from 'react'
|
||||
import io from 'socket.io-client'
|
||||
import axios from 'axios'
|
||||
import { Car, AlertCircle, CheckCircle, XCircle, Clock } from 'lucide-react'
|
||||
|
||||
// Env var logic for Vite
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
|
||||
const socket = io(API_URL);
|
||||
|
||||
function App() {
|
||||
const [token, setToken] = useState(localStorage.getItem('token'));
|
||||
const [userRole, setUserRole] = useState(localStorage.getItem('role'));
|
||||
const [username, setUsername] = useState(localStorage.getItem('username'));
|
||||
const [plates, setPlates] = useState([]);
|
||||
const [detections, setDetections] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [newPlate, setNewPlate] = useState({ number: '', owner: '' });
|
||||
|
||||
const setAuth = (newToken, newRole, newUser) => {
|
||||
setToken(newToken);
|
||||
setUserRole(newRole);
|
||||
setUsername(newUser);
|
||||
const handleRegister = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (!newPlate.number) return;
|
||||
await axios.post(`${API_URL}/api/plates`, {
|
||||
number: newPlate.number.toUpperCase(),
|
||||
owner: newPlate.owner
|
||||
});
|
||||
setNewPlate({ number: '', owner: '' });
|
||||
setShowModal(false);
|
||||
fetchPlates();
|
||||
} catch (err) {
|
||||
alert('Error adding plate: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('role');
|
||||
localStorage.removeItem('username');
|
||||
setToken(null);
|
||||
setUserRole(null);
|
||||
setUsername(null);
|
||||
// Load initial data
|
||||
useEffect(() => {
|
||||
fetchPlates();
|
||||
|
||||
// Socket listeners
|
||||
socket.on('new_detection', (data) => {
|
||||
console.log('New detection:', data);
|
||||
setDetections(prev => [data, ...prev].slice(0, 10)); // Keep last 10
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.off('new_detection');
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchPlates = async () => {
|
||||
try {
|
||||
const res = await axios.get(`${API_URL}/api/plates`);
|
||||
setPlates(res.data);
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Protected Route Component
|
||||
const ProtectedRoute = ({ children, allowedRoles }) => {
|
||||
if (!token) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
if (allowedRoles && !allowedRoles.includes(userRole)) {
|
||||
return <Navigate to={userRole === 'ADMIN' ? '/admin' : '/user'} replace />;
|
||||
}
|
||||
return children;
|
||||
const StatusBadge = ({ status }) => {
|
||||
const colors = {
|
||||
GRANTED: 'bg-green-500/20 text-green-400 border-green-500/50',
|
||||
DENIED: 'bg-red-500/20 text-red-400 border-red-500/50',
|
||||
UNKNOWN: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/50',
|
||||
ALLOWED: 'bg-green-500/20 text-green-400 border-green-500/50',
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium border ${colors[status] || colors.UNKNOWN}`}>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/login"
|
||||
element={
|
||||
!token ? (
|
||||
<Login
|
||||
setToken={(t) => setToken(t)}
|
||||
setUserRole={(r) => setUserRole(r)}
|
||||
setUsername={(u) => setUsername(u)} // Adding this prop to Login might be needed
|
||||
/>
|
||||
) : (
|
||||
<Navigate to={userRole === 'ADMIN' ? '/admin' : '/user'} replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<ProtectedRoute allowedRoles={['ADMIN']}>
|
||||
<div className="relative">
|
||||
<button onClick={handleLogout} className="absolute top-4 right-4 text-slate-400 hover:text-white z-50">Logout</button>
|
||||
<AdminDashboard token={token} />
|
||||
<div className="min-h-screen bg-slate-900 text-slate-100 p-8">
|
||||
{/* Modal */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 backdrop-blur-sm">
|
||||
<div className="bg-slate-800 p-6 rounded-2xl w-full max-w-md border border-slate-700 shadow-2xl transform transition-all scale-100">
|
||||
<h3 className="text-xl font-bold mb-4">Register New Plate</h3>
|
||||
<form onSubmit={handleRegister} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1">Plate Number</label>
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={newPlate.number}
|
||||
onChange={e => setNewPlate({ ...newPlate, number: e.target.value.toUpperCase() })}
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 focus:ring-2 focus:ring-blue-500 outline-none font-mono uppercase"
|
||||
placeholder="ABCD12"
|
||||
/>
|
||||
</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>
|
||||
<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>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<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">
|
||||
|
||||
<Route path="/" element={<Navigate to="/login" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
{/* Header */}
|
||||
<header className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-3 bg-blue-600 rounded-lg">
|
||||
<Car size={32} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold bg-gradient-to-r from-blue-400 to-cyan-300 bg-clip-text text-transparent">
|
||||
Control Patente AI
|
||||
</h1>
|
||||
<p className="text-slate-400">Real-time ALPR Monitoring System</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 text-sm text-slate-400">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse"></div>
|
||||
<span>System Online</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
|
||||
{/* Live Detections Feed */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<h2 className="text-xl font-semibold flex items-center space-x-2">
|
||||
<Clock className="text-blue-400" />
|
||||
<span>Live Detections</span>
|
||||
</h2>
|
||||
|
||||
<div className="bg-slate-800/50 rounded-2xl p-6 border border-slate-700/50 backdrop-blur-sm min-h-[400px]">
|
||||
|
||||
{/* Video Feed */}
|
||||
<div className="mb-6 rounded-xl overflow-hidden bg-black aspect-video relative border border-slate-700 shadow-lg">
|
||||
<img
|
||||
src="http://localhost:5001/video_feed"
|
||||
alt="Live Camera Feed"
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
e.target.style.display = 'none';
|
||||
e.target.nextSibling.style.display = 'flex';
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center text-slate-500 hidden bg-slate-900">
|
||||
<p>Camera Offline or Connecting...</p>
|
||||
</div>
|
||||
<div className="absolute top-4 right-4 animate-pulse">
|
||||
<div className="px-2 py-1 bg-red-600 rounded text-xs font-bold text-white uppercase tracking-wider">
|
||||
LIVE
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detections List */}
|
||||
<h3 className="text-sm font-semibold text-slate-400 uppercase tracking-wider mb-4">Recent Scans</h3>
|
||||
|
||||
{detections.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center text-slate-500 space-y-4 text-center py-8">
|
||||
<p>No detections yet...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{detections.map((d, i) => (
|
||||
<div key={i} className="flex items-center justify-between p-4 bg-slate-800 border border-slate-700 rounded-xl hover:bg-slate-750 transition-colors">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="p-2 bg-slate-700 rounded-lg font-mono text-xl tracking-wider font-bold">
|
||||
{d.plate}
|
||||
</div>
|
||||
<div className="text-sm text-slate-400">
|
||||
{new Date(d.timestamp).toLocaleTimeString()}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={d.status} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Database / Stats */}
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold flex items-center space-x-2">
|
||||
<CheckCircle className="text-green-400" />
|
||||
<span>Registered Plates</span>
|
||||
</h2>
|
||||
|
||||
<div className="bg-slate-800/50 rounded-2xl p-6 border border-slate-700/50 backdrop-blur-sm max-h-[600px] overflow-y-auto">
|
||||
{loading ? (
|
||||
<p className="text-center text-slate-500">Loading database...</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{plates.map((p) => (
|
||||
<div key={p.id} className="flex items-center justify-between p-3 bg-slate-800/80 rounded-lg border border-slate-700/50">
|
||||
<div>
|
||||
<div className="font-mono font-bold text-slate-200">{p.number}</div>
|
||||
<div className="text-xs text-slate-400">{p.owner || 'Unknown Owner'}</div>
|
||||
</div>
|
||||
<span className="text-xs font-medium text-green-400 bg-green-500/10 px-2 py-1 rounded">
|
||||
{p.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{plates.length === 0 && (
|
||||
<p className="text-center text-slate-500 text-sm">No plates registered.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="w-full mt-4 py-2 bg-blue-600 hover:bg-blue-500 rounded-lg font-medium transition-colors text-sm"
|
||||
onClick={() => setShowModal(true)}
|
||||
>
|
||||
+ Register New Plate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App;
|
||||
export default App
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -1,27 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import './i18n'
|
||||
import App from './App.jsx'
|
||||
|
||||
// Función para remover la pantalla de carga
|
||||
const removeLoadingScreen = () => {
|
||||
const loadingScreen = document.getElementById('loading-screen');
|
||||
if (loadingScreen) {
|
||||
loadingScreen.classList.add('fade-out');
|
||||
setTimeout(() => {
|
||||
loadingScreen.remove();
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
|
||||
// Renderizar React y remover loading screen
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
// Remover loading screen cuando React esté montado
|
||||
// Pequeño delay para asegurar que la UI esté lista
|
||||
setTimeout(removeLoadingScreen, 100);
|
||||
|
||||
@@ -1,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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -4,25 +4,4 @@ import react from '@vitejs/plugin-react'
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
allowedHosts: ['demo.v1ru5.cl'],
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://backend:3000',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/socket.io': {
|
||||
target: 'http://backend:3000',
|
||||
ws: true
|
||||
},
|
||||
'/video_feed': {
|
||||
target: 'http://alpr-service:5001',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/dataset': {
|
||||
target: 'http://alpr-service:5001',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
764
get-docker.sh
764
get-docker.sh
@@ -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
|
||||
Reference in New Issue
Block a user