1 Commits

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

View File

@@ -69,7 +69,17 @@ Mantén los contenedores de Docker corriendo (Backend, DB, Frontend) y ejecuta e
El script pedirá permisos de cámara. Una vez otorgados, verás el video en el Dashboard.
#### 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.

View File

@@ -4,8 +4,8 @@ import requests
import os
import time
import threading
import numpy as np
import re
from queue import Queue
from flask import Flask, Response
from flask_cors import CORS
from ultralytics import YOLO
@@ -13,157 +13,147 @@ from ultralytics import YOLO
# Configuration
BACKEND_URL = os.environ.get('BACKEND_URL', 'http://localhost:3000')
CAMERA_ID = 0
PROCESS_INTERVAL = 1.5 # Más reactivo
MODEL_PATH = 'best.pt'
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=5)
def send_plate(plate_number):
"""Envía la patente detectada al backend"""
try:
url = f"{BACKEND_URL}/api/detect"
requests.post(url, json={'plate_number': plate_number}, timeout=3)
print(f"✓ Plate sent: {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_and_send(text):
"""Valida formato chileno y envía"""
# Formato nuevo: XXXX-00 | Formato antiguo: XX-0000
if re.match(r'^[A-Z]{4}\d{2}$', text) or re.match(r'^[A-Z]{2}\d{4}$', text):
send_plate(text)
return True
return False
def alpr_loop():
global outputFrame, lock, latest_detections
def ocr_worker(reader):
"""Hilo dedicado para OCR - no bloquea el stream"""
while True:
try:
plate_img = ocr_queue.get(timeout=1)
if plate_img is None:
continue
print("Initializing EasyOCR...")
reader = easyocr.Reader(['en'], gpu=False)
print("EasyOCR initialized.")
# Preprocesamiento para mejor OCR
gray = cv2.cvtColor(plate_img, cv2.COLOR_BGR2GRAY)
ocr_results = reader.readtext(gray, detail=0, paragraph=False,
allowlist='ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789')
for text in ocr_results:
clean_text = ''.join(e for e in text if e.isalnum()).upper()
if len(clean_text) >= 6:
validate_and_send(clean_text)
except:
pass
def camera_loop():
"""Hilo principal de captura - mantiene FPS alto"""
global outputFrame, latest_detections
print("🚀 Initializing ALPR System...")
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 worker de OCR
ocr_thread = threading.Thread(target=ocr_worker, args=(reader,), daemon=True)
ocr_thread.start()
print("✅ System ready!")
if not cap.isOpened():
print("Error: Could not open video device.")
return
last_process_time = 0
frame_count = 0
while True:
# Captura eficiente - solo 2 grabs
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()
# Procesar ALPR cada PROCESS_INTERVAL segundos
# Detection Processing
if current_time - last_process_time > PROCESS_INTERVAL:
last_process_time = current_time
# YOLO detection - usar imgsz pequeño para velocidad
results = model(frame, verbose=False, imgsz=320, conf=0.5)
# 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))
# Extraer imagen de placa y enviar a cola OCR
plate_img = frame[y1:y2, x1:x2].copy()
if plate_img.size > 0 and not ocr_queue.full():
ocr_queue.put(plate_img)
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]
# Actualizar frame para streaming (sin bloquear)
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) # ~30 FPS para el stream
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():
return {"status": "ok", "service": "alpr"}
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)

View File

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

View File

@@ -5,7 +5,7 @@
"main": "src/index.js",
"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",

View File

@@ -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 {

View File

@@ -23,271 +23,34 @@ 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 {
// Filter based on role
const where = req.user.role === 'ADMIN' ? {} : { addedById: req.user.id };
const plates = await prisma.plate.findMany({
where,
include: { addedBy: { select: { username: true } } }
});
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';
// Admin -> ALLOWED, User -> PENDING
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' }
});
// Notify Admin via WebSocket
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; // ALLOWED or DENIED
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 }
});
// Notify Users via WebSocket
io.emit('plate_status_updated', plate);
res.json(plate);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Admin: Delete Plate (Optional but good to have)
// Delete Plate (Admin or Owner)
app.delete('/api/plates/:id', authenticateToken, async (req, res) => {
const { id } = req.params;
try {
const plate = await prisma.plate.findUnique({ where: { id: parseInt(id) } });
if (!plate) return res.status(404).json({ error: 'Plate not found' });
// Check permissions
if (req.user.role !== 'ADMIN' && plate.addedById !== req.user.id) {
return res.status(403).json({ error: 'Unauthorized' });
}
await prisma.plate.delete({ where: { id: parseInt(id) } });
io.emit('plate_deleted', { id: parseInt(id) });
res.json({ message: 'Plate deleted' });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 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; // Format: YYYY-MM-DD
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
}
});
// Notify Admin via WebSocket
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' }
});
// Notify Users via WebSocket
io.emit('people_updated', { userId });
res.json({ message: 'Bulk approval successful' });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Detection Endpoint (from Python)
app.post('/api/detect', async (req, res) => {
const { plate_number } = req.body;
console.log(`Detected: ${plate_number}`);
const DUPLICATE_COOLDOWN_MS = 30000; // 30 seconds
try {
// Check for recent duplicate
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 }
@@ -329,28 +92,7 @@ app.post('/api/detect', async (req, res) => {
}
});
const bcrypt = require('bcryptjs');
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...');
const hashedPassword = await bcrypt.hash('admin123', 10);
await prisma.user.create({
data: {
username: 'admin',
password: hashedPassword,
role: 'ADMIN'
}
});
console.log('Default admin created: admin / admin123');
}
} catch (err) {
console.error('Error seeding admin user:', err);
}
});

View File

@@ -1,26 +0,0 @@
const jwt = require('jsonwebtoken');
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-this';
const authenticateToken = (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) return res.sendStatus(401);
jwt.verify(token, JWT_SECRET, (err, user) => {
if (err) return res.sendStatus(403);
req.user = user;
next();
});
};
const isAdmin = (req, res, next) => {
if (req.user && req.user.role === 'ADMIN') {
next();
} else {
res.status(403).json({ error: 'Admin access required' });
}
};
module.exports = { authenticateToken, isAdmin, JWT_SECRET };

View File

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

View File

@@ -17,7 +17,7 @@ services:
- 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
@@ -36,7 +36,6 @@ services:
condition: service_healthy
networks:
- backend-net
restart: unless-stopped
volumes:
- ./backend:/app
- /app/node_modules
@@ -45,8 +44,6 @@ services:
alpr-service:
build: ./alpr-service
container_name: controlpatente-alpr
ports:
- "5001:5001" # Permite acceder al stream de video desde el nave
environment:
- BACKEND_URL=http://backend:3000
# On Mac, you usually cannot pass /dev/video0 directly.
@@ -59,6 +56,7 @@ services:
depends_on:
- backend
restart: unless-stopped
# Add privilege for hardware access
privileged: true
# Frontend Service (React)
@@ -69,13 +67,12 @@ services:
- "5173:5173"
networks:
- backend-net
restart: unless-stopped
volumes:
- ./frontend:/app
- /app/node_modules
environment:
- VITE_API_URL=
- VITE_ALPR_STREAM_URL=
- VITE_API_URL=http://localhost:3000
networks:
backend-net:
driver: bridge

View File

@@ -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",

View File

@@ -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 (
<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>
</ProtectedRoute>
}
/>
<Route
path="/user"
element={
<ProtectedRoute allowedRoles={['USER', 'ADMIN']}> {/* Admin can view user db too usually, but separate for now */}
<div className="relative">
<button onClick={handleLogout} className="absolute top-4 right-4 text-slate-400 hover:text-white z-50">Logout</button>
<UserDashboard token={token} username={username} />
</div>
</ProtectedRoute>
}
/>
<Route path="/" element={<Navigate to="/login" replace />} />
</Routes>
</BrowserRouter>
<span className={`px-3 py-1 rounded-full text-xs font-medium border ${colors[status] || colors.UNKNOWN}`}>
{status}
</span>
);
};
return (
<div className="min-h-screen bg-slate-900 text-slate-100 p-8">
{/* Modal */}
{showModal && (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 backdrop-blur-sm">
<div className="bg-slate-800 p-6 rounded-2xl w-full max-w-md border border-slate-700 shadow-2xl transform transition-all scale-100">
<h3 className="text-xl font-bold mb-4">Register New Plate</h3>
<form onSubmit={handleRegister} className="space-y-4">
<div>
<label className="block text-sm text-slate-400 mb-1">Plate Number</label>
<input
autoFocus
type="text"
value={newPlate.number}
onChange={e => setNewPlate({ ...newPlate, number: e.target.value.toUpperCase() })}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 focus:ring-2 focus:ring-blue-500 outline-none font-mono uppercase"
placeholder="ABCD12"
/>
</div>
<div>
<label className="block text-sm text-slate-400 mb-1">Owner Name</label>
<input
type="text"
value={newPlate.owner}
onChange={e => setNewPlate({ ...newPlate, owner: e.target.value })}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 focus:ring-2 focus:ring-blue-500 outline-none"
placeholder="John Doe"
/>
</div>
<div className="flex space-x-3 mt-6">
<button
type="button"
onClick={() => setShowModal(false)}
className="flex-1 py-2 bg-slate-700 hover:bg-slate-600 rounded-lg font-medium transition-colors"
>
Cancel
</button>
<button
type="submit"
className="flex-1 py-2 bg-blue-600 hover:bg-blue-500 rounded-lg font-medium transition-colors"
>
Register
</button>
</div>
</form>
</div>
</div>
)}
<div className="max-w-7xl mx-auto space-y-8">
{/* Header */}
<header className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="p-3 bg-blue-600 rounded-lg">
<Car size={32} />
</div>
<div>
<h1 className="text-3xl font-bold bg-gradient-to-r from-blue-400 to-cyan-300 bg-clip-text text-transparent">
Control Patente AI
</h1>
<p className="text-slate-400">Real-time ALPR Monitoring System</p>
</div>
</div>
<div className="flex items-center space-x-2 text-sm text-slate-400">
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse"></div>
<span>System Online</span>
</div>
</header>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Live Detections Feed */}
<div className="lg:col-span-2 space-y-6">
<h2 className="text-xl font-semibold flex items-center space-x-2">
<Clock className="text-blue-400" />
<span>Live Detections</span>
</h2>
<div className="bg-slate-800/50 rounded-2xl p-6 border border-slate-700/50 backdrop-blur-sm min-h-[400px]">
{/* Video Feed */}
<div className="mb-6 rounded-xl overflow-hidden bg-black aspect-video relative border border-slate-700 shadow-lg">
<img
src="http://localhost:5001/video_feed"
alt="Live Camera Feed"
className="w-full h-full object-cover"
onError={(e) => {
e.target.style.display = 'none';
e.target.nextSibling.style.display = 'flex';
}}
/>
<div className="absolute inset-0 flex items-center justify-center text-slate-500 hidden bg-slate-900">
<p>Camera Offline or Connecting...</p>
</div>
<div className="absolute top-4 right-4 animate-pulse">
<div className="px-2 py-1 bg-red-600 rounded text-xs font-bold text-white uppercase tracking-wider">
LIVE
</div>
</div>
</div>
{/* Detections List */}
<h3 className="text-sm font-semibold text-slate-400 uppercase tracking-wider mb-4">Recent Scans</h3>
{detections.length === 0 ? (
<div className="flex flex-col items-center justify-center text-slate-500 space-y-4 text-center py-8">
<p>No detections yet...</p>
</div>
) : (
<div className="space-y-4">
{detections.map((d, i) => (
<div key={i} className="flex items-center justify-between p-4 bg-slate-800 border border-slate-700 rounded-xl hover:bg-slate-750 transition-colors">
<div className="flex items-center space-x-4">
<div className="p-2 bg-slate-700 rounded-lg font-mono text-xl tracking-wider font-bold">
{d.plate}
</div>
<div className="text-sm text-slate-400">
{new Date(d.timestamp).toLocaleTimeString()}
</div>
</div>
<StatusBadge status={d.status} />
</div>
))}
</div>
)}
</div>
</div>
{/* Database / Stats */}
<div className="space-y-6">
<h2 className="text-xl font-semibold flex items-center space-x-2">
<CheckCircle className="text-green-400" />
<span>Registered Plates</span>
</h2>
<div className="bg-slate-800/50 rounded-2xl p-6 border border-slate-700/50 backdrop-blur-sm max-h-[600px] overflow-y-auto">
{loading ? (
<p className="text-center text-slate-500">Loading database...</p>
) : (
<div className="space-y-3">
{plates.map((p) => (
<div key={p.id} className="flex items-center justify-between p-3 bg-slate-800/80 rounded-lg border border-slate-700/50">
<div>
<div className="font-mono font-bold text-slate-200">{p.number}</div>
<div className="text-xs text-slate-400">{p.owner || 'Unknown Owner'}</div>
</div>
<span className="text-xs font-medium text-green-400 bg-green-500/10 px-2 py-1 rounded">
{p.status}
</span>
</div>
))}
{plates.length === 0 && (
<p className="text-center text-slate-500 text-sm">No plates registered.</p>
)}
</div>
)}
<button
className="w-full mt-4 py-2 bg-blue-600 hover:bg-blue-500 rounded-lg font-medium transition-colors text-sm"
onClick={() => setShowModal(true)}
>
+ Register New Plate
</button>
</div>
</div>
</div>
</div>
</div>
)
}
export default App;
export default App

View File

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

View File

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

View File

@@ -1,464 +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 } 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 }
useEffect(() => {
fetchData();
// Live detection listener
socket.on('new_detection', (data) => {
setDetections(prev => [data, ...prev].slice(0, 10));
});
// Real-time updates for approvals
socket.on('new_plate_registered', () => fetchData());
socket.on('new_person_registered', () => fetchData());
socket.on('plate_status_updated', () => fetchData()); // Reused for consistency
socket.on('plate_deleted', () => fetchData());
socket.on('person_deleted', () => fetchData());
return () => {
socket.off('new_detection');
socket.off('new_plate_registered');
socket.off('new_person_registered');
socket.off('plate_status_updated');
socket.off('plate_deleted');
socket.off('person_deleted');
};
}, [token]);
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 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 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>
{/* 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>
</div>
);
}
export default AdminDashboard;

View File

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

View File

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

View File

@@ -4,21 +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
}
}
}
})

View File

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