Compare commits
24 Commits
Integracio
...
de399fe3f8
| Author | SHA1 | Date | |
|---|---|---|---|
| de399fe3f8 | |||
| b9409cc48c | |||
| 634365a45d | |||
| db75d2964f | |||
| 82fbc54349 | |||
| 9ee325a294 | |||
| 7fdf30ee3b | |||
| 7df80ebcff | |||
| f9f841e4f1 | |||
| d6f90c19f1 | |||
| fbcf7fb9c8 | |||
| 522b885dfe | |||
| 445dddc1df | |||
| 10eed4cd26 | |||
| f6564ec2a6 | |||
| b91de06e29 | |||
| ad52d300ad | |||
| 690c4d6ad7 | |||
| d598e61985 | |||
| f557a1b2e9 | |||
| 1b12d51cde | |||
| d5b32fb4a2 | |||
| 5c1681339c | |||
| a9687711fa |
12
README.md
12
README.md
@@ -69,17 +69,7 @@ Mantén los contenedores de Docker corriendo (Backend, DB, Frontend) y ejecuta e
|
|||||||
|
|
||||||
El script pedirá permisos de cámara. Una vez otorgados, verás el video en el Dashboard.
|
El script pedirá permisos de cámara. Una vez otorgados, verás el video en el Dashboard.
|
||||||
|
|
||||||
|
#### Opción B: Ejecución Full Docker (Linux / Raspberry Pi)
|
||||||
#### Opción B: Ejecución en Windows (Híbrida)
|
|
||||||
|
|
||||||
1. Asegúrate de tener **Docker Desktop** corriendo.
|
|
||||||
2. Abre la carpeta `alpr-service`.
|
|
||||||
3. Haz doble clic en el archivo `run_windows.bat`.
|
|
||||||
* Este script instalará las dependencias automáticamente.
|
|
||||||
* Configurará las variables de entorno.
|
|
||||||
* Iniciará el reconocimiento de patentes.
|
|
||||||
|
|
||||||
#### Opción C: Ejecución Full Docker (Linux / Raspberry Pi)
|
|
||||||
|
|
||||||
En sistemas Linux nativos donde se pueden mapear dispositivos (ej. `/dev/video0`), simplemente descomenta la sección `devices` en `docker-compose.yml` y todo correrá dentro de Docker.
|
En sistemas Linux nativos donde se pueden mapear dispositivos (ej. `/dev/video0`), simplemente descomenta la sección `devices` en `docker-compose.yml` y todo correrá dentro de Docker.
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import requests
|
|||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import threading
|
import threading
|
||||||
import numpy as np
|
|
||||||
import re
|
import re
|
||||||
|
from queue import Queue
|
||||||
from flask import Flask, Response
|
from flask import Flask, Response
|
||||||
from flask_cors import CORS
|
from flask_cors import CORS
|
||||||
from ultralytics import YOLO
|
from ultralytics import YOLO
|
||||||
@@ -13,147 +13,157 @@ from ultralytics import YOLO
|
|||||||
# Configuration
|
# Configuration
|
||||||
BACKEND_URL = os.environ.get('BACKEND_URL', 'http://localhost:3000')
|
BACKEND_URL = os.environ.get('BACKEND_URL', 'http://localhost:3000')
|
||||||
CAMERA_ID = 0
|
CAMERA_ID = 0
|
||||||
PROCESS_INTERVAL = 0.5 # Faster processing with YOLO (it's efficient)
|
PROCESS_INTERVAL = 1.5 # Más reactivo
|
||||||
CONFIDENCE_THRESHOLD = 0.4
|
MODEL_PATH = 'best.pt'
|
||||||
MODEL_PATH = 'best.pt' # Expecting the model here
|
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
CORS(app)
|
CORS(app)
|
||||||
|
|
||||||
# Global variables
|
# Shared state
|
||||||
outputFrame = None
|
outputFrame = None
|
||||||
lock = threading.Lock()
|
frame_lock = threading.Lock()
|
||||||
# Store latest detections for visualization
|
|
||||||
latest_detections = []
|
latest_detections = []
|
||||||
|
detection_lock = threading.Lock()
|
||||||
|
|
||||||
|
# Cola para procesamiento OCR asíncrono
|
||||||
|
ocr_queue = Queue(maxsize=5)
|
||||||
|
|
||||||
def send_plate(plate_number):
|
def send_plate(plate_number):
|
||||||
|
"""Envía la patente detectada al backend"""
|
||||||
try:
|
try:
|
||||||
url = f"{BACKEND_URL}/api/detect"
|
url = f"{BACKEND_URL}/api/detect"
|
||||||
payload = {'plate_number': plate_number}
|
requests.post(url, json={'plate_number': plate_number}, timeout=3)
|
||||||
print(f"Sending plate: {plate_number} to {url}")
|
print(f"✓ Plate sent: {plate_number}")
|
||||||
requests.post(url, json=payload, timeout=2)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error sending plate: {e}")
|
print(f"✗ Error sending plate: {e}")
|
||||||
|
|
||||||
def alpr_loop():
|
def validate_and_send(text):
|
||||||
global outputFrame, lock, latest_detections
|
"""Valida formato chileno y envía"""
|
||||||
|
# Formato nuevo: XXXX-00 | Formato antiguo: XX-0000
|
||||||
print("Initializing EasyOCR...")
|
if re.match(r'^[A-Z]{4}\d{2}$', text) or re.match(r'^[A-Z]{2}\d{4}$', text):
|
||||||
reader = easyocr.Reader(['en'], gpu=False)
|
send_plate(text)
|
||||||
print("EasyOCR initialized.")
|
return True
|
||||||
|
return False
|
||||||
# Load YOLO Model
|
|
||||||
print(f"Loading YOLO model from {MODEL_PATH}...")
|
|
||||||
try:
|
|
||||||
model = YOLO(MODEL_PATH)
|
|
||||||
print("YOLO model loaded successfully!")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error loading YOLO model: {e}")
|
|
||||||
print("CRITICAL: Please place the 'best.pt' file in the alpr-service directory.")
|
|
||||||
return
|
|
||||||
|
|
||||||
cap = cv2.VideoCapture(CAMERA_ID)
|
|
||||||
time.sleep(2.0)
|
|
||||||
|
|
||||||
if not cap.isOpened():
|
|
||||||
print("Error: Could not open video device.")
|
|
||||||
return
|
|
||||||
|
|
||||||
last_process_time = 0
|
|
||||||
|
|
||||||
|
def ocr_worker(reader):
|
||||||
|
"""Hilo dedicado para OCR - no bloquea el stream"""
|
||||||
while True:
|
while True:
|
||||||
ret, frame = cap.read()
|
try:
|
||||||
if not ret:
|
plate_img = ocr_queue.get(timeout=1)
|
||||||
print("Failed to grab frame")
|
if plate_img is None:
|
||||||
time.sleep(1)
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Resize for performance
|
# Preprocesamiento para mejor OCR
|
||||||
frame = cv2.resize(frame, (640, 480))
|
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...")
|
||||||
|
try:
|
||||||
|
model = YOLO(MODEL_PATH)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Critical Error loading model: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("📝 Initializing EasyOCR...")
|
||||||
|
reader = easyocr.Reader(['en'], gpu=False)
|
||||||
|
|
||||||
|
# Iniciar worker de OCR
|
||||||
|
ocr_thread = threading.Thread(target=ocr_worker, args=(reader,), daemon=True)
|
||||||
|
ocr_thread.start()
|
||||||
|
|
||||||
|
print("✅ System ready!")
|
||||||
|
|
||||||
|
last_process_time = 0
|
||||||
|
frame_count = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
# Captura eficiente - solo 2 grabs
|
||||||
|
cap.grab()
|
||||||
|
cap.grab()
|
||||||
|
ret, frame = cap.retrieve()
|
||||||
|
|
||||||
|
if not ret:
|
||||||
|
time.sleep(0.01)
|
||||||
|
continue
|
||||||
|
|
||||||
|
frame_count += 1
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
|
|
||||||
# Detection Processing
|
# Procesar ALPR cada PROCESS_INTERVAL segundos
|
||||||
if current_time - last_process_time > PROCESS_INTERVAL:
|
if current_time - last_process_time > PROCESS_INTERVAL:
|
||||||
last_process_time = current_time
|
last_process_time = current_time
|
||||||
|
|
||||||
# Run YOLO Inference
|
# YOLO detection - usar imgsz pequeño para velocidad
|
||||||
results = model(frame, verbose=False)
|
results = model(frame, verbose=False, imgsz=320, conf=0.5)
|
||||||
|
|
||||||
detections = []
|
|
||||||
|
|
||||||
|
new_detections = []
|
||||||
for r in results:
|
for r in results:
|
||||||
boxes = r.boxes
|
for box in r.boxes:
|
||||||
for box in boxes:
|
x1, y1, x2, y2 = map(int, box.xyxy[0])
|
||||||
# Bounding Box
|
|
||||||
x1, y1, x2, y2 = box.xyxy[0]
|
|
||||||
x1, y1, x2, y2 = int(x1), int(y1), int(x2), int(y2)
|
|
||||||
conf = float(box.conf[0])
|
conf = float(box.conf[0])
|
||||||
|
new_detections.append((x1, y1, x2, y2, conf))
|
||||||
|
|
||||||
if conf > 0.5: # Valid plate detection
|
# Extraer imagen de placa y enviar a cola OCR
|
||||||
# Visualization data
|
plate_img = frame[y1:y2, x1:x2].copy()
|
||||||
detections.append((x1, y1, x2, y2, conf))
|
if plate_img.size > 0 and not ocr_queue.full():
|
||||||
|
ocr_queue.put(plate_img)
|
||||||
|
|
||||||
# Crop Plate
|
with detection_lock:
|
||||||
plate_img = frame[y1:y2, x1:x2]
|
latest_detections = new_detections
|
||||||
|
|
||||||
# Run OCR on Crop
|
# Actualizar frame para streaming (sin bloquear)
|
||||||
try:
|
display_frame = frame
|
||||||
ocr_results = reader.readtext(plate_img)
|
with detection_lock:
|
||||||
for (_, text, prob) in ocr_results:
|
|
||||||
if prob > CONFIDENCE_THRESHOLD:
|
|
||||||
clean_text = ''.join(e for e in text if e.isalnum()).upper()
|
|
||||||
validate_and_send(clean_text)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"OCR Error on crop: {e}")
|
|
||||||
|
|
||||||
with lock:
|
|
||||||
latest_detections = detections
|
|
||||||
|
|
||||||
# Draw Detections on Frame for Stream
|
|
||||||
display_frame = frame.copy()
|
|
||||||
with lock:
|
|
||||||
for (x1, y1, x2, y2, conf) in latest_detections:
|
for (x1, y1, x2, y2, conf) in latest_detections:
|
||||||
cv2.rectangle(display_frame, (x1, y1), (x2, y2), (0, 255, 0), 2)
|
cv2.rectangle(display_frame, (x1, y1), (x2, y2), (0, 255, 0), 2)
|
||||||
cv2.putText(display_frame, f"Plate {conf:.2f}", (x1, y1-10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 2)
|
cv2.putText(display_frame, f"{conf:.0%}", (x1, y1-5),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1)
|
||||||
|
|
||||||
|
with frame_lock:
|
||||||
outputFrame = display_frame
|
outputFrame = display_frame
|
||||||
|
|
||||||
time.sleep(0.01)
|
|
||||||
|
|
||||||
def validate_and_send(text):
|
|
||||||
# Chilean Plate Regex Patterns
|
|
||||||
is_valid = False
|
|
||||||
if re.match(r'^[A-Z]{4}\d{2}$', text): # BBBB11
|
|
||||||
is_valid = True
|
|
||||||
elif re.match(r'^[A-Z]{2}\d{4}$', text): # BB1111
|
|
||||||
is_valid = True
|
|
||||||
|
|
||||||
if is_valid:
|
|
||||||
print(f"Detected Valid Plate: {text}")
|
|
||||||
send_plate(text)
|
|
||||||
|
|
||||||
def generate():
|
def generate():
|
||||||
global outputFrame, lock
|
"""Generador para streaming MJPEG"""
|
||||||
|
global outputFrame
|
||||||
while True:
|
while True:
|
||||||
with lock:
|
time.sleep(0.033) # ~30 FPS para el stream
|
||||||
|
with frame_lock:
|
||||||
if outputFrame is None:
|
if outputFrame is None:
|
||||||
continue
|
continue
|
||||||
(flag, encodedImage) = cv2.imencode(".jpg", outputFrame)
|
_, encoded = cv2.imencode(".jpg", outputFrame, [cv2.IMWRITE_JPEG_QUALITY, 75])
|
||||||
if not flag:
|
yield b'--frame\r\nContent-Type: image/jpeg\r\n\r\n' + encoded.tobytes() + b'\r\n'
|
||||||
continue
|
|
||||||
|
|
||||||
yield(b'--frame\r\n' b'Content-Type: image/jpeg\r\n\r\n' +
|
|
||||||
bytearray(encodedImage) + b'\r\n')
|
|
||||||
|
|
||||||
@app.route("/video_feed")
|
@app.route("/video_feed")
|
||||||
def video_feed():
|
def video_feed():
|
||||||
return Response(generate(), mimetype="multipart/x-mixed-replace; boundary=frame")
|
return Response(generate(), mimetype="multipart/x-mixed-replace; boundary=frame")
|
||||||
|
|
||||||
if __name__ == "__main__":
|
@app.route("/health")
|
||||||
t = threading.Thread(target=alpr_loop)
|
def health():
|
||||||
t.daemon = True
|
return {"status": "ok", "service": "alpr"}
|
||||||
t.start()
|
|
||||||
|
|
||||||
print("Starting Video Stream on port 5001...")
|
if __name__ == "__main__":
|
||||||
|
t = threading.Thread(target=camera_loop, daemon=True)
|
||||||
|
t.start()
|
||||||
app.run(host="0.0.0.0", port=5001, debug=False, threaded=True, use_reloader=False)
|
app.run(host="0.0.0.0", port=5001, debug=False, threaded=True, use_reloader=False)
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
@echo off
|
|
||||||
TITLE ControlPatente AI - ALPR Service
|
|
||||||
|
|
||||||
echo ========================================================
|
|
||||||
echo Inicializando Servicio de Reconocimiento de Patentes
|
|
||||||
echo Windows Launcher
|
|
||||||
echo ========================================================
|
|
||||||
echo.
|
|
||||||
|
|
||||||
cd /d "%~dp0"
|
|
||||||
|
|
||||||
echo [1/3] Verificando entorno Python...
|
|
||||||
python --version >nul 2>&1
|
|
||||||
if %errorlevel% neq 0 (
|
|
||||||
echo ERROR: Python no esta instalado o no esta en el PATH.
|
|
||||||
echo Por favor instala Python desde https://python.org
|
|
||||||
pause
|
|
||||||
exit /b
|
|
||||||
)
|
|
||||||
|
|
||||||
echo [2/3] Instalando dependencias (si faltan)...
|
|
||||||
pip install -r requirements.txt
|
|
||||||
pip install flask flask-cors ultralytics
|
|
||||||
|
|
||||||
echo.
|
|
||||||
echo [3/3] Iniciando Servicio...
|
|
||||||
echo.
|
|
||||||
|
|
||||||
set BACKEND_URL=http://localhost:3000
|
|
||||||
|
|
||||||
python main.py
|
|
||||||
|
|
||||||
pause
|
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node src/index.js",
|
"start": "node src/index.js",
|
||||||
"dev": "nodemon src/index.js",
|
"dev": "npx prisma db push && nodemon src/index.js",
|
||||||
"migrate": "npx prisma migrate dev"
|
"migrate": "npx prisma migrate dev"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -13,7 +13,9 @@
|
|||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"pg": "^8.11.0",
|
"pg": "^8.11.0",
|
||||||
"socket.io": "^4.6.1",
|
"socket.io": "^4.6.1",
|
||||||
"@prisma/client": "^5.0.0"
|
"@prisma/client": "^5.0.0",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"jsonwebtoken": "^9.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^2.0.22",
|
"nodemon": "^2.0.22",
|
||||||
|
|||||||
@@ -8,12 +8,35 @@ datasource db {
|
|||||||
url = env("DATABASE_URL")
|
url = env("DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
username String @unique
|
||||||
|
password String
|
||||||
|
role String @default("USER") // ADMIN, USER
|
||||||
|
plates Plate[]
|
||||||
|
people Person[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Person {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
rut String @unique
|
||||||
|
name String
|
||||||
|
status String @default("PENDING") // PENDING, APPROVED, DENIED
|
||||||
|
startDate DateTime
|
||||||
|
endDate DateTime
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
addedBy User? @relation(fields: [addedById], references: [id])
|
||||||
|
addedById Int?
|
||||||
|
}
|
||||||
|
|
||||||
model Plate {
|
model Plate {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
number String @unique
|
number String @unique
|
||||||
owner String?
|
owner String?
|
||||||
status String @default("ALLOWED") // ALLOWED, DENIED
|
status String @default("PENDING") // PENDING, ALLOWED, DENIED
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
addedBy User? @relation(fields: [addedById], references: [id])
|
||||||
|
addedById Int?
|
||||||
}
|
}
|
||||||
|
|
||||||
model AccessLog {
|
model AccessLog {
|
||||||
|
|||||||
@@ -23,34 +23,271 @@ app.get('/', (req, res) => {
|
|||||||
res.send('ALPR Backend Running');
|
res.send('ALPR Backend Running');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const authRoutes = require('./routes/auth');
|
||||||
|
const { authenticateToken, isAdmin } = require('./middleware/auth');
|
||||||
|
|
||||||
|
app.use('/api/auth', authRoutes);
|
||||||
|
|
||||||
// Plates CRUD
|
// Plates CRUD
|
||||||
app.get('/api/plates', async (req, res) => {
|
app.get('/api/plates', authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const plates = await prisma.plate.findMany();
|
// 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 } } }
|
||||||
|
});
|
||||||
res.json(plates);
|
res.json(plates);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/plates', async (req, res) => {
|
app.post('/api/plates', authenticateToken, async (req, res) => {
|
||||||
const { number, owner, status } = req.body;
|
const { number, owner } = req.body;
|
||||||
|
const isAdm = req.user.role === 'ADMIN';
|
||||||
|
// Admin -> ALLOWED, User -> PENDING
|
||||||
|
const status = isAdm ? 'ALLOWED' : 'PENDING';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const plate = await prisma.plate.create({
|
const plate = await prisma.plate.create({
|
||||||
data: { number, owner, status: status || 'ALLOWED' }
|
data: {
|
||||||
|
number,
|
||||||
|
owner,
|
||||||
|
status,
|
||||||
|
addedById: req.user.id
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Notify Admin via WebSocket
|
||||||
|
io.emit('new_plate_registered', plate);
|
||||||
|
|
||||||
res.json(plate);
|
res.json(plate);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Admin: Approve/Reject Plate
|
||||||
|
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)
|
// Detection Endpoint (from Python)
|
||||||
app.post('/api/detect', async (req, res) => {
|
app.post('/api/detect', async (req, res) => {
|
||||||
const { plate_number } = req.body;
|
const { plate_number } = req.body;
|
||||||
console.log(`Detected: ${plate_number}`);
|
console.log(`Detected: ${plate_number}`);
|
||||||
|
|
||||||
|
const DUPLICATE_COOLDOWN_MS = 30000; // 30 seconds
|
||||||
|
|
||||||
try {
|
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
|
// Check if plate exists
|
||||||
let plate = await prisma.plate.findUnique({
|
let plate = await prisma.plate.findUnique({
|
||||||
where: { number: plate_number }
|
where: { number: plate_number }
|
||||||
@@ -92,7 +329,28 @@ app.post('/api/detect', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
server.listen(PORT, () => {
|
server.listen(PORT, async () => {
|
||||||
console.log(`Server running on port ${PORT}`);
|
console.log(`Server running on port ${PORT}`);
|
||||||
|
|
||||||
|
// Seed Admin User if none exists
|
||||||
|
try {
|
||||||
|
const userCount = await prisma.user.count();
|
||||||
|
if (userCount === 0) {
|
||||||
|
console.log('No users found. Creating default admin user...');
|
||||||
|
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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
26
backend/src/middleware/auth.js
Normal file
26
backend/src/middleware/auth.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
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 };
|
||||||
77
backend/src/routes/auth.js
Normal file
77
backend/src/routes/auth.js
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const { JWT_SECRET, authenticateToken, isAdmin } = require('../middleware/auth');
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
// Register (Protected - Admin only or Open? Plan said Admin creates users)
|
||||||
|
// Let's allow open registration but default to USER role, or only Admin can create.
|
||||||
|
// Requirement: "administrador sea capaz de crear y borrar usuarios".
|
||||||
|
// So we will make register protected by isAdmin or just login.
|
||||||
|
// For initial setup we might need a seed or allow open registration for the first user.
|
||||||
|
// Let's implement a public login and a protected register for now.
|
||||||
|
|
||||||
|
router.post('/login', async (req, res) => {
|
||||||
|
const { username, password } = req.body;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.findUnique({ where: { username } });
|
||||||
|
if (!user) return res.status(400).json({ error: 'User not found' });
|
||||||
|
|
||||||
|
const validPassword = await bcrypt.compare(password, user.password);
|
||||||
|
if (!validPassword) return res.status(400).json({ error: 'Invalid password' });
|
||||||
|
|
||||||
|
const token = jwt.sign({ id: user.id, username: user.username, role: user.role }, JWT_SECRET, { expiresIn: '1h' });
|
||||||
|
res.json({ token, role: user.role, username: user.username });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Admin: Create User
|
||||||
|
router.post('/register', authenticateToken, isAdmin, async (req, res) => {
|
||||||
|
const { username, password, role } = req.body;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const hashedPassword = await bcrypt.hash(password, 10);
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
username,
|
||||||
|
password: hashedPassword,
|
||||||
|
role: role || 'USER'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
res.json({ message: 'User created', userId: user.id });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Admin: Delete User
|
||||||
|
router.delete('/:id', authenticateToken, isAdmin, async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
try {
|
||||||
|
await prisma.user.delete({ where: { id: parseInt(id) } });
|
||||||
|
res.json({ message: 'User deleted' });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Admin: List Users
|
||||||
|
router.get('/', authenticateToken, isAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
select: { id: true, username: true, role: true } // Don't return passwords
|
||||||
|
});
|
||||||
|
res.json(users);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -36,6 +36,7 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
networks:
|
networks:
|
||||||
- backend-net
|
- backend-net
|
||||||
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
@@ -44,6 +45,8 @@ services:
|
|||||||
alpr-service:
|
alpr-service:
|
||||||
build: ./alpr-service
|
build: ./alpr-service
|
||||||
container_name: controlpatente-alpr
|
container_name: controlpatente-alpr
|
||||||
|
ports:
|
||||||
|
- "5001:5001" # Permite acceder al stream de video desde el nave
|
||||||
environment:
|
environment:
|
||||||
- BACKEND_URL=http://backend:3000
|
- BACKEND_URL=http://backend:3000
|
||||||
# On Mac, you usually cannot pass /dev/video0 directly.
|
# On Mac, you usually cannot pass /dev/video0 directly.
|
||||||
@@ -56,7 +59,6 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# Add privilege for hardware access
|
|
||||||
privileged: true
|
privileged: true
|
||||||
|
|
||||||
# Frontend Service (React)
|
# Frontend Service (React)
|
||||||
@@ -67,12 +69,13 @@ services:
|
|||||||
- "5173:5173"
|
- "5173:5173"
|
||||||
networks:
|
networks:
|
||||||
- backend-net
|
- backend-net
|
||||||
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- ./frontend:/app
|
- ./frontend:/app
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
environment:
|
environment:
|
||||||
- VITE_API_URL=http://localhost:3000
|
- VITE_API_URL=
|
||||||
|
- VITE_ALPR_STREAM_URL=
|
||||||
networks:
|
networks:
|
||||||
backend-net:
|
backend-net:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|||||||
@@ -14,7 +14,11 @@
|
|||||||
"lucide-react": "^0.260.0",
|
"lucide-react": "^0.260.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"socket.io-client": "^4.7.1"
|
"socket.io-client": "^4.7.1",
|
||||||
|
"react-router-dom": "^6.14.2",
|
||||||
|
"jwt-decode": "^3.1.2",
|
||||||
|
"i18next": "^23.10.0",
|
||||||
|
"react-i18next": "^14.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.2.15",
|
"@types/react": "^18.2.15",
|
||||||
|
|||||||
@@ -1,246 +1,87 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react';
|
||||||
import io from 'socket.io-client'
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import axios from 'axios'
|
import Login from './pages/Login';
|
||||||
import { Car, AlertCircle, CheckCircle, XCircle, Clock } from 'lucide-react'
|
import AdminDashboard from './pages/AdminDashboard';
|
||||||
|
import UserDashboard from './pages/UserDashboard';
|
||||||
// Env var logic for Vite
|
import './i18n'; // Initialize translations
|
||||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
|
|
||||||
const socket = io(API_URL);
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [plates, setPlates] = useState([]);
|
const [token, setToken] = useState(localStorage.getItem('token'));
|
||||||
const [detections, setDetections] = useState([]);
|
const [userRole, setUserRole] = useState(localStorage.getItem('role'));
|
||||||
const [loading, setLoading] = useState(true);
|
const [username, setUsername] = useState(localStorage.getItem('username'));
|
||||||
const [showModal, setShowModal] = useState(false);
|
|
||||||
const [newPlate, setNewPlate] = useState({ number: '', owner: '' });
|
|
||||||
|
|
||||||
const handleRegister = async (e) => {
|
const setAuth = (newToken, newRole, newUser) => {
|
||||||
e.preventDefault();
|
setToken(newToken);
|
||||||
try {
|
setUserRole(newRole);
|
||||||
if (!newPlate.number) return;
|
setUsername(newUser);
|
||||||
await axios.post(`${API_URL}/api/plates`, {
|
};
|
||||||
number: newPlate.number.toUpperCase(),
|
|
||||||
owner: newPlate.owner
|
const handleLogout = () => {
|
||||||
});
|
localStorage.removeItem('token');
|
||||||
setNewPlate({ number: '', owner: '' });
|
localStorage.removeItem('role');
|
||||||
setShowModal(false);
|
localStorage.removeItem('username');
|
||||||
fetchPlates();
|
setToken(null);
|
||||||
} catch (err) {
|
setUserRole(null);
|
||||||
alert('Error adding plate: ' + err.message);
|
setUsername(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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 />;
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
};
|
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 (
|
return (
|
||||||
<span className={`px-3 py-1 rounded-full text-xs font-medium border ${colors[status] || colors.UNKNOWN}`}>
|
<BrowserRouter>
|
||||||
{status}
|
<Routes>
|
||||||
</span>
|
<Route
|
||||||
);
|
path="/login"
|
||||||
};
|
element={
|
||||||
|
!token ? (
|
||||||
return (
|
<Login
|
||||||
<div className="min-h-screen bg-slate-900 text-slate-100 p-8">
|
setToken={(t) => setToken(t)}
|
||||||
{/* Modal */}
|
setUserRole={(r) => setUserRole(r)}
|
||||||
{showModal && (
|
setUsername={(u) => setUsername(u)} // Adding this prop to Login might be needed
|
||||||
<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">
|
<Navigate to={userRole === 'ADMIN' ? '/admin' : '/user'} replace />
|
||||||
{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
|
<Route
|
||||||
|
path="/admin"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute allowedRoles={['ADMIN']}>
|
||||||
|
<div className="relative">
|
||||||
|
<button onClick={handleLogout} className="absolute top-4 right-4 text-slate-400 hover:text-white z-50">Logout</button>
|
||||||
|
<AdminDashboard token={token} />
|
||||||
|
</div>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/user"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute allowedRoles={['USER', 'ADMIN']}> {/* Admin can view user db too usually, but separate for now */}
|
||||||
|
<div className="relative">
|
||||||
|
<button onClick={handleLogout} className="absolute top-4 right-4 text-slate-400 hover:text-white z-50">Logout</button>
|
||||||
|
<UserDashboard token={token} username={username} />
|
||||||
|
</div>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route path="/" element={<Navigate to="/login" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
|
|||||||
24
frontend/src/components/LanguageSelector.jsx
Normal file
24
frontend/src/components/LanguageSelector.jsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Globe } from 'lucide-react';
|
||||||
|
|
||||||
|
function LanguageSelector() {
|
||||||
|
const { i18n } = useTranslation();
|
||||||
|
|
||||||
|
const toggleLanguage = () => {
|
||||||
|
const newLang = i18n.language === 'es' ? 'en' : 'es';
|
||||||
|
i18n.changeLanguage(newLang);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={toggleLanguage}
|
||||||
|
className="flex items-center gap-2 px-3 py-1 bg-slate-700 hover:bg-slate-600 rounded-md text-sm text-slate-200 transition-colors"
|
||||||
|
title="Switch Language / Cambiar Idioma"
|
||||||
|
>
|
||||||
|
<Globe size={16} />
|
||||||
|
<span className="font-bold uppercase">{i18n.language}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LanguageSelector;
|
||||||
143
frontend/src/i18n.js
Normal file
143
frontend/src/i18n.js
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import i18n from 'i18next';
|
||||||
|
import { initReactI18next } from 'react-i18next';
|
||||||
|
|
||||||
|
// Translations
|
||||||
|
const resources = {
|
||||||
|
en: {
|
||||||
|
translation: {
|
||||||
|
// General
|
||||||
|
"title": "VigIA Control",
|
||||||
|
"logout": "Logout",
|
||||||
|
"login_title": "Login",
|
||||||
|
"login_subtitle": "Sign in to access the control panel",
|
||||||
|
"username": "Username",
|
||||||
|
"password": "Password",
|
||||||
|
"sign_in": "Sign In",
|
||||||
|
"welcome": "Welcome",
|
||||||
|
|
||||||
|
// Admin Dashboard
|
||||||
|
"monitor_area": "Monitor Area",
|
||||||
|
"plate_approvals": "Plate Approvals",
|
||||||
|
"user_management": "User Management",
|
||||||
|
"visitor_approvals": "Visitor Approvals",
|
||||||
|
"pending_approvals": "Pending Approvals",
|
||||||
|
"approve": "Approve",
|
||||||
|
"deny": "Deny",
|
||||||
|
"all_plates": "All Registered Plates",
|
||||||
|
"create_user": "Create New User",
|
||||||
|
"user_list": "User List",
|
||||||
|
"delete": "Delete",
|
||||||
|
|
||||||
|
// User Dashboard
|
||||||
|
"my_plates_title": "My Registered Plates",
|
||||||
|
"register_plate": "Register New Plate",
|
||||||
|
"plate_number": "Plate Number (e.g. AA-BB-11)",
|
||||||
|
"owner_desc": "Owner / Description",
|
||||||
|
"submit_plate": "Register Plate",
|
||||||
|
"my_visitors_title": "My Visitors",
|
||||||
|
"register_visitor": "Register Visitor",
|
||||||
|
"visitor_rut": "RUT (12345678-9)",
|
||||||
|
"full_name": "Full Name",
|
||||||
|
"submit_visitor": "Register Visitor",
|
||||||
|
"access_days": "Access Duration",
|
||||||
|
"1_day": "1 Day Access",
|
||||||
|
"2_days": "2 Days Access",
|
||||||
|
"1_week": "1 Week Access",
|
||||||
|
|
||||||
|
// Status
|
||||||
|
"active": "ACTIVE",
|
||||||
|
"pending": "PENDING",
|
||||||
|
"denied": "DENIED",
|
||||||
|
"approved": "APPROVED",
|
||||||
|
|
||||||
|
// Messages
|
||||||
|
"confirm_delete_plate": "Are you sure you want to delete this plate?",
|
||||||
|
"confirm_delete_visitor": "Are you sure you want to delete this visitor?",
|
||||||
|
|
||||||
|
// Visitor Check
|
||||||
|
"visitor_check": "Visitor Check",
|
||||||
|
"check_status": "Check Status",
|
||||||
|
"enter_rut": "Enter RUT",
|
||||||
|
"visitor_found": "Visitor Found",
|
||||||
|
"visitor_not_found": "Visitor Not Found",
|
||||||
|
"access_granted_until": "Access granted until",
|
||||||
|
"registered_by": "Registered by",
|
||||||
|
"no_record": "No record found for this RUT."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
es: {
|
||||||
|
translation: {
|
||||||
|
// General
|
||||||
|
"title": "Control VigIA",
|
||||||
|
"logout": "Cerrar sesión",
|
||||||
|
"login_title": "Iniciar Sesión",
|
||||||
|
"login_subtitle": "Ingresa para acceder al panel de control",
|
||||||
|
"username": "Nombre de usuario",
|
||||||
|
"password": "Contraseña",
|
||||||
|
"sign_in": "Ingresar",
|
||||||
|
"welcome": "Bienvenido",
|
||||||
|
|
||||||
|
// Admin Dashboard
|
||||||
|
"monitor_area": "Monitor",
|
||||||
|
"plate_approvals": "Aprobación Patentes",
|
||||||
|
"user_management": "Gestión Usuarios",
|
||||||
|
"visitor_approvals": "Aprobación Visitas",
|
||||||
|
"pending_approvals": "Aprobaciones Pendientes",
|
||||||
|
"approve": "Aprobar",
|
||||||
|
"deny": "Rechazar",
|
||||||
|
"all_plates": "Todas las Patentes",
|
||||||
|
"create_user": "Crear Usuario",
|
||||||
|
"user_list": "Lista de Usuarios",
|
||||||
|
"delete": "Eliminar",
|
||||||
|
|
||||||
|
// User Dashboard
|
||||||
|
"my_plates_title": "Mis Patentes Registradas",
|
||||||
|
"register_plate": "Registrar Nueva Patente",
|
||||||
|
"plate_number": "Patente (ej. AA-BB-11)",
|
||||||
|
"owner_desc": "Propietario / Descripción",
|
||||||
|
"submit_plate": "Registrar Patente",
|
||||||
|
"my_visitors_title": "Mis Visitas",
|
||||||
|
"register_visitor": "Registrar Visita",
|
||||||
|
"visitor_rut": "RUT (12345678-9)",
|
||||||
|
"full_name": "Nombre Completo",
|
||||||
|
"submit_visitor": "Registrar Visita",
|
||||||
|
"access_days": "Duración Acceso",
|
||||||
|
"1_day": "Acceso 1 Día",
|
||||||
|
"2_days": "Acceso 2 Días",
|
||||||
|
"1_week": "Acceso 1 Semana",
|
||||||
|
|
||||||
|
// Status
|
||||||
|
"active": "ACTIVO",
|
||||||
|
"pending": "PENDIENTE",
|
||||||
|
"denied": "DENEGADO",
|
||||||
|
"approved": "APROBADO",
|
||||||
|
|
||||||
|
// Messages
|
||||||
|
"confirm_delete_plate": "¿Estás seguro de que quieres eliminar esta patente?",
|
||||||
|
"confirm_delete_visitor": "¿Estás seguro de que quieres eliminar esta visita?",
|
||||||
|
|
||||||
|
// Visitor Check
|
||||||
|
"visitor_check": "Consultar Visita",
|
||||||
|
"check_status": "Verificar Estado",
|
||||||
|
"enter_rut": "Ingresar RUT",
|
||||||
|
"visitor_found": "Visita Encontrada",
|
||||||
|
"visitor_not_found": "Visita No Encontrada",
|
||||||
|
"access_granted_until": "Acceso permitido hasta",
|
||||||
|
"registered_by": "Registrado por",
|
||||||
|
"no_record": "No se encontró registro para este RUT."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
i18n
|
||||||
|
.use(initReactI18next)
|
||||||
|
.init({
|
||||||
|
resources,
|
||||||
|
lng: "es", // Default language
|
||||||
|
fallbackLng: "en",
|
||||||
|
interpolation: {
|
||||||
|
escapeValue: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default i18n;
|
||||||
464
frontend/src/pages/AdminDashboard.jsx
Normal file
464
frontend/src/pages/AdminDashboard.jsx
Normal file
@@ -0,0 +1,464 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
|
||||||
86
frontend/src/pages/Login.jsx
Normal file
86
frontend/src/pages/Login.jsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { Lock, User } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import LanguageSelector from '../components/LanguageSelector';
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL || '';
|
||||||
|
|
||||||
|
function Login({ setToken, setUserRole, setUsername }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [formData, setFormData] = useState({ username: '', password: '' });
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
const res = await axios.post(`${API_URL}/api/auth/login`, formData);
|
||||||
|
localStorage.setItem('token', res.data.token);
|
||||||
|
localStorage.setItem('role', res.data.role);
|
||||||
|
localStorage.setItem('username', res.data.username);
|
||||||
|
|
||||||
|
setToken(res.data.token);
|
||||||
|
setUserRole(res.data.role);
|
||||||
|
setUsername(res.data.username);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.error || 'Login failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-slate-950 relative">
|
||||||
|
<div className="absolute top-4 right-4">
|
||||||
|
<LanguageSelector />
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-900 p-8 rounded-2xl shadow-2xl w-96 border border-slate-800">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-3xl font-bold bg-gradient-to-r from-blue-500 to-purple-500 bg-clip-text text-transparent">
|
||||||
|
{t('title')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-slate-400 mt-2">{t('login_subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-500/10 border border-red-500 text-red-500 p-3 rounded mb-4 text-center">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-400 mb-1">{t('username')}</label>
|
||||||
|
<div className="relative">
|
||||||
|
<User className="absolute left-3 top-3 text-slate-500" size={20} />
|
||||||
|
<input
|
||||||
|
className="w-full bg-slate-950 border border-slate-700 rounded-lg py-2.5 pl-10 text-white focus:ring-2 focus:ring-blue-500 outline-none"
|
||||||
|
placeholder="admin"
|
||||||
|
value={formData.username}
|
||||||
|
onChange={e => setFormData({ ...formData, username: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-400 mb-1">{t('password')}</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="absolute left-3 top-3 text-slate-500" size={20} />
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="w-full bg-slate-950 border border-slate-700 rounded-lg py-2.5 pl-10 text-white focus:ring-2 focus:ring-blue-500 outline-none"
|
||||||
|
placeholder="••••••"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={e => setFormData({ ...formData, password: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="w-full bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-500 hover:to-purple-500 py-3 rounded-lg font-bold shadow-lg shadow-blue-500/20 transition-all">
|
||||||
|
{t('sign_in')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Login;
|
||||||
272
frontend/src/pages/UserDashboard.jsx
Normal file
272
frontend/src/pages/UserDashboard.jsx
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { Car, Clock, CheckCircle, AlertCircle, Users, Trash2, PlusCircle, UserPlus, AlertTriangle } from 'lucide-react';
|
||||||
|
import io from 'socket.io-client';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import LanguageSelector from '../components/LanguageSelector';
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL || '';
|
||||||
|
const socket = io(API_URL);
|
||||||
|
|
||||||
|
function UserDashboard({ token, username }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [plates, setPlates] = useState([]);
|
||||||
|
const [newPlate, setNewPlate] = useState({ number: '', owner: '' });
|
||||||
|
const [detections, setDetections] = useState([]);
|
||||||
|
const [people, setPeople] = useState([]);
|
||||||
|
const [newPerson, setNewPerson] = useState({ name: '', rut: '', durationDays: 1 });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPlates();
|
||||||
|
fetchPeople();
|
||||||
|
|
||||||
|
// Listen for live detections (optional, maybe user wants to see their plates detected?)
|
||||||
|
// For now, let's show all global detections but emphasize this is a "Portal"
|
||||||
|
socket.on('new_detection', (data) => {
|
||||||
|
setDetections(prev => [data, ...prev].slice(0, 5));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Real-time status updates
|
||||||
|
socket.on('new_plate_registered', () => fetchPlates()); // Sync when plate added
|
||||||
|
socket.on('plate_status_updated', () => fetchPlates());
|
||||||
|
socket.on('people_updated', () => fetchPeople());
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.off('new_detection');
|
||||||
|
socket.off('new_plate_registered');
|
||||||
|
socket.off('plate_status_updated');
|
||||||
|
socket.off('people_updated');
|
||||||
|
};
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
const fetchPlates = async () => {
|
||||||
|
try {
|
||||||
|
const res = await axios.get(`${API_URL}/api/plates`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
// Backend already filters by user, so use data directly
|
||||||
|
setPlates(res.data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchPeople = async () => {
|
||||||
|
try {
|
||||||
|
const res = await axios.get(`${API_URL}/api/people`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
setPeople(res.data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRegister = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
await axios.post(`${API_URL}/api/plates`, {
|
||||||
|
number: newPlate.number.toUpperCase(),
|
||||||
|
owner: newPlate.owner
|
||||||
|
}, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
setNewPlate({ number: '', owner: '' });
|
||||||
|
fetchPlates();
|
||||||
|
// Alert removed for better UX
|
||||||
|
} catch (err) {
|
||||||
|
alert('Error: ' + err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddPerson = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
await axios.post(`${API_URL}/api/people`, newPerson, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
setNewPerson({ name: '', rut: '', durationDays: 1 });
|
||||||
|
fetchPeople();
|
||||||
|
// Alert removed for better UX
|
||||||
|
} catch (err) {
|
||||||
|
alert('Error: ' + (err.response?.data?.error || err.message));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeletePlate = async (id) => {
|
||||||
|
if (!confirm(t('confirm_delete_plate'))) return;
|
||||||
|
try {
|
||||||
|
await axios.delete(`${API_URL}/api/plates/${id}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
fetchPlates();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.response?.data?.error || err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeletePerson = async (id) => {
|
||||||
|
if (!confirm(t('confirm_delete_visitor'))) return;
|
||||||
|
try {
|
||||||
|
await axios.delete(`${API_URL}/api/people/${id}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
fetchPeople();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.response?.data?.error || err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-900 text-slate-100 p-8">
|
||||||
|
<div className="max-w-6xl mx-auto space-y-8">
|
||||||
|
|
||||||
|
<header className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold bg-gradient-to-r from-blue-400 to-cyan-300 bg-clip-text text-transparent mb-2">
|
||||||
|
{t('welcome')}, {username}
|
||||||
|
</h1>
|
||||||
|
<p className="text-slate-400">Manage your vehicles and visitors.</p>
|
||||||
|
</div>
|
||||||
|
<LanguageSelector />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
|
{/* Plate Registration Form (Existing) */}
|
||||||
|
<div className="bg-slate-800 rounded-2xl p-6 border border-slate-700">
|
||||||
|
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
|
||||||
|
<PlusCircle className="text-blue-500" /> {t('register_plate')}
|
||||||
|
</h2>
|
||||||
|
<form onSubmit={handleRegister} className="space-y-4">
|
||||||
|
<input
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 font-mono text-lg"
|
||||||
|
placeholder={t('plate_number')}
|
||||||
|
value={newPlate.number}
|
||||||
|
onChange={e => setNewPlate({ ...newPlate, number: e.target.value.toUpperCase() })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3"
|
||||||
|
placeholder={t('owner_desc')}
|
||||||
|
value={newPlate.owner}
|
||||||
|
onChange={e => setNewPlate({ ...newPlate, owner: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button className="w-full py-3 bg-blue-600 hover:bg-blue-500 rounded-lg font-bold">{t('submit_plate')}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Visitor Registration Form (Existing) */}
|
||||||
|
<div className="bg-slate-800 rounded-2xl p-6 border border-slate-700">
|
||||||
|
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
|
||||||
|
<UserPlus className="text-purple-500" /> {t('register_visitor')}
|
||||||
|
</h2>
|
||||||
|
<form onSubmit={handleAddPerson} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<input
|
||||||
|
className="bg-slate-900 border border-slate-700 rounded-lg p-3"
|
||||||
|
placeholder={t('visitor_rut')}
|
||||||
|
value={newPerson.rut}
|
||||||
|
onChange={e => setNewPerson({ ...newPerson, rut: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className="bg-slate-900 border border-slate-700 rounded-lg p-3"
|
||||||
|
value={newPerson.durationDays}
|
||||||
|
onChange={e => setNewPerson({ ...newPerson, durationDays: parseInt(e.target.value) })}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="1">{t('1_day')}</option>
|
||||||
|
<option value="2">{t('2_days')}</option>
|
||||||
|
<option value="7">{t('1_week')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3"
|
||||||
|
placeholder={t('full_name')}
|
||||||
|
value={newPerson.name}
|
||||||
|
onChange={e => setNewPerson({ ...newPerson, name: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button className="w-full py-3 bg-purple-600 hover:bg-purple-500 rounded-lg font-bold">{t('submit_visitor')}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* My Plates List */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold mb-4">{t('my_plates_title')}</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{plates.length === 0 && <p className="text-slate-500 col-span-3">No plates registered.</p>}
|
||||||
|
{plates.map(plate => (
|
||||||
|
<div key={plate.id} className="flex items-center justify-between p-4 bg-slate-900 rounded-xl border border-slate-700 group">
|
||||||
|
<div>
|
||||||
|
<div className="font-mono text-xl font-bold tracking-wider">{plate.number}</div>
|
||||||
|
<div className="text-sm text-slate-400">{plate.owner}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{plate.status === 'ALLOWED' && (
|
||||||
|
<span className="flex items-center gap-1 text-green-400 bg-green-900/30 px-3 py-1 rounded-full text-xs font-bold">
|
||||||
|
<CheckCircle size={14} /> {t('active')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{plate.status === 'PENDING' && (
|
||||||
|
<span className="flex items-center gap-1 text-yellow-400 bg-yellow-900/30 px-3 py-1 rounded-full text-xs font-bold">
|
||||||
|
<Clock size={14} /> {t('pending')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{plate.status === 'DENIED' && (
|
||||||
|
<span className="flex items-center gap-1 text-red-400 bg-red-900/30 px-3 py-1 rounded-full text-xs font-bold">
|
||||||
|
<AlertTriangle size={14} /> {t('denied')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeletePlate(plate.id)}
|
||||||
|
className="text-slate-600 hover:text-red-500 transition-colors p-1"
|
||||||
|
title={t('delete')}
|
||||||
|
>
|
||||||
|
<Trash2 size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* My Visitors List */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold mb-4">{t('my_visitors_title')}</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{people.length === 0 && <p className="text-slate-500 col-span-3">No visitors registered.</p>}
|
||||||
|
{people.map(p => (
|
||||||
|
<div key={p.id} className="flex items-center justify-between p-4 bg-slate-900 rounded-xl border border-slate-700">
|
||||||
|
<div>
|
||||||
|
<div className="font-bold">{p.name}</div>
|
||||||
|
<div className="text-sm text-slate-400 font-mono">{p.rut}</div>
|
||||||
|
<div className="text-xs text-slate-500 mt-1">Until: {new Date(p.endDate).toLocaleDateString()}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className={`px-2 py-1 rounded text-xs font-bold ${p.status === 'APPROVED' ? 'bg-green-900 text-green-300' :
|
||||||
|
p.status === 'DENIED' ? 'bg-red-900 text-red-300' : 'bg-yellow-900 text-yellow-300'
|
||||||
|
}`}>
|
||||||
|
{p.status === 'APPROVED' ? t('approved') : p.status === 'DENIED' ? t('denied') : t('pending')}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeletePerson(p.id)}
|
||||||
|
className="text-slate-600 hover:text-red-500 transition-colors p-1"
|
||||||
|
title={t('delete')}
|
||||||
|
>
|
||||||
|
<Trash2 size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UserDashboard;
|
||||||
@@ -4,4 +4,21 @@ import react from '@vitejs/plugin-react'
|
|||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
allowedHosts: ['demo.v1ru5.cl'],
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://backend:3000',
|
||||||
|
changeOrigin: true
|
||||||
|
},
|
||||||
|
'/socket.io': {
|
||||||
|
target: 'http://backend:3000',
|
||||||
|
ws: true
|
||||||
|
},
|
||||||
|
'/video_feed': {
|
||||||
|
target: 'http://alpr-service:5001',
|
||||||
|
changeOrigin: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
764
get-docker.sh
Normal file
764
get-docker.sh
Normal file
@@ -0,0 +1,764 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
# Docker Engine for Linux installation script.
|
||||||
|
#
|
||||||
|
# This script is intended as a convenient way to configure docker's package
|
||||||
|
# repositories and to install Docker Engine, This script is not recommended
|
||||||
|
# for production environments. Before running this script, make yourself familiar
|
||||||
|
# with potential risks and limitations, and refer to the installation manual
|
||||||
|
# at https://docs.docker.com/engine/install/ for alternative installation methods.
|
||||||
|
#
|
||||||
|
# The script:
|
||||||
|
#
|
||||||
|
# - Requires `root` or `sudo` privileges to run.
|
||||||
|
# - Attempts to detect your Linux distribution and version and configure your
|
||||||
|
# package management system for you.
|
||||||
|
# - Doesn't allow you to customize most installation parameters.
|
||||||
|
# - Installs dependencies and recommendations without asking for confirmation.
|
||||||
|
# - Installs the latest stable release (by default) of Docker CLI, Docker Engine,
|
||||||
|
# Docker Buildx, Docker Compose, containerd, and runc. When using this script
|
||||||
|
# to provision a machine, this may result in unexpected major version upgrades
|
||||||
|
# of these packages. Always test upgrades in a test environment before
|
||||||
|
# deploying to your production systems.
|
||||||
|
# - Isn't designed to upgrade an existing Docker installation. When using the
|
||||||
|
# script to update an existing installation, dependencies may not be updated
|
||||||
|
# to the expected version, resulting in outdated versions.
|
||||||
|
#
|
||||||
|
# Source code is available at https://github.com/docker/docker-install/
|
||||||
|
#
|
||||||
|
# Usage
|
||||||
|
# ==============================================================================
|
||||||
|
#
|
||||||
|
# To install the latest stable versions of Docker CLI, Docker Engine, and their
|
||||||
|
# dependencies:
|
||||||
|
#
|
||||||
|
# 1. download the script
|
||||||
|
#
|
||||||
|
# $ curl -fsSL https://get.docker.com -o install-docker.sh
|
||||||
|
#
|
||||||
|
# 2. verify the script's content
|
||||||
|
#
|
||||||
|
# $ cat install-docker.sh
|
||||||
|
#
|
||||||
|
# 3. run the script with --dry-run to verify the steps it executes
|
||||||
|
#
|
||||||
|
# $ sh install-docker.sh --dry-run
|
||||||
|
#
|
||||||
|
# 4. run the script either as root, or using sudo to perform the installation.
|
||||||
|
#
|
||||||
|
# $ sudo sh install-docker.sh
|
||||||
|
#
|
||||||
|
# Command-line options
|
||||||
|
# ==============================================================================
|
||||||
|
#
|
||||||
|
# --version <VERSION>
|
||||||
|
# Use the --version option to install a specific version, for example:
|
||||||
|
#
|
||||||
|
# $ sudo sh install-docker.sh --version 23.0
|
||||||
|
#
|
||||||
|
# --channel <stable|test>
|
||||||
|
#
|
||||||
|
# Use the --channel option to install from an alternative installation channel.
|
||||||
|
# The following example installs the latest versions from the "test" channel,
|
||||||
|
# which includes pre-releases (alpha, beta, rc):
|
||||||
|
#
|
||||||
|
# $ sudo sh install-docker.sh --channel test
|
||||||
|
#
|
||||||
|
# Alternatively, use the script at https://test.docker.com, which uses the test
|
||||||
|
# channel as default.
|
||||||
|
#
|
||||||
|
# --mirror <Aliyun|AzureChinaCloud>
|
||||||
|
#
|
||||||
|
# Use the --mirror option to install from a mirror supported by this script.
|
||||||
|
# Available mirrors are "Aliyun" (https://mirrors.aliyun.com/docker-ce), and
|
||||||
|
# "AzureChinaCloud" (https://mirror.azure.cn/docker-ce), for example:
|
||||||
|
#
|
||||||
|
# $ sudo sh install-docker.sh --mirror AzureChinaCloud
|
||||||
|
#
|
||||||
|
# --setup-repo
|
||||||
|
#
|
||||||
|
# Use the --setup-repo option to configure Docker's package repositories without
|
||||||
|
# installing Docker packages. This is useful when you want to add the repository
|
||||||
|
# but install packages separately:
|
||||||
|
#
|
||||||
|
# $ sudo sh install-docker.sh --setup-repo
|
||||||
|
#
|
||||||
|
# Automatic Service Start
|
||||||
|
#
|
||||||
|
# By default, this script automatically starts the Docker daemon and enables the docker
|
||||||
|
# service after installation if systemd is used as init.
|
||||||
|
#
|
||||||
|
# If you prefer to start the service manually, use the --no-autostart option:
|
||||||
|
#
|
||||||
|
# $ sudo sh install-docker.sh --no-autostart
|
||||||
|
#
|
||||||
|
# Note: Starting the service requires appropriate privileges to manage system services.
|
||||||
|
#
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
# Git commit from https://github.com/docker/docker-install when
|
||||||
|
# the script was uploaded (Should only be modified by upload job):
|
||||||
|
SCRIPT_COMMIT_SHA="8b33a64d28ec86a1121623f1d349801b48f2837b"
|
||||||
|
|
||||||
|
# strip "v" prefix if present
|
||||||
|
VERSION="${VERSION#v}"
|
||||||
|
|
||||||
|
# The channel to install from:
|
||||||
|
# * stable
|
||||||
|
# * test
|
||||||
|
DEFAULT_CHANNEL_VALUE="stable"
|
||||||
|
if [ -z "$CHANNEL" ]; then
|
||||||
|
CHANNEL=$DEFAULT_CHANNEL_VALUE
|
||||||
|
fi
|
||||||
|
|
||||||
|
DEFAULT_DOWNLOAD_URL="https://download.docker.com"
|
||||||
|
if [ -z "$DOWNLOAD_URL" ]; then
|
||||||
|
DOWNLOAD_URL=$DEFAULT_DOWNLOAD_URL
|
||||||
|
fi
|
||||||
|
|
||||||
|
DEFAULT_REPO_FILE="docker-ce.repo"
|
||||||
|
if [ -z "$REPO_FILE" ]; then
|
||||||
|
REPO_FILE="$DEFAULT_REPO_FILE"
|
||||||
|
# Automatically default to a staging repo fora
|
||||||
|
# a staging download url (download-stage.docker.com)
|
||||||
|
case "$DOWNLOAD_URL" in
|
||||||
|
*-stage*) REPO_FILE="docker-ce-staging.repo";;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
mirror=''
|
||||||
|
DRY_RUN=${DRY_RUN:-}
|
||||||
|
REPO_ONLY=${REPO_ONLY:-0}
|
||||||
|
NO_AUTOSTART=${NO_AUTOSTART:-0}
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--channel)
|
||||||
|
CHANNEL="$2"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--dry-run)
|
||||||
|
DRY_RUN=1
|
||||||
|
;;
|
||||||
|
--mirror)
|
||||||
|
mirror="$2"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--version)
|
||||||
|
VERSION="${2#v}"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--setup-repo)
|
||||||
|
REPO_ONLY=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--no-autostart)
|
||||||
|
NO_AUTOSTART=1
|
||||||
|
;;
|
||||||
|
--*)
|
||||||
|
echo "Illegal option $1"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
shift $(( $# > 0 ? 1 : 0 ))
|
||||||
|
done
|
||||||
|
|
||||||
|
case "$mirror" in
|
||||||
|
Aliyun)
|
||||||
|
DOWNLOAD_URL="https://mirrors.aliyun.com/docker-ce"
|
||||||
|
;;
|
||||||
|
AzureChinaCloud)
|
||||||
|
DOWNLOAD_URL="https://mirror.azure.cn/docker-ce"
|
||||||
|
;;
|
||||||
|
"")
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
>&2 echo "unknown mirror '$mirror': use either 'Aliyun', or 'AzureChinaCloud'."
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
case "$CHANNEL" in
|
||||||
|
stable|test)
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
>&2 echo "unknown CHANNEL '$CHANNEL': use either stable or test."
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
command_exists() {
|
||||||
|
command -v "$@" > /dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
# version_gte checks if the version specified in $VERSION is at least the given
|
||||||
|
# SemVer (Maj.Minor[.Patch]), or CalVer (YY.MM) version.It returns 0 (success)
|
||||||
|
# if $VERSION is either unset (=latest) or newer or equal than the specified
|
||||||
|
# version, or returns 1 (fail) otherwise.
|
||||||
|
#
|
||||||
|
# examples:
|
||||||
|
#
|
||||||
|
# VERSION=23.0
|
||||||
|
# version_gte 23.0 // 0 (success)
|
||||||
|
# version_gte 20.10 // 0 (success)
|
||||||
|
# version_gte 19.03 // 0 (success)
|
||||||
|
# version_gte 26.1 // 1 (fail)
|
||||||
|
version_gte() {
|
||||||
|
if [ -z "$VERSION" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
version_compare "$VERSION" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# version_compare compares two version strings (either SemVer (Major.Minor.Path),
|
||||||
|
# or CalVer (YY.MM) version strings. It returns 0 (success) if version A is newer
|
||||||
|
# or equal than version B, or 1 (fail) otherwise. Patch releases and pre-release
|
||||||
|
# (-alpha/-beta) are not taken into account
|
||||||
|
#
|
||||||
|
# examples:
|
||||||
|
#
|
||||||
|
# version_compare 23.0.0 20.10 // 0 (success)
|
||||||
|
# version_compare 23.0 20.10 // 0 (success)
|
||||||
|
# version_compare 20.10 19.03 // 0 (success)
|
||||||
|
# version_compare 20.10 20.10 // 0 (success)
|
||||||
|
# version_compare 19.03 20.10 // 1 (fail)
|
||||||
|
version_compare() (
|
||||||
|
set +x
|
||||||
|
|
||||||
|
yy_a="$(echo "$1" | cut -d'.' -f1)"
|
||||||
|
yy_b="$(echo "$2" | cut -d'.' -f1)"
|
||||||
|
if [ "$yy_a" -lt "$yy_b" ]; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [ "$yy_a" -gt "$yy_b" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
mm_a="$(echo "$1" | cut -d'.' -f2)"
|
||||||
|
mm_b="$(echo "$2" | cut -d'.' -f2)"
|
||||||
|
|
||||||
|
# trim leading zeros to accommodate CalVer
|
||||||
|
mm_a="${mm_a#0}"
|
||||||
|
mm_b="${mm_b#0}"
|
||||||
|
|
||||||
|
if [ "${mm_a:-0}" -lt "${mm_b:-0}" ]; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 0
|
||||||
|
)
|
||||||
|
|
||||||
|
is_dry_run() {
|
||||||
|
if [ -z "$DRY_RUN" ]; then
|
||||||
|
return 1
|
||||||
|
else
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
is_wsl() {
|
||||||
|
case "$(uname -r)" in
|
||||||
|
*microsoft* ) true ;; # WSL 2
|
||||||
|
*Microsoft* ) true ;; # WSL 1
|
||||||
|
* ) false;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
is_darwin() {
|
||||||
|
case "$(uname -s)" in
|
||||||
|
*darwin* ) true ;;
|
||||||
|
*Darwin* ) true ;;
|
||||||
|
* ) false;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
deprecation_notice() {
|
||||||
|
distro=$1
|
||||||
|
distro_version=$2
|
||||||
|
echo
|
||||||
|
printf "\033[91;1mDEPRECATION WARNING\033[0m\n"
|
||||||
|
printf " This Linux distribution (\033[1m%s %s\033[0m) reached end-of-life and is no longer supported by this script.\n" "$distro" "$distro_version"
|
||||||
|
echo " No updates or security fixes will be released for this distribution, and users are recommended"
|
||||||
|
echo " to upgrade to a currently maintained version of $distro."
|
||||||
|
echo
|
||||||
|
printf "Press \033[1mCtrl+C\033[0m now to abort this script, or wait for the installation to continue."
|
||||||
|
echo
|
||||||
|
sleep 10
|
||||||
|
}
|
||||||
|
|
||||||
|
get_distribution() {
|
||||||
|
lsb_dist=""
|
||||||
|
# Every system that we officially support has /etc/os-release
|
||||||
|
if [ -r /etc/os-release ]; then
|
||||||
|
lsb_dist="$(. /etc/os-release && echo "$ID")"
|
||||||
|
fi
|
||||||
|
# Returning an empty string here should be alright since the
|
||||||
|
# case statements don't act unless you provide an actual value
|
||||||
|
echo "$lsb_dist"
|
||||||
|
}
|
||||||
|
|
||||||
|
start_docker_daemon() {
|
||||||
|
# Use systemctl if available (for systemd-based systems)
|
||||||
|
if command_exists systemctl; then
|
||||||
|
is_dry_run || >&2 echo "Using systemd to manage Docker service"
|
||||||
|
if (
|
||||||
|
is_dry_run || set -x
|
||||||
|
$sh_c systemctl enable --now docker.service 2>/dev/null
|
||||||
|
); then
|
||||||
|
is_dry_run || echo "INFO: Docker daemon enabled and started" >&2
|
||||||
|
else
|
||||||
|
is_dry_run || echo "WARNING: unable to enable the docker service" >&2
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# No service management available (container environment)
|
||||||
|
if ! is_dry_run; then
|
||||||
|
>&2 echo "Note: Running in a container environment without service management"
|
||||||
|
>&2 echo "Docker daemon cannot be started automatically in this environment"
|
||||||
|
>&2 echo "The Docker packages have been installed successfully"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
>&2 echo
|
||||||
|
}
|
||||||
|
|
||||||
|
echo_docker_as_nonroot() {
|
||||||
|
if is_dry_run; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if command_exists docker && [ -e /var/run/docker.sock ]; then
|
||||||
|
(
|
||||||
|
set -x
|
||||||
|
$sh_c 'docker version'
|
||||||
|
) || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# intentionally mixed spaces and tabs here -- tabs are stripped by "<<-EOF", spaces are kept in the output
|
||||||
|
echo
|
||||||
|
echo "================================================================================"
|
||||||
|
echo
|
||||||
|
if version_gte "20.10"; then
|
||||||
|
echo "To run Docker as a non-privileged user, consider setting up the"
|
||||||
|
echo "Docker daemon in rootless mode for your user:"
|
||||||
|
echo
|
||||||
|
echo " dockerd-rootless-setuptool.sh install"
|
||||||
|
echo
|
||||||
|
echo "Visit https://docs.docker.com/go/rootless/ to learn about rootless mode."
|
||||||
|
echo
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
echo "To run the Docker daemon as a fully privileged service, but granting non-root"
|
||||||
|
echo "users access, refer to https://docs.docker.com/go/daemon-access/"
|
||||||
|
echo
|
||||||
|
echo "WARNING: Access to the remote API on a privileged Docker daemon is equivalent"
|
||||||
|
echo " to root access on the host. Refer to the 'Docker daemon attack surface'"
|
||||||
|
echo " documentation for details: https://docs.docker.com/go/attack-surface/"
|
||||||
|
echo
|
||||||
|
echo "================================================================================"
|
||||||
|
echo
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if this is a forked Linux distro
|
||||||
|
check_forked() {
|
||||||
|
|
||||||
|
# Check for lsb_release command existence, it usually exists in forked distros
|
||||||
|
if command_exists lsb_release; then
|
||||||
|
# Check if the `-u` option is supported
|
||||||
|
set +e
|
||||||
|
lsb_release -a -u > /dev/null 2>&1
|
||||||
|
lsb_release_exit_code=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Check if the command has exited successfully, it means we're in a forked distro
|
||||||
|
if [ "$lsb_release_exit_code" = "0" ]; then
|
||||||
|
# Print info about current distro
|
||||||
|
cat <<-EOF
|
||||||
|
You're using '$lsb_dist' version '$dist_version'.
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Get the upstream release info
|
||||||
|
lsb_dist=$(lsb_release -a -u 2>&1 | tr '[:upper:]' '[:lower:]' | grep -E 'id' | cut -d ':' -f 2 | tr -d '[:space:]')
|
||||||
|
dist_version=$(lsb_release -a -u 2>&1 | tr '[:upper:]' '[:lower:]' | grep -E 'codename' | cut -d ':' -f 2 | tr -d '[:space:]')
|
||||||
|
|
||||||
|
# Print info about upstream distro
|
||||||
|
cat <<-EOF
|
||||||
|
Upstream release is '$lsb_dist' version '$dist_version'.
|
||||||
|
EOF
|
||||||
|
else
|
||||||
|
if [ -r /etc/debian_version ] && [ "$lsb_dist" != "ubuntu" ] && [ "$lsb_dist" != "raspbian" ]; then
|
||||||
|
if [ "$lsb_dist" = "osmc" ]; then
|
||||||
|
# OSMC runs Raspbian
|
||||||
|
lsb_dist=raspbian
|
||||||
|
else
|
||||||
|
# We're Debian and don't even know it!
|
||||||
|
lsb_dist=debian
|
||||||
|
fi
|
||||||
|
dist_version="$(sed 's/\/.*//' /etc/debian_version | sed 's/\..*//')"
|
||||||
|
case "$dist_version" in
|
||||||
|
13)
|
||||||
|
dist_version="trixie"
|
||||||
|
;;
|
||||||
|
12)
|
||||||
|
dist_version="bookworm"
|
||||||
|
;;
|
||||||
|
11)
|
||||||
|
dist_version="bullseye"
|
||||||
|
;;
|
||||||
|
10)
|
||||||
|
dist_version="buster"
|
||||||
|
;;
|
||||||
|
9)
|
||||||
|
dist_version="stretch"
|
||||||
|
;;
|
||||||
|
8)
|
||||||
|
dist_version="jessie"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
do_install() {
|
||||||
|
echo "# Executing docker install script, commit: $SCRIPT_COMMIT_SHA"
|
||||||
|
|
||||||
|
if command_exists docker; then
|
||||||
|
cat >&2 <<-'EOF'
|
||||||
|
Warning: the "docker" command appears to already exist on this system.
|
||||||
|
|
||||||
|
If you already have Docker installed, this script can cause trouble, which is
|
||||||
|
why we're displaying this warning and provide the opportunity to cancel the
|
||||||
|
installation.
|
||||||
|
|
||||||
|
If you installed the current Docker package using this script and are using it
|
||||||
|
again to update Docker, you can ignore this message, but be aware that the
|
||||||
|
script resets any custom changes in the deb and rpm repo configuration
|
||||||
|
files to match the parameters passed to the script.
|
||||||
|
|
||||||
|
You may press Ctrl+C now to abort this script.
|
||||||
|
EOF
|
||||||
|
( set -x; sleep 20 )
|
||||||
|
fi
|
||||||
|
|
||||||
|
user="$(id -un 2>/dev/null || true)"
|
||||||
|
|
||||||
|
sh_c='sh -c'
|
||||||
|
if [ "$user" != 'root' ]; then
|
||||||
|
if command_exists sudo; then
|
||||||
|
sh_c='sudo -E sh -c'
|
||||||
|
elif command_exists su; then
|
||||||
|
sh_c='su -c'
|
||||||
|
else
|
||||||
|
cat >&2 <<-'EOF'
|
||||||
|
Error: this installer needs the ability to run commands as root.
|
||||||
|
We are unable to find either "sudo" or "su" available to make this happen.
|
||||||
|
EOF
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if is_dry_run; then
|
||||||
|
sh_c="echo"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# perform some very rudimentary platform detection
|
||||||
|
lsb_dist=$( get_distribution )
|
||||||
|
lsb_dist="$(echo "$lsb_dist" | tr '[:upper:]' '[:lower:]')"
|
||||||
|
|
||||||
|
if is_wsl; then
|
||||||
|
echo
|
||||||
|
echo "WSL DETECTED: We recommend using Docker Desktop for Windows."
|
||||||
|
echo "Please get Docker Desktop from https://www.docker.com/products/docker-desktop/"
|
||||||
|
echo
|
||||||
|
cat >&2 <<-'EOF'
|
||||||
|
|
||||||
|
You may press Ctrl+C now to abort this script.
|
||||||
|
EOF
|
||||||
|
( set -x; sleep 20 )
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$lsb_dist" in
|
||||||
|
|
||||||
|
ubuntu)
|
||||||
|
if command_exists lsb_release; then
|
||||||
|
dist_version="$(lsb_release --codename | cut -f2)"
|
||||||
|
fi
|
||||||
|
if [ -z "$dist_version" ] && [ -r /etc/lsb-release ]; then
|
||||||
|
dist_version="$(. /etc/lsb-release && echo "$DISTRIB_CODENAME")"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
|
debian|raspbian)
|
||||||
|
dist_version="$(sed 's/\/.*//' /etc/debian_version | sed 's/\..*//')"
|
||||||
|
case "$dist_version" in
|
||||||
|
13)
|
||||||
|
dist_version="trixie"
|
||||||
|
;;
|
||||||
|
12)
|
||||||
|
dist_version="bookworm"
|
||||||
|
;;
|
||||||
|
11)
|
||||||
|
dist_version="bullseye"
|
||||||
|
;;
|
||||||
|
10)
|
||||||
|
dist_version="buster"
|
||||||
|
;;
|
||||||
|
9)
|
||||||
|
dist_version="stretch"
|
||||||
|
;;
|
||||||
|
8)
|
||||||
|
dist_version="jessie"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
|
||||||
|
centos|rhel)
|
||||||
|
if [ -z "$dist_version" ] && [ -r /etc/os-release ]; then
|
||||||
|
dist_version="$(. /etc/os-release && echo "$VERSION_ID")"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
|
*)
|
||||||
|
if command_exists lsb_release; then
|
||||||
|
dist_version="$(lsb_release --release | cut -f2)"
|
||||||
|
fi
|
||||||
|
if [ -z "$dist_version" ] && [ -r /etc/os-release ]; then
|
||||||
|
dist_version="$(. /etc/os-release && echo "$VERSION_ID")"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Check if this is a forked Linux distro
|
||||||
|
check_forked
|
||||||
|
|
||||||
|
# Print deprecation warnings for distro versions that recently reached EOL,
|
||||||
|
# but may still be commonly used (especially LTS versions).
|
||||||
|
case "$lsb_dist.$dist_version" in
|
||||||
|
centos.8|centos.7|rhel.7)
|
||||||
|
deprecation_notice "$lsb_dist" "$dist_version"
|
||||||
|
;;
|
||||||
|
debian.buster|debian.stretch|debian.jessie)
|
||||||
|
deprecation_notice "$lsb_dist" "$dist_version"
|
||||||
|
;;
|
||||||
|
raspbian.buster|raspbian.stretch|raspbian.jessie)
|
||||||
|
deprecation_notice "$lsb_dist" "$dist_version"
|
||||||
|
;;
|
||||||
|
ubuntu.focal|ubuntu.bionic|ubuntu.xenial|ubuntu.trusty)
|
||||||
|
deprecation_notice "$lsb_dist" "$dist_version"
|
||||||
|
;;
|
||||||
|
ubuntu.oracular|ubuntu.mantic|ubuntu.lunar|ubuntu.kinetic|ubuntu.impish|ubuntu.hirsute|ubuntu.groovy|ubuntu.eoan|ubuntu.disco|ubuntu.cosmic)
|
||||||
|
deprecation_notice "$lsb_dist" "$dist_version"
|
||||||
|
;;
|
||||||
|
fedora.*)
|
||||||
|
if [ "$dist_version" -lt 41 ]; then
|
||||||
|
deprecation_notice "$lsb_dist" "$dist_version"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Run setup for each distro accordingly
|
||||||
|
case "$lsb_dist" in
|
||||||
|
ubuntu|debian|raspbian)
|
||||||
|
pre_reqs="ca-certificates curl"
|
||||||
|
apt_repo="deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] $DOWNLOAD_URL/linux/$lsb_dist $dist_version $CHANNEL"
|
||||||
|
(
|
||||||
|
if ! is_dry_run; then
|
||||||
|
set -x
|
||||||
|
fi
|
||||||
|
$sh_c 'apt-get -qq update >/dev/null'
|
||||||
|
$sh_c "DEBIAN_FRONTEND=noninteractive apt-get -y -qq install $pre_reqs >/dev/null"
|
||||||
|
$sh_c 'install -m 0755 -d /etc/apt/keyrings'
|
||||||
|
$sh_c "curl -fsSL \"$DOWNLOAD_URL/linux/$lsb_dist/gpg\" -o /etc/apt/keyrings/docker.asc"
|
||||||
|
$sh_c "chmod a+r /etc/apt/keyrings/docker.asc"
|
||||||
|
$sh_c "echo \"$apt_repo\" > /etc/apt/sources.list.d/docker.list"
|
||||||
|
$sh_c 'apt-get -qq update >/dev/null'
|
||||||
|
)
|
||||||
|
|
||||||
|
if [ "$REPO_ONLY" = "1" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
pkg_version=""
|
||||||
|
if [ -n "$VERSION" ]; then
|
||||||
|
if is_dry_run; then
|
||||||
|
echo "# WARNING: VERSION pinning is not supported in DRY_RUN"
|
||||||
|
else
|
||||||
|
# Will work for incomplete versions IE (17.12), but may not actually grab the "latest" if in the test channel
|
||||||
|
pkg_pattern="$(echo "$VERSION" | sed 's/-ce-/~ce~.*/g' | sed 's/-/.*/g')"
|
||||||
|
search_command="apt-cache madison docker-ce | grep '$pkg_pattern' | head -1 | awk '{\$1=\$1};1' | cut -d' ' -f 3"
|
||||||
|
pkg_version="$($sh_c "$search_command")"
|
||||||
|
echo "INFO: Searching repository for VERSION '$VERSION'"
|
||||||
|
echo "INFO: $search_command"
|
||||||
|
if [ -z "$pkg_version" ]; then
|
||||||
|
echo
|
||||||
|
echo "ERROR: '$VERSION' not found amongst apt-cache madison results"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if version_gte "18.09"; then
|
||||||
|
search_command="apt-cache madison docker-ce-cli | grep '$pkg_pattern' | head -1 | awk '{\$1=\$1};1' | cut -d' ' -f 3"
|
||||||
|
echo "INFO: $search_command"
|
||||||
|
cli_pkg_version="=$($sh_c "$search_command")"
|
||||||
|
fi
|
||||||
|
pkg_version="=$pkg_version"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
(
|
||||||
|
pkgs="docker-ce${pkg_version%=}"
|
||||||
|
if version_gte "18.09"; then
|
||||||
|
# older versions didn't ship the cli and containerd as separate packages
|
||||||
|
pkgs="$pkgs docker-ce-cli${cli_pkg_version%=} containerd.io"
|
||||||
|
fi
|
||||||
|
if version_gte "20.10"; then
|
||||||
|
pkgs="$pkgs docker-compose-plugin docker-ce-rootless-extras$pkg_version"
|
||||||
|
fi
|
||||||
|
if version_gte "23.0"; then
|
||||||
|
pkgs="$pkgs docker-buildx-plugin"
|
||||||
|
fi
|
||||||
|
if version_gte "28.2"; then
|
||||||
|
pkgs="$pkgs docker-model-plugin"
|
||||||
|
fi
|
||||||
|
if ! is_dry_run; then
|
||||||
|
set -x
|
||||||
|
fi
|
||||||
|
$sh_c "DEBIAN_FRONTEND=noninteractive apt-get -y -qq install $pkgs >/dev/null"
|
||||||
|
)
|
||||||
|
if [ "$NO_AUTOSTART" != "1" ]; then
|
||||||
|
start_docker_daemon
|
||||||
|
fi
|
||||||
|
echo_docker_as_nonroot
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
centos|fedora|rhel)
|
||||||
|
if [ "$(uname -m)" = "s390x" ]; then
|
||||||
|
echo "Effective v27.5, please consult RHEL distro statement for s390x support."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
repo_file_url="$DOWNLOAD_URL/linux/$lsb_dist/$REPO_FILE"
|
||||||
|
(
|
||||||
|
if ! is_dry_run; then
|
||||||
|
set -x
|
||||||
|
fi
|
||||||
|
if command_exists dnf5; then
|
||||||
|
$sh_c "dnf -y -q --setopt=install_weak_deps=False install dnf-plugins-core"
|
||||||
|
$sh_c "dnf5 config-manager addrepo --overwrite --save-filename=docker-ce.repo --from-repofile='$repo_file_url'"
|
||||||
|
|
||||||
|
if [ "$CHANNEL" != "stable" ]; then
|
||||||
|
$sh_c "dnf5 config-manager setopt \"docker-ce-*.enabled=0\""
|
||||||
|
$sh_c "dnf5 config-manager setopt \"docker-ce-$CHANNEL.enabled=1\""
|
||||||
|
fi
|
||||||
|
$sh_c "dnf makecache"
|
||||||
|
elif command_exists dnf; then
|
||||||
|
$sh_c "dnf -y -q --setopt=install_weak_deps=False install dnf-plugins-core"
|
||||||
|
$sh_c "rm -f /etc/yum.repos.d/docker-ce.repo /etc/yum.repos.d/docker-ce-staging.repo"
|
||||||
|
$sh_c "dnf config-manager --add-repo $repo_file_url"
|
||||||
|
|
||||||
|
if [ "$CHANNEL" != "stable" ]; then
|
||||||
|
$sh_c "dnf config-manager --set-disabled \"docker-ce-*\""
|
||||||
|
$sh_c "dnf config-manager --set-enabled \"docker-ce-$CHANNEL\""
|
||||||
|
fi
|
||||||
|
$sh_c "dnf makecache"
|
||||||
|
else
|
||||||
|
$sh_c "yum -y -q install yum-utils"
|
||||||
|
$sh_c "rm -f /etc/yum.repos.d/docker-ce.repo /etc/yum.repos.d/docker-ce-staging.repo"
|
||||||
|
$sh_c "yum-config-manager --add-repo $repo_file_url"
|
||||||
|
|
||||||
|
if [ "$CHANNEL" != "stable" ]; then
|
||||||
|
$sh_c "yum-config-manager --disable \"docker-ce-*\""
|
||||||
|
$sh_c "yum-config-manager --enable \"docker-ce-$CHANNEL\""
|
||||||
|
fi
|
||||||
|
$sh_c "yum makecache"
|
||||||
|
fi
|
||||||
|
)
|
||||||
|
|
||||||
|
if [ "$REPO_ONLY" = "1" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
pkg_version=""
|
||||||
|
if command_exists dnf; then
|
||||||
|
pkg_manager="dnf"
|
||||||
|
pkg_manager_flags="-y -q --best"
|
||||||
|
else
|
||||||
|
pkg_manager="yum"
|
||||||
|
pkg_manager_flags="-y -q"
|
||||||
|
fi
|
||||||
|
if [ -n "$VERSION" ]; then
|
||||||
|
if is_dry_run; then
|
||||||
|
echo "# WARNING: VERSION pinning is not supported in DRY_RUN"
|
||||||
|
else
|
||||||
|
if [ "$lsb_dist" = "fedora" ]; then
|
||||||
|
pkg_suffix="fc$dist_version"
|
||||||
|
else
|
||||||
|
pkg_suffix="el"
|
||||||
|
fi
|
||||||
|
pkg_pattern="$(echo "$VERSION" | sed 's/-ce-/\\\\.ce.*/g' | sed 's/-/.*/g').*$pkg_suffix"
|
||||||
|
search_command="$pkg_manager list --showduplicates docker-ce | grep '$pkg_pattern' | tail -1 | awk '{print \$2}'"
|
||||||
|
pkg_version="$($sh_c "$search_command")"
|
||||||
|
echo "INFO: Searching repository for VERSION '$VERSION'"
|
||||||
|
echo "INFO: $search_command"
|
||||||
|
if [ -z "$pkg_version" ]; then
|
||||||
|
echo
|
||||||
|
echo "ERROR: '$VERSION' not found amongst $pkg_manager list results"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if version_gte "18.09"; then
|
||||||
|
# older versions don't support a cli package
|
||||||
|
search_command="$pkg_manager list --showduplicates docker-ce-cli | grep '$pkg_pattern' | tail -1 | awk '{print \$2}'"
|
||||||
|
cli_pkg_version="$($sh_c "$search_command" | cut -d':' -f 2)"
|
||||||
|
fi
|
||||||
|
# Cut out the epoch and prefix with a '-'
|
||||||
|
pkg_version="-$(echo "$pkg_version" | cut -d':' -f 2)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
(
|
||||||
|
pkgs="docker-ce$pkg_version"
|
||||||
|
if version_gte "18.09"; then
|
||||||
|
# older versions didn't ship the cli and containerd as separate packages
|
||||||
|
if [ -n "$cli_pkg_version" ]; then
|
||||||
|
pkgs="$pkgs docker-ce-cli-$cli_pkg_version containerd.io"
|
||||||
|
else
|
||||||
|
pkgs="$pkgs docker-ce-cli containerd.io"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if version_gte "20.10"; then
|
||||||
|
pkgs="$pkgs docker-compose-plugin docker-ce-rootless-extras$pkg_version"
|
||||||
|
fi
|
||||||
|
if version_gte "23.0"; then
|
||||||
|
pkgs="$pkgs docker-buildx-plugin docker-model-plugin"
|
||||||
|
fi
|
||||||
|
if ! is_dry_run; then
|
||||||
|
set -x
|
||||||
|
fi
|
||||||
|
$sh_c "$pkg_manager $pkg_manager_flags install $pkgs"
|
||||||
|
)
|
||||||
|
if [ "$NO_AUTOSTART" != "1" ]; then
|
||||||
|
start_docker_daemon
|
||||||
|
fi
|
||||||
|
echo_docker_as_nonroot
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
sles)
|
||||||
|
echo "Effective v27.5, please consult SLES distro statement for s390x support."
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
if [ -z "$lsb_dist" ]; then
|
||||||
|
if is_darwin; then
|
||||||
|
echo
|
||||||
|
echo "ERROR: Unsupported operating system 'macOS'"
|
||||||
|
echo "Please get Docker Desktop from https://www.docker.com/products/docker-desktop"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
echo "ERROR: Unsupported distribution '$lsb_dist'"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# wrapped up in a function so that we have some protection against only getting
|
||||||
|
# half the file during "curl | sh"
|
||||||
|
do_install
|
||||||
Reference in New Issue
Block a user