Add FaceRecognition
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 10s

This commit is contained in:
2026-07-22 18:30:31 +03:00
parent 88d9b6861b
commit 081f368aa0
7 changed files with 1006 additions and 2 deletions
+597
View File
@@ -0,0 +1,597 @@
import face_recognition
import cv2
import os
import time
import threading
import queue
import base64
import zipfile
import json
import csv
from datetime import datetime
from flask import Flask, request, render_template_string, Response, jsonify, send_from_directory, abort
from flask_socketio import SocketIO, emit
app = Flask(__name__)
socketio = SocketIO(app, cors_allowed_origins="*", async_mode='threading')
# === КОНФИГУРАЦИЯ ===
CONFIG_FILE = "config.json"
DEFAULT_CONFIG = {
"camera_ip": "192.168.1.100",
"camera_stream": 1, # 1 - низкое качество, 2 - среднее, 0 - высокое
"retention_days": 7,
"process_interval": 0.2
}
# === ЖЕСТКИЕ НАСТРОЙКИ КАМЕРЫ (СКРЫТЫ ОТ ОПЕРАТОРА) ===
CAMERA_USER = "admin"
CAMERA_PASS = "4NUDZhJ7"
# Формат URL для Dahua/Hikvision. {stream} заменится на номер потока
URL_TEMPLATE = "rtsp://{user}:{password}@{ip}/cam/realmonitor?channel=1&subtype={stream}"
def load_config():
if not os.path.exists(CONFIG_FILE):
with open(CONFIG_FILE, 'w') as f:
json.dump(DEFAULT_CONFIG, f, indent=4)
return DEFAULT_CONFIG
with open(CONFIG_FILE, 'r') as f:
return json.load(f)
config = load_config()
# Глобальные переменные
known_face_encodings = []
known_face_names = []
frame_queue = queue.Queue(maxsize=2)
last_processed_time = 0
PROCESS_INTERVAL = float(config.get("process_interval", 0.2))
SAVE_DIR = "Лица"
DATASET_DIR = "dataset"
LOG_FILE = "access_log.csv"
ARCHIVE_DIR = "logs_archive"
RETENTION_DAYS = int(config.get("retention_days", 7))
last_sent_identity = None
is_person_visible = False
camera_thread_instance = None
camera_stop_event = threading.Event()
# === ИНИЦИАЛИЗАЦИЯ ПАПОК ===
for d in [SAVE_DIR, DATASET_DIR, ARCHIVE_DIR, os.path.join(SAVE_DIR, "Unknown")]:
os.makedirs(d, exist_ok=True)
if not os.path.exists(LOG_FILE):
with open(LOG_FILE, 'w', newline='', encoding='utf-8') as f:
writer = csv.writer(f, delimiter=';')
writer.writerow(["Дата", "Время", "Имя", "Скриншот"])
def load_faces():
global known_face_encodings, known_face_names
print("Loading faces...")
known_face_encodings = []
known_face_names = []
for filename in os.listdir(DATASET_DIR):
if filename.endswith((".jpg", ".jpeg", ".png")):
try:
image = face_recognition.load_image_file(os.path.join(DATASET_DIR, filename))
encodings = face_recognition.face_encodings(image)
if len(encodings) > 0:
known_face_encodings.append(encodings[0])
known_face_names.append(os.path.splitext(filename)[0])
except Exception as e:
print(f"Error loading {filename}: {e}")
print(f"✅ Loaded {len(known_face_names)} faces.")
load_faces()
def get_base64_image(path):
try:
with open(path, "rb") as img_file:
return base64.b64encode(img_file.read()).decode('utf-8')
except:
return ""
def cleanup_old_files():
now = time.time()
cutoff = now - (RETENTION_DAYS * 86400)
for root, dirs, files in os.walk(SAVE_DIR):
for file in files:
if file.endswith(".jpg"):
filepath = os.path.join(root, file)
if os.path.getmtime(filepath) < cutoff:
try: os.remove(filepath)
except: pass
if os.path.exists(LOG_FILE) and os.path.getsize(LOG_FILE) > 0:
if os.path.getmtime(LOG_FILE) < cutoff:
zip_name = os.path.join(ARCHIVE_DIR, f"log_{datetime.now().strftime('%Y%m%d_%H%M%S')}.zip")
try:
with zipfile.ZipFile(zip_name, 'w', zipfile.ZIP_DEFLATED) as zipf:
zipf.write(LOG_FILE)
open(LOG_FILE, 'w').close()
except: pass
def write_log(name, screenshot_path):
timestamp = datetime.now()
with open(LOG_FILE, "a", newline='', encoding="utf-8") as f:
writer = csv.writer(f, delimiter=';')
writer.writerow([
timestamp.strftime("%Y-%m-%d"),
timestamp.strftime("%H:%M:%S"),
name,
screenshot_path
])
def process_frame(frame):
global last_processed_time, last_sent_identity, is_person_visible
small_frame = cv2.resize(frame, (0, 0), fx=0.25, fy=0.25)
rgb_small_frame = cv2.cvtColor(small_frame, cv2.COLOR_BGR2RGB)
face_locations = face_recognition.face_locations(rgb_small_frame)
if not face_locations:
if is_person_visible:
is_person_visible = False
last_sent_identity = None
return frame
top, right, bottom, left = face_locations[0]
face_encoding = face_recognition.face_encodings(rgb_small_frame, [face_locations[0]])[0]
matches = face_recognition.compare_faces(known_face_encodings, face_encoding)
name = "Unknown"
if True in matches:
name = known_face_names[matches.index(True)]
full_top, full_right = top * 4, right * 4
full_bottom, full_left = bottom * 4, left * 4
should_notify = (name != last_sent_identity) or (not is_person_visible)
if should_notify:
screenshot_path = ""
b64_img = ""
pad_y, pad_x = 60, 40
h, w = frame.shape[:2]
crop = frame[max(0,full_top-pad_y):min(h,full_bottom+pad_y), max(0,full_left-pad_x):min(w,full_right+pad_x)]
if crop.size > 0:
user_dir = os.path.join(SAVE_DIR, name)
os.makedirs(user_dir, exist_ok=True)
filename = f"{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}.jpg"
filepath = os.path.join(user_dir, filename)
cv2.imwrite(filepath, crop, [int(cv2.IMWRITE_JPEG_QUALITY), 100])
screenshot_path = filepath
b64_img = get_base64_image(filepath)
write_log(name, filepath)
threading.Thread(target=cleanup_old_files).start()
if name != "Unknown":
if not b64_img:
emp_photo_path = os.path.join(DATASET_DIR, f"{name}.jpg")
b64_img = get_base64_image(emp_photo_path)
socketio.emit('new_event', {'type': 'known', 'name': name, 'photo': b64_img, 'time': datetime.now().strftime("%H:%M:%S")})
else:
if b64_img:
socketio.emit('new_event', {'type': 'unknown', 'name': 'НЕИЗВЕСТНЫЙ', 'photo': b64_img, 'path': screenshot_path, 'time': datetime.now().strftime("%H:%M:%S")})
last_sent_identity = name
is_person_visible = True
color = (0, 255, 0) if name != "Unknown" else (0, 0, 255)
cv2.rectangle(frame, (full_left, full_top), (full_right, full_bottom), color, 2)
cv2.putText(frame, name, (full_left + 6, full_bottom - 6), cv2.FONT_HERSHEY_DUPLEX, 1.0, (255, 255, 255), 1)
return frame
def camera_thread(camera_url):
print(f"🔌 Connecting to: {camera_url}")
video_capture = cv2.VideoCapture(camera_url)
video_capture.set(cv2.CAP_PROP_BUFFERSIZE, 1)
while not camera_stop_event.is_set():
success, frame = video_capture.read()
if not success:
time.sleep(1)
video_capture.release()
video_capture = cv2.VideoCapture(camera_url)
continue
if frame_queue.full():
try: frame_queue.get_nowait()
except: pass
frame_queue.put(frame)
time.sleep(0.01)
video_capture.release()
def start_camera():
global camera_thread_instance, camera_stop_event
if camera_thread_instance and camera_thread_instance.is_alive():
camera_stop_event.set()
camera_thread_instance.join(timeout=5)
camera_stop_event.clear()
ip = config.get("camera_ip", "")
stream = config.get("camera_stream", 1)
if ip:
# ИСПРАВЛЕНО: pass заменено на password
url = URL_TEMPLATE.format(user=CAMERA_USER, password=CAMERA_PASS, ip=ip, stream=stream)
camera_thread_instance = threading.Thread(target=camera_thread, args=(url,), daemon=True)
camera_thread_instance.start()
print(f"📷 Camera started for IP: {ip} (Stream: {stream})")
else:
print("⚠️ Camera IP not configured")
start_camera()
# === API ENDPOINTS ===
@app.route('/api/config', methods=['GET', 'POST'])
def api_config():
global config, PROCESS_INTERVAL, RETENTION_DAYS
if request.method == 'POST':
data = request.json
if 'camera_ip' in data: config['camera_ip'] = data['camera_ip']
if 'camera_stream' in data: config['camera_stream'] = int(data['camera_stream'])
config['retention_days'] = int(data.get('retention_days', 7))
config['process_interval'] = float(data.get('process_interval', 0.2))
with open(CONFIG_FILE, 'w') as f:
json.dump(config, f, indent=4)
PROCESS_INTERVAL = config['process_interval']
RETENTION_DAYS = config['retention_days']
# Перезапускаем камеру при изменении IP или потока
if any(k in data for k in ['camera_ip', 'camera_stream']):
start_camera()
return jsonify({"status": "ok"})
return jsonify(config)
@app.route('/api/log')
def api_log():
entries = []
if os.path.exists(LOG_FILE):
with open(LOG_FILE, 'r', encoding='utf-8') as f:
reader = csv.reader(f, delimiter=';')
next(reader, None)
for row in reader:
if len(row) >= 4:
safe_path = row[3].replace("\\", "/")
entries.append({
'date': row[0], 'time': row[1],
'name': row[2], 'path': safe_path
})
entries.reverse()
return jsonify(entries)
@app.route('/api/upload', methods=['POST'])
def api_upload():
name = request.form.get('name', '').strip()
photo = request.files.get('photo')
if not name or not photo:
return jsonify({"error": "Name and photo required"}), 400
filename = f"{name.replace(' ', '_')}.jpg"
path = os.path.join(DATASET_DIR, filename)
photo.save(path)
load_faces()
return jsonify({"status": "ok", "name": name})
@app.route('/api/image/<path:filename>')
def serve_image(filename):
safe_dir = os.path.abspath(SAVE_DIR)
requested_path = os.path.abspath(os.path.join(SAVE_DIR, filename))
if requested_path.startswith(safe_dir) and os.path.exists(requested_path):
return send_from_directory(SAVE_DIR, filename)
return abort(404)
@app.route('/video_feed')
def video_feed():
def generate():
global last_processed_time
while True:
try:
frame = frame_queue.get(timeout=5)
if time.time() - last_processed_time >= PROCESS_INTERVAL:
frame = process_frame(frame)
last_processed_time = time.time()
ret, buffer = cv2.imencode('.jpg', frame)
yield (b'--frame\r\nContent-Type: image/jpeg\r\n\r\n' + buffer.tobytes() + b'\r\n')
except: continue
return Response(generate(), mimetype='multipart/x-mixed-replace; boundary=frame')
@app.route('/')
def index():
return render_template_string('''
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Face Recognition System</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.js"></script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Segoe UI', sans-serif; background: #121212; color: white; height: 100vh; overflow: hidden; display: flex; flex-direction: column; }
.nav { display: flex; background: #1e1e1e; border-bottom: 1px solid #333; padding: 0 20px; }
.nav-btn { padding: 15px 25px; cursor: pointer; color: #888; font-weight: 600; border-bottom: 3px solid transparent; transition: 0.3s; }
.nav-btn:hover { color: white; }
.nav-btn.active { color: #4CAF50; border-bottom-color: #4CAF50; }
.tab-content { display: none; flex: 1; overflow: hidden; }
.tab-content.active { display: flex; }
.dashboard { display: flex; height: 100%; }
.sidebar { width: 300px; background: #1e1e1e; padding: 15px; overflow-y: auto; border-right: 1px solid #333; display: flex; flex-direction: column; }
.sidebar.right { border-right: none; border-left: 1px solid #333; }
.main { flex: 1; padding: 20px; display: flex; align-items: center; justify-content: center; background: #000; position: relative; }
.card { background: #2c2c2c; padding: 12px; margin-bottom: 12px; border-radius: 10px; display: flex; align-items: center; cursor: pointer; transition: transform 0.2s; border-left: 4px solid #4CAF50; position: relative; }
.card:hover { transform: translateX(5px); background: #333; }
.card.alarm { border-left: 4px solid #ff4444; background: #3a2222; animation: pulse 1.5s infinite; }
.card img { width: 50px; height: 50px; border-radius: 50%; object-fit: cover; margin-right: 15px; border: 2px solid #fff; }
.card.alarm img { border-color: #ff4444; }
.delete-btn { position: absolute; top: 5px; right: 5px; width: 20px; height: 20px; background: rgba(0,0,0,0.5); color: #aaa; border-radius: 50%; text-align: center; line-height: 20px; font-size: 12px; cursor: pointer; display: none; z-index: 2; }
.card:hover .delete-btn { display: block; }
.delete-btn:hover { background: #ff4444; color: white; }
@keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(255,68,68,0.4); } 70% { box-shadow: 0 0 0 10px rgba(255,68,68,0); } 100% { box-shadow: 0 0 0 0 rgba(255,68,68,0); } }
.col-header { text-align: center; font-size: 14px; font-weight: bold; padding: 10px 0; border-bottom: 1px solid #333; margin-bottom: 10px; position: sticky; top: 0; background: #1e1e1e; z-index: 10; }
.col-known .col-header { color: #4CAF50; }
.col-unknown .col-header { color: #ff4444; }
.header-bar { position: absolute; top: 20px; left: 20px; background: rgba(0,0,0,0.7); padding: 10px 20px; border-radius: 20px; display: flex; align-items: center; gap: 10px; backdrop-filter: blur(5px); z-index: 5; }
.dot { width: 12px; height: 12px; border-radius: 50%; background: #4CAF50; box-shadow: 0 0 8px #4CAF50; transition: 0.3s; }
.btn-sound { background: #333; border: 1px solid #555; color: #aaa; padding: 6px 12px; border-radius: 15px; cursor: pointer; font-size: 11px; margin-left: 10px; }
.name { font-weight: 600; font-size: 14px; }
.time { font-size: 11px; color: #777; margin-top: 2px; }
.log-container { flex: 1; padding: 30px; overflow-y: auto; }
.log-table { width: 100%; border-collapse: collapse; }
.log-table th, .log-table td { padding: 12px 15px; text-align: left; border-bottom: 1px solid #333; }
.log-table th { background: #1e1e1e; color: #888; font-size: 12px; text-transform: uppercase; letter-spacing: 1px; position: sticky; top: 0; }
.log-table tr:hover { background: #1e1e1e; }
.log-table .thumb { width: 50px; height: 50px; border-radius: 8px; object-fit: cover; cursor: pointer; border: 1px solid #444; }
.log-badge { padding: 3px 10px; border-radius: 10px; font-size: 11px; font-weight: bold; }
.log-badge.known { background: rgba(76,175,80,0.2); color: #4CAF50; }
.log-badge.unknown { background: rgba(255,68,68,0.2); color: #ff4444; }
.settings-container { flex: 1; padding: 40px; max-width: 700px; margin: 0 auto; overflow-y: auto; }
.form-group { margin-bottom: 20px; }
.form-group label { display: block; color: #888; font-size: 12px; text-transform: uppercase; margin-bottom: 8px; letter-spacing: 1px; }
.form-group input, .form-group select { width: 100%; padding: 12px 15px; background: #2c2c2c; border: 1px solid #444; border-radius: 8px; color: white; font-size: 14px; outline: none; transition: 0.3s; }
.form-group input:focus, .form-group select:focus { border-color: #4CAF50; }
.btn { padding: 12px 30px; background: #4CAF50; color: white; border: none; border-radius: 8px; cursor: pointer; font-size: 14px; font-weight: 600; transition: 0.3s; }
.btn:hover { background: #43a047; }
.btn-upload { background: #2196F3; margin-bottom: 20px; }
.msg { margin-top: 10px; padding: 10px; border-radius: 8px; display: none; }
.msg.ok { display: block; background: rgba(76,175,80,0.2); color: #4CAF50; }
.msg.err { display: block; background: rgba(255,68,68,0.2); color: #ff4444; }
.modal { display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.9); justify-content: center; align-items: center; }
.modal-content { max-width: 90%; max-height: 90%; border-radius: 8px; }
.close { position: absolute; top: 20px; right: 30px; color: #f1f1f1; font-size: 40px; font-weight: bold; cursor: pointer; }
</style>
</head>
<body>
<div class="nav">
<div class="nav-btn active" onclick="switchTab('dashboard')">📹 Монитор</div>
<div class="nav-btn" onclick="switchTab('log')">📋 Журнал</div>
<div class="nav-btn" onclick="switchTab('settings')">⚙️ Настройки</div>
</div>
<div id="tab-dashboard" class="tab-content active dashboard">
<div class="sidebar col-known">
<div class="col-header">✅ СОТРУДНИКИ</div>
<div id="known-list"></div>
</div>
<div class="main">
<div class="header-bar">
<div id="statusDot" class="dot"></div>
<span id="statusText">LIVE</span>
<button class="btn-sound" onclick="enableAudio()">🔊 Звук</button>
</div>
<img src="/video_feed" style="width:100%;height:100%;object-fit:contain;border-radius:8px;">
</div>
<div class="sidebar right col-unknown">
<div class="col-header">⚠️ НЕИЗВЕСТНЫЕ</div>
<div id="unknown-list"></div>
</div>
</div>
<div id="tab-log" class="tab-content">
<div class="log-container">
<h2 style="margin-bottom:20px;color:#888;text-transform:uppercase;letter-spacing:1px;">Журнал посещений</h2>
<table class="log-table">
<thead><tr><th>Дата</th><th>Время</th><th>Имя</th><th>Статус</th><th>Фото</th></tr></thead>
<tbody id="log-body"></tbody>
</table>
</div>
</div>
<div id="tab-settings" class="tab-content">
<div class="settings-container">
<h2 style="margin-bottom:30px;color:#888;text-transform:uppercase;letter-spacing:1px;">Настройки системы</h2>
<div style="background:#1e1e1e;padding:25px;border-radius:12px;margin-bottom:30px;">
<h3 style="margin-bottom:15px;font-size:16px;">➕ Добавить сотрудника</h3>
<form id="uploadForm" enctype="multipart/form-data">
<div class="form-group"><label>Имя / Фамилия</label><input type="text" name="name" required placeholder="Иванов Иван"></div>
<div class="form-group"><label>Фото лица</label><input type="file" name="photo" accept="image/*" required></div>
<button type="submit" class="btn btn-upload">Загрузить</button>
</form>
<div id="uploadMsg" class="msg"></div>
</div>
<div style="background:#1e1e1e;padding:25px;border-radius:12px;">
<h3 style="margin-bottom:15px;font-size:16px;">📷 Подключение камеры</h3>
<div class="form-group"><label>IP-адрес камеры</label><input type="text" id="cfg-ip" placeholder="192.168.1.100"></div>
<div class="form-group">
<label>Номер потока (качество)</label>
<select id="cfg-stream">
<option value="1">Поток 1 (Низкое качество / Быстро)</option>
<option value="2">Поток 2 (Среднее качество)</option>
<option value="0">Поток 0 (Высокое качество / HD)</option>
</select>
</div>
<h3 style="margin:25px 0 15px;font-size:16px;">⚙️ Система</h3>
<div class="form-group"><label>Хранить данные (дней)</label><input type="number" id="cfg-retention" min="1" max="365"></div>
<div class="form-group"><label>Интервал обработки (сек)</label><input type="number" id="cfg-interval" step="0.1" min="0.1" max="2"></div>
<button class="btn" onclick="saveConfig()">💾 Сохранить и применить</button>
<div id="configMsg" class="msg"></div>
</div>
</div>
</div>
<div id="imageModal" class="modal" onclick="closeModal()">
<span class="close">&times;</span>
<img class="modal-content" id="modalImg">
</div>
<script>
const socket = io();
let audioEnabled = false;
const alarmSound = new Audio('https://actions.google.com/sounds/v1/alarms/beep_short.ogg');
function switchTab(tab) {
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
document.getElementById('tab-' + tab).classList.add('active');
event.target.classList.add('active');
if (tab === 'log') loadLog();
if (tab === 'settings') loadConfig();
}
function openModal(src) {
document.getElementById('imageModal').style.display = 'flex';
document.getElementById('modalImg').src = src;
}
function closeModal() { document.getElementById('imageModal').style.display = 'none'; }
function enableAudio() {
audioEnabled = true;
alarmSound.play().then(() => alarmSound.pause()).catch(()=>{});
document.querySelector('.btn-sound').innerText = "🔊 Вкл";
document.querySelector('.btn-sound').style.color = "#4CAF50";
}
function removeCard(btn) {
const card = btn.closest('.card');
card.style.opacity = '0';
setTimeout(() => card.remove(), 300);
}
socket.on('new_event', (data) => {
const div = document.createElement('div');
div.className = data.type === 'unknown' ? 'card alarm' : 'card';
let deleteBtn = '';
if (data.type === 'unknown') {
deleteBtn = `<div class="delete-btn" onclick="event.stopPropagation(); removeCard(this)">✕</div>`;
}
div.onclick = () => openModal(`data:image/jpeg;base64,${data.photo}`);
div.innerHTML = `${deleteBtn}<img src="data:image/jpeg;base64,${data.photo}"><div><div class="name">${data.name}</div><div class="time">${data.time}</div></div>`;
const list = data.type === 'unknown' ? document.getElementById('unknown-list') : document.getElementById('known-list');
list.prepend(div);
if (list.children.length > 20) list.lastChild.remove();
const dot = document.getElementById('statusDot');
const st = document.getElementById('statusText');
if (data.type === 'unknown') {
dot.style.background = '#ff4444'; dot.style.boxShadow = '0 0 10px #ff4444';
st.innerText = "ТРЕВОГА"; st.style.color = "#ff4444";
if (audioEnabled) alarmSound.play();
} else {
dot.style.background = '#4CAF50'; dot.style.boxShadow = '0 0 8px #4CAF50';
st.innerText = "LIVE"; st.style.color = "white";
}
});
document.getElementById('uploadForm').addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const msg = document.getElementById('uploadMsg');
try {
const res = await fetch('/api/upload', { method: 'POST', body: fd });
const data = await res.json();
if (res.ok) { msg.className = 'msg ok'; msg.textContent = `✅ ${data.name} добавлен!`; e.target.reset(); }
else { msg.className = 'msg err'; msg.textContent = `❌ ${data.error}`; }
} catch(err) { msg.className = 'msg err'; msg.textContent = '❌ Ошибка сети'; }
});
async function loadConfig() {
const res = await fetch('/api/config');
const cfg = await res.json();
document.getElementById('cfg-ip').value = cfg.camera_ip || '';
document.getElementById('cfg-stream').value = cfg.camera_stream || 1;
document.getElementById('cfg-retention').value = cfg.retention_days || 7;
document.getElementById('cfg-interval').value = cfg.process_interval || 0.2;
}
async function saveConfig() {
const msg = document.getElementById('configMsg');
const body = {
camera_ip: document.getElementById('cfg-ip').value,
camera_stream: document.getElementById('cfg-stream').value,
retention_days: parseInt(document.getElementById('cfg-retention').value),
process_interval: parseFloat(document.getElementById('cfg-interval').value)
};
try {
const res = await fetch('/api/config', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
if (res.ok) {
msg.className = 'msg ok';
msg.textContent = '✅ Настройки сохранены! Камера переподключается...';
}
else { msg.className = 'msg err'; msg.textContent = '❌ Ошибка сохранения'; }
} catch(err) { msg.className = 'msg err'; msg.textContent = '❌ Ошибка сети'; }
}
async function loadLog() {
const res = await fetch('/api/log');
const entries = await res.json();
const tbody = document.getElementById('log-body');
tbody.innerHTML = '';
entries.forEach(e => {
const isKnown = !e.name.includes('НЕИЗВЕСТНЫЙ') && !e.name.includes('Unknown');
const badge = isKnown
? '<span class="log-badge known">Сотрудник</span>'
: '<span class="log-badge unknown">Неизвестный</span>';
const imgUrl = `/api/image/${e.path}`;
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${e.date}</td>
<td>${e.time}</td>
<td style="font-weight:600">${e.name}</td>
<td>${badge}</td>
<td><img class="thumb" src="${imgUrl}" onerror="this.style.display='none'" onclick="openModal('${imgUrl}')" title="Нажмите для увеличения"></td>
`;
tbody.appendChild(tr);
});
}
</script>
</body>
</html>
''')
if __name__ == '__main__':
socketio.run(app, host='0.0.0.0', port=5000, debug=False)