init commit

This commit is contained in:
2025-09-28 22:00:44 +09:00
commit 25cb9d9c8f
5877 changed files with 582116 additions and 0 deletions

View File

@@ -0,0 +1,453 @@
class DeviceManager {
constructor() {
this.devices = new Map(); // deviceId -> DeviceInfo
this.operators = new Map(); // operatorId -> OperatorInfo
}
/**
* Регистрация Android устройства
* @param {string} deviceId
* @param {object} deviceInfo
* @param {object} socket
* @returns {DeviceInfo}
*/
registerDevice(deviceId, deviceInfo, socket) {
const device = new DeviceInfo(deviceId, deviceInfo, socket);
this.devices.set(deviceId, device);
return device;
}
/**
* Регистрация оператора
* @param {string} operatorId
* @param {object} operatorInfo
* @param {object} socket
* @returns {OperatorInfo}
*/
registerOperator(operatorId, operatorInfo, socket) {
const operator = new OperatorInfo(operatorId, operatorInfo, socket);
this.operators.set(operatorId, operator);
return operator;
}
/**
* Получение устройства
* @param {string} deviceId
* @returns {DeviceInfo|null}
*/
getDevice(deviceId) {
return this.devices.get(deviceId) || null;
}
/**
* Получение оператора
* @param {string} operatorId
* @returns {OperatorInfo|null}
*/
getOperator(operatorId) {
return this.operators.get(operatorId) || null;
}
/**
* Получение всех подключенных устройств
* @returns {DeviceInfo[]}
*/
getConnectedDevices() {
return Array.from(this.devices.values()).filter(device => device.isConnected());
}
/**
* Получение всех подключенных операторов
* @returns {OperatorInfo[]}
*/
getConnectedOperators() {
return Array.from(this.operators.values()).filter(operator => operator.isConnected());
}
/**
* Получение доступных устройств для оператора
* @param {string} operatorId
* @returns {DeviceInfo[]}
*/
getAvailableDevicesForOperator(operatorId) {
return this.getConnectedDevices().filter(device => {
// Устройство доступно если оно подключено и не занято активной сессией
return device.isConnected() && device.canAcceptNewSession();
});
}
/**
* Отключение устройства
* @param {string} deviceId
*/
disconnectDevice(deviceId) {
const device = this.devices.get(deviceId);
if (device) {
device.disconnect();
this.devices.delete(deviceId);
}
}
/**
* Отключение оператора
* @param {string} operatorId
*/
disconnectOperator(operatorId) {
const operator = this.operators.get(operatorId);
if (operator) {
operator.disconnect();
this.operators.delete(operatorId);
}
}
/**
* Обновление местоположения устройства
* @param {string} deviceId
* @param {object} location
*/
updateDeviceLocation(deviceId, location) {
const device = this.devices.get(deviceId);
if (device) {
device.updateLocation(location);
}
}
/**
* Обновление статуса устройства
* @param {string} deviceId
* @param {string} status
*/
updateDeviceStatus(deviceId, status) {
const device = this.devices.get(deviceId);
if (device) {
device.updateStatus(status);
}
}
/**
* Получение статистики
* @returns {object}
*/
getStats() {
const devices = Array.from(this.devices.values());
const operators = Array.from(this.operators.values());
return {
totalDevices: devices.length,
connectedDevices: devices.filter(d => d.isConnected()).length,
totalOperators: operators.length,
connectedOperators: operators.filter(o => o.isConnected()).length,
devicesByPlatform: this.getDevicesByPlatform(),
devicesByStatus: this.getDevicesByStatus(),
averageUptime: this.calculateAverageUptime()
};
}
/**
* Статистика устройств по платформе
* @returns {object}
*/
getDevicesByPlatform() {
const stats = {};
Array.from(this.devices.values()).forEach(device => {
const platform = device.deviceInfo.manufacturer || 'unknown';
stats[platform] = (stats[platform] || 0) + 1;
});
return stats;
}
/**
* Статистика устройств по статусу
* @returns {object}
*/
getDevicesByStatus() {
const stats = {};
Array.from(this.devices.values()).forEach(device => {
stats[device.status] = (stats[device.status] || 0) + 1;
});
return stats;
}
/**
* Расчет среднего времени работы
* @returns {number}
*/
calculateAverageUptime() {
const devices = Array.from(this.devices.values());
if (devices.length === 0) return 0;
const totalUptime = devices.reduce((sum, device) => sum + device.getUptime(), 0);
return Math.round(totalUptime / devices.length / 1000); // в секундах
}
/**
* Очистка отключенных устройств и операторов
*/
cleanup() {
// Удаляем отключенные устройства старше 5 минут
const cutoffTime = Date.now() - (5 * 60 * 1000);
for (const [deviceId, device] of this.devices) {
if (!device.isConnected() && device.lastSeen < cutoffTime) {
this.devices.delete(deviceId);
}
}
for (const [operatorId, operator] of this.operators) {
if (!operator.isConnected() && operator.lastSeen < cutoffTime) {
this.operators.delete(operatorId);
}
}
}
}
class DeviceInfo {
constructor(deviceId, deviceInfo, socket) {
this.deviceId = deviceId;
this.deviceInfo = deviceInfo;
this.socket = socket;
this.status = 'connected';
this.connectedAt = Date.now();
this.lastSeen = Date.now();
this.location = null;
this.activeSessions = new Set();
this.totalSessions = 0;
this.capabilities = this.parseCapabilities(deviceInfo);
this.healthCheck = {
lastPing: Date.now(),
responseTime: null,
errors: 0
};
}
/**
* Парсинг возможностей устройства
* @param {object} deviceInfo
* @returns {object}
*/
parseCapabilities(deviceInfo) {
const availableCameras = deviceInfo.availableCameras?.split(',') || ['back'];
return {
cameras: availableCameras,
hasMultipleCameras: availableCameras.length > 1,
hasUltraWide: availableCameras.includes('ultra_wide'),
hasTelephoto: availableCameras.includes('telephoto'),
supportedResolutions: deviceInfo.supportedResolutions || ['720p', '1080p'],
maxConcurrentSessions: deviceInfo.maxConcurrentSessions || 1
};
}
/**
* Проверка подключения
* @returns {boolean}
*/
isConnected() {
return this.socket && this.socket.connected && this.status === 'connected';
}
/**
* Может ли устройство принять новую сессию
* @returns {boolean}
*/
canAcceptNewSession() {
return this.isConnected() &&
this.activeSessions.size < this.capabilities.maxConcurrentSessions &&
this.status === 'connected';
}
/**
* Добавление активной сессии
* @param {string} sessionId
*/
addSession(sessionId) {
this.activeSessions.add(sessionId);
this.totalSessions++;
this.updateStatus('busy');
}
/**
* Удаление активной сессии
* @param {string} sessionId
*/
removeSession(sessionId) {
this.activeSessions.delete(sessionId);
if (this.activeSessions.size === 0) {
this.updateStatus('connected');
}
}
/**
* Обновление статуса
* @param {string} status
*/
updateStatus(status) {
this.status = status;
this.lastSeen = Date.now();
}
/**
* Обновление местоположения
* @param {object} location
*/
updateLocation(location) {
this.location = {
latitude: location.latitude,
longitude: location.longitude,
accuracy: location.accuracy,
timestamp: Date.now()
};
}
/**
* Отключение устройства
*/
disconnect() {
this.status = 'disconnected';
this.lastSeen = Date.now();
}
/**
* Получение времени работы
* @returns {number}
*/
getUptime() {
return Date.now() - this.connectedAt;
}
/**
* Обновление health check
* @param {number} responseTime
*/
updateHealthCheck(responseTime) {
this.healthCheck.lastPing = Date.now();
this.healthCheck.responseTime = responseTime;
this.healthCheck.errors = 0;
}
/**
* Регистрация ошибки
*/
recordError() {
this.healthCheck.errors++;
}
/**
* Получение сводки устройства
* @returns {object}
*/
getSummary() {
return {
deviceId: this.deviceId,
deviceInfo: this.deviceInfo,
status: this.status,
isConnected: this.isConnected(),
connectedAt: new Date(this.connectedAt).toISOString(),
lastSeen: new Date(this.lastSeen).toISOString(),
uptime: Math.round(this.getUptime() / 1000), // в секундах
location: this.location,
capabilities: this.capabilities,
activeSessions: this.activeSessions.size,
totalSessions: this.totalSessions,
canAcceptSession: this.canAcceptNewSession(),
healthCheck: this.healthCheck
};
}
}
class OperatorInfo {
constructor(operatorId, operatorInfo, socket) {
this.operatorId = operatorId;
this.operatorInfo = operatorInfo || {};
this.socket = socket;
this.status = 'connected';
this.connectedAt = Date.now();
this.lastSeen = Date.now();
this.activeSessions = new Set();
this.totalSessions = 0;
this.permissions = this.operatorInfo.permissions || ['view_cameras', 'request_camera'];
}
/**
* Проверка подключения
* @returns {boolean}
*/
isConnected() {
return this.socket && this.socket.connected && this.status === 'connected';
}
/**
* Проверка разрешений
* @param {string} permission
* @returns {boolean}
*/
hasPermission(permission) {
return this.permissions.includes(permission) || this.permissions.includes('admin');
}
/**
* Добавление активной сессии
* @param {string} sessionId
*/
addSession(sessionId) {
this.activeSessions.add(sessionId);
this.totalSessions++;
}
/**
* Удаление активной сессии
* @param {string} sessionId
*/
removeSession(sessionId) {
this.activeSessions.delete(sessionId);
}
/**
* Обновление последнего времени активности
*/
updateActivity() {
this.lastSeen = Date.now();
}
/**
* Отключение оператора
*/
disconnect() {
this.status = 'disconnected';
this.lastSeen = Date.now();
}
/**
* Получение времени работы
* @returns {number}
*/
getUptime() {
return Date.now() - this.connectedAt;
}
/**
* Получение сводки оператора
* @returns {object}
*/
getSummary() {
return {
operatorId: this.operatorId,
operatorInfo: this.operatorInfo,
status: this.status,
isConnected: this.isConnected(),
connectedAt: new Date(this.connectedAt).toISOString(),
lastSeen: new Date(this.lastSeen).toISOString(),
uptime: Math.round(this.getUptime() / 1000), // в секундах
activeSessions: this.activeSessions.size,
totalSessions: this.totalSessions,
permissions: this.permissions
};
}
}
module.exports = {
DeviceManager,
DeviceInfo,
OperatorInfo
};

View File

@@ -0,0 +1,385 @@
const { v4: uuidv4 } = require('uuid');
class SessionManager {
constructor() {
this.sessions = new Map(); // sessionId -> SessionInfo
this.deviceSessions = new Map(); // deviceId -> Set(sessionIds)
this.operatorSessions = new Map(); // operatorId -> Set(sessionIds)
}
/**
* Создание новой сессии
* @param {string} deviceId
* @param {string} operatorId
* @param {string} cameraType
* @returns {SessionInfo}
*/
createSession(deviceId, operatorId, cameraType = 'back') {
const sessionId = uuidv4();
const session = new SessionInfo(sessionId, deviceId, operatorId, cameraType);
this.sessions.set(sessionId, session);
// Добавляем к устройству
if (!this.deviceSessions.has(deviceId)) {
this.deviceSessions.set(deviceId, new Set());
}
this.deviceSessions.get(deviceId).add(sessionId);
// Добавляем к оператору
if (!this.operatorSessions.has(operatorId)) {
this.operatorSessions.set(operatorId, new Set());
}
this.operatorSessions.get(operatorId).add(sessionId);
return session;
}
/**
* Получение сессии по ID
* @param {string} sessionId
* @returns {SessionInfo|null}
*/
getSession(sessionId) {
return this.sessions.get(sessionId) || null;
}
/**
* Получение всех сессий устройства
* @param {string} deviceId
* @returns {SessionInfo[]}
*/
getDeviceSessions(deviceId) {
const sessionIds = this.deviceSessions.get(deviceId);
if (!sessionIds) return [];
return Array.from(sessionIds)
.map(id => this.sessions.get(id))
.filter(session => session !== undefined);
}
/**
* Получение всех сессий оператора
* @param {string} operatorId
* @returns {SessionInfo[]}
*/
getOperatorSessions(operatorId) {
const sessionIds = this.operatorSessions.get(operatorId);
if (!sessionIds) return [];
return Array.from(sessionIds)
.map(id => this.sessions.get(id))
.filter(session => session !== undefined);
}
/**
* Обновление состояния сессии
* @param {string} sessionId
* @param {string} status
* @param {object} metadata
*/
updateSession(sessionId, status, metadata = {}) {
const session = this.sessions.get(sessionId);
if (session) {
session.updateStatus(status, metadata);
}
}
/**
* Завершение сессии
* @param {string} sessionId
* @returns {SessionInfo|null}
*/
closeSession(sessionId) {
const session = this.sessions.get(sessionId);
if (!session) return null;
session.close();
// Удаляем из устройства
const deviceSessions = this.deviceSessions.get(session.deviceId);
if (deviceSessions) {
deviceSessions.delete(sessionId);
if (deviceSessions.size === 0) {
this.deviceSessions.delete(session.deviceId);
}
}
// Удаляем из оператора
const operatorSessions = this.operatorSessions.get(session.operatorId);
if (operatorSessions) {
operatorSessions.delete(sessionId);
if (operatorSessions.size === 0) {
this.operatorSessions.delete(session.operatorId);
}
}
this.sessions.delete(sessionId);
return session;
}
/**
* Закрытие всех сессий устройства
* @param {string} deviceId
*/
closeDeviceSessions(deviceId) {
const sessions = this.getDeviceSessions(deviceId);
sessions.forEach(session => this.closeSession(session.sessionId));
}
/**
* Закрытие всех сессий оператора
* @param {string} operatorId
*/
closeOperatorSessions(operatorId) {
const sessions = this.getOperatorSessions(operatorId);
sessions.forEach(session => this.closeSession(session.sessionId));
}
/**
* Получение статистики
* @returns {object}
*/
getStats() {
const sessions = Array.from(this.sessions.values());
return {
totalSessions: sessions.length,
activeSessions: sessions.filter(s => s.status === 'active').length,
pendingSessions: sessions.filter(s => s.status === 'pending').length,
closedSessions: sessions.filter(s => s.status === 'closed').length,
devices: this.deviceSessions.size,
operators: this.operatorSessions.size,
avgSessionDuration: this.calculateAverageSessionDuration(),
sessionsByCamera: this.getSessionsByCameraType()
};
}
/**
* Расчет средней продолжительности сессий
* @returns {number}
*/
calculateAverageSessionDuration() {
const closedSessions = Array.from(this.sessions.values())
.filter(s => s.status === 'closed' && s.endTime);
if (closedSessions.length === 0) return 0;
const totalDuration = closedSessions.reduce((sum, session) => {
return sum + (session.endTime - session.startTime);
}, 0);
return Math.round(totalDuration / closedSessions.length / 1000); // в секундах
}
/**
* Статистика по типам камер
* @returns {object}
*/
getSessionsByCameraType() {
const stats = {};
Array.from(this.sessions.values()).forEach(session => {
stats[session.cameraType] = (stats[session.cameraType] || 0) + 1;
});
return stats;
}
/**
* Получение всех активных сессий
* @returns {SessionInfo[]}
*/
getActiveSessions() {
return Array.from(this.sessions.values())
.filter(session => session.status === 'active');
}
/**
* Очистка старых закрытых сессий (старше N минут)
* @param {number} maxAgeMinutes
*/
cleanupOldSessions(maxAgeMinutes = 60) {
const cutoffTime = Date.now() - (maxAgeMinutes * 60 * 1000);
const sessionsToRemove = [];
for (const [sessionId, session] of this.sessions) {
if (session.status === 'closed' && session.endTime < cutoffTime) {
sessionsToRemove.push(sessionId);
}
}
sessionsToRemove.forEach(sessionId => {
const session = this.sessions.get(sessionId);
if (session) {
this.sessions.delete(sessionId);
// Очистка из устройств и операторов
const deviceSessions = this.deviceSessions.get(session.deviceId);
if (deviceSessions) {
deviceSessions.delete(sessionId);
}
const operatorSessions = this.operatorSessions.get(session.operatorId);
if (operatorSessions) {
operatorSessions.delete(sessionId);
}
}
});
return sessionsToRemove.length;
}
}
class SessionInfo {
constructor(sessionId, deviceId, operatorId, cameraType) {
this.sessionId = sessionId;
this.deviceId = deviceId;
this.operatorId = operatorId;
this.cameraType = cameraType;
this.status = 'pending'; // pending, active, closed, error
this.startTime = Date.now();
this.endTime = null;
this.metadata = {};
this.events = [];
this.webrtcState = null;
this.quality = null;
this.bandwidth = null;
}
/**
* Обновление статуса сессии
* @param {string} status
* @param {object} metadata
*/
updateStatus(status, metadata = {}) {
this.status = status;
this.metadata = { ...this.metadata, ...metadata };
this.events.push({
timestamp: Date.now(),
event: 'status_change',
data: { status, metadata }
});
if (status === 'active' && !this.startTime) {
this.startTime = Date.now();
}
}
/**
* Обновление WebRTC состояния
* @param {string} state
*/
updateWebRTCState(state) {
this.webrtcState = state;
this.events.push({
timestamp: Date.now(),
event: 'webrtc_state_change',
data: { state }
});
}
/**
* Обновление качества потока
* @param {object} qualityData
*/
updateQuality(qualityData) {
this.quality = qualityData;
this.events.push({
timestamp: Date.now(),
event: 'quality_update',
data: qualityData
});
}
/**
* Смена типа камеры
* @param {string} newCameraType
*/
switchCamera(newCameraType) {
const oldType = this.cameraType;
this.cameraType = newCameraType;
this.events.push({
timestamp: Date.now(),
event: 'camera_switch',
data: { from: oldType, to: newCameraType }
});
}
/**
* Закрытие сессии
*/
close() {
this.status = 'closed';
this.endTime = Date.now();
this.events.push({
timestamp: Date.now(),
event: 'session_closed',
data: { duration: this.getDuration() }
});
}
/**
* Получение продолжительности сессии в миллисекундах
* @returns {number}
*/
getDuration() {
const endTime = this.endTime || Date.now();
return endTime - this.startTime;
}
/**
* Получение человекочитаемой продолжительности
* @returns {string}
*/
getFormattedDuration() {
const duration = this.getDuration();
const seconds = Math.floor(duration / 1000) % 60;
const minutes = Math.floor(duration / (1000 * 60)) % 60;
const hours = Math.floor(duration / (1000 * 60 * 60));
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
}
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
/**
* Получение сводки сессии
* @returns {object}
*/
getSummary() {
return {
sessionId: this.sessionId,
deviceId: this.deviceId,
operatorId: this.operatorId,
cameraType: this.cameraType,
status: this.status,
duration: this.getFormattedDuration(),
durationMs: this.getDuration(),
startTime: new Date(this.startTime).toISOString(),
endTime: this.endTime ? new Date(this.endTime).toISOString() : null,
webrtcState: this.webrtcState,
quality: this.quality,
eventsCount: this.events.length
};
}
/**
* Получение полной информации о сессии
* @returns {object}
*/
getFullInfo() {
return {
...this.getSummary(),
metadata: this.metadata,
events: this.events
};
}
}
module.exports = {
SessionManager,
SessionInfo
};

411
backend/src/routes/admin.js Normal file
View File

@@ -0,0 +1,411 @@
const express = require('express');
const router = express.Router();
/**
* GET /api/admin/stats
* Получение общей статистики системы
*/
router.get('/stats', (req, res) => {
try {
const { deviceManager, sessionManager } = req.app.locals;
const deviceStats = deviceManager.getStats();
const sessionStats = sessionManager.getStats();
const systemStats = {
timestamp: new Date().toISOString(),
uptime: Math.round(process.uptime()),
memory: process.memoryUsage(),
devices: deviceStats,
sessions: sessionStats,
system: {
nodeVersion: process.version,
platform: process.platform,
arch: process.arch
}
};
res.json({
success: true,
stats: systemStats
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
/**
* GET /api/admin/devices
* Получение списка всех устройств (включая отключенные)
*/
router.get('/devices', (req, res) => {
try {
const { deviceManager } = req.app.locals;
const { status, limit = 100, offset = 0 } = req.query;
let devices = Array.from(deviceManager.devices.values());
// Фильтрация по статусу
if (status) {
devices = devices.filter(device => device.status === status);
}
// Пагинация
const total = devices.length;
devices = devices.slice(offset, offset + parseInt(limit));
const devicesData = devices.map(device => device.getSummary());
res.json({
success: true,
devices: devicesData,
pagination: {
total,
limit: parseInt(limit),
offset: parseInt(offset),
hasMore: (offset + parseInt(limit)) < total
}
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
/**
* GET /api/admin/operators
* Получение списка всех операторов
*/
router.get('/operators', (req, res) => {
try {
const { deviceManager } = req.app.locals;
const { status, limit = 100, offset = 0 } = req.query;
let operators = Array.from(deviceManager.operators.values());
// Фильтрация по статусу
if (status) {
operators = operators.filter(operator => operator.status === status);
}
// Пагинация
const total = operators.length;
operators = operators.slice(offset, offset + parseInt(limit));
const operatorsData = operators.map(operator => operator.getSummary());
res.json({
success: true,
operators: operatorsData,
pagination: {
total,
limit: parseInt(limit),
offset: parseInt(offset),
hasMore: (offset + parseInt(limit)) < total
}
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
/**
* GET /api/admin/sessions
* Получение списка всех сессий
*/
router.get('/sessions', (req, res) => {
try {
const { sessionManager } = req.app.locals;
const { status, deviceId, operatorId, limit = 100, offset = 0 } = req.query;
let sessions = Array.from(sessionManager.sessions.values());
// Фильтрация
if (status) {
sessions = sessions.filter(session => session.status === status);
}
if (deviceId) {
sessions = sessions.filter(session => session.deviceId === deviceId);
}
if (operatorId) {
sessions = sessions.filter(session => session.operatorId === operatorId);
}
// Сортировка по времени создания (новые первыми)
sessions.sort((a, b) => b.startTime - a.startTime);
// Пагинация
const total = sessions.length;
sessions = sessions.slice(offset, offset + parseInt(limit));
const sessionsData = sessions.map(session => session.getSummary());
res.json({
success: true,
sessions: sessionsData,
pagination: {
total,
limit: parseInt(limit),
offset: parseInt(offset),
hasMore: (offset + parseInt(limit)) < total
}
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
/**
* GET /api/admin/sessions/:sessionId
* Получение детальной информации о сессии (включая события)
*/
router.get('/sessions/:sessionId', (req, res) => {
try {
const { sessionId } = req.params;
const { sessionManager } = req.app.locals;
const session = sessionManager.getSession(sessionId);
if (!session) {
return res.status(404).json({ error: 'Session not found' });
}
res.json({
success: true,
session: session.getFullInfo()
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
/**
* POST /api/admin/sessions/:sessionId/terminate
* Принудительное завершение сессии
*/
router.post('/sessions/:sessionId/terminate', (req, res) => {
try {
const { sessionId } = req.params;
const { reason = 'Terminated by admin' } = req.body;
const { sessionManager, deviceManager } = req.app.locals;
const session = sessionManager.getSession(sessionId);
if (!session) {
return res.status(404).json({ error: 'Session not found' });
}
const device = deviceManager.getDevice(session.deviceId);
const operator = deviceManager.getOperator(session.operatorId);
// Уведомляем участников
if (device && device.isConnected()) {
device.socket.emit('camera:disconnect', { sessionId, reason });
device.removeSession(sessionId);
}
if (operator && operator.isConnected()) {
operator.socket.emit('camera:disconnected', { sessionId, reason });
operator.removeSession(sessionId);
}
// Закрываем сессию
session.updateStatus('terminated', { reason, terminatedBy: 'admin' });
sessionManager.closeSession(sessionId);
req.app.locals.logger.warn(`Session ${sessionId} terminated by admin: ${reason}`);
res.json({
success: true,
message: 'Session terminated',
sessionId
});
} catch (error) {
req.app.locals.logger.error('Error terminating session', error);
res.status(500).json({ error: error.message });
}
});
/**
* POST /api/admin/devices/:deviceId/disconnect
* Принудительное отключение устройства
*/
router.post('/devices/:deviceId/disconnect', (req, res) => {
try {
const { deviceId } = req.params;
const { reason = 'Disconnected by admin' } = req.body;
const { deviceManager, sessionManager } = req.app.locals;
const device = deviceManager.getDevice(deviceId);
if (!device) {
return res.status(404).json({ error: 'Device not found' });
}
// Завершаем все активные сессии устройства
const deviceSessions = sessionManager.getDeviceSessions(deviceId);
deviceSessions.forEach(session => {
if (session.status === 'active' || session.status === 'pending') {
const operator = deviceManager.getOperator(session.operatorId);
if (operator && operator.isConnected()) {
operator.socket.emit('camera:disconnected', {
sessionId: session.sessionId,
reason: `Device disconnected: ${reason}`
});
operator.removeSession(session.sessionId);
}
session.updateStatus('terminated', { reason, terminatedBy: 'admin' });
sessionManager.closeSession(session.sessionId);
}
});
// Отключаем устройство
if (device.isConnected()) {
device.socket.emit('force:disconnect', { reason });
device.socket.disconnect();
}
deviceManager.disconnectDevice(deviceId);
req.app.locals.logger.warn(`Device ${deviceId} disconnected by admin: ${reason}`);
res.json({
success: true,
message: 'Device disconnected',
deviceId,
terminatedSessions: deviceSessions.length
});
} catch (error) {
req.app.locals.logger.error('Error disconnecting device', error);
res.status(500).json({ error: error.message });
}
});
/**
* POST /api/admin/cleanup
* Очистка старых сессий и отключенных устройств
*/
router.post('/cleanup', (req, res) => {
try {
const { maxAgeMinutes = 60 } = req.body;
const { deviceManager, sessionManager } = req.app.locals;
// Очистка старых сессий
const cleanedSessions = sessionManager.cleanupOldSessions(maxAgeMinutes);
// Очистка отключенных устройств
deviceManager.cleanup();
req.app.locals.logger.info(`Cleanup completed: ${cleanedSessions} sessions removed`);
res.json({
success: true,
message: 'Cleanup completed',
removedSessions: cleanedSessions
});
} catch (error) {
req.app.locals.logger.error('Error during cleanup', error);
res.status(500).json({ error: error.message });
}
});
/**
* GET /api/admin/health
* Проверка состояния системы
*/
router.get('/health', (req, res) => {
try {
const { deviceManager, sessionManager } = req.app.locals;
const health = {
status: 'healthy',
timestamp: new Date().toISOString(),
uptime: Math.round(process.uptime()),
memory: {
used: Math.round(process.memoryUsage().heapUsed / 1024 / 1024),
total: Math.round(process.memoryUsage().heapTotal / 1024 / 1024),
external: Math.round(process.memoryUsage().external / 1024 / 1024)
},
connections: {
devices: deviceManager.getConnectedDevices().length,
operators: deviceManager.getConnectedOperators().length,
activeSessions: sessionManager.getActiveSessions().length
}
};
// Проверка на критические состояния
if (health.memory.used / health.memory.total > 0.9) {
health.status = 'warning';
health.warnings = health.warnings || [];
health.warnings.push('High memory usage');
}
if (health.uptime < 60) {
health.status = 'warning';
health.warnings = health.warnings || [];
health.warnings.push('Recently restarted');
}
res.json({
success: true,
health
});
} catch (error) {
res.status(500).json({
success: false,
health: {
status: 'unhealthy',
error: error.message,
timestamp: new Date().toISOString()
}
});
}
});
/**
* GET /api/admin/logs
* Получение логов системы (последние записи)
*/
router.get('/logs', (req, res) => {
try {
const fs = require('fs');
const path = require('path');
const { lines = 100 } = req.query;
const logFile = path.join(process.cwd(), 'god-eye.log');
if (!fs.existsSync(logFile)) {
return res.json({
success: true,
logs: [],
message: 'Log file not found'
});
}
const logData = fs.readFileSync(logFile, 'utf8');
const logLines = logData.trim().split('\n').slice(-parseInt(lines));
const logs = logLines.map(line => {
try {
return JSON.parse(line);
} catch (e) {
return { message: line, timestamp: new Date().toISOString(), level: 'info' };
}
}).reverse(); // Новые записи сверху
res.json({
success: true,
logs,
total: logs.length
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
module.exports = router;

View File

@@ -0,0 +1,337 @@
const express = require('express');
const router = express.Router();
/**
* Middleware для проверки аутентификации оператора
*/
const authenticateOperator = (req, res, next) => {
const operatorId = req.headers['x-operator-id'];
if (!operatorId) {
return res.status(401).json({ error: 'Operator ID required' });
}
const operator = req.app.locals.deviceManager.getOperator(operatorId);
if (!operator || !operator.isConnected()) {
return res.status(401).json({ error: 'Invalid or disconnected operator' });
}
req.operator = operator;
next();
};
/**
* Middleware для проверки разрешений
*/
const requirePermission = (permission) => {
return (req, res, next) => {
if (!req.operator.hasPermission(permission)) {
return res.status(403).json({ error: `Permission '${permission}' required` });
}
next();
};
};
/**
* GET /api/operators/devices
* Получение списка доступных устройств
*/
router.get('/devices', authenticateOperator, requirePermission('view_cameras'), (req, res) => {
try {
const { deviceManager } = req.app.locals;
const devices = deviceManager.getAvailableDevicesForOperator(req.operator.operatorId);
const devicesData = devices.map(device => device.getSummary());
res.json({
success: true,
devices: devicesData,
total: devicesData.length
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
/**
* GET /api/operators/devices/:deviceId
* Получение информации о конкретном устройстве
*/
router.get('/devices/:deviceId', authenticateOperator, requirePermission('view_cameras'), (req, res) => {
try {
const { deviceManager } = req.app.locals;
const device = deviceManager.getDevice(req.params.deviceId);
if (!device) {
return res.status(404).json({ error: 'Device not found' });
}
res.json({
success: true,
device: device.getSummary()
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
/**
* POST /api/operators/camera/request
* Создание запроса на доступ к камере
*/
router.post('/camera/request', authenticateOperator, requirePermission('request_camera'), (req, res) => {
try {
const { deviceId, cameraType = 'back' } = req.body;
if (!deviceId) {
return res.status(400).json({ error: 'Device ID required' });
}
const { deviceManager, sessionManager, io } = req.app.locals;
const device = deviceManager.getDevice(deviceId);
if (!device) {
return res.status(404).json({ error: 'Device not found' });
}
if (!device.canAcceptNewSession()) {
return res.status(409).json({ error: 'Device is busy or unavailable' });
}
// Создаем сессию
const session = sessionManager.createSession(deviceId, req.operator.operatorId, cameraType);
// Отправляем запрос на Android устройство
device.socket.emit('camera:request', {
sessionId: session.sessionId,
operatorId: req.operator.operatorId,
cameraType
});
// Добавляем сессию к устройству и оператору
device.addSession(session.sessionId);
req.operator.addSession(session.sessionId);
req.app.locals.logger.info(`Camera request created: ${session.sessionId}`, {
deviceId,
operatorId: req.operator.operatorId,
cameraType
});
res.json({
success: true,
sessionId: session.sessionId,
message: 'Camera request sent to device'
});
} catch (error) {
req.app.locals.logger.error('Error creating camera request', error);
res.status(500).json({ error: error.message });
}
});
/**
* POST /api/operators/camera/:sessionId/switch
* Переключение типа камеры в активной сессии
*/
router.post('/camera/:sessionId/switch', authenticateOperator, requirePermission('request_camera'), (req, res) => {
try {
const { sessionId } = req.params;
const { cameraType } = req.body;
if (!cameraType) {
return res.status(400).json({ error: 'Camera type required' });
}
const { sessionManager, deviceManager, io } = req.app.locals;
const session = sessionManager.getSession(sessionId);
if (!session) {
return res.status(404).json({ error: 'Session not found' });
}
if (session.operatorId !== req.operator.operatorId) {
return res.status(403).json({ error: 'Access denied to this session' });
}
if (session.status !== 'active') {
return res.status(409).json({ error: 'Session is not active' });
}
const device = deviceManager.getDevice(session.deviceId);
if (!device || !device.isConnected()) {
return res.status(409).json({ error: 'Device not available' });
}
// Отправляем команду переключения камеры
device.socket.emit('camera:switch', {
sessionId,
cameraType
});
// Обновляем сессию
session.switchCamera(cameraType);
req.app.locals.logger.info(`Camera switch requested: ${sessionId} -> ${cameraType}`);
res.json({
success: true,
message: `Camera switch to ${cameraType} requested`
});
} catch (error) {
req.app.locals.logger.error('Error switching camera', error);
res.status(500).json({ error: error.message });
}
});
/**
* DELETE /api/operators/camera/:sessionId
* Завершение сессии камеры
*/
router.delete('/camera/:sessionId', authenticateOperator, requirePermission('request_camera'), (req, res) => {
try {
const { sessionId } = req.params;
const { sessionManager, deviceManager } = req.app.locals;
const session = sessionManager.getSession(sessionId);
if (!session) {
return res.status(404).json({ error: 'Session not found' });
}
if (session.operatorId !== req.operator.operatorId) {
return res.status(403).json({ error: 'Access denied to this session' });
}
const device = deviceManager.getDevice(session.deviceId);
// Отправляем команду отключения (если устройство подключено)
if (device && device.isConnected()) {
device.socket.emit('camera:disconnect', { sessionId });
device.removeSession(sessionId);
}
// Закрываем сессию
sessionManager.closeSession(sessionId);
req.operator.removeSession(sessionId);
req.app.locals.logger.info(`Session terminated: ${sessionId}`);
res.json({
success: true,
message: 'Session terminated'
});
} catch (error) {
req.app.locals.logger.error('Error terminating session', error);
res.status(500).json({ error: error.message });
}
});
/**
* GET /api/operators/sessions
* Получение активных сессий оператора
*/
router.get('/sessions', authenticateOperator, (req, res) => {
try {
const { sessionManager } = req.app.locals;
const sessions = sessionManager.getOperatorSessions(req.operator.operatorId);
const sessionsData = sessions.map(session => session.getSummary());
res.json({
success: true,
sessions: sessionsData,
total: sessionsData.length
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
/**
* GET /api/operators/sessions/:sessionId
* Получение детальной информации о сессии
*/
router.get('/sessions/:sessionId', authenticateOperator, (req, res) => {
try {
const { sessionId } = req.params;
const { sessionManager } = req.app.locals;
const session = sessionManager.getSession(sessionId);
if (!session) {
return res.status(404).json({ error: 'Session not found' });
}
if (session.operatorId !== req.operator.operatorId && !req.operator.hasPermission('admin')) {
return res.status(403).json({ error: 'Access denied to this session' });
}
res.json({
success: true,
session: session.getFullInfo()
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
/**
* GET /api/operators/profile
* Получение профиля текущего оператора
*/
router.get('/profile', authenticateOperator, (req, res) => {
try {
res.json({
success: true,
operator: req.operator.getSummary()
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
/**
* POST /api/operators/ping/:deviceId
* Проверка доступности устройства
*/
router.post('/ping/:deviceId', authenticateOperator, requirePermission('view_cameras'), (req, res) => {
try {
const { deviceId } = req.params;
const { deviceManager } = req.app.locals;
const device = deviceManager.getDevice(deviceId);
if (!device || !device.isConnected()) {
return res.status(404).json({ error: 'Device not found or disconnected' });
}
const pingStart = Date.now();
// Отправляем ping и ждем pong
device.socket.emit('ping', { timestamp: pingStart }, (response) => {
const responseTime = Date.now() - pingStart;
device.updateHealthCheck(responseTime);
res.json({
success: true,
deviceId,
responseTime,
timestamp: new Date().toISOString()
});
});
// Таймаут для ping
setTimeout(() => {
if (!res.headersSent) {
device.recordError();
res.status(408).json({ error: 'Device ping timeout' });
}
}, 5000);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
module.exports = router;

381
backend/src/server.js Normal file
View File

@@ -0,0 +1,381 @@
const express = require('express');
const http = require('http');
const socketIo = require('socket.io');
const cors = require('cors');
const { v4: uuidv4 } = require('uuid');
const winston = require('winston');
// Импорт наших менеджеров
const { SessionManager } = require('./managers/SessionManager');
const { DeviceManager } = require('./managers/DeviceManager');
// Импорт роутов
const operatorsRouter = require('./routes/operators');
const adminRouter = require('./routes/admin');
// Настройка логгера
const logger = winston.createLogger({
level: 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
transports: [
new winston.transports.Console(),
new winston.transports.File({ filename: 'god-eye.log' })
]
});
const app = express();
const server = http.createServer(app);
const io = socketIo(server, {
cors: {
origin: "*",
methods: ["GET", "POST"]
}
});
// Middleware
app.use(cors());
app.use(express.json());
app.use(express.static('public')); // Для статических файлов демо
// Инициализация менеджеров
const sessionManager = new SessionManager();
const deviceManager = new DeviceManager();
// Делаем менеджеры доступными в роутах
app.locals.sessionManager = sessionManager;
app.locals.deviceManager = deviceManager;
app.locals.logger = logger;
app.locals.io = io;
// Подключаем роуты
app.use('/api/operators', operatorsRouter);
app.use('/api/admin', adminRouter);
// Основные REST API маршруты для мониторинга
app.get('/api/status', (req, res) => {
const deviceStats = deviceManager.getStats();
const sessionStats = sessionManager.getStats();
res.json({
status: 'online',
timestamp: new Date().toISOString(),
uptime: Math.round(process.uptime()),
devices: deviceStats,
sessions: sessionStats
});
});
app.get('/api/devices', (req, res) => {
const devices = deviceManager.getConnectedDevices();
const devicesData = devices.map(device => device.getSummary());
res.json({
success: true,
devices: devicesData,
total: devicesData.length
});
});
// WebSocket обработчики
io.on('connection', (socket) => {
logger.info(`New connection: ${socket.id}`);
// Регистрация Android клиента
socket.on('register:android', (data) => {
const { deviceId, deviceInfo } = data;
// Регистрируем устройство через DeviceManager
const device = deviceManager.registerDevice(deviceId, deviceInfo, socket);
logger.info(`Android client registered: ${deviceId}`, deviceInfo);
// Уведомляем всех операторов о новом устройстве
const operatorSockets = Array.from(deviceManager.operators.values())
.filter(op => op.isConnected())
.map(op => op.socket);
operatorSockets.forEach(opSocket => {
opSocket.emit('device:connected', {
deviceId,
deviceInfo: device.getSummary()
});
});
socket.emit('register:success', { deviceId });
});
// Регистрация оператора
socket.on('register:operator', (data) => {
const { operatorId, operatorInfo } = data;
const finalOperatorId = operatorId || uuidv4();
// Регистрируем оператора через DeviceManager
const operator = deviceManager.registerOperator(finalOperatorId, operatorInfo, socket);
logger.info(`Operator registered: ${finalOperatorId}`);
// Отправляем список доступных устройств
const availableDevices = deviceManager.getAvailableDevicesForOperator(finalOperatorId);
const devicesData = availableDevices.map(device => device.getSummary());
socket.emit('register:success', {
operatorId: finalOperatorId,
availableDevices: devicesData
});
});
// Запрос на подключение к камере (только через WebSocket для обратной совместимости)
socket.on('camera:request', (data) => {
const { deviceId, cameraType = 'back' } = data;
// Получаем оператора из менеджера устройств
const operator = Array.from(deviceManager.operators.values())
.find(op => op.socket === socket);
if (!operator) {
socket.emit('camera:error', { error: 'Operator not registered' });
return;
}
const device = deviceManager.getDevice(deviceId);
if (!device || !device.canAcceptNewSession()) {
socket.emit('camera:error', { error: 'Device not available' });
return;
}
// Создаем сессию
const session = sessionManager.createSession(deviceId, operator.operatorId, cameraType);
logger.info(`Camera request: operator ${operator.operatorId} -> device ${deviceId}`);
// Отправляем запрос Android клиенту
device.socket.emit('camera:request', {
sessionId: session.sessionId,
operatorId: operator.operatorId,
cameraType
});
// Добавляем сессию к участникам
device.addSession(session.sessionId);
operator.addSession(session.sessionId);
socket.emit('camera:request-sent', { sessionId: session.sessionId, deviceId });
});
// Ответ от Android клиента на запрос камеры
socket.on('camera:response', (data) => {
const { sessionId, accepted, streamUrl, error } = data;
const session = sessionManager.getSession(sessionId);
if (!session) {
socket.emit('camera:error', { error: 'Session not found' });
return;
}
const operator = deviceManager.getOperator(session.operatorId);
if (!operator || !operator.isConnected()) {
socket.emit('camera:error', { error: 'Operator not found' });
return;
}
if (accepted) {
session.updateStatus('active', { streamUrl });
logger.info(`Camera stream started: session ${sessionId}`);
operator.socket.emit('camera:stream-ready', { sessionId, streamUrl });
} else {
session.updateStatus('denied', { error });
logger.info(`Camera request denied: session ${sessionId}`, error);
operator.socket.emit('camera:denied', { sessionId, error });
// Очищаем отклоненную сессию
const device = deviceManager.getDevice(session.deviceId);
if (device) device.removeSession(sessionId);
operator.removeSession(sessionId);
sessionManager.closeSession(sessionId);
}
});
// WebRTC сигнализация
socket.on('webrtc:offer', (data) => {
const { sessionId, offer } = data;
const session = sessionManager.getSession(sessionId);
if (session) {
// Определяем получателя (Android -> Operator или Operator -> Android)
const device = deviceManager.getDevice(session.deviceId);
const operator = deviceManager.getOperator(session.operatorId);
// Если отправитель - устройство, то получатель - оператор
if (device && device.socket === socket && operator && operator.isConnected()) {
operator.socket.emit('webrtc:offer', { sessionId, offer });
session.updateWebRTCState('offer_sent');
}
// Если отправитель - оператор, то получатель - устройство
else if (operator && operator.socket === socket && device && device.isConnected()) {
device.socket.emit('webrtc:offer', { sessionId, offer });
session.updateWebRTCState('offer_sent');
}
}
});
socket.on('webrtc:answer', (data) => {
const { sessionId, answer } = data;
const session = sessionManager.getSession(sessionId);
if (session) {
const device = deviceManager.getDevice(session.deviceId);
const operator = deviceManager.getOperator(session.operatorId);
// Если отправитель - устройство, то получатель - оператор
if (device && device.socket === socket && operator && operator.isConnected()) {
operator.socket.emit('webrtc:answer', { sessionId, answer });
session.updateWebRTCState('answer_sent');
}
// Если отправитель - оператор, то получатель - устройство
else if (operator && operator.socket === socket && device && device.isConnected()) {
device.socket.emit('webrtc:answer', { sessionId, answer });
session.updateWebRTCState('answer_sent');
}
}
});
socket.on('webrtc:ice-candidate', (data) => {
const { sessionId, candidate } = data;
const session = sessionManager.getSession(sessionId);
if (session) {
const device = deviceManager.getDevice(session.deviceId);
const operator = deviceManager.getOperator(session.operatorId);
// Пересылаем ICE кандидата другой стороне
if (device && device.socket === socket && operator && operator.isConnected()) {
operator.socket.emit('webrtc:ice-candidate', { sessionId, candidate });
} else if (operator && operator.socket === socket && device && device.isConnected()) {
device.socket.emit('webrtc:ice-candidate', { sessionId, candidate });
}
}
});
// Переключение типа камеры
socket.on('camera:switch', (data) => {
const { sessionId, cameraType } = data;
const session = sessionManager.getSession(sessionId);
if (session) {
const device = deviceManager.getDevice(session.deviceId);
const operator = deviceManager.getOperator(session.operatorId);
// Проверяем права доступа
if (operator && operator.socket === socket && device && device.isConnected()) {
device.socket.emit('camera:switch', { sessionId, cameraType });
session.switchCamera(cameraType);
logger.info(`Camera switch requested: ${sessionId} -> ${cameraType}`);
}
}
});
// Завершение сессии
socket.on('camera:disconnect', (data) => {
const { sessionId } = data;
const session = sessionManager.getSession(sessionId);
if (session) {
const device = deviceManager.getDevice(session.deviceId);
const operator = deviceManager.getOperator(session.operatorId);
// Уведомляем участников
if (device && device.isConnected()) {
device.socket.emit('camera:disconnect', { sessionId });
device.removeSession(sessionId);
}
if (operator && operator.isConnected()) {
operator.socket.emit('camera:disconnected', { sessionId });
operator.removeSession(sessionId);
}
sessionManager.closeSession(sessionId);
logger.info(`Camera session ended: ${sessionId}`);
}
});
// Ping-Pong для проверки соединения
socket.on('ping', (data, callback) => {
if (callback) {
callback({ timestamp: Date.now(), ...data });
}
});
// Обработка отключения
socket.on('disconnect', (reason) => {
logger.info(`Client disconnected: ${socket.id}, reason: ${reason}`);
// Находим устройство или оператора по сокету
const device = Array.from(deviceManager.devices.values())
.find(d => d.socket === socket);
const operator = Array.from(deviceManager.operators.values())
.find(op => op.socket === socket);
if (device) {
// Уведомляем операторов об отключении устройства
const operators = deviceManager.getConnectedOperators();
operators.forEach(op => {
op.socket.emit('device:disconnected', {
deviceId: device.deviceId
});
});
// Завершаем активные сессии устройства
sessionManager.closeDeviceSessions(device.deviceId);
deviceManager.disconnectDevice(device.deviceId);
}
if (operator) {
// Завершаем активные сессии оператора
sessionManager.closeOperatorSessions(operator.operatorId);
deviceManager.disconnectOperator(operator.operatorId);
}
});
});
// Периодическая очистка старых сессий и устройств
setInterval(() => {
try {
sessionManager.cleanupOldSessions(60); // Очистка сессий старше 60 минут
deviceManager.cleanup(); // Очистка отключенных устройств
} catch (error) {
logger.error('Error during periodic cleanup', error);
}
}, 5 * 60 * 1000); // Каждые 5 минут
// Обработка graceful shutdown
process.on('SIGTERM', () => {
logger.info('SIGTERM received, shutting down gracefully');
server.close(() => {
logger.info('Process terminated');
process.exit(0);
});
});
process.on('SIGINT', () => {
logger.info('SIGINT received, shutting down gracefully');
server.close(() => {
logger.info('Process terminated');
process.exit(0);
});
});
const PORT = process.env.PORT || 3001;
server.listen(PORT, () => {
logger.info(`GodEye Backend Server running on port ${PORT}`);
console.log(`🚀 Server started on http://localhost:${PORT}`);
console.log(`📊 Admin API: http://localhost:${PORT}/api/admin/stats`);
console.log(`👥 Operators API: http://localhost:${PORT}/api/operators/devices`);
console.log(`📱 Status: http://localhost:${PORT}/api/status`);
console.log(`🌐 Demo: http://localhost:${PORT}/`);
});