init commit
This commit is contained in:
803
backend/public/demo.js
Normal file
803
backend/public/demo.js
Normal file
@@ -0,0 +1,803 @@
|
||||
// Глобальные переменные
|
||||
let socket = null;
|
||||
let androidSocket = null;
|
||||
let operatorSocket = null;
|
||||
let localStream = null;
|
||||
let peerConnection = null;
|
||||
let currentSessionId = null;
|
||||
|
||||
// Конфигурация WebRTC
|
||||
const rtcConfig = {
|
||||
iceServers: [
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
{ urls: 'stun:stun1.l.google.com:19302' }
|
||||
]
|
||||
};
|
||||
|
||||
// Инициализация при загрузке страницы
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initializeSocket();
|
||||
loadSystemStats();
|
||||
|
||||
// Обновление статистики каждые 5 секунд
|
||||
setInterval(loadSystemStats, 5000);
|
||||
});
|
||||
|
||||
// Управление вкладками
|
||||
function showTab(tabName) {
|
||||
// Скрываем все вкладки
|
||||
document.querySelectorAll('.tab-content').forEach(tab => {
|
||||
tab.classList.remove('active');
|
||||
});
|
||||
|
||||
// Убираем активный класс с кнопок
|
||||
document.querySelectorAll('.tab').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
});
|
||||
|
||||
// Показываем выбранную вкладку
|
||||
document.getElementById(tabName).classList.add('active');
|
||||
event.target.classList.add('active');
|
||||
}
|
||||
|
||||
// Инициализация основного сокета для мониторинга
|
||||
function initializeSocket() {
|
||||
socket = io();
|
||||
|
||||
socket.on('connect', () => {
|
||||
logMessage('info', 'Подключение к серверу установлено');
|
||||
updateConnectionStatus(true);
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
logMessage('warn', 'Подключение к серверу потеряно');
|
||||
updateConnectionStatus(false);
|
||||
});
|
||||
|
||||
socket.on('device:connected', (data) => {
|
||||
logMessage('info', `Устройство подключено: ${data.deviceId}`);
|
||||
});
|
||||
|
||||
socket.on('device:disconnected', (data) => {
|
||||
logMessage('warn', `Устройство отключено: ${data.deviceId}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Обновление статуса подключения
|
||||
function updateConnectionStatus(connected) {
|
||||
const statusCard = document.getElementById('connection-status');
|
||||
const statusText = document.getElementById('connection-text');
|
||||
|
||||
if (connected) {
|
||||
statusCard.className = 'status-card status-connected';
|
||||
statusText.textContent = '✅ Подключено к серверу';
|
||||
} else {
|
||||
statusCard.className = 'status-card status-disconnected';
|
||||
statusText.textContent = '❌ Нет подключения к серверу';
|
||||
}
|
||||
}
|
||||
|
||||
// Загрузка системной статистики
|
||||
async function loadSystemStats() {
|
||||
try {
|
||||
const response = await fetch('/api/status');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.devices && data.sessions) {
|
||||
document.getElementById('stat-devices').textContent = data.devices.connectedDevices || 0;
|
||||
document.getElementById('stat-operators').textContent = data.devices.connectedOperators || 0;
|
||||
document.getElementById('stat-sessions').textContent = data.sessions.activeSessions || 0;
|
||||
document.getElementById('stat-uptime').textContent = Math.round(data.uptime / 60) || 0;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки статистики:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Система логирования
|
||||
function logMessage(level, message) {
|
||||
const logs = document.getElementById('system-logs');
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
const logEntry = document.createElement('div');
|
||||
logEntry.className = `log-entry log-${level}`;
|
||||
logEntry.textContent = `[${timestamp}] ${message}`;
|
||||
|
||||
logs.appendChild(logEntry);
|
||||
logs.scrollTop = logs.scrollHeight;
|
||||
|
||||
// Ограничиваем количество логов
|
||||
const maxLogs = 100;
|
||||
while (logs.children.length > maxLogs) {
|
||||
logs.removeChild(logs.firstChild);
|
||||
}
|
||||
}
|
||||
|
||||
function clearLogs() {
|
||||
document.getElementById('system-logs').innerHTML = '';
|
||||
}
|
||||
|
||||
// === ANDROID DEVICE SIMULATION ===
|
||||
|
||||
function connectAndroid() {
|
||||
const deviceId = document.getElementById('android-device-id').value;
|
||||
if (!deviceId) {
|
||||
alert('Введите Device ID');
|
||||
return;
|
||||
}
|
||||
|
||||
androidSocket = io();
|
||||
|
||||
androidSocket.on('connect', () => {
|
||||
const deviceInfo = {
|
||||
model: document.getElementById('android-model').value,
|
||||
manufacturer: document.getElementById('android-manufacturer').value,
|
||||
androidVersion: document.getElementById('android-version').value,
|
||||
availableCameras: Array.from(document.getElementById('android-cameras').selectedOptions)
|
||||
.map(option => option.value).join(',')
|
||||
};
|
||||
|
||||
androidSocket.emit('register:android', { deviceId, deviceInfo });
|
||||
logMessage('info', `Android устройство подключается: ${deviceId}`);
|
||||
});
|
||||
|
||||
androidSocket.on('register:success', (data) => {
|
||||
logMessage('info', `Android устройство зарегистрировано: ${data.deviceId}`);
|
||||
document.getElementById('android-connect').disabled = true;
|
||||
document.getElementById('android-disconnect').disabled = false;
|
||||
});
|
||||
|
||||
androidSocket.on('camera:request', (data) => {
|
||||
showCameraRequest(data);
|
||||
});
|
||||
|
||||
androidSocket.on('camera:switch', (data) => {
|
||||
logMessage('info', `Запрос переключения камеры: ${data.cameraType}`);
|
||||
showAlert('info', `Переключение камеры на: ${data.cameraType}`);
|
||||
});
|
||||
|
||||
androidSocket.on('camera:disconnect', (data) => {
|
||||
logMessage('warn', `Сессия завершена: ${data.sessionId}`);
|
||||
removeCameraSession(data.sessionId);
|
||||
});
|
||||
|
||||
setupAndroidWebRTC();
|
||||
}
|
||||
|
||||
function disconnectAndroid() {
|
||||
if (androidSocket) {
|
||||
androidSocket.disconnect();
|
||||
androidSocket = null;
|
||||
logMessage('warn', 'Android устройство отключено');
|
||||
|
||||
document.getElementById('android-connect').disabled = false;
|
||||
document.getElementById('android-disconnect').disabled = true;
|
||||
|
||||
// Очищаем запросы и сессии
|
||||
document.getElementById('android-requests').innerHTML = '';
|
||||
document.getElementById('android-sessions').innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
function showCameraRequest(data) {
|
||||
const container = document.getElementById('android-requests');
|
||||
const requestDiv = document.createElement('div');
|
||||
requestDiv.className = 'session-card session-pending';
|
||||
requestDiv.innerHTML = `
|
||||
<h4>Запрос камеры</h4>
|
||||
<p><strong>Сессия:</strong> ${data.sessionId}</p>
|
||||
<p><strong>Оператор:</strong> ${data.operatorId}</p>
|
||||
<p><strong>Камера:</strong> ${data.cameraType}</p>
|
||||
<button class="btn btn-success" onclick="acceptCameraRequest('${data.sessionId}')">Принять</button>
|
||||
<button class="btn btn-danger" onclick="declineCameraRequest('${data.sessionId}')">Отклонить</button>
|
||||
`;
|
||||
|
||||
container.appendChild(requestDiv);
|
||||
logMessage('info', `Получен запрос камеры от ${data.operatorId}`);
|
||||
}
|
||||
|
||||
function acceptCameraRequest(sessionId) {
|
||||
if (androidSocket) {
|
||||
androidSocket.emit('camera:response', {
|
||||
sessionId,
|
||||
accepted: true,
|
||||
streamUrl: 'webrtc'
|
||||
});
|
||||
|
||||
// Перемещаем в активные сессии
|
||||
moveToActiveSessions(sessionId, 'active');
|
||||
logMessage('info', `Запрос камеры принят: ${sessionId}`);
|
||||
}
|
||||
}
|
||||
|
||||
function declineCameraRequest(sessionId) {
|
||||
if (androidSocket) {
|
||||
androidSocket.emit('camera:response', {
|
||||
sessionId,
|
||||
accepted: false,
|
||||
error: 'Пользователь отклонил запрос'
|
||||
});
|
||||
|
||||
// Удаляем запрос
|
||||
removeCameraRequest(sessionId);
|
||||
logMessage('warn', `Запрос камеры отклонен: ${sessionId}`);
|
||||
}
|
||||
}
|
||||
|
||||
function moveToActiveSessions(sessionId, status) {
|
||||
removeCameraRequest(sessionId);
|
||||
|
||||
const container = document.getElementById('android-sessions');
|
||||
const sessionDiv = document.createElement('div');
|
||||
sessionDiv.className = `session-card session-${status}`;
|
||||
sessionDiv.id = `android-session-${sessionId}`;
|
||||
sessionDiv.innerHTML = `
|
||||
<h4>Активная сессия</h4>
|
||||
<p><strong>ID:</strong> ${sessionId}</p>
|
||||
<p><strong>Статус:</strong> ${status}</p>
|
||||
<button class="btn btn-danger" onclick="endAndroidSession('${sessionId}')">Завершить</button>
|
||||
`;
|
||||
|
||||
container.appendChild(sessionDiv);
|
||||
currentSessionId = sessionId;
|
||||
document.getElementById('current-session-id').value = sessionId;
|
||||
}
|
||||
|
||||
function removeCameraRequest(sessionId) {
|
||||
const container = document.getElementById('android-requests');
|
||||
const requests = container.children;
|
||||
for (let i = 0; i < requests.length; i++) {
|
||||
if (requests[i].innerHTML.includes(sessionId)) {
|
||||
container.removeChild(requests[i]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function removeCameraSession(sessionId) {
|
||||
const sessionElement = document.getElementById(`android-session-${sessionId}`);
|
||||
if (sessionElement) {
|
||||
sessionElement.remove();
|
||||
}
|
||||
|
||||
if (currentSessionId === sessionId) {
|
||||
currentSessionId = null;
|
||||
document.getElementById('current-session-id').value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function endAndroidSession(sessionId) {
|
||||
if (androidSocket) {
|
||||
androidSocket.emit('camera:disconnect', { sessionId });
|
||||
removeCameraSession(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
// === OPERATOR SIMULATION ===
|
||||
|
||||
function connectOperator() {
|
||||
const operatorId = document.getElementById('operator-id').value;
|
||||
if (!operatorId) {
|
||||
alert('Введите Operator ID');
|
||||
return;
|
||||
}
|
||||
|
||||
operatorSocket = io();
|
||||
|
||||
operatorSocket.on('connect', () => {
|
||||
operatorSocket.emit('register:operator', {
|
||||
operatorId,
|
||||
operatorInfo: {
|
||||
name: 'Demo Operator',
|
||||
permissions: ['view_cameras', 'request_camera']
|
||||
}
|
||||
});
|
||||
logMessage('info', `Оператор подключается: ${operatorId}`);
|
||||
});
|
||||
|
||||
operatorSocket.on('register:success', (data) => {
|
||||
logMessage('info', `Оператор зарегистрирован: ${data.operatorId}`);
|
||||
document.getElementById('operator-connect').disabled = true;
|
||||
document.getElementById('operator-disconnect').disabled = false;
|
||||
|
||||
showAvailableDevices(data.availableDevices || []);
|
||||
});
|
||||
|
||||
operatorSocket.on('device:connected', (data) => {
|
||||
logMessage('info', `Новое устройство доступно: ${data.deviceId}`);
|
||||
refreshDevices();
|
||||
});
|
||||
|
||||
operatorSocket.on('camera:stream-ready', (data) => {
|
||||
logMessage('info', `Камера готова: ${data.sessionId}`);
|
||||
showOperatorSession(data.sessionId, 'active');
|
||||
});
|
||||
|
||||
operatorSocket.on('camera:denied', (data) => {
|
||||
logMessage('warn', `Запрос отклонен: ${data.error}`);
|
||||
showAlert('warning', `Запрос камеры отклонен: ${data.error}`);
|
||||
});
|
||||
|
||||
setupOperatorWebRTC();
|
||||
}
|
||||
|
||||
function disconnectOperator() {
|
||||
if (operatorSocket) {
|
||||
operatorSocket.disconnect();
|
||||
operatorSocket = null;
|
||||
logMessage('warn', 'Оператор отключен');
|
||||
|
||||
document.getElementById('operator-connect').disabled = false;
|
||||
document.getElementById('operator-disconnect').disabled = true;
|
||||
|
||||
// Очищаем списки
|
||||
document.getElementById('available-devices').innerHTML = '';
|
||||
document.getElementById('operator-sessions').innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshDevices() {
|
||||
const operatorId = document.getElementById('operator-id').value;
|
||||
if (!operatorId) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/operators/devices', {
|
||||
headers: { 'X-Operator-Id': operatorId }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
showAvailableDevices(data.devices || []);
|
||||
}
|
||||
} catch (error) {
|
||||
logMessage('error', 'Ошибка загрузки устройств: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function showAvailableDevices(devices) {
|
||||
const container = document.getElementById('available-devices');
|
||||
container.innerHTML = '';
|
||||
|
||||
devices.forEach(device => {
|
||||
const deviceDiv = document.createElement('div');
|
||||
deviceDiv.className = `device-card ${device.isConnected ? 'device-online' : 'device-offline'}`;
|
||||
deviceDiv.innerHTML = `
|
||||
<h4>${device.deviceInfo.model || 'Unknown Device'}</h4>
|
||||
<p><strong>ID:</strong> ${device.deviceId}</p>
|
||||
<p><strong>Статус:</strong> ${device.status}</p>
|
||||
<p><strong>Камеры:</strong> ${device.capabilities?.cameras?.join(', ') || 'Unknown'}</p>
|
||||
${device.canAcceptSession ?
|
||||
`<select id="camera-${device.deviceId}">
|
||||
${device.capabilities?.cameras?.map(camera =>
|
||||
`<option value="${camera}">${getCameraName(camera)}</option>`
|
||||
).join('') || '<option value="back">Основная</option>'}
|
||||
</select>
|
||||
<button class="btn" onclick="requestCamera('${device.deviceId}')">Запросить камеру</button>`
|
||||
: '<p style="color: #666;">Недоступен для новых сессий</p>'
|
||||
}
|
||||
`;
|
||||
container.appendChild(deviceDiv);
|
||||
});
|
||||
}
|
||||
|
||||
function getCameraName(cameraType) {
|
||||
const names = {
|
||||
'back': 'Основная',
|
||||
'front': 'Фронтальная',
|
||||
'ultra_wide': 'Широкоугольная',
|
||||
'telephoto': 'Телеобъектив'
|
||||
};
|
||||
return names[cameraType] || cameraType;
|
||||
}
|
||||
|
||||
async function requestCamera(deviceId) {
|
||||
const operatorId = document.getElementById('operator-id').value;
|
||||
const cameraSelect = document.getElementById(`camera-${deviceId}`);
|
||||
const cameraType = cameraSelect ? cameraSelect.value : 'back';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/operators/camera/request', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Operator-Id': operatorId
|
||||
},
|
||||
body: JSON.stringify({ deviceId, cameraType })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
logMessage('info', `Запрос камеры отправлен: ${data.sessionId}`);
|
||||
showOperatorSession(data.sessionId, 'pending');
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showAlert('danger', 'Ошибка запроса камеры: ' + error.error);
|
||||
}
|
||||
} catch (error) {
|
||||
logMessage('error', 'Ошибка запроса камеры: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function showOperatorSession(sessionId, status) {
|
||||
const container = document.getElementById('operator-sessions');
|
||||
const existingSession = document.getElementById(`operator-session-${sessionId}`);
|
||||
|
||||
if (existingSession) {
|
||||
// Обновляем существующую сессию
|
||||
existingSession.className = `session-card session-${status}`;
|
||||
const statusElement = existingSession.querySelector('.session-status');
|
||||
if (statusElement) statusElement.textContent = status;
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionDiv = document.createElement('div');
|
||||
sessionDiv.className = `session-card session-${status}`;
|
||||
sessionDiv.id = `operator-session-${sessionId}`;
|
||||
sessionDiv.innerHTML = `
|
||||
<h4>Сессия камеры</h4>
|
||||
<p><strong>ID:</strong> ${sessionId}</p>
|
||||
<p><strong>Статус:</strong> <span class="session-status">${status}</span></p>
|
||||
<button class="btn" onclick="switchCamera('${sessionId}', 'front')">Фронтальная</button>
|
||||
<button class="btn" onclick="switchCamera('${sessionId}', 'back')">Основная</button>
|
||||
<button class="btn btn-danger" onclick="endOperatorSession('${sessionId}')">Завершить</button>
|
||||
`;
|
||||
|
||||
container.appendChild(sessionDiv);
|
||||
currentSessionId = sessionId;
|
||||
document.getElementById('current-session-id').value = sessionId;
|
||||
}
|
||||
|
||||
async function switchCamera(sessionId, cameraType) {
|
||||
const operatorId = document.getElementById('operator-id').value;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/operators/camera/${sessionId}/switch`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Operator-Id': operatorId
|
||||
},
|
||||
body: JSON.stringify({ cameraType })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
logMessage('info', `Переключение камеры: ${cameraType}`);
|
||||
showAlert('info', `Запрос переключения камеры на: ${getCameraName(cameraType)}`);
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showAlert('danger', 'Ошибка переключения камеры: ' + error.error);
|
||||
}
|
||||
} catch (error) {
|
||||
logMessage('error', 'Ошибка переключения камеры: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function endOperatorSession(sessionId) {
|
||||
const operatorId = document.getElementById('operator-id').value;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/operators/camera/${sessionId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-Operator-Id': operatorId }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const sessionElement = document.getElementById(`operator-session-${sessionId}`);
|
||||
if (sessionElement) sessionElement.remove();
|
||||
|
||||
if (currentSessionId === sessionId) {
|
||||
currentSessionId = null;
|
||||
document.getElementById('current-session-id').value = '';
|
||||
}
|
||||
|
||||
logMessage('info', `Сессия завершена: ${sessionId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logMessage('error', 'Ошибка завершения сессии: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshSessions() {
|
||||
const operatorId = document.getElementById('operator-id').value;
|
||||
if (!operatorId) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/operators/sessions', {
|
||||
headers: { 'X-Operator-Id': operatorId }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const container = document.getElementById('operator-sessions');
|
||||
container.innerHTML = '';
|
||||
|
||||
data.sessions.forEach(session => {
|
||||
showOperatorSession(session.sessionId, session.status);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logMessage('error', 'Ошибка загрузки сессий: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// === ADMIN FUNCTIONS ===
|
||||
|
||||
async function getSystemHealth() {
|
||||
try {
|
||||
const response = await fetch('/api/admin/health');
|
||||
const data = await response.json();
|
||||
|
||||
const container = document.getElementById('admin-info');
|
||||
container.innerHTML = `
|
||||
<div class="alert alert-${data.health.status === 'healthy' ? 'success' : 'warning'}">
|
||||
<h4>Состояние системы: ${data.health.status}</h4>
|
||||
<p><strong>Память:</strong> ${data.health.memory.used}MB / ${data.health.memory.total}MB</p>
|
||||
<p><strong>Время работы:</strong> ${data.health.uptime} сек</p>
|
||||
<p><strong>Подключения:</strong> ${data.health.connections.devices} устройств, ${data.health.connections.operators} операторов</p>
|
||||
${data.health.warnings ? `<p><strong>Предупреждения:</strong> ${data.health.warnings.join(', ')}</p>` : ''}
|
||||
</div>
|
||||
`;
|
||||
} catch (error) {
|
||||
showAlert('danger', 'Ошибка проверки здоровья: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function getSystemStats() {
|
||||
try {
|
||||
const response = await fetch('/api/admin/stats');
|
||||
const data = await response.json();
|
||||
|
||||
const container = document.getElementById('admin-info');
|
||||
container.innerHTML = `
|
||||
<div class="alert alert-info">
|
||||
<h4>Детальная статистика</h4>
|
||||
<pre>${JSON.stringify(data.stats, null, 2)}</pre>
|
||||
</div>
|
||||
`;
|
||||
} catch (error) {
|
||||
showAlert('danger', 'Ошибка загрузки статистики: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupSystem() {
|
||||
try {
|
||||
const response = await fetch('/api/admin/cleanup', { method: 'POST' });
|
||||
const data = await response.json();
|
||||
|
||||
showAlert('success', `Очистка завершена. Удалено сессий: ${data.removedSessions}`);
|
||||
logMessage('info', `Системная очистка: удалено ${data.removedSessions} сессий`);
|
||||
} catch (error) {
|
||||
showAlert('danger', 'Ошибка очистки: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function getAllDevices() {
|
||||
try {
|
||||
const response = await fetch('/api/admin/devices');
|
||||
const data = await response.json();
|
||||
|
||||
const container = document.getElementById('all-devices');
|
||||
container.innerHTML = '';
|
||||
|
||||
data.devices.forEach(device => {
|
||||
const deviceDiv = document.createElement('div');
|
||||
deviceDiv.className = `device-card ${device.isConnected ? 'device-online' : 'device-offline'}`;
|
||||
deviceDiv.innerHTML = `
|
||||
<h5>${device.deviceInfo.model || 'Unknown Device'}</h5>
|
||||
<p><strong>ID:</strong> ${device.deviceId}</p>
|
||||
<p><strong>Статус:</strong> ${device.status}</p>
|
||||
<p><strong>Активных сессий:</strong> ${device.activeSessions}</p>
|
||||
<p><strong>Время работы:</strong> ${device.uptime} сек</p>
|
||||
`;
|
||||
container.appendChild(deviceDiv);
|
||||
});
|
||||
} catch (error) {
|
||||
showAlert('danger', 'Ошибка загрузки устройств: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function getAllSessions() {
|
||||
try {
|
||||
const response = await fetch('/api/admin/sessions');
|
||||
const data = await response.json();
|
||||
|
||||
const container = document.getElementById('all-sessions');
|
||||
container.innerHTML = '';
|
||||
|
||||
data.sessions.forEach(session => {
|
||||
const sessionDiv = document.createElement('div');
|
||||
sessionDiv.className = `session-card session-${session.status}`;
|
||||
sessionDiv.innerHTML = `
|
||||
<h5>Сессия ${session.sessionId}</h5>
|
||||
<p><strong>Устройство:</strong> ${session.deviceId}</p>
|
||||
<p><strong>Оператор:</strong> ${session.operatorId}</p>
|
||||
<p><strong>Статус:</strong> ${session.status}</p>
|
||||
<p><strong>Продолжительность:</strong> ${session.duration}</p>
|
||||
<p><strong>Камера:</strong> ${getCameraName(session.cameraType)}</p>
|
||||
`;
|
||||
container.appendChild(sessionDiv);
|
||||
});
|
||||
} catch (error) {
|
||||
showAlert('danger', 'Ошибка загрузки сессий: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// === WebRTC TEST ===
|
||||
|
||||
function setupAndroidWebRTC() {
|
||||
if (!androidSocket) return;
|
||||
|
||||
androidSocket.on('webrtc:offer', async (data) => {
|
||||
try {
|
||||
const pc = getOrCreatePeerConnection();
|
||||
await pc.setRemoteDescription(new RTCSessionDescription({
|
||||
type: 'offer',
|
||||
sdp: data.offer
|
||||
}));
|
||||
|
||||
const answer = await pc.createAnswer();
|
||||
await pc.setLocalDescription(answer);
|
||||
|
||||
androidSocket.emit('webrtc:answer', {
|
||||
sessionId: data.sessionId,
|
||||
answer: answer.sdp
|
||||
});
|
||||
|
||||
logMessage('info', 'WebRTC answer отправлен');
|
||||
} catch (error) {
|
||||
logMessage('error', 'WebRTC ошибка: ' + error.message);
|
||||
}
|
||||
});
|
||||
|
||||
androidSocket.on('webrtc:ice-candidate', async (data) => {
|
||||
try {
|
||||
const pc = getOrCreatePeerConnection();
|
||||
const candidate = JSON.parse(data.candidate);
|
||||
await pc.addIceCandidate(new RTCIceCandidate(candidate));
|
||||
} catch (error) {
|
||||
logMessage('error', 'ICE candidate ошибка: ' + error.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setupOperatorWebRTC() {
|
||||
if (!operatorSocket) return;
|
||||
|
||||
operatorSocket.on('webrtc:answer', async (data) => {
|
||||
try {
|
||||
const pc = getOrCreatePeerConnection();
|
||||
await pc.setRemoteDescription(new RTCSessionDescription({
|
||||
type: 'answer',
|
||||
sdp: data.answer
|
||||
}));
|
||||
logMessage('info', 'WebRTC answer получен');
|
||||
} catch (error) {
|
||||
logMessage('error', 'WebRTC ошибка: ' + error.message);
|
||||
}
|
||||
});
|
||||
|
||||
operatorSocket.on('webrtc:ice-candidate', async (data) => {
|
||||
try {
|
||||
const pc = getOrCreatePeerConnection();
|
||||
const candidate = JSON.parse(data.candidate);
|
||||
await pc.addIceCandidate(new RTCIceCandidate(candidate));
|
||||
} catch (error) {
|
||||
logMessage('error', 'ICE candidate ошибка: ' + error.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getOrCreatePeerConnection() {
|
||||
if (!peerConnection) {
|
||||
peerConnection = new RTCPeerConnection(rtcConfig);
|
||||
|
||||
peerConnection.onicecandidate = (event) => {
|
||||
if (event.candidate) {
|
||||
const candidateData = JSON.stringify({
|
||||
candidate: event.candidate.candidate,
|
||||
sdpMid: event.candidate.sdpMid,
|
||||
sdpMLineIndex: event.candidate.sdpMLineIndex
|
||||
});
|
||||
|
||||
if (androidSocket) {
|
||||
androidSocket.emit('webrtc:ice-candidate', {
|
||||
sessionId: currentSessionId,
|
||||
candidate: candidateData
|
||||
});
|
||||
} else if (operatorSocket) {
|
||||
operatorSocket.emit('webrtc:ice-candidate', {
|
||||
sessionId: currentSessionId,
|
||||
candidate: candidateData
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
peerConnection.ontrack = (event) => {
|
||||
const remoteVideo = document.getElementById('remoteVideo');
|
||||
remoteVideo.srcObject = event.streams[0];
|
||||
remoteVideo.style.display = 'block';
|
||||
logMessage('info', 'Получен удаленный видеопоток');
|
||||
};
|
||||
|
||||
peerConnection.onconnectionstatechange = () => {
|
||||
const state = peerConnection.connectionState;
|
||||
logMessage('info', `WebRTC состояние: ${state}`);
|
||||
|
||||
const statusDiv = document.getElementById('webrtc-status');
|
||||
statusDiv.innerHTML = `
|
||||
<div class="alert alert-info">
|
||||
<strong>WebRTC состояние:</strong> ${state}
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
}
|
||||
|
||||
return peerConnection;
|
||||
}
|
||||
|
||||
async function startLocalVideo() {
|
||||
try {
|
||||
localStream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { width: 640, height: 480 },
|
||||
audio: true
|
||||
});
|
||||
|
||||
const localVideo = document.getElementById('localVideo');
|
||||
localVideo.srcObject = localStream;
|
||||
|
||||
const pc = getOrCreatePeerConnection();
|
||||
localStream.getTracks().forEach(track => {
|
||||
pc.addTrack(track, localStream);
|
||||
});
|
||||
|
||||
logMessage('info', 'Локальное видео запущено');
|
||||
} catch (error) {
|
||||
logMessage('error', 'Ошибка доступа к камере: ' + error.message);
|
||||
showAlert('danger', 'Ошибка доступа к камере: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function stopLocalVideo() {
|
||||
if (localStream) {
|
||||
localStream.getTracks().forEach(track => track.stop());
|
||||
localStream = null;
|
||||
|
||||
const localVideo = document.getElementById('localVideo');
|
||||
localVideo.srcObject = null;
|
||||
|
||||
logMessage('info', 'Локальное видео остановлено');
|
||||
}
|
||||
|
||||
if (peerConnection) {
|
||||
peerConnection.close();
|
||||
peerConnection = null;
|
||||
|
||||
const remoteVideo = document.getElementById('remoteVideo');
|
||||
remoteVideo.srcObject = null;
|
||||
remoteVideo.style.display = 'none';
|
||||
|
||||
logMessage('info', 'WebRTC соединение закрыто');
|
||||
}
|
||||
}
|
||||
|
||||
// Вспомогательная функция для показа уведомлений
|
||||
function showAlert(type, message) {
|
||||
// Создаем временное уведомление
|
||||
const alert = document.createElement('div');
|
||||
alert.className = `alert alert-${type}`;
|
||||
alert.textContent = message;
|
||||
alert.style.position = 'fixed';
|
||||
alert.style.top = '20px';
|
||||
alert.style.right = '20px';
|
||||
alert.style.zIndex = '9999';
|
||||
alert.style.minWidth = '300px';
|
||||
|
||||
document.body.appendChild(alert);
|
||||
|
||||
// Удаляем через 5 секунд
|
||||
setTimeout(() => {
|
||||
if (document.body.contains(alert)) {
|
||||
document.body.removeChild(alert);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
481
backend/public/index.html
Normal file
481
backend/public/index.html
Normal file
@@ -0,0 +1,481 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GodEye Signal Center - Demo</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: #f5f7fa;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
margin-bottom: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
padding: 15px 20px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
background: #f8f9ff;
|
||||
border: none;
|
||||
font-size: 1rem;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background: #e8ebff;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.status-connected {
|
||||
border-left: 4px solid #28a745;
|
||||
}
|
||||
|
||||
.status-disconnected {
|
||||
border-left: 4px solid #dc3545;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
margin: 5px;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #5a6fd8;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #dc3545;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: #28a745;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background: #218838;
|
||||
}
|
||||
|
||||
.device-card, .session-card {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.device-online {
|
||||
border-left: 4px solid #28a745;
|
||||
}
|
||||
|
||||
.device-offline {
|
||||
border-left: 4px solid #6c757d;
|
||||
}
|
||||
|
||||
.session-active {
|
||||
border-left: 4px solid #007bff;
|
||||
}
|
||||
|
||||
.session-pending {
|
||||
border-left: 4px solid #ffc107;
|
||||
}
|
||||
|
||||
.session-closed {
|
||||
border-left: 4px solid #6c757d;
|
||||
}
|
||||
|
||||
.logs {
|
||||
background: #1e1e1e;
|
||||
color: #f8f8f2;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
font-family: 'Courier New', monospace;
|
||||
height: 300px;
|
||||
overflow-y: auto;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
margin-bottom: 5px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.log-info { color: #66d9ef; }
|
||||
.log-warn { color: #f4bf75; }
|
||||
.log-error { color: #f92672; }
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.video-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
margin: 20px auto;
|
||||
}
|
||||
|
||||
#localVideo, #remoteVideo {
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 15px;
|
||||
margin-bottom: 15px;
|
||||
border-radius: 5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background: #d1ecf1;
|
||||
color: #0c5460;
|
||||
border: 1px solid #bee5eb;
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
border: 1px solid #ffeaa7;
|
||||
}
|
||||
|
||||
.alert-danger {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.tabs {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🎥 GodEye Signal Center</h1>
|
||||
<p>Веб-демонстрация системы удаленного доступа к камерам</p>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab active" onclick="showTab('overview')">Обзор</button>
|
||||
<button class="tab" onclick="showTab('android')">Android Device</button>
|
||||
<button class="tab" onclick="showTab('operator')">Operator</button>
|
||||
<button class="tab" onclick="showTab('admin')">Admin</button>
|
||||
<button class="tab" onclick="showTab('webrtc')">WebRTC Test</button>
|
||||
</div>
|
||||
|
||||
<!-- Overview Tab -->
|
||||
<div id="overview" class="tab-content active">
|
||||
<h2>Системная статистика</h2>
|
||||
<div class="stats-grid" id="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-number" id="stat-devices">-</div>
|
||||
<div class="stat-label">Подключенных устройств</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-number" id="stat-operators">-</div>
|
||||
<div class="stat-label">Активных операторов</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-number" id="stat-sessions">-</div>
|
||||
<div class="stat-label">Активных сессий</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-number" id="stat-uptime">-</div>
|
||||
<div class="stat-label">Время работы (мин)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-card" id="connection-status">
|
||||
<h3>Состояние подключения</h3>
|
||||
<p id="connection-text">Подключение...</p>
|
||||
</div>
|
||||
|
||||
<h3>Логи системы</h3>
|
||||
<div class="logs" id="system-logs"></div>
|
||||
<button class="btn" onclick="clearLogs()">Очистить логи</button>
|
||||
</div>
|
||||
|
||||
<!-- Android Device Tab -->
|
||||
<div id="android" class="tab-content">
|
||||
<h2>Симулятор Android устройства</h2>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Device ID:</label>
|
||||
<input type="text" id="android-device-id" value="demo-device-001" placeholder="Введите ID устройства">
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div>
|
||||
<h3>Информация об устройстве</h3>
|
||||
<div class="form-group">
|
||||
<label>Модель:</label>
|
||||
<input type="text" id="android-model" value="Samsung Galaxy S21">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Производитель:</label>
|
||||
<input type="text" id="android-manufacturer" value="Samsung">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Версия Android:</label>
|
||||
<input type="text" id="android-version" value="12">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Доступные камеры:</label>
|
||||
<select id="android-cameras" multiple>
|
||||
<option value="back" selected>Основная</option>
|
||||
<option value="front" selected>Фронтальная</option>
|
||||
<option value="ultra_wide">Широкоугольная</option>
|
||||
<option value="telephoto">Телеобъектив</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-success" id="android-connect" onclick="connectAndroid()">
|
||||
Подключить устройство
|
||||
</button>
|
||||
<button class="btn btn-danger" id="android-disconnect" onclick="disconnectAndroid()" disabled>
|
||||
Отключить устройство
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3>Входящие запросы</h3>
|
||||
<div id="android-requests"></div>
|
||||
|
||||
<h3>Активные сессии</h3>
|
||||
<div id="android-sessions"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Operator Tab -->
|
||||
<div id="operator" class="tab-content">
|
||||
<h2>Панель оператора</h2>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Operator ID:</label>
|
||||
<input type="text" id="operator-id" value="demo-operator-001" placeholder="Введите ID оператора">
|
||||
</div>
|
||||
|
||||
<button class="btn btn-success" id="operator-connect" onclick="connectOperator()">
|
||||
Подключиться как оператор
|
||||
</button>
|
||||
<button class="btn btn-danger" id="operator-disconnect" onclick="disconnectOperator()" disabled>
|
||||
Отключиться
|
||||
</button>
|
||||
|
||||
<div class="grid">
|
||||
<div>
|
||||
<h3>Доступные устройства</h3>
|
||||
<button class="btn" onclick="refreshDevices()">Обновить список</button>
|
||||
<div id="available-devices"></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3>Мои сессии</h3>
|
||||
<button class="btn" onclick="refreshSessions()">Обновить сессии</button>
|
||||
<div id="operator-sessions"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Admin Tab -->
|
||||
<div id="admin" class="tab-content">
|
||||
<h2>Административная панель</h2>
|
||||
|
||||
<div class="grid">
|
||||
<div>
|
||||
<h3>Управление системой</h3>
|
||||
<button class="btn" onclick="getSystemHealth()">Проверить здоровье системы</button>
|
||||
<button class="btn" onclick="getSystemStats()">Получить статистику</button>
|
||||
<button class="btn btn-danger" onclick="cleanupSystem()">Очистить систему</button>
|
||||
|
||||
<div id="admin-info"></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3>Все устройства</h3>
|
||||
<button class="btn" onclick="getAllDevices()">Загрузить устройства</button>
|
||||
<div id="all-devices"></div>
|
||||
|
||||
<h3>Все сессии</h3>
|
||||
<button class="btn" onclick="getAllSessions()">Загрузить сессии</button>
|
||||
<div id="all-sessions"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- WebRTC Test Tab -->
|
||||
<div id="webrtc" class="tab-content">
|
||||
<h2>Тест WebRTC соединения</h2>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<strong>Информация:</strong> Этот раздел демонстрирует WebRTC соединение между устройством и оператором.
|
||||
Сначала подключитесь как Android устройство, затем как оператор и создайте сессию.
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Текущая сессия:</label>
|
||||
<input type="text" id="current-session-id" placeholder="ID активной сессии" readonly>
|
||||
</div>
|
||||
|
||||
<button class="btn" onclick="startLocalVideo()">Запустить локальное видео</button>
|
||||
<button class="btn" onclick="stopLocalVideo()">Остановить видео</button>
|
||||
|
||||
<div class="video-container">
|
||||
<video id="localVideo" autoplay muted playsinline></video>
|
||||
<video id="remoteVideo" autoplay playsinline style="display: none;"></video>
|
||||
</div>
|
||||
|
||||
<div id="webrtc-status"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/socket.io/socket.io.js"></script>
|
||||
<script src="demo.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user