Compare commits
23 Commits
a9687711fa
...
main
| 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 |
@@ -4,8 +4,8 @@ import requests
|
||||
import os
|
||||
import time
|
||||
import threading
|
||||
import numpy as np
|
||||
import re
|
||||
from queue import Queue
|
||||
from flask import Flask, Response
|
||||
from flask_cors import CORS
|
||||
from ultralytics import YOLO
|
||||
@@ -13,147 +13,157 @@ from ultralytics import YOLO
|
||||
# Configuration
|
||||
BACKEND_URL = os.environ.get('BACKEND_URL', 'http://localhost:3000')
|
||||
CAMERA_ID = 0
|
||||
PROCESS_INTERVAL = 0.5 # Faster processing with YOLO (it's efficient)
|
||||
CONFIDENCE_THRESHOLD = 0.4
|
||||
MODEL_PATH = 'best.pt' # Expecting the model here
|
||||
PROCESS_INTERVAL = 1.5 # Más reactivo
|
||||
MODEL_PATH = 'best.pt'
|
||||
|
||||
app = Flask(__name__)
|
||||
CORS(app)
|
||||
|
||||
# Global variables
|
||||
# Shared state
|
||||
outputFrame = None
|
||||
lock = threading.Lock()
|
||||
# Store latest detections for visualization
|
||||
frame_lock = threading.Lock()
|
||||
latest_detections = []
|
||||
detection_lock = threading.Lock()
|
||||
|
||||
# Cola para procesamiento OCR asíncrono
|
||||
ocr_queue = Queue(maxsize=5)
|
||||
|
||||
def send_plate(plate_number):
|
||||
"""Envía la patente detectada al backend"""
|
||||
try:
|
||||
url = f"{BACKEND_URL}/api/detect"
|
||||
payload = {'plate_number': plate_number}
|
||||
print(f"Sending plate: {plate_number} to {url}")
|
||||
requests.post(url, json=payload, timeout=2)
|
||||
requests.post(url, json={'plate_number': plate_number}, timeout=3)
|
||||
print(f"✓ Plate sent: {plate_number}")
|
||||
except Exception as e:
|
||||
print(f"Error sending plate: {e}")
|
||||
print(f"✗ Error sending plate: {e}")
|
||||
|
||||
def alpr_loop():
|
||||
global outputFrame, lock, latest_detections
|
||||
|
||||
print("Initializing EasyOCR...")
|
||||
reader = easyocr.Reader(['en'], gpu=False)
|
||||
print("EasyOCR initialized.")
|
||||
|
||||
# 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 validate_and_send(text):
|
||||
"""Valida formato chileno y envía"""
|
||||
# Formato nuevo: XXXX-00 | Formato antiguo: XX-0000
|
||||
if re.match(r'^[A-Z]{4}\d{2}$', text) or re.match(r'^[A-Z]{2}\d{4}$', text):
|
||||
send_plate(text)
|
||||
return True
|
||||
return False
|
||||
|
||||
def ocr_worker(reader):
|
||||
"""Hilo dedicado para OCR - no bloquea el stream"""
|
||||
while True:
|
||||
ret, frame = cap.read()
|
||||
if not ret:
|
||||
print("Failed to grab frame")
|
||||
time.sleep(1)
|
||||
try:
|
||||
plate_img = ocr_queue.get(timeout=1)
|
||||
if plate_img is None:
|
||||
continue
|
||||
|
||||
# Resize for performance
|
||||
frame = cv2.resize(frame, (640, 480))
|
||||
# Preprocesamiento para mejor OCR
|
||||
gray = cv2.cvtColor(plate_img, cv2.COLOR_BGR2GRAY)
|
||||
|
||||
ocr_results = reader.readtext(gray, detail=0, paragraph=False,
|
||||
allowlist='ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789')
|
||||
for text in ocr_results:
|
||||
clean_text = ''.join(e for e in text if e.isalnum()).upper()
|
||||
if len(clean_text) >= 6:
|
||||
validate_and_send(clean_text)
|
||||
except:
|
||||
pass
|
||||
|
||||
def camera_loop():
|
||||
"""Hilo principal de captura - mantiene FPS alto"""
|
||||
global outputFrame, latest_detections
|
||||
|
||||
print("🚀 Initializing ALPR System...")
|
||||
print("📷 Loading camera...")
|
||||
|
||||
cap = cv2.VideoCapture(CAMERA_ID)
|
||||
cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*'MJPG'))
|
||||
cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
|
||||
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)
|
||||
cap.set(cv2.CAP_PROP_FPS, 30)
|
||||
cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
|
||||
|
||||
print("🧠 Loading YOLO model...")
|
||||
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()
|
||||
|
||||
# Detection Processing
|
||||
# Procesar ALPR cada PROCESS_INTERVAL segundos
|
||||
if current_time - last_process_time > PROCESS_INTERVAL:
|
||||
last_process_time = current_time
|
||||
|
||||
# Run YOLO Inference
|
||||
results = model(frame, verbose=False)
|
||||
|
||||
detections = []
|
||||
# YOLO detection - usar imgsz pequeño para velocidad
|
||||
results = model(frame, verbose=False, imgsz=320, conf=0.5)
|
||||
|
||||
new_detections = []
|
||||
for r in results:
|
||||
boxes = r.boxes
|
||||
for box in boxes:
|
||||
# Bounding Box
|
||||
x1, y1, x2, y2 = box.xyxy[0]
|
||||
x1, y1, x2, y2 = int(x1), int(y1), int(x2), int(y2)
|
||||
for box in r.boxes:
|
||||
x1, y1, x2, y2 = map(int, box.xyxy[0])
|
||||
conf = float(box.conf[0])
|
||||
new_detections.append((x1, y1, x2, y2, conf))
|
||||
|
||||
if conf > 0.5: # Valid plate detection
|
||||
# Visualization data
|
||||
detections.append((x1, y1, x2, y2, conf))
|
||||
# Extraer imagen de placa y enviar a cola OCR
|
||||
plate_img = frame[y1:y2, x1:x2].copy()
|
||||
if plate_img.size > 0 and not ocr_queue.full():
|
||||
ocr_queue.put(plate_img)
|
||||
|
||||
# Crop Plate
|
||||
plate_img = frame[y1:y2, x1:x2]
|
||||
with detection_lock:
|
||||
latest_detections = new_detections
|
||||
|
||||
# Run OCR on Crop
|
||||
try:
|
||||
ocr_results = reader.readtext(plate_img)
|
||||
for (_, text, prob) in ocr_results:
|
||||
if prob > CONFIDENCE_THRESHOLD:
|
||||
clean_text = ''.join(e for e in text if e.isalnum()).upper()
|
||||
validate_and_send(clean_text)
|
||||
except Exception as e:
|
||||
print(f"OCR Error on crop: {e}")
|
||||
|
||||
with lock:
|
||||
latest_detections = detections
|
||||
|
||||
# Draw Detections on Frame for Stream
|
||||
display_frame = frame.copy()
|
||||
with lock:
|
||||
# Actualizar frame para streaming (sin bloquear)
|
||||
display_frame = frame
|
||||
with detection_lock:
|
||||
for (x1, y1, x2, y2, conf) in latest_detections:
|
||||
cv2.rectangle(display_frame, (x1, y1), (x2, y2), (0, 255, 0), 2)
|
||||
cv2.putText(display_frame, f"Plate {conf:.2f}", (x1, y1-10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 2)
|
||||
cv2.putText(display_frame, f"{conf:.0%}", (x1, y1-5),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1)
|
||||
|
||||
with frame_lock:
|
||||
outputFrame = display_frame
|
||||
|
||||
time.sleep(0.01)
|
||||
|
||||
def validate_and_send(text):
|
||||
# Chilean Plate Regex Patterns
|
||||
is_valid = False
|
||||
if re.match(r'^[A-Z]{4}\d{2}$', text): # BBBB11
|
||||
is_valid = True
|
||||
elif re.match(r'^[A-Z]{2}\d{4}$', text): # BB1111
|
||||
is_valid = True
|
||||
|
||||
if is_valid:
|
||||
print(f"Detected Valid Plate: {text}")
|
||||
send_plate(text)
|
||||
|
||||
def generate():
|
||||
global outputFrame, lock
|
||||
"""Generador para streaming MJPEG"""
|
||||
global outputFrame
|
||||
while True:
|
||||
with lock:
|
||||
time.sleep(0.033) # ~30 FPS para el stream
|
||||
with frame_lock:
|
||||
if outputFrame is None:
|
||||
continue
|
||||
(flag, encodedImage) = cv2.imencode(".jpg", outputFrame)
|
||||
if not flag:
|
||||
continue
|
||||
|
||||
yield(b'--frame\r\n' b'Content-Type: image/jpeg\r\n\r\n' +
|
||||
bytearray(encodedImage) + b'\r\n')
|
||||
_, encoded = cv2.imencode(".jpg", outputFrame, [cv2.IMWRITE_JPEG_QUALITY, 75])
|
||||
yield b'--frame\r\nContent-Type: image/jpeg\r\n\r\n' + encoded.tobytes() + b'\r\n'
|
||||
|
||||
@app.route("/video_feed")
|
||||
def video_feed():
|
||||
return Response(generate(), mimetype="multipart/x-mixed-replace; boundary=frame")
|
||||
|
||||
if __name__ == "__main__":
|
||||
t = threading.Thread(target=alpr_loop)
|
||||
t.daemon = True
|
||||
t.start()
|
||||
@app.route("/health")
|
||||
def health():
|
||||
return {"status": "ok", "service": "alpr"}
|
||||
|
||||
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)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "nodemon src/index.js",
|
||||
"dev": "npx prisma db push && nodemon src/index.js",
|
||||
"migrate": "npx prisma migrate dev"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -13,7 +13,9 @@
|
||||
"express": "^4.18.2",
|
||||
"pg": "^8.11.0",
|
||||
"socket.io": "^4.6.1",
|
||||
"@prisma/client": "^5.0.0"
|
||||
"@prisma/client": "^5.0.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"jsonwebtoken": "^9.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^2.0.22",
|
||||
|
||||
@@ -8,12 +8,35 @@ datasource db {
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
username String @unique
|
||||
password String
|
||||
role String @default("USER") // ADMIN, USER
|
||||
plates Plate[]
|
||||
people Person[]
|
||||
}
|
||||
|
||||
model Person {
|
||||
id Int @id @default(autoincrement())
|
||||
rut String @unique
|
||||
name String
|
||||
status String @default("PENDING") // PENDING, APPROVED, DENIED
|
||||
startDate DateTime
|
||||
endDate DateTime
|
||||
createdAt DateTime @default(now())
|
||||
addedBy User? @relation(fields: [addedById], references: [id])
|
||||
addedById Int?
|
||||
}
|
||||
|
||||
model Plate {
|
||||
id Int @id @default(autoincrement())
|
||||
number String @unique
|
||||
owner String?
|
||||
status String @default("ALLOWED") // ALLOWED, DENIED
|
||||
status String @default("PENDING") // PENDING, ALLOWED, DENIED
|
||||
createdAt DateTime @default(now())
|
||||
addedBy User? @relation(fields: [addedById], references: [id])
|
||||
addedById Int?
|
||||
}
|
||||
|
||||
model AccessLog {
|
||||
|
||||
@@ -23,34 +23,271 @@ app.get('/', (req, res) => {
|
||||
res.send('ALPR Backend Running');
|
||||
});
|
||||
|
||||
const authRoutes = require('./routes/auth');
|
||||
const { authenticateToken, isAdmin } = require('./middleware/auth');
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
|
||||
// Plates CRUD
|
||||
app.get('/api/plates', async (req, res) => {
|
||||
app.get('/api/plates', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const plates = await prisma.plate.findMany();
|
||||
// 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);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/plates', async (req, res) => {
|
||||
const { number, owner, status } = req.body;
|
||||
app.post('/api/plates', authenticateToken, async (req, res) => {
|
||||
const { number, owner } = req.body;
|
||||
const isAdm = req.user.role === 'ADMIN';
|
||||
// Admin -> ALLOWED, User -> PENDING
|
||||
const status = isAdm ? 'ALLOWED' : 'PENDING';
|
||||
|
||||
try {
|
||||
const plate = await prisma.plate.create({
|
||||
data: { number, owner, status: status || 'ALLOWED' }
|
||||
data: {
|
||||
number,
|
||||
owner,
|
||||
status,
|
||||
addedById: req.user.id
|
||||
}
|
||||
});
|
||||
|
||||
// Notify Admin via WebSocket
|
||||
io.emit('new_plate_registered', plate);
|
||||
|
||||
res.json(plate);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Admin: Approve/Reject Plate
|
||||
app.put('/api/plates/:id/approve', authenticateToken, isAdmin, async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { status } = req.body; // ALLOWED or DENIED
|
||||
|
||||
if (!['ALLOWED', 'DENIED'].includes(status)) {
|
||||
return res.status(400).json({ error: 'Invalid status' });
|
||||
}
|
||||
|
||||
try {
|
||||
const plate = await prisma.plate.update({
|
||||
where: { id: parseInt(id) },
|
||||
data: { status }
|
||||
});
|
||||
|
||||
// Notify Users via WebSocket
|
||||
io.emit('plate_status_updated', plate);
|
||||
|
||||
res.json(plate);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Admin: Delete Plate (Optional but good to have)
|
||||
// Delete Plate (Admin or Owner)
|
||||
app.delete('/api/plates/:id', authenticateToken, async (req, res) => {
|
||||
const { id } = req.params;
|
||||
try {
|
||||
const plate = await prisma.plate.findUnique({ where: { id: parseInt(id) } });
|
||||
if (!plate) return res.status(404).json({ error: 'Plate not found' });
|
||||
|
||||
// Check permissions
|
||||
if (req.user.role !== 'ADMIN' && plate.addedById !== req.user.id) {
|
||||
return res.status(403).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
await prisma.plate.delete({ where: { id: parseInt(id) } });
|
||||
|
||||
io.emit('plate_deleted', { id: parseInt(id) });
|
||||
|
||||
res.json({ message: 'Plate deleted' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete Person (Admin or Owner)
|
||||
app.delete('/api/people/:id', authenticateToken, async (req, res) => {
|
||||
const { id } = req.params;
|
||||
try {
|
||||
const person = await prisma.person.findUnique({ where: { id: parseInt(id) } });
|
||||
if (!person) return res.status(404).json({ error: 'Person not found' });
|
||||
|
||||
if (req.user.role !== 'ADMIN' && person.addedById !== req.user.id) {
|
||||
return res.status(403).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
await prisma.person.delete({ where: { id: parseInt(id) } });
|
||||
|
||||
io.emit('person_deleted', { id: parseInt(id) });
|
||||
|
||||
res.json({ message: 'Person deleted' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// History Endpoint
|
||||
app.get('/api/history', async (req, res) => {
|
||||
const { date } = req.query; // Format: YYYY-MM-DD
|
||||
if (!date) {
|
||||
return res.status(400).json({ error: 'Date is required' });
|
||||
}
|
||||
|
||||
const startDate = new Date(date);
|
||||
startDate.setHours(0, 0, 0, 0);
|
||||
|
||||
const endDate = new Date(date);
|
||||
endDate.setHours(23, 59, 59, 999);
|
||||
|
||||
try {
|
||||
const logs = await prisma.accessLog.findMany({
|
||||
where: {
|
||||
timestamp: {
|
||||
gte: startDate,
|
||||
lte: endDate
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
timestamp: 'desc'
|
||||
}
|
||||
});
|
||||
res.json(logs);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Recent Scans Endpoint (Last 5 Hours)
|
||||
app.get('/api/recent', async (req, res) => {
|
||||
try {
|
||||
const fiveHoursAgo = new Date(Date.now() - 5 * 60 * 60 * 1000);
|
||||
const logs = await prisma.accessLog.findMany({
|
||||
where: {
|
||||
timestamp: {
|
||||
gte: fiveHoursAgo
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
timestamp: 'desc'
|
||||
}
|
||||
});
|
||||
res.json(logs);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Helper: RUT Validation
|
||||
function validateRut(rut) {
|
||||
if (!rut || !/^[0-9]+-[0-9kK]{1}$/.test(rut)) return false;
|
||||
let [num, dv] = rut.split('-');
|
||||
let total = 0;
|
||||
let multiple = 2;
|
||||
for (let i = num.length - 1; i >= 0; i--) {
|
||||
total += parseInt(num.charAt(i)) * multiple;
|
||||
multiple = (multiple + 1) % 8 || 2;
|
||||
}
|
||||
let res = 11 - (total % 11);
|
||||
let finalDv = res === 11 ? '0' : res === 10 ? 'K' : res.toString();
|
||||
return finalDv.toUpperCase() === dv.toUpperCase();
|
||||
}
|
||||
|
||||
// People CRUD
|
||||
app.get('/api/people', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const where = req.user.role === 'ADMIN' ? {} : { addedById: req.user.id };
|
||||
const people = await prisma.person.findMany({
|
||||
where,
|
||||
include: { addedBy: { select: { username: true } } }
|
||||
});
|
||||
res.json(people);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/people', authenticateToken, async (req, res) => {
|
||||
const { rut, name, durationDays } = req.body;
|
||||
if (!validateRut(rut)) {
|
||||
return res.status(400).json({ error: 'Invalid RUT format (12345678-K)' });
|
||||
}
|
||||
|
||||
const startDate = new Date();
|
||||
const endDate = new Date();
|
||||
endDate.setDate(endDate.getDate() + parseInt(durationDays || 1));
|
||||
|
||||
try {
|
||||
const person = await prisma.person.create({
|
||||
data: {
|
||||
rut,
|
||||
name,
|
||||
startDate,
|
||||
endDate,
|
||||
status: 'PENDING',
|
||||
addedById: req.user.id
|
||||
}
|
||||
});
|
||||
|
||||
// Notify Admin via WebSocket
|
||||
io.emit('new_person_registered', person);
|
||||
|
||||
res.json(person);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Admin: Bulk Approve People by User
|
||||
app.post('/api/people/bulk-approve', authenticateToken, isAdmin, async (req, res) => {
|
||||
const { userId } = req.body;
|
||||
try {
|
||||
await prisma.person.updateMany({
|
||||
where: { addedById: parseInt(userId), status: 'PENDING' },
|
||||
data: { status: 'APPROVED' }
|
||||
});
|
||||
|
||||
// Notify Users via WebSocket
|
||||
io.emit('people_updated', { userId });
|
||||
|
||||
res.json({ message: 'Bulk approval successful' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Detection Endpoint (from Python)
|
||||
app.post('/api/detect', async (req, res) => {
|
||||
const { plate_number } = req.body;
|
||||
console.log(`Detected: ${plate_number}`);
|
||||
|
||||
const DUPLICATE_COOLDOWN_MS = 30000; // 30 seconds
|
||||
|
||||
try {
|
||||
// Check for recent duplicate
|
||||
const lastLog = await prisma.accessLog.findFirst({
|
||||
where: { plateNumber: plate_number },
|
||||
orderBy: { timestamp: 'desc' }
|
||||
});
|
||||
|
||||
if (lastLog) {
|
||||
const timeDiff = new Date() - new Date(lastLog.timestamp);
|
||||
if (timeDiff < DUPLICATE_COOLDOWN_MS) {
|
||||
console.log(`Duplicate detection ignored for ${plate_number} (${timeDiff}ms since last)`);
|
||||
return res.json({ message: 'Duplicate detection ignored', ignored: true, accessStatus: lastLog.accessStatus });
|
||||
}
|
||||
}
|
||||
|
||||
// Check if plate exists
|
||||
let plate = await prisma.plate.findUnique({
|
||||
where: { number: plate_number }
|
||||
@@ -92,7 +329,28 @@ app.post('/api/detect', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
server.listen(PORT, () => {
|
||||
server.listen(PORT, async () => {
|
||||
console.log(`Server running on port ${PORT}`);
|
||||
|
||||
// Seed Admin User if none exists
|
||||
try {
|
||||
const userCount = await prisma.user.count();
|
||||
if (userCount === 0) {
|
||||
console.log('No users found. Creating default admin user...');
|
||||
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
|
||||
networks:
|
||||
- backend-net
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- /app/node_modules
|
||||
@@ -44,6 +45,8 @@ services:
|
||||
alpr-service:
|
||||
build: ./alpr-service
|
||||
container_name: controlpatente-alpr
|
||||
ports:
|
||||
- "5001:5001" # Permite acceder al stream de video desde el nave
|
||||
environment:
|
||||
- BACKEND_URL=http://backend:3000
|
||||
# On Mac, you usually cannot pass /dev/video0 directly.
|
||||
@@ -56,7 +59,6 @@ services:
|
||||
depends_on:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
# Add privilege for hardware access
|
||||
privileged: true
|
||||
|
||||
# Frontend Service (React)
|
||||
@@ -67,12 +69,13 @@ services:
|
||||
- "5173:5173"
|
||||
networks:
|
||||
- backend-net
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
- /app/node_modules
|
||||
environment:
|
||||
- VITE_API_URL=http://localhost:3000
|
||||
|
||||
- VITE_API_URL=
|
||||
- VITE_ALPR_STREAM_URL=
|
||||
networks:
|
||||
backend-net:
|
||||
driver: bridge
|
||||
|
||||
@@ -14,7 +14,11 @@
|
||||
"lucide-react": "^0.260.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"socket.io-client": "^4.7.1"
|
||||
"socket.io-client": "^4.7.1",
|
||||
"react-router-dom": "^6.14.2",
|
||||
"jwt-decode": "^3.1.2",
|
||||
"i18next": "^23.10.0",
|
||||
"react-i18next": "^14.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.15",
|
||||
|
||||
@@ -1,246 +1,87 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import io from 'socket.io-client'
|
||||
import axios from 'axios'
|
||||
import { Car, AlertCircle, CheckCircle, XCircle, Clock } from 'lucide-react'
|
||||
|
||||
// Env var logic for Vite
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
|
||||
const socket = io(API_URL);
|
||||
import { useState, useEffect } from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import Login from './pages/Login';
|
||||
import AdminDashboard from './pages/AdminDashboard';
|
||||
import UserDashboard from './pages/UserDashboard';
|
||||
import './i18n'; // Initialize translations
|
||||
|
||||
function App() {
|
||||
const [plates, setPlates] = useState([]);
|
||||
const [detections, setDetections] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [newPlate, setNewPlate] = useState({ number: '', owner: '' });
|
||||
const [token, setToken] = useState(localStorage.getItem('token'));
|
||||
const [userRole, setUserRole] = useState(localStorage.getItem('role'));
|
||||
const [username, setUsername] = useState(localStorage.getItem('username'));
|
||||
|
||||
const handleRegister = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (!newPlate.number) return;
|
||||
await axios.post(`${API_URL}/api/plates`, {
|
||||
number: newPlate.number.toUpperCase(),
|
||||
owner: newPlate.owner
|
||||
});
|
||||
setNewPlate({ number: '', owner: '' });
|
||||
setShowModal(false);
|
||||
fetchPlates();
|
||||
} catch (err) {
|
||||
alert('Error adding plate: ' + err.message);
|
||||
const setAuth = (newToken, newRole, newUser) => {
|
||||
setToken(newToken);
|
||||
setUserRole(newRole);
|
||||
setUsername(newUser);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('role');
|
||||
localStorage.removeItem('username');
|
||||
setToken(null);
|
||||
setUserRole(null);
|
||||
setUsername(null);
|
||||
};
|
||||
|
||||
// Protected Route Component
|
||||
const ProtectedRoute = ({ children, allowedRoles }) => {
|
||||
if (!token) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
};
|
||||
|
||||
// Load initial data
|
||||
useEffect(() => {
|
||||
fetchPlates();
|
||||
|
||||
// Socket listeners
|
||||
socket.on('new_detection', (data) => {
|
||||
console.log('New detection:', data);
|
||||
setDetections(prev => [data, ...prev].slice(0, 10)); // Keep last 10
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.off('new_detection');
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchPlates = async () => {
|
||||
try {
|
||||
const res = await axios.get(`${API_URL}/api/plates`);
|
||||
setPlates(res.data);
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setLoading(false);
|
||||
if (allowedRoles && !allowedRoles.includes(userRole)) {
|
||||
return <Navigate to={userRole === 'ADMIN' ? '/admin' : '/user'} replace />;
|
||||
}
|
||||
};
|
||||
|
||||
const StatusBadge = ({ status }) => {
|
||||
const colors = {
|
||||
GRANTED: 'bg-green-500/20 text-green-400 border-green-500/50',
|
||||
DENIED: 'bg-red-500/20 text-red-400 border-red-500/50',
|
||||
UNKNOWN: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/50',
|
||||
ALLOWED: 'bg-green-500/20 text-green-400 border-green-500/50',
|
||||
return children;
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium border ${colors[status] || colors.UNKNOWN}`}>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-900 text-slate-100 p-8">
|
||||
{/* Modal */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 backdrop-blur-sm">
|
||||
<div className="bg-slate-800 p-6 rounded-2xl w-full max-w-md border border-slate-700 shadow-2xl transform transition-all scale-100">
|
||||
<h3 className="text-xl font-bold mb-4">Register New Plate</h3>
|
||||
<form onSubmit={handleRegister} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1">Plate Number</label>
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={newPlate.number}
|
||||
onChange={e => setNewPlate({ ...newPlate, number: e.target.value.toUpperCase() })}
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 focus:ring-2 focus:ring-blue-500 outline-none font-mono uppercase"
|
||||
placeholder="ABCD12"
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/login"
|
||||
element={
|
||||
!token ? (
|
||||
<Login
|
||||
setToken={(t) => setToken(t)}
|
||||
setUserRole={(r) => setUserRole(r)}
|
||||
setUsername={(u) => setUsername(u)} // Adding this prop to Login might be needed
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1">Owner Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newPlate.owner}
|
||||
onChange={e => setNewPlate({ ...newPlate, owner: e.target.value })}
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 focus:ring-2 focus:ring-blue-500 outline-none"
|
||||
placeholder="John Doe"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex space-x-3 mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowModal(false)}
|
||||
className="flex-1 py-2 bg-slate-700 hover:bg-slate-600 rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 py-2 bg-blue-600 hover:bg-blue-500 rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Register
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="max-w-7xl mx-auto space-y-8">
|
||||
|
||||
{/* Header */}
|
||||
<header className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-3 bg-blue-600 rounded-lg">
|
||||
<Car size={32} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold bg-gradient-to-r from-blue-400 to-cyan-300 bg-clip-text text-transparent">
|
||||
Control Patente AI
|
||||
</h1>
|
||||
<p className="text-slate-400">Real-time ALPR Monitoring System</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 text-sm text-slate-400">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse"></div>
|
||||
<span>System Online</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
|
||||
{/* Live Detections Feed */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<h2 className="text-xl font-semibold flex items-center space-x-2">
|
||||
<Clock className="text-blue-400" />
|
||||
<span>Live Detections</span>
|
||||
</h2>
|
||||
|
||||
<div className="bg-slate-800/50 rounded-2xl p-6 border border-slate-700/50 backdrop-blur-sm min-h-[400px]">
|
||||
|
||||
{/* Video Feed */}
|
||||
<div className="mb-6 rounded-xl overflow-hidden bg-black aspect-video relative border border-slate-700 shadow-lg">
|
||||
<img
|
||||
src="http://localhost:5001/video_feed"
|
||||
alt="Live Camera Feed"
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
e.target.style.display = 'none';
|
||||
e.target.nextSibling.style.display = 'flex';
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center text-slate-500 hidden bg-slate-900">
|
||||
<p>Camera Offline or Connecting...</p>
|
||||
</div>
|
||||
<div className="absolute top-4 right-4 animate-pulse">
|
||||
<div className="px-2 py-1 bg-red-600 rounded text-xs font-bold text-white uppercase tracking-wider">
|
||||
LIVE
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detections List */}
|
||||
<h3 className="text-sm font-semibold text-slate-400 uppercase tracking-wider mb-4">Recent Scans</h3>
|
||||
|
||||
{detections.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center text-slate-500 space-y-4 text-center py-8">
|
||||
<p>No detections yet...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{detections.map((d, i) => (
|
||||
<div key={i} className="flex items-center justify-between p-4 bg-slate-800 border border-slate-700 rounded-xl hover:bg-slate-750 transition-colors">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="p-2 bg-slate-700 rounded-lg font-mono text-xl tracking-wider font-bold">
|
||||
{d.plate}
|
||||
</div>
|
||||
<div className="text-sm text-slate-400">
|
||||
{new Date(d.timestamp).toLocaleTimeString()}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={d.status} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Database / Stats */}
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold flex items-center space-x-2">
|
||||
<CheckCircle className="text-green-400" />
|
||||
<span>Registered Plates</span>
|
||||
</h2>
|
||||
|
||||
<div className="bg-slate-800/50 rounded-2xl p-6 border border-slate-700/50 backdrop-blur-sm max-h-[600px] overflow-y-auto">
|
||||
{loading ? (
|
||||
<p className="text-center text-slate-500">Loading database...</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{plates.map((p) => (
|
||||
<div key={p.id} className="flex items-center justify-between p-3 bg-slate-800/80 rounded-lg border border-slate-700/50">
|
||||
<div>
|
||||
<div className="font-mono font-bold text-slate-200">{p.number}</div>
|
||||
<div className="text-xs text-slate-400">{p.owner || 'Unknown Owner'}</div>
|
||||
</div>
|
||||
<span className="text-xs font-medium text-green-400 bg-green-500/10 px-2 py-1 rounded">
|
||||
{p.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{plates.length === 0 && (
|
||||
<p className="text-center text-slate-500 text-sm">No plates registered.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="w-full mt-4 py-2 bg-blue-600 hover:bg-blue-500 rounded-lg font-medium transition-colors text-sm"
|
||||
onClick={() => setShowModal(true)}
|
||||
>
|
||||
+ Register New Plate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<Navigate to={userRole === 'ADMIN' ? '/admin' : '/user'} replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
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/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
allowedHosts: ['demo.v1ru5.cl'],
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://backend:3000',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/socket.io': {
|
||||
target: 'http://backend:3000',
|
||||
ws: true
|
||||
},
|
||||
'/video_feed': {
|
||||
target: 'http://alpr-service:5001',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
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