diff --git a/.gitignore b/.gitignore index 1d2078f..f2c58e1 100644 --- a/.gitignore +++ b/.gitignore @@ -96,4 +96,5 @@ frontend/data/*.json # Demo seed data: ignore demo mux outputs and the seeding command backend/api/management/commands/seed_demo_data.py +backend/api/management/commands/recreate_alerts.py backend/data/multiplexors/DemoMux.csv \ No newline at end of file diff --git a/backend/api/account/serializers/alert_serializers.py b/backend/api/account/serializers/alert_serializers.py index 111f371..e3be954 100644 --- a/backend/api/account/serializers/alert_serializers.py +++ b/backend/api/account/serializers/alert_serializers.py @@ -9,10 +9,11 @@ class AlertSerializer(serializers.ModelSerializer): object = serializers.SerializerMethodField() metric_value = serializers.SerializerMethodField() detector_type = serializers.SerializerMethodField() + detector_id = serializers.SerializerMethodField() class Meta: model = Alert - fields = ('id', 'name', 'object', 'metric_value', 'detector_type', 'message', 'severity', 'created_at', 'resolved') + fields = ('id', 'name', 'object', 'metric_value', 'detector_type', 'detector_id', 'message', 'severity', 'created_at', 'resolved') @extend_schema_field(OpenApiTypes.STR) def get_name(self, obj) -> str: @@ -38,3 +39,9 @@ class AlertSerializer(serializers.ModelSerializer): if sensor_type is None: return '' return (getattr(sensor_type, 'code', '') or '').upper() + + @extend_schema_field(OpenApiTypes.STR) + def get_detector_id(self, obj) -> str: + if hasattr(obj, 'sensor') and obj.sensor: + return obj.sensor.name or f"{obj.sensor.sensor_type.code}-{obj.sensor.id}" + return "" diff --git a/backend/api/account/serializers/objects_serializers.py b/backend/api/account/serializers/objects_serializers.py index 1bcdc12..6df34e8 100644 --- a/backend/api/account/serializers/objects_serializers.py +++ b/backend/api/account/serializers/objects_serializers.py @@ -15,7 +15,7 @@ class ZoneSerializer(serializers.ModelSerializer): class Meta: model = Zone - fields = ('id', 'name', 'sensors') + fields = ('id', 'name', 'floor', 'image_path', 'model_path', 'order', 'sensors') class ObjectSerializer(serializers.ModelSerializer): zones = ZoneSerializer(many=True, read_only=True) diff --git a/backend/api/account/urls.py b/backend/api/account/urls.py index 1a97d81..f381902 100644 --- a/backend/api/account/urls.py +++ b/backend/api/account/urls.py @@ -4,6 +4,7 @@ from .views.objects_views import ObjectView from .views.sensors_views import SensorView from .views.alert_views import AlertView, ReportView from .views.dashboard_views import DashboardView +from .views.zones_views import ZoneView from drf_spectacular.views import ( SpectacularAPIView, SpectacularSwaggerView, @@ -36,4 +37,6 @@ urlpatterns = [ path("get-reports/", ReportView.as_view({'post': 'get_reports'}), name="reports"), path("get-dashboard/", DashboardView.as_view(), name="dashboard"), + + path("get-zones/", ZoneView.as_view(), name="zones"), ] diff --git a/backend/api/account/views/alert_views.py b/backend/api/account/views/alert_views.py index bab356a..15b315a 100644 --- a/backend/api/account/views/alert_views.py +++ b/backend/api/account/views/alert_views.py @@ -116,7 +116,11 @@ class ReportView(ViewSet): @extend_schema( summary="Генерация отчета", description="Генерирует отчет в выбранном формате (PDF или CSV)", - request={'application/json': {'type': 'object', 'properties': {'report_format': {'type': 'string', 'enum': ['pdf', 'csv']}}}}, + request={'application/json': {'type': 'object', 'properties': { + 'report_format': {'type': 'string', 'enum': ['pdf', 'csv']}, + 'hours': {'type': 'integer', 'description': 'Количество часов для фильтрации по времени'}, + 'detector_ids': {'type': 'array', 'items': {'type': 'string'}, 'description': 'Список ID датчиков для фильтрации (может быть числом или строкой вида "GLE-79")'} + }}}, methods=['POST'], responses={ 200: OpenApiResponse( @@ -153,6 +157,10 @@ class ReportView(ViewSet): status=status.HTTP_400_BAD_REQUEST ) + # Получаем параметры фильтрации + hours = request.data.get('hours') + detector_ids = request.data.get('detector_ids', []) + alerts = Alert.objects.select_related( 'sensor', 'sensor__signal_format', @@ -163,6 +171,35 @@ class ReportView(ViewSet): 'sensor__zones__object' ).all() + # Фильтрация по времени (если указано количество часов) + if hours and isinstance(hours, (int, float)) and hours > 0: + from datetime import timedelta + cutoff_time = timezone.now() - timedelta(hours=hours) + alerts = alerts.filter(created_at__gte=cutoff_time) + + # Фильтрация по датчикам (если указаны ID) + if detector_ids and isinstance(detector_ids, list) and len(detector_ids) > 0: + # Конвертируем строковые ID в числовые ID базы данных + numeric_ids = [] + for detector_id in detector_ids: + if isinstance(detector_id, (int, float)): + numeric_ids.append(int(detector_id)) + elif isinstance(detector_id, str): + # Парсим строковые ID вида "GLE-79" или "GA-123" + try: + if '-' in detector_id: + # Формат "TYPE-ID" - извлекаем числовую часть + numeric_ids.append(int(detector_id.split('-')[-1])) + else: + # Предполагаем, что это просто число в строковом формате + numeric_ids.append(int(detector_id)) + except (ValueError, IndexError): + # Пропускаем некорректные ID + continue + + if numeric_ids: + alerts = alerts.filter(sensor_id__in=numeric_ids) + # текущая дата для имени файла timestamp = timezone.now().strftime("%Y%m%d_%H%M%S") diff --git a/backend/api/account/views/dashboard_views.py b/backend/api/account/views/dashboard_views.py index e4e4358..8dee4ef 100644 --- a/backend/api/account/views/dashboard_views.py +++ b/backend/api/account/views/dashboard_views.py @@ -60,8 +60,9 @@ class DashboardView(APIView): if time_period not in ['720', '168', '72', '24']: return Response( - {"error": "Неверный период. Допустимые значения: 720, 168, 72, 24"}, - status=status.HTTP_400_BAD_REQUEST +- {"error": "Неверный период. Допустимые значения: 720, 168, 72, 24"}, ++ {"error": "Неверный период. Допустимые значения: 24, 72, 168, 720"}, + status=status.HTTP_400_BAD_REQUEST ) # определяем начальную дату diff --git a/backend/api/account/views/zones_views.py b/backend/api/account/views/zones_views.py new file mode 100644 index 0000000..fd6c4b1 --- /dev/null +++ b/backend/api/account/views/zones_views.py @@ -0,0 +1,66 @@ +from rest_framework import status +from rest_framework.views import APIView +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from drf_spectacular.utils import extend_schema, OpenApiResponse, OpenApiExample + +from api.account.serializers.objects_serializers import ZoneSerializer +from sitemanagement.models import Zone + +from api.utils.decorators import handle_exceptions + +@extend_schema(tags=['Зоны']) +class ZoneView(APIView): + permission_classes = [IsAuthenticated] + serializer_class = ZoneSerializer + + @extend_schema( + summary="Получение зон для указанного объекта", + description="Возвращает список зон для объекта по query-параметру objectId", + responses={ + 200: OpenApiResponse( + response=ZoneSerializer, + description="Зоны успешно получены", + examples=[ + OpenApiExample( + 'Успешный ответ', + value=[ + { + "id": 1, + "name": "Зона 1", + "floor": 1, + "image_path": "test_image_2.png", + "model_path": "/static-models/AerBIM-Monitor_ASM-HTViewer_Expo2017Astana_20250908_L_+76190.glb", + "order": 0, + "sensors": [ + { + "id": 10, + "name": "Датчик 1", + "serial_number": "GA-123", + "sensor_type": "Тип датчика 1" + } + ] + } + ] + ) + ] + ) + } + ) + @handle_exceptions + def get(self, request): + """Получение всех зон для указанного объекта""" + object_id = request.query_params.get('objectId') or request.query_params.get('object_id') or request.query_params.get('object') + if not object_id: + return Response({"error": "objectId query parameter is required"}, status=status.HTTP_400_BAD_REQUEST) + + try: + zones = Zone.objects.filter(object_id=object_id).prefetch_related( + 'sensors', + 'sensors__sensor_type' + ).order_by('order', 'name') + + serializer = ZoneSerializer(zones, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + except Zone.DoesNotExist: + return Response({"error": "Зоны не найдены"}, status=status.HTTP_404_NOT_FOUND) \ No newline at end of file diff --git a/backend/sitemanagement/admin.py b/backend/sitemanagement/admin.py index 22b2ac6..69ad3a5 100644 --- a/backend/sitemanagement/admin.py +++ b/backend/sitemanagement/admin.py @@ -76,9 +76,9 @@ class ObjectAdmin(admin.ModelAdmin): @admin.register(Zone) class ZoneAdmin(admin.ModelAdmin): - list_display = ('object', 'floor', 'name') - list_filter = ('object', 'floor') - search_fields = ('object__title', 'name') + list_display = ('object', 'floor', 'order', 'name', 'image_path', 'model_path') + list_filter = ('object', 'floor', 'order') + search_fields = ('object__title', 'name', 'image_path', 'model_path') list_per_page = 10 list_max_show_all = 100 list_display_links = ('object',) \ No newline at end of file diff --git a/backend/sitemanagement/migrations/0008_alter_zone_options_zone_image_path_zone_model_path_and_more.py b/backend/sitemanagement/migrations/0008_alter_zone_options_zone_image_path_zone_model_path_and_more.py new file mode 100644 index 0000000..aeea878 --- /dev/null +++ b/backend/sitemanagement/migrations/0008_alter_zone_options_zone_image_path_zone_model_path_and_more.py @@ -0,0 +1,32 @@ +# Generated by Django 5.2.5 on 2025-12-22 17:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('sitemanagement', '0007_alter_zone_options_zone_floor'), + ] + + operations = [ + migrations.AlterModelOptions( + name='zone', + options={'ordering': ['object', 'floor', 'order', 'name'], 'verbose_name': 'Зона', 'verbose_name_plural': 'Зоны'}, + ), + migrations.AddField( + model_name='zone', + name='image_path', + field=models.CharField(blank=True, default='test_image.png', help_text="Например 'public/images/test_image.png' или имя файла из public/images", max_length=255, verbose_name='Путь к изображению'), + ), + migrations.AddField( + model_name='zone', + name='model_path', + field=models.CharField(blank=True, help_text="Например 'frontend/assets/big-models/model.glb' или относительный путь к модели", max_length=255, null=True, verbose_name='Путь к 3D модели'), + ), + migrations.AddField( + model_name='zone', + name='order', + field=models.PositiveSmallIntegerField(default=0, verbose_name='Порядок'), + ), + ] diff --git a/backend/sitemanagement/migrations/0009_alter_zone_image_path.py b/backend/sitemanagement/migrations/0009_alter_zone_image_path.py new file mode 100644 index 0000000..2a73bca --- /dev/null +++ b/backend/sitemanagement/migrations/0009_alter_zone_image_path.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.5 on 2025-12-22 17:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('sitemanagement', '0008_alter_zone_options_zone_image_path_zone_model_path_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='zone', + name='image_path', + field=models.CharField(blank=True, default='test_image.png', help_text="Например 'public/images/test_image.png' или имя файла из public/images", max_length=255, verbose_name='Путь к изображению'), + ), + ] diff --git a/backend/sitemanagement/migrations/0010_alter_zone_image_path_alter_zone_model_path.py b/backend/sitemanagement/migrations/0010_alter_zone_image_path_alter_zone_model_path.py new file mode 100644 index 0000000..fc35d35 --- /dev/null +++ b/backend/sitemanagement/migrations/0010_alter_zone_image_path_alter_zone_model_path.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.5 on 2025-12-24 19:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('sitemanagement', '0009_alter_zone_image_path'), + ] + + operations = [ + migrations.AlterField( + model_name='zone', + name='image_path', + field=models.CharField(blank=True, default='test_image.png', help_text="Например 'test_image_2.png'", max_length=255, verbose_name='Путь к изображению'), + ), + migrations.AlterField( + model_name='zone', + name='model_path', + field=models.CharField(blank=True, help_text="Например '/static-models/AerBIM-Monitor_ASM-HTViewer_Expo2017Astana_20250908_L_+76190.glb'", max_length=255, null=True, verbose_name='Путь к 3D модели'), + ), + ] diff --git a/backend/sitemanagement/models.py b/backend/sitemanagement/models.py index 22bd4ec..f1ffc35 100644 --- a/backend/sitemanagement/models.py +++ b/backend/sitemanagement/models.py @@ -175,6 +175,9 @@ class Zone(models.Model): object = models.ForeignKey(Object, on_delete=models.CASCADE, related_name="zones", verbose_name="Объект") name = models.CharField(max_length=255, verbose_name="Название") floor = models.PositiveSmallIntegerField(verbose_name="Этаж") + image_path = models.CharField(max_length=255, verbose_name="Путь к изображению", blank=True, default="test_image.png", help_text="Например 'test_image_2.png'") + model_path = models.CharField(max_length=255, verbose_name="Путь к 3D модели", blank=True, null=True, help_text="Например '/static-models/AerBIM-Monitor_ASM-HTViewer_Expo2017Astana_20250908_L_+76190.glb'") + order = models.PositiveSmallIntegerField(default=0, verbose_name="Порядок") sensors = models.ManyToManyField(Sensor, related_name="zones", verbose_name="Датчики") created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -182,7 +185,7 @@ class Zone(models.Model): class Meta: verbose_name = "Зона" verbose_name_plural = "Зоны" - ordering = ["object", "floor", "name"] # сортировка сначала по объекту, потом по этажу + ordering = ["object", "floor", "order", "name"] # сортировка по объекту, этажу, порядку, названию def clean(self): from django.core.exceptions import ValidationError diff --git a/frontend/app/(protected)/alerts/page.tsx b/frontend/app/(protected)/alerts/page.tsx index 61adfd5..c885c89 100644 --- a/frontend/app/(protected)/alerts/page.tsx +++ b/frontend/app/(protected)/alerts/page.tsx @@ -5,13 +5,14 @@ import { useRouter, useSearchParams } from 'next/navigation' import Sidebar from '../../../components/ui/Sidebar' import useNavigationStore from '../../store/navigationStore' import DetectorList from '../../../components/alerts/DetectorList' +import AlertsList from '../../../components/alerts/AlertsList' import ExportMenu from '../../../components/ui/ExportMenu' import { useSession } from 'next-auth/react' const AlertsPage: React.FC = () => { const router = useRouter() const searchParams = useSearchParams() - const { currentObject, setCurrentObject } = useNavigationStore() + const { currentObject, setCurrentObject, selectedDetector } = useNavigationStore() const [selectedDetectors, setSelectedDetectors] = useState([]) const { data: session } = useSession() @@ -22,10 +23,9 @@ const AlertsPage: React.FC = () => { timestamp: string acknowledged: boolean priority: string - detector_id?: number + detector_id?: string detector_name?: string location?: string - object?: string } const [alerts, setAlerts] = useState([]) @@ -40,6 +40,13 @@ const AlertsPage: React.FC = () => { } }, [urlObjectId, urlObjectTitle, currentObject.id, setCurrentObject]) + // Auto-select detector when it comes from navigation store + useEffect(() => { + if (selectedDetector && !selectedDetectors.includes(selectedDetector.detector_id)) { + setSelectedDetectors(prev => [...prev, selectedDetector.detector_id]) + } + }, [selectedDetector, selectedDetectors]) + useEffect(() => { const loadAlerts = async () => { try { @@ -55,7 +62,11 @@ const AlertsPage: React.FC = () => { }) const data = Array.isArray(payload?.data) ? payload.data : (payload?.data?.alerts || []) console.log('[AlertsPage] parsed alerts:', data) - setAlerts(data as AlertItem[]) + console.log('[AlertsPage] Sample alert structure:', data[0]) + console.log('[AlertsPage] Alerts with detector_id:', data.filter((alert: any) => alert.detector_id).length) + + console.log('[AlertsPage] using transformed alerts:', data) + setAlerts(data) } catch (e) { console.error('Failed to load alerts:', e) } @@ -177,87 +188,15 @@ const AlertsPage: React.FC = () => { objectId={objectId || undefined} selectedDetectors={selectedDetectors} onDetectorSelect={handleDetectorSelect} + initialSearchTerm={selectedDetector?.detector_id.toString() || ''} /> - {/* История тревог */} -
-
-

История тревог

- Всего: {alerts.length} -
-
- - - - - - - - - - - - - - {alerts.map((item) => ( - - - - - - - - - - ))} - {alerts.length === 0 && ( - - - - )} - -
ДетекторСтатусСообщениеМестоположениеПриоритетПодтвержденоВремя
-
{item.detector_name || 'Детектор'}
- {item.detector_id ? ( -
ID: {item.detector_id}
- ) : null} -
-
-
- - {item.type === 'critical' ? 'Критический' : item.type === 'warning' ? 'Предупреждение' : 'Информация'} - -
-
-
{item.message}
-
-
{item.location || '-'}
-
- - {item.priority === 'high' ? 'Высокий' : item.priority === 'medium' ? 'Средний' : 'Низкий'} - - - - {item.acknowledged ? 'Да' : 'Нет'} - - - -
{new Date(item.timestamp).toLocaleString('ru-RU')}
-
Записей не найдено
-
+
+
diff --git a/frontend/app/(protected)/navigation/page.tsx b/frontend/app/(protected)/navigation/page.tsx index a190aeb..73053db 100644 --- a/frontend/app/(protected)/navigation/page.tsx +++ b/frontend/app/(protected)/navigation/page.tsx @@ -107,6 +107,41 @@ const NavigationPage: React.FC = () => { const [detectorsError, setDetectorsError] = useState(null) const [modelError, setModelError] = useState(null) const [isModelReady, setIsModelReady] = useState(false) + const [focusedSensorId, setFocusedSensorId] = useState(null) + const [highlightAllSensors, setHighlightAllSensors] = useState(false) + + useEffect(() => { + if (selectedDetector === null && selectedAlert === null) { + setFocusedSensorId(null); + } + }, [selectedDetector, selectedAlert]); + + // Управление выделением всех сенсоров при открытии/закрытии меню Sensors + useEffect(() => { + console.log('[NavigationPage] showSensors changed:', showSensors, 'modelReady:', isModelReady) + if (showSensors && isModelReady) { + // При открытии меню Sensors - выделяем все сенсоры (только если модель готова) + console.log('[NavigationPage] Setting highlightAllSensors to TRUE') + setHighlightAllSensors(true) + setFocusedSensorId(null) + } else if (!showSensors) { + // При закрытии меню Sensors - сбрасываем выделение + console.log('[NavigationPage] Setting highlightAllSensors to FALSE') + setHighlightAllSensors(false) + } + }, [showSensors, isModelReady]) + + // Дополнительный эффект для задержки выделения сенсоров при открытии меню + useEffect(() => { + if (showSensors && isModelReady) { + const timer = setTimeout(() => { + console.log('[NavigationPage] Delayed highlightAllSensors to TRUE') + setHighlightAllSensors(true) + }, 500) // Задержка 500мс для полной инициализации модели + + return () => clearTimeout(timer) + } + }, [showSensors, isModelReady]) const urlObjectId = searchParams.get('objectId') const urlObjectTitle = searchParams.get('objectTitle') @@ -133,10 +168,10 @@ const NavigationPage: React.FC = () => { }, [selectedModelPath]); useEffect(() => { - if (urlObjectId && urlObjectTitle && (!currentObject.id || currentObject.id !== urlObjectId)) { - setCurrentObject(urlObjectId, urlObjectTitle) + if (urlObjectId && (!currentObject.id || currentObject.id !== urlObjectId)) { + setCurrentObject(urlObjectId, urlObjectTitle ?? currentObject.title ?? undefined) } - }, [urlObjectId, urlObjectTitle, currentObject.id, setCurrentObject]) + }, [urlObjectId, urlObjectTitle, currentObject.id, currentObject.title, setCurrentObject]) useEffect(() => { const loadDetectors = async () => { @@ -177,18 +212,28 @@ const NavigationPage: React.FC = () => { return } - if (selectedDetector?.detector_id === detector.detector_id && showDetectorMenu) { - setShowDetectorMenu(false) - setSelectedDetector(null) + if (selectedDetector?.serial_number === detector.serial_number && showDetectorMenu) { + closeDetectorMenu() } else { setSelectedDetector(detector) setShowDetectorMenu(true) + setFocusedSensorId(detector.serial_number) + setShowAlertMenu(false) + setSelectedAlert(null) + // При открытии меню детектора - сбрасываем множественное выделение + setHighlightAllSensors(false) } } const closeDetectorMenu = () => { setShowDetectorMenu(false) setSelectedDetector(null) + setFocusedSensorId(null) + setSelectedAlert(null) + // При закрытии меню детектора из Sensors - выделяем все сенсоры снова + if (showSensors) { + setHighlightAllSensors(true) + } } const handleNotificationClick = (notification: NotificationType) => { @@ -209,6 +254,12 @@ const NavigationPage: React.FC = () => { const closeAlertMenu = () => { setShowAlertMenu(false) setSelectedAlert(null) + setFocusedSensorId(null) + setSelectedDetector(null) + // При закрытии меню алерта из Sensors - выделяем все сенсоры снова + if (showSensors) { + setHighlightAllSensors(true) + } } const handleAlertClick = (alert: AlertType) => { @@ -219,19 +270,86 @@ const NavigationPage: React.FC = () => { ) if (detector) { - console.log('[NavigationPage] Found detector for alert:', detector) - - setSelectedAlert(alert) - setShowAlertMenu(true) - - setSelectedDetector(detector) - - console.log('[NavigationPage] Showing AlertMenu for alert:', alert.detector_name) + if (selectedAlert?.id === alert.id && showAlertMenu) { + closeAlertMenu() + } else { + setSelectedAlert(alert) + setShowAlertMenu(true) + setFocusedSensorId(detector.serial_number) + setShowDetectorMenu(false) + setSelectedDetector(null) + // При открытии меню алерта - сбрасываем множественное выделение + setHighlightAllSensors(false) + console.log('[NavigationPage] Showing AlertMenu for alert:', alert.detector_name) + } } else { console.warn('[NavigationPage] Could not find detector for alert:', alert.detector_id) } } + const handleSensorSelection = (serialNumber: string | null) => { + if (serialNumber === null) { + setFocusedSensorId(null); + closeDetectorMenu(); + closeAlertMenu(); + // If we're in Sensors menu and no sensor is selected, highlight all sensors + if (showSensors) { + setHighlightAllSensors(true); + } + return; + } + + if (focusedSensorId === serialNumber) { + setFocusedSensorId(null); + closeDetectorMenu(); + closeAlertMenu(); + // If we're in Sensors menu and deselected the current sensor, highlight all sensors + if (showSensors) { + setHighlightAllSensors(true); + } + return; + } + + // При выборе конкретного сенсора - сбрасываем множественное выделение + setHighlightAllSensors(false) + + const detector = Object.values(detectorsData?.detectors || {}).find( + (d) => d.serial_number === serialNumber + ); + + if (detector) { + if (showFloorNavigation || showListOfDetectors) { + handleDetectorMenuClick(detector); + } else if (detector.notifications && detector.notifications.length > 0) { + const sortedNotifications = [...detector.notifications].sort((a, b) => { + const priorityOrder: { [key: string]: number } = { critical: 0, warning: 1, info: 2 }; + return priorityOrder[a.priority.toLowerCase()] - priorityOrder[b.priority.toLowerCase()]; + }); + const notification = sortedNotifications[0]; + const alert: AlertType = { + ...notification, + detector_id: detector.detector_id, + detector_name: detector.name, + location: detector.location, + object: detector.object, + status: detector.status, + type: notification.type || 'info', + }; + handleAlertClick(alert); + } else { + handleDetectorMenuClick(detector); + } + } else { + setFocusedSensorId(null); + closeDetectorMenu(); + closeAlertMenu(); + // If we're in Sensors menu and no valid detector found, highlight all sensors + if (showSensors) { + setHighlightAllSensors(true); + } + } + }; + const getStatusText = (status: string) => { const s = (status || '').toLowerCase() switch (s) { @@ -371,9 +489,9 @@ const NavigationPage: React.FC = () => { @@ -414,7 +532,7 @@ const NavigationPage: React.FC = () => { ) : ( - { @@ -425,7 +543,11 @@ const NavigationPage: React.FC = () => { }} onModelLoaded={handleModelLoaded} onError={handleModelError} - focusSensorId={selectedDetector?.serial_number ?? selectedAlert?.detector_id?.toString() ?? null} + activeMenu={showSensors ? 'sensors' : showFloorNavigation ? 'floor' : showListOfDetectors ? 'detectors' : null} + focusSensorId={focusedSensorId} + highlightAllSensors={highlightAllSensors} + isSensorSelectionEnabled={showSensors || showFloorNavigation || showListOfDetectors} + onSensorPick={handleSensorSelection} renderOverlay={({ anchor }) => ( <> {selectedAlert && showAlertMenu && anchor ? ( @@ -449,12 +571,12 @@ const NavigationPage: React.FC = () => { ) : null} )} - /> - )} + /> + )} + - - + ) } diff --git a/frontend/app/(protected)/objects/page.tsx b/frontend/app/(protected)/objects/page.tsx index 9c9c0dc..0f1a428 100644 --- a/frontend/app/(protected)/objects/page.tsx +++ b/frontend/app/(protected)/objects/page.tsx @@ -10,11 +10,27 @@ import { useRouter } from 'next/navigation' const transformRawToObjectData = (raw: any): ObjectData => { const rawId = raw?.id ?? raw?.object_id ?? raw?.uuid ?? raw?.name const object_id = typeof rawId === 'number' ? `object_${rawId}` : String(rawId ?? '') + + // Если объект имеет числовой идентификатор, возвращаем его в виде строки с префиксом 'object_' + const deriveTitle = (): string => { + const t = (raw?.title || '').toString().trim() + if (t) return t + const idStr = String(rawId ?? '').toString() + const numMatch = typeof rawId === 'number' + ? rawId + : (() => { const m = idStr.match(/\d+/); return m ? Number(m[0]) : undefined })() + if (typeof numMatch === 'number' && !Number.isNaN(numMatch)) { + return `Объект ${numMatch}` + } + // Если объект не имеет числовой идентификатор, возвращаем его строковый идентификатор + return idStr ? `Объект ${idStr}` : `Объект ${object_id}` + } + return { object_id, - title: raw?.title ?? `Объект ${object_id}`, + title: deriveTitle(), description: raw?.description ?? `Описание объекта ${raw?.title ?? object_id}`, - image: raw?.image ?? '/images/test_image.png', + image: raw?.image ?? null, location: raw?.location ?? raw?.address ?? 'Не указано', floors: Number(raw?.floors ?? 0), area: String(raw?.area ?? ''), diff --git a/frontend/app/(protected)/reports/page.tsx b/frontend/app/(protected)/reports/page.tsx index aa68bc8..60fb5d4 100644 --- a/frontend/app/(protected)/reports/page.tsx +++ b/frontend/app/(protected)/reports/page.tsx @@ -13,7 +13,7 @@ const ReportsPage: React.FC = () => { const router = useRouter() const searchParams = useSearchParams() const { data: session } = useSession() - const { currentObject, setCurrentObject } = useNavigationStore() + const { currentObject, setCurrentObject, selectedDetector } = useNavigationStore() const [selectedDetectors, setSelectedDetectors] = useState([]) const [detectorsData, setDetectorsData] = useState({ detectors: {} }) @@ -28,6 +28,13 @@ const ReportsPage: React.FC = () => { } }, [urlObjectId, urlObjectTitle, currentObject.id, setCurrentObject]) + // Автовыбор detector id из стора + useEffect(() => { + if (selectedDetector && !selectedDetectors.includes(selectedDetector.detector_id)) { + setSelectedDetectors(prev => [...prev, selectedDetector.detector_id]) + } + }, [selectedDetector, selectedDetectors]) + useEffect(() => { const loadDetectors = async () => { try { @@ -139,15 +146,21 @@ const ReportsPage: React.FC = () => { - - {/* Selection table to choose detectors to include in the report */} +
- +
- - {/* Existing notifications-based list */} +
- +
diff --git a/frontend/app/api/get-alerts/route.ts b/frontend/app/api/get-alerts/route.ts index 9355c19..7de7916 100644 --- a/frontend/app/api/get-alerts/route.ts +++ b/frontend/app/api/get-alerts/route.ts @@ -95,6 +95,7 @@ export async function GET(req: NextRequest) { const priority = severity === 'critical' ? 'high' : severity === 'warning' ? 'medium' : 'low' return { id: a.id, + detector_id: a.detector_id, detector_name: a.name || a.detector_name, message: a.message, type, diff --git a/frontend/app/api/get-reports/route.ts b/frontend/app/api/get-reports/route.ts index 849c214..b5c778d 100644 --- a/frontend/app/api/get-reports/route.ts +++ b/frontend/app/api/get-reports/route.ts @@ -45,15 +45,22 @@ export async function POST(req: NextRequest) { }) } - const body = await req.json().catch(() => ({ })) as { format?: 'csv' | 'pdf', hours?: number } + const body = await req.json().catch(() => ({ })) as { format?: 'csv' | 'pdf', hours?: number, detector_ids?: number[] } const reportFormat = (body.format || '').toLowerCase() const url = new URL(req.url) const qpFormat = (url.searchParams.get('format') || '').toLowerCase() const qpHoursRaw = url.searchParams.get('hours') + const qpDetectorIds = url.searchParams.get('detector_ids') const qpHours = qpHoursRaw ? Number(qpHoursRaw) : undefined const finalFormat = reportFormat || qpFormat const finalHours = typeof body.hours === 'number' ? body.hours : (typeof qpHours === 'number' && !Number.isNaN(qpHours) ? qpHours : 168) + const finalDetectorIds = body.detector_ids || (qpDetectorIds ? qpDetectorIds.split(',').map(id => Number(id)) : undefined) + + const requestBody: any = { report_format: finalFormat, hours: finalHours } + if (finalDetectorIds && finalDetectorIds.length > 0) { + requestBody.detector_ids = finalDetectorIds + } let backendRes = await fetch(`${backendUrl}/account/get-reports/`, { method: 'POST', @@ -61,7 +68,7 @@ export async function POST(req: NextRequest) { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}`, }, - body: JSON.stringify({ report_format: finalFormat, hours: finalHours }), + body: JSON.stringify(requestBody), }) if (!backendRes.ok && backendRes.status === 404) { diff --git a/frontend/app/api/get-zones/route.ts b/frontend/app/api/get-zones/route.ts new file mode 100644 index 0000000..5ddecec --- /dev/null +++ b/frontend/app/api/get-zones/route.ts @@ -0,0 +1,184 @@ +import { NextResponse, NextRequest } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { getToken } from 'next-auth/jwt' + +export async function GET(req: NextRequest) { + try { + const session = await getServerSession(authOptions) + + const authHeader = req.headers.get('authorization') || req.headers.get('Authorization') + const bearer = authHeader && authHeader.toLowerCase().startsWith('bearer ') ? authHeader.slice(7) : undefined + + const secret = process.env.NEXTAUTH_SECRET + const token = secret ? (await getToken({ req, secret }).catch(() => null)) : null + + let accessToken: string | undefined = session?.accessToken || bearer || (token as any)?.accessToken + const refreshToken: string | undefined = session?.refreshToken || (token as any)?.refreshToken + + if (!accessToken && refreshToken) { + try { + const refreshRes = await fetch(`${process.env.BACKEND_URL}/auth/refresh/`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh: refreshToken }), + }) + + if (refreshRes.ok) { + const refreshed = await refreshRes.json() + accessToken = refreshed.access + } else { + const errorText = await refreshRes.text() + let errorData: { error?: string; detail?: string; code?: string } = {} + try { + errorData = JSON.parse(errorText) + } catch { + errorData = { error: errorText } + } + + const errorMessage = (errorData.error as string) || (errorData.detail as string) || '' + if (typeof errorMessage === 'string' && + (errorMessage.includes('Token is expired') || + errorMessage.includes('expired') || + errorData.code === 'token_not_valid')) { + console.warn('Refresh token expired, user needs to re-authenticate') + } else { + console.error('Token refresh failed:', errorData.error || errorData.detail || 'Unknown error') + } + } + } catch (error) { + console.error('Error during token refresh:', error) + } + } + + if (!accessToken) { + return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) + } + + const backendUrl = process.env.BACKEND_URL + if (!backendUrl) { + return NextResponse.json({ success: false, error: 'BACKEND_URL is not configured' }, { status: 500 }) + } + + const { searchParams } = new URL(req.url) + const objectId = searchParams.get('objectId') + if (!objectId) { + return NextResponse.json({ success: false, error: 'objectId query parameter is required' }, { status: 400 }) + } + + // Нормализуем objectId с фронтенда вида "object_2" к числовому идентификатору бэкенда "2" + const normalizedObjectId = /^object_(\d+)$/.test(objectId) ? objectId.replace(/^object_/, '') : objectId + + const zonesRes = await fetch(`${backendUrl}/account/get-zones/?objectId=${encodeURIComponent(normalizedObjectId)}`, { + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + cache: 'no-store', + }) + + const payloadText = await zonesRes.text() + let payload: any + try { payload = JSON.parse(payloadText) } catch { payload = payloadText } + + // Отладка: наблюдаем статус ответа и предполагаемую длину + console.log( + '[api/get-zones] objectId=%s normalized=%s status=%d payloadType=%s length=%d', + objectId, + normalizedObjectId, + zonesRes.status, + typeof payload, + Array.isArray((payload as any)?.data) + ? (payload as any).data.length + : Array.isArray(payload) + ? payload.length + : 0 + ) + + if (!zonesRes.ok) { + if (payload && typeof payload === 'object') { + if (payload.code === 'token_not_valid' || + (payload.detail && typeof payload.detail === 'string' && (payload.detail.includes('Token is expired') || payload.detail.includes('Given token not valid'))) || + (payload.messages && Array.isArray(payload.messages) && payload.messages.some((msg: any) => + msg.message && typeof msg.message === 'string' && msg.message.includes('Token is expired') + ))) { + console.warn('Access token expired, user needs to re-authenticate') + return NextResponse.json({ + success: false, + error: 'Authentication required - please log in again' + }, { status: 401 }) + } + } + + // Резервный путь: пробуем получить список объектов и извлечь зоны для указанного objectId + try { + const objectsRes = await fetch(`${backendUrl}/account/get-objects/`, { + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + cache: 'no-store', + }) + + const objectsText = await objectsRes.text() + let objectsPayload: any + try { objectsPayload = JSON.parse(objectsText) } catch { objectsPayload = objectsText } + + if (objectsRes.ok) { + const objectsList: any[] = Array.isArray(objectsPayload?.data) ? objectsPayload.data + : (Array.isArray(objectsPayload) ? objectsPayload : (Array.isArray(objectsPayload?.objects) ? objectsPayload.objects : [])) + + const target = objectsList.find((o: any) => ( + o?.id === normalizedObjectId || o?.object_id === normalizedObjectId || o?.slug === normalizedObjectId || o?.identifier === normalizedObjectId + )) + + const rawZones: any[] = target?.zones || target?.zone_list || target?.areas || target?.Зоны || [] + const normalized = Array.isArray(rawZones) ? rawZones.map((z: any, idx: number) => ({ + id: z?.id ?? z?.zone_id ?? `${normalizedObjectId}_zone_${idx}`, + name: z?.name ?? z?.zone_name ?? `Зона ${idx + 1}`, + floor: typeof z?.floor === 'number' ? z.floor : (typeof z?.level === 'number' ? z.level : 0), + image_path: z?.image_path ?? z?.image ?? z?.preview_image ?? null, + model_path: z?.model_path ?? z?.model ?? z?.modelUrl ?? null, + order: typeof z?.order === 'number' ? z.order : 0, + sensors: Array.isArray(z?.sensors) ? z.sensors : (Array.isArray(z?.detectors) ? z.detectors : []), + })) : [] + + // Отладка: длина массива в резервном пути + console.log('[api/get-zones:fallback] normalized length=%d', normalized.length) + + // Возвращаем успешный ответ с нормализованными зонами (может быть пустой массив) + return NextResponse.json({ success: true, data: normalized }, { status: 200 }) + } + } catch (fallbackErr) { + console.warn('Fallback get-objects failed:', fallbackErr) + } + + // Если дошли до сюда, возвращаем успешный ответ с пустым списком, чтобы не ломать UI + return NextResponse.json({ success: true, data: [] }, { status: 200 }) + } + + // Распаковываем массив зон от бэкенда в плоский список в поле data + const zonesData: any[] = Array.isArray((payload as any)?.data) + ? (payload as any).data + : Array.isArray(payload) + ? (payload as any) + : Array.isArray((payload as any)?.zones) + ? (payload as any).zones + : [] + + return NextResponse.json({ success: true, data: zonesData }, { status: 200 }) + // Нормализация: при необходимости используем запасной image_path на стороне клиента + return NextResponse.json({ success: true, data: payload }) + } catch (error) { + console.error('Error fetching zones data:', error) + return NextResponse.json( + { + success: false, + error: 'Failed to fetch zones data', + }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/frontend/app/store/navigationStore.ts b/frontend/app/store/navigationStore.ts index 703488e..6988e35 100644 --- a/frontend/app/store/navigationStore.ts +++ b/frontend/app/store/navigationStore.ts @@ -1,5 +1,6 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' +import type { Zone } from '@/app/types' export interface DetectorType { detector_id: number @@ -55,29 +56,40 @@ export interface NavigationStore { navigationHistory: string[] currentSubmenu: string | null currentModelPath: string | null - + + // Состояния Зон + currentZones: Zone[] + zonesCache: Record + zonesLoading: boolean + zonesError: string | null + showMonitoring: boolean showFloorNavigation: boolean showNotifications: boolean showListOfDetectors: boolean showSensors: boolean - + selectedDetector: DetectorType | null showDetectorMenu: boolean selectedNotification: NotificationType | null showNotificationDetectorInfo: boolean selectedAlert: AlertType | null showAlertMenu: boolean - + setCurrentObject: (id: string | undefined, title: string | undefined) => void clearCurrentObject: () => void addToHistory: (path: string) => void goBack: () => string | null setCurrentModelPath: (path: string) => void - + setCurrentSubmenu: (submenu: string | null) => void clearSubmenu: () => void - + + // Действия с зонами + loadZones: (objectId: string) => Promise + setZones: (zones: Zone[]) => void + clearZones: () => void + openMonitoring: () => void closeMonitoring: () => void openFloorNavigation: () => void @@ -88,20 +100,20 @@ export interface NavigationStore { closeListOfDetectors: () => void openSensors: () => void closeSensors: () => void - + closeAllMenus: () => void - + clearSelections: () => void + setSelectedDetector: (detector: DetectorType | null) => void setShowDetectorMenu: (show: boolean) => void setSelectedNotification: (notification: NotificationType | null) => void setShowNotificationDetectorInfo: (show: boolean) => void setSelectedAlert: (alert: AlertType | null) => void setShowAlertMenu: (show: boolean) => void - + isOnNavigationPage: () => boolean getCurrentRoute: () => string | null getActiveSidebarItem: () => number - PREFERRED_MODEL: string } const useNavigationStore = create()( @@ -114,13 +126,18 @@ const useNavigationStore = create()( navigationHistory: [], currentSubmenu: null, currentModelPath: null, - + + currentZones: [], + zonesCache: {}, + zonesLoading: false, + zonesError: null, + showMonitoring: false, showFloorNavigation: false, showNotifications: false, showListOfDetectors: false, showSensors: false, - + selectedDetector: null, showDetectorMenu: false, selectedNotification: null, @@ -128,8 +145,6 @@ const useNavigationStore = create()( selectedAlert: null, showAlertMenu: false, - PREFERRED_MODEL: 'AerBIM-Monitor_ASM-HT-Viewer_Expo2017Astana_20250910', - setCurrentObject: (id: string | undefined, title: string | undefined) => set({ currentObject: { id, title } }), @@ -164,18 +179,64 @@ const useNavigationStore = create()( clearSubmenu: () => set({ currentSubmenu: null }), - - openMonitoring: () => set({ - showMonitoring: true, - showFloorNavigation: false, - showNotifications: false, - showListOfDetectors: false, - currentSubmenu: 'monitoring', - showDetectorMenu: false, - selectedDetector: null, - showNotificationDetectorInfo: false, - selectedNotification: null - }), + + loadZones: async (objectId: string) => { + const cache = get().zonesCache + const cached = cache[objectId] + const hasCached = Array.isArray(cached) && cached.length > 0 + if (hasCached) { + // Показываем кэшированные зоны сразу, но обновляем в фоне + set({ currentZones: cached, zonesLoading: true, zonesError: null }) + } else { + set({ zonesLoading: true, zonesError: null }) + } + try { + const res = await fetch(`/api/get-zones?objectId=${encodeURIComponent(objectId)}`, { cache: 'no-store' }) + const text = await res.text() + let payload: string | Record + try { payload = JSON.parse(text) } catch { payload = text } + if (!res.ok) throw new Error(typeof payload === 'string' ? payload : (payload?.error as string || 'Не удалось получить зоны')) + const zones: Zone[] = typeof payload === 'string' ? [] : + Array.isArray(payload?.data) ? payload.data as Zone[] : + (payload?.data && typeof payload.data === 'object' && 'zones' in payload.data ? (payload.data as { zones?: Zone[] }).zones : + payload?.zones ? payload.zones as Zone[] : []) || [] + const normalized = zones.map((z) => ({ + ...z, + image_path: z.image_path ?? null, + })) + set((state) => ({ + currentZones: normalized, + zonesCache: { ...state.zonesCache, [objectId]: normalized }, + zonesLoading: false, + zonesError: null, + })) + } catch (e: unknown) { + set({ zonesLoading: false, zonesError: (e as Error)?.message || 'Ошибка при загрузке зон' }) + } + }, + + setZones: (zones: Zone[]) => set({ currentZones: zones }), + clearZones: () => set({ currentZones: [] }), + + openMonitoring: () => { + set({ + showMonitoring: true, + showFloorNavigation: false, + showNotifications: false, + showListOfDetectors: false, + currentSubmenu: 'monitoring', + showDetectorMenu: false, + selectedDetector: null, + showNotificationDetectorInfo: false, + selectedNotification: null, + zonesError: null // Очищаем ошибку зон при открытии мониторинга + }) + const objId = get().currentObject.id + if (objId) { + // Вызываем загрузку зон сразу, но обновляем в фоне + get().loadZones(objId) + } + }, closeMonitoring: () => set({ showMonitoring: false, @@ -254,22 +315,26 @@ const useNavigationStore = create()( selectedDetector: null, currentSubmenu: null }), - - closeAllMenus: () => set({ - showMonitoring: false, - showFloorNavigation: false, - showNotifications: false, - showListOfDetectors: false, - showSensors: false, - showDetectorMenu: false, + + closeAllMenus: () => { + set({ + showMonitoring: false, + showFloorNavigation: false, + showNotifications: false, + showListOfDetectors: false, + showSensors: false, + currentSubmenu: null, + }); + get().clearSelections(); + }, + + clearSelections: () => set({ selectedDetector: null, - showNotificationDetectorInfo: false, - selectedNotification: null, - showAlertMenu: false, + showDetectorMenu: false, selectedAlert: null, - currentSubmenu: null + showAlertMenu: false, }), - + setSelectedDetector: (detector: DetectorType | null) => set({ selectedDetector: detector }), setShowDetectorMenu: (show: boolean) => set({ showDetectorMenu: show }), setSelectedNotification: (notification: NotificationType | null) => set({ selectedNotification: notification }), diff --git a/frontend/app/types.ts b/frontend/app/types.ts index c44ee82..73ac123 100644 --- a/frontend/app/types.ts +++ b/frontend/app/types.ts @@ -1,2 +1,2 @@ -import type { ValidationRules, ValidationErrors, User, UserState } from './types/index' -export type { ValidationRules, ValidationErrors, User, UserState } \ No newline at end of file +import type { ValidationRules, ValidationErrors, User, UserState, Zone } from './types/index' +export type { ValidationRules, ValidationErrors, User, UserState, Zone } \ No newline at end of file diff --git a/frontend/app/types/index.ts b/frontend/app/types/index.ts index 6f2fac7..c7ac681 100644 --- a/frontend/app/types/index.ts +++ b/frontend/app/types/index.ts @@ -64,3 +64,18 @@ export interface TextInputProps { error?: string min?: string } + +export interface Zone { + id: number + name: string + floor: number + image_path?: string | null + model_path?: string | null + order?: number + sensors?: Array<{ + id: number + name: string + serial_number: string + sensor_type: string + }> +} diff --git a/frontend/components/alerts/AlertsList.tsx b/frontend/components/alerts/AlertsList.tsx new file mode 100644 index 0000000..9b2d850 --- /dev/null +++ b/frontend/components/alerts/AlertsList.tsx @@ -0,0 +1,145 @@ +import React, { useState, useMemo } from 'react' + +interface AlertItem { + id: number + type: string + message: string + timestamp: string + acknowledged: boolean + priority: string + detector_id?: string + detector_name?: string + location?: string + object?: string +} + +interface AlertsListProps { + alerts: AlertItem[] + onAcknowledgeToggle: (alertId: number) => void + initialSearchTerm?: string +} + +const AlertsList: React.FC = ({ alerts, onAcknowledgeToggle, initialSearchTerm = '' }) => { + const [searchTerm, setSearchTerm] = useState(initialSearchTerm) + + const filteredAlerts = useMemo(() => { + return alerts.filter(alert => { + const matchesSearch = searchTerm === '' || alert.detector_id?.toString() === searchTerm + return matchesSearch + }) + }, [alerts, searchTerm]) + + const getStatusColor = (type: string) => { + switch (type) { + case 'critical': return '#b3261e' + case 'warning': return '#fd7c22' + case 'info': return '#00ff00' + default: return '#666' + } + } + + return ( +
+ {/* Поиск */} +
+
+ setSearchTerm(e.target.value)} + className="bg-[#161824] text-white placeholder-gray-400 px-4 py-2 rounded-lg border border-gray-600 focus:border-blue-500 focus:outline-none w-64" + /> + + + +
+
+ + {/* Таблица алертов */} +
+
+

История тревог

+ Всего: {filteredAlerts.length} +
+
+ + + + + + + + + + + + + + {filteredAlerts.map((item) => ( + + + + + + + + + + ))} + {filteredAlerts.length === 0 && ( + + + + )} + +
ДетекторСтатусСообщениеМестоположениеПриоритетПодтвержденоВремя
+
{item.detector_name || 'Детектор'}
+ {item.detector_id ? ( +
ID: {item.detector_id}
+ ) : null} +
+
+
+ + {item.type === 'critical' ? 'Критический' : item.type === 'warning' ? 'Предупреждение' : 'Информация'} + +
+
+
{item.message}
+
+
{item.location || '-'}
+
+ + {item.priority === 'high' ? 'Высокий' : item.priority === 'medium' ? 'Средний' : 'Низкий'} + + + + {item.acknowledged ? 'Да' : 'Нет'} + + + +
{new Date(item.timestamp).toLocaleString('ru-RU')}
+
+ Записей не найдено +
+
+
+
+ ) +} + +export default AlertsList \ No newline at end of file diff --git a/frontend/components/alerts/DetectorList.tsx b/frontend/components/alerts/DetectorList.tsx index f45a63a..c744e10 100644 --- a/frontend/components/alerts/DetectorList.tsx +++ b/frontend/components/alerts/DetectorList.tsx @@ -31,18 +31,16 @@ interface RawDetector { }> } -type FilterType = 'all' | 'critical' | 'warning' | 'normal' - interface DetectorListProps { objectId?: string selectedDetectors: number[] onDetectorSelect: (detectorId: number, selected: boolean) => void + initialSearchTerm?: string } -const DetectorList: React.FC = ({ objectId, selectedDetectors, onDetectorSelect }) => { +const DetectorList: React.FC = ({ objectId, selectedDetectors, onDetectorSelect, initialSearchTerm = '' }) => { const [detectors, setDetectors] = useState([]) - const [selectedFilter, setSelectedFilter] = useState('all') - const [searchTerm, setSearchTerm] = useState('') + const [searchTerm, setSearchTerm] = useState(initialSearchTerm) useEffect(() => { const loadDetectors = async () => { @@ -72,14 +70,8 @@ const DetectorList: React.FC = ({ objectId, selectedDetectors loadDetectors() }, [objectId]) - const filteredDetectors = detectors.filter(detector => { - const matchesSearch = detector.name.toLowerCase().includes(searchTerm.toLowerCase()) || - detector.location.toLowerCase().includes(searchTerm.toLowerCase()) - - if (selectedFilter === 'all') return matchesSearch - if (selectedFilter === 'critical') return matchesSearch && detector.status === '#b3261e' - if (selectedFilter === 'warning') return matchesSearch && detector.status === '#fd7c22' - if (selectedFilter === 'normal') return matchesSearch && detector.status === '#00ff00' + const filteredDetectors = detectors.filter(detector => { + const matchesSearch = searchTerm === '' || detector.detector_id.toString() === searchTerm return matchesSearch }) @@ -88,53 +80,13 @@ const DetectorList: React.FC = ({ objectId, selectedDetectors
- - - -
setSearchTerm(e.target.value)} className="bg-[#161824] text-white placeholder-gray-400 px-4 py-2 rounded-lg border border-gray-600 focus:border-blue-500 focus:outline-none w-64" diff --git a/frontend/components/dashboard/Dashboard.tsx b/frontend/components/dashboard/Dashboard.tsx index eea95b8..777db9d 100644 --- a/frontend/components/dashboard/Dashboard.tsx +++ b/frontend/components/dashboard/Dashboard.tsx @@ -60,7 +60,7 @@ const Dashboard: React.FC = () => { loadDashboard() }, [objectTitle, selectedChartPeriod, selectedSensorType]) - // Separate effect for table data based on table period + // Отдельный эффект для загрузки таблицы по выбранному периоду useEffect(() => { const loadTableData = async () => { try { @@ -195,7 +195,7 @@ const Dashboard: React.FC = () => { - + @@ -207,15 +207,13 @@ const Dashboard: React.FC = () => { {/* Карты-графики */}
({ value: d.value }))} /> @@ -236,7 +234,7 @@ const Dashboard: React.FC = () => { - + diff --git a/frontend/components/model/ModelViewer.tsx b/frontend/components/model/ModelViewer.tsx index 853b6dc..0d6ed81 100644 --- a/frontend/components/model/ModelViewer.tsx +++ b/frontend/components/model/ModelViewer.tsx @@ -39,8 +39,12 @@ export interface ModelViewerProps { } }) => void onError?: (error: string) => void + activeMenu?: string | null focusSensorId?: string | null renderOverlay?: (params: { anchor: { left: number; top: number } | null; info?: { name?: string; sensorId?: string } | null }) => React.ReactNode + isSensorSelectionEnabled?: boolean + onSensorPick?: (sensorId: string | null) => void + highlightAllSensors?: boolean } const ModelViewer: React.FC = ({ @@ -50,6 +54,9 @@ const ModelViewer: React.FC = ({ onError, focusSensorId, renderOverlay, + isSensorSelectionEnabled, + onSensorPick, + highlightAllSensors, }) => { const canvasRef = useRef(null) const engineRef = useRef>(null) @@ -61,6 +68,7 @@ const ModelViewer: React.FC = ({ const isDisposedRef = useRef(false) const importedMeshesRef = useRef([]) const highlightLayerRef = useRef(null) + const highlightedMeshesRef = useRef([]) const chosenMeshRef = useRef(null) const [overlayPos, setOverlayPos] = useState<{ left: number; top: number } | null>(null) const [overlayData, setOverlayData] = useState<{ name?: string; sensorId?: string } | null>(null) @@ -214,7 +222,7 @@ const ModelViewer: React.FC = ({ if (!canvasRef.current || isInitializedRef.current) return const canvas = canvasRef.current - const engine = new Engine(canvas, true) + const engine = new Engine(canvas, true, { stencil: true }) engineRef.current = engine engine.runRenderLoop(() => { @@ -419,29 +427,40 @@ const ModelViewer: React.FC = ({ }, [modelPath, onError, onModelLoaded]) useEffect(() => { + console.log('[ModelViewer] highlightAllSensors effect triggered:', { highlightAllSensors, modelReady, sceneReady: !!sceneRef.current }) if (!sceneRef.current || isDisposedRef.current || !modelReady) return + // Если включено выделение всех сенсоров - выделяем их все + if (highlightAllSensors) { + console.log('[ModelViewer] Calling highlightAllSensorMeshes()') + highlightAllSensorMeshes() + return + } + const sensorId = (focusSensorId ?? '').trim() if (!sensorId) { console.log('[ModelViewer] Focus cleared (no Sensor_ID provided)') - + for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 } + highlightedMeshesRef.current = [] highlightLayerRef.current?.removeAllMeshes() chosenMeshRef.current = null setOverlayPos(null) setOverlayData(null) return - } + } const allMeshes = importedMeshesRef.current || [] if (allMeshes.length === 0) { console.warn('[ModelViewer] No meshes available for sensor matching') + for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 } + highlightedMeshesRef.current = [] highlightLayerRef.current?.removeAllMeshes() chosenMeshRef.current = null setOverlayPos(null) setOverlayData(null) return - } + } const sensorMeshes = allMeshes.filter((m: any) => { try { @@ -516,15 +535,26 @@ const ModelViewer: React.FC = ({ const hl = highlightLayerRef.current if (hl) { + // Переключаем группу рендеринга для предыдущего выделенного меша + for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 } + highlightedMeshesRef.current = [] + hl.removeAllMeshes() if (chosen instanceof Mesh) { + chosen.renderingGroupId = 1 + highlightedMeshesRef.current.push(chosen) hl.addMesh(chosen, new Color3(1, 1, 0)) } else if (chosen instanceof InstancedMesh) { + // Сохраняем исходный меш для инстанса + chosen.sourceMesh.renderingGroupId = 1 + highlightedMeshesRef.current.push(chosen.sourceMesh) hl.addMesh(chosen.sourceMesh, new Color3(1, 1, 0)) } else { const children = typeof (chosen as any)?.getChildMeshes === 'function' ? (chosen as any).getChildMeshes() : [] for (const cm of children) { if (cm instanceof Mesh) { + cm.renderingGroupId = 1 + highlightedMeshesRef.current.push(cm) hl.addMesh(cm, new Color3(1, 1, 0)) } } @@ -534,18 +564,144 @@ const ModelViewer: React.FC = ({ setOverlayData({ name: chosen.name, sensorId }) } catch (error) { console.error('[ModelViewer] Error focusing on sensor mesh:', error) + for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 } + highlightedMeshesRef.current = [] + highlightLayerRef.current?.removeAllMeshes() + chosenMeshRef.current = null + setOverlayPos(null) + setOverlayData(null) + } + } else { + for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 } + highlightedMeshesRef.current = [] highlightLayerRef.current?.removeAllMeshes() chosenMeshRef.current = null setOverlayPos(null) setOverlayData(null) - } - } else { - highlightLayerRef.current?.removeAllMeshes() - chosenMeshRef.current = null - setOverlayPos(null) - setOverlayData(null) } - }, [focusSensorId, modelReady]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [focusSensorId, modelReady, highlightAllSensors]) + + // Помощь: извлечь Sensor_ID из метаданных меша (совпадающая логика с фокусом) + const getSensorIdFromMesh = React.useCallback((m: AbstractMesh | null): string | null => { + if (!m) return null + try { + const meta: any = (m as any)?.metadata + const extras: any = meta?.gltf?.extras ?? meta?.extras ?? (m as any)?.extras + const sid = extras?.Sensor_ID ?? extras?.sensor_id ?? extras?.SERIAL_NUMBER ?? extras?.serial_number ?? extras?.detector_id + if (sid != null) return String(sid).trim() + const monitoringSensorInstance = extras?.bonsaiPset_ARBM_PSet_MonitoringSensor_Instance + if (monitoringSensorInstance && typeof monitoringSensorInstance === 'string') { + try { + const parsedInstance = JSON.parse(monitoringSensorInstance) + const instanceSensorId = parsedInstance?.Sensor_ID + if (instanceSensorId != null) return String(instanceSensorId).trim() + } catch (parseError) { + console.warn('[ModelViewer] Error parsing MonitoringSensor_Instance JSON in pick:', parseError) + } + } + } catch { + } + return null + }, []) + + // Функция для выделения всех сенсоров на модели + const highlightAllSensorMeshes = React.useCallback(() => { + console.log('[ModelViewer] highlightAllSensorMeshes called') + const scene = sceneRef.current + if (!scene || !highlightLayerRef.current) { + console.log('[ModelViewer] Cannot highlight - scene or highlightLayer not ready:', { + sceneReady: !!scene, + highlightLayerReady: !!highlightLayerRef.current + }) + return + } + + const allMeshes = importedMeshesRef.current || [] + + // Use the same logic as getSensorIdFromMesh to identify sensor meshes + const sensorMeshes = allMeshes.filter((m: any) => { + try { + const sensorId = getSensorIdFromMesh(m) + if (sensorId) { + console.log(`[ModelViewer] Found sensor mesh: ${m.name} (id: ${m.id}, sensorId: ${sensorId})`) + return true + } + return false + } catch (error) { + console.warn('[ModelViewer] Error filtering sensor mesh:', error) + return false + } + }) + + console.log(`[ModelViewer] Found ${sensorMeshes.length} sensor meshes out of ${allMeshes.length} total meshes`) + + if (sensorMeshes.length === 0) { + console.log('[ModelViewer] No sensor meshes found to highlight') + return + } + + // Clear previous highlights + for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 } + highlightedMeshesRef.current = [] + highlightLayerRef.current?.removeAllMeshes() + + // Highlight all sensor meshes + sensorMeshes.forEach((mesh: any) => { + try { + if (mesh instanceof Mesh) { + mesh.renderingGroupId = 1 + highlightedMeshesRef.current.push(mesh) + highlightLayerRef.current?.addMesh(mesh, new Color3(1, 1, 0)) + } else if (mesh instanceof InstancedMesh) { + mesh.sourceMesh.renderingGroupId = 1 + highlightedMeshesRef.current.push(mesh.sourceMesh) + highlightLayerRef.current?.addMesh(mesh.sourceMesh, new Color3(1, 1, 0)) + } else { + const children = typeof mesh.getChildMeshes === 'function' ? mesh.getChildMeshes() : [] + for (const cm of children) { + if (cm instanceof Mesh) { + cm.renderingGroupId = 1 + highlightedMeshesRef.current.push(cm) + highlightLayerRef.current?.addMesh(cm, new Color3(1, 1, 0)) + } + } + } + } catch (error) { + console.warn('[ModelViewer] Error highlighting sensor mesh:', error) + } + }) + + console.log(`[ModelViewer] Successfully highlighted ${highlightedMeshesRef.current.length} sensor meshes`) + }, [getSensorIdFromMesh]) + + // Включение выбора на основе взаимодействия с моделью только при готовности модели и включении выбора сенсоров + useEffect(() => { + const scene = sceneRef.current + if (!scene || !modelReady || !isSensorSelectionEnabled) return + + const pickObserver = scene.onPointerObservable.add((pointerInfo: PointerInfo) => { + if (pointerInfo.type !== PointerEventTypes.POINTERPICK) return + const pick = pointerInfo.pickInfo + if (!pick || !pick.hit) { + onSensorPick?.(null) + return + } + + const pickedMesh = pick.pickedMesh + const sensorId = getSensorIdFromMesh(pickedMesh) + + if (sensorId) { + onSensorPick?.(sensorId) + } else { + onSensorPick?.(null) + } + }) + + return () => { + scene.onPointerObservable.remove(pickObserver) + } + }, [modelReady, isSensorSelectionEnabled, onSensorPick, getSensorIdFromMesh]) // Расчет позиции оверлея const computeOverlayPosition = React.useCallback((mesh: AbstractMesh | null) => { diff --git a/frontend/components/model/SceneToolbar.tsx b/frontend/components/model/SceneToolbar.tsx index 458f265..bd11148 100644 --- a/frontend/components/model/SceneToolbar.tsx +++ b/frontend/components/model/SceneToolbar.tsx @@ -1,6 +1,7 @@ import React, { useState } from 'react'; import Image from 'next/image'; import useNavigationStore from '@/app/store/navigationStore'; +import type { Zone } from '@/app/types'; interface ToolbarButton { icon: string; @@ -32,7 +33,7 @@ const SceneToolbar: React.FC = ({ navMenuActive = false, }) => { const [isZoomOpen, setIsZoomOpen] = useState(false); - const { PREFERRED_MODEL, showMonitoring, openMonitoring, closeMonitoring } = useNavigationStore(); + const { showMonitoring, openMonitoring, closeMonitoring, currentZones, loadZones, currentObject } = useNavigationStore(); const handleToggleNavMenu = () => { if (showMonitoring) { @@ -43,25 +44,46 @@ const SceneToolbar: React.FC = ({ }; const handleHomeClick = async () => { - if (onSelectModel) { - try { - const res = await fetch('/api/big-models/list'); - if (!res.ok) { - throw new Error('Failed to fetch models list'); - } - const data = await res.json(); - const items: { name: string; path: string }[] = Array.isArray(data?.models) ? data.models : []; - const preferredModelName = PREFERRED_MODEL.split('/').pop()?.split('.').slice(0, -1).join('.') || ''; - const preferredModel = items.find(model => (model.path.split('/').pop()?.split('.').slice(0, -1).join('.') || '') === preferredModelName); + if (!onSelectModel) return; - if (preferredModel) { - onSelectModel(preferredModel.path); - } else { - console.error('Preferred model not found in the list'); + try { + let zones: Zone[] = Array.isArray(currentZones) ? currentZones : []; + + // Если зоны ещё не загружены, откройте Monitoring и загрузите зоны для текущего объекта + if ((!zones || zones.length === 0) && currentObject?.id) { + if (!showMonitoring) { + openMonitoring(); } - } catch (error) { - console.error('Error fetching models list:', error); + await loadZones(currentObject.id); + zones = useNavigationStore.getState().currentZones || []; } + + if (!Array.isArray(zones) || zones.length === 0) { + console.warn('No zones available to select a model from.'); + return; + } + + const sorted = zones.slice().sort((a: Zone, b: Zone) => { + const oa = typeof a.order === 'number' ? a.order : 0; + const ob = typeof b.order === 'number' ? b.order : 0; + if (oa !== ob) return oa - ob; + return (a.name || '').localeCompare(b.name || ''); + }); + + const top = sorted[0]; + let chosenPath: string | null = top?.model_path && String(top.model_path).trim() ? top.model_path! : null; + if (!chosenPath) { + const nextWithModel = sorted.find((z) => z.model_path && String(z.model_path).trim()); + chosenPath = nextWithModel?.model_path ?? null; + } + + if (chosenPath) { + onSelectModel(chosenPath); + } else { + console.warn('No zone has a valid model_path to open.'); + } + } catch (error) { + console.error('Error selecting top zone model:', error); } }; diff --git a/frontend/components/navigation/AlertMenu.tsx b/frontend/components/navigation/AlertMenu.tsx index 284ce89..7f465d3 100644 --- a/frontend/components/navigation/AlertMenu.tsx +++ b/frontend/components/navigation/AlertMenu.tsx @@ -1,7 +1,10 @@ 'use client' import React from 'react' - +import { useRouter } from 'next/navigation' +import useNavigationStore from '@/app/store/navigationStore' +import AreaChart from '../dashboard/AreaChart' + interface AlertType { id: number detector_id: number @@ -26,6 +29,9 @@ interface AlertMenuProps { } const AlertMenu: React.FC = ({ alert, isOpen, onClose, getStatusText, compact = false, anchor = null }) => { + const router = useRouter() + const { setSelectedDetector, currentObject } = useNavigationStore() + if (!isOpen) return null const formatDate = (dateString: string) => { @@ -39,16 +45,8 @@ const AlertMenu: React.FC = ({ alert, isOpen, onClose, getStatus }) } - const getPriorityColor = (priority: string) => { - switch (priority.toLowerCase()) { - case 'high': return 'text-red-400' - case 'medium': return 'text-orange-400' - case 'low': return 'text-green-400' - default: return 'text-gray-400' - } - } - const getPriorityText = (priority: string) => { + if (typeof priority !== 'string') return 'Неизвестно'; switch (priority.toLowerCase()) { case 'high': return 'Высокий' case 'medium': return 'Средний' @@ -57,14 +55,141 @@ const AlertMenu: React.FC = ({ alert, isOpen, onClose, getStatus } } + const getStatusColorCircle = (status: string) => { + // Use hex colors from Alerts submenu system + if (status === '#b3261e') return 'bg-red-500' + if (status === '#fd7c22') return 'bg-orange-500' + if (status === '#00ff00') return 'bg-green-500' + + // Fallback for text-based status + switch (status?.toLowerCase()) { + case 'critical': return 'bg-red-500' + case 'warning': return 'bg-orange-500' + case 'normal': return 'bg-green-500' + default: return 'bg-gray-500' + } + } + + const getTimeAgo = (timestamp: string) => { + const now = new Date() + const alertTime = new Date(timestamp) + const diffMs = now.getTime() - alertTime.getTime() + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)) + const diffMonths = Math.floor(diffDays / 30) + + if (diffMonths > 0) { + return `${diffMonths} ${diffMonths === 1 ? 'месяц' : diffMonths < 5 ? 'месяца' : 'месяцев'}` + } else if (diffDays > 0) { + return `${diffDays} ${diffDays === 1 ? 'день' : diffDays < 5 ? 'дня' : 'дней'}` + } else { + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)) + if (diffHours > 0) { + return `${diffHours} ${diffHours === 1 ? 'час' : diffHours < 5 ? 'часа' : 'часов'}` + } else { + const diffMinutes = Math.floor(diffMs / (1000 * 60)) + return `${diffMinutes} ${diffMinutes === 1 ? 'минута' : diffMinutes < 5 ? 'минуты' : 'минут'}` + } + } + } + + const handleReportsClick = () => { + const currentUrl = new URL(window.location.href) + const objectId = currentUrl.searchParams.get('objectId') || currentObject.id + const objectTitle = currentUrl.searchParams.get('objectTitle') || currentObject.title + + const detectorData = { + detector_id: alert.detector_id, + name: alert.detector_name, + serial_number: '', + object: alert.object || '', + status: '', + type: '', + detector_type: '', + location: alert.location || '', + floor: 0, + checked: false, + notifications: [] + } + setSelectedDetector(detectorData) + + let reportsUrl = '/reports' + const params = new URLSearchParams() + + if (objectId) params.set('objectId', objectId) + if (objectTitle) params.set('objectTitle', objectTitle) + + if (params.toString()) { + reportsUrl += `?${params.toString()}` + } + + router.push(reportsUrl) + } + + const handleHistoryClick = () => { + const currentUrl = new URL(window.location.href) + const objectId = currentUrl.searchParams.get('objectId') || currentObject.id + const objectTitle = currentUrl.searchParams.get('objectTitle') || currentObject.title + + const detectorData = { + detector_id: alert.detector_id, + name: alert.detector_name, + serial_number: '', + object: alert.object || '', + status: '', + type: '', + detector_type: '', + location: alert.location || '', + floor: 0, + checked: false, + notifications: [] + } + setSelectedDetector(detectorData) + + let alertsUrl = '/alerts' + const params = new URLSearchParams() + + if (objectId) params.set('objectId', objectId) + if (objectTitle) params.set('objectTitle', objectTitle) + + if (params.toString()) { + alertsUrl += `?${params.toString()}` + } + + router.push(alertsUrl) + } + + // Данные для графика (мок данные) + const chartData: { timestamp: string; value: number }[] = [ + { timestamp: new Date(Date.now() - 6 * 24 * 60 * 60 * 1000).toISOString(), value: 75 }, + { timestamp: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(), value: 82 }, + { timestamp: new Date(Date.now() - 4 * 24 * 60 * 60 * 1000).toISOString(), value: 78 }, + { timestamp: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(), value: 85 }, + { timestamp: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), value: 90 }, + { timestamp: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(), value: 88 }, + { timestamp: new Date().toISOString(), value: 92 } + ] + if (compact && anchor) { return (
-
-
+
+
-
Датч.{alert.detector_name}
-
{getStatusText(alert.status)}
+
{alert.detector_name}
+
+
+ +
-
-
-
-
Приоритет
-
- {getPriorityText(alert.priority)} -
-
-
-
Время
-
{formatDate(alert.timestamp)}
+ {/* Тело: 3 строки / 2 колонки */} +
+ {/* Строка 1: Статус */} +
+
Статус
+
+
+
{getStatusText(alert.status)}
-
-
Сообщение
-
{alert.message}
+ {/* Строка 2: Причина тревоги */} +
+
Причина тревоги
+
{alert.message}
-
-
Местоположение
-
{alert.location}
+ {/* Строка 3: Временная метка */} +
+
Временная метка
+
{getTimeAgo(alert.timestamp)}
- -
- - + + {/* Добавлен раздел дата/время и график для компактного режима */} +
+
{formatDate(alert.timestamp)}
+ +
+ +
+
-
-
+
) } @@ -122,64 +240,89 @@ const AlertMenu: React.FC = ({ alert, isOpen, onClose, getStatus

- Датч.{alert.detector_name} + {alert.detector_name}

-
- - + +
+ + {/* Тело: Две колонки - Три строки */} +
+ {/* Колонка 1 - Строка 1: Статус */} +
+
Статус
+
+
+ {getStatusText(alert.status)} +
+
+ + {/* Колонка 1 - Строка 2: Причина тревоги */} +
+
Причина тревоги
+
{alert.message}
+
+
+
Причина тревоги
+
{alert.message}
+
+ + {/* Колонка 1 - Строка 3: Временная метка */} +
+
Временная метка
+
{getTimeAgo(alert.timestamp)}
+
+ + {/* Колонка 2 - Строка 1: Приоритет */} +
+
Приоритет
+
{getPriorityText(alert.priority)}
+
+ + {/* Колонка 2 - Строка 2: Локация */} +
+
Локация
+
{alert.location}
+
+ + {/* Колонка 2 - Строка 3: Объект */} +
+
Объект
+
{alert.object}
+
+ + {/* Колонка 2 - Строка 4: Отчет */} +
+
Отчет
+
Доступен
+
+ + {/* Колонка 2 - Строка 5: История */} +
+
История
+
Просмотр
- {/* Табличка с информацией об алерте */} -
-
-
-
Маркировка по проекту
-
{alert.detector_name}
-
-
-
Приоритет
-
- {getPriorityText(alert.priority)} -
-
+ {/* Низ: Две строки - первая содержит дату/время, вторая строка ниже - наш график */} +
+ {/* Строка 1: Дата/время */} +
+
Дата/время
+
{formatDate(alert.timestamp)}
-
-
-
Местоположение
-
{alert.location}
+ {/* Charts */} +
+
+
-
-
Статус
-
{getStatusText(alert.status)}
-
-
- -
-
-
Время события
-
{formatDate(alert.timestamp)}
-
-
-
Тип алерта
-
{alert.type}
-
-
- -
-
Сообщение
-
{alert.message}
diff --git a/frontend/components/navigation/DetectorMenu.tsx b/frontend/components/navigation/DetectorMenu.tsx index 69a5b2d..2ce8c6f 100644 --- a/frontend/components/navigation/DetectorMenu.tsx +++ b/frontend/components/navigation/DetectorMenu.tsx @@ -1,7 +1,9 @@ 'use client' import React from 'react' - +import { useRouter } from 'next/navigation' +import useNavigationStore from '@/app/store/navigationStore' + interface DetectorType { detector_id: number name: string @@ -13,7 +15,7 @@ interface DetectorType { detector_type: string location: string floor: number - notifications?: Array<{ + notifications: Array<{ id: number type: string message: string @@ -22,7 +24,7 @@ interface DetectorType { priority: string }> } - + interface DetectorMenuProps { detector: DetectorType isOpen: boolean @@ -32,10 +34,14 @@ interface DetectorMenuProps { anchor?: { left: number; top: number } | null } +// Главный компонент меню детектора +// Показывает детальную информацию о датчике с возможностью навигации к отчетам и истории const DetectorMenu: React.FC = ({ detector, isOpen, onClose, getStatusText, compact = false, anchor = null }) => { + const router = useRouter() + const { setSelectedDetector, currentObject } = useNavigationStore() if (!isOpen) return null - // Получаем самую свежую временную метку из уведомлений + // Определение последней временной метки из уведомлений детектора const latestTimestamp = (() => { const list = detector.notifications ?? [] if (!Array.isArray(list) || list.length === 0) return null @@ -48,6 +54,7 @@ const DetectorMenu: React.FC = ({ detector, isOpen, onClose, ? latestTimestamp.toLocaleString('ru-RU', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : 'Нет данных' + // Определение типа детектора и его отображаемого названия const rawDetectorTypeCode = (detector.detector_type || '').toUpperCase() const deriveCodeFromType = (): string => { const t = (detector.type || '').toLowerCase() @@ -58,16 +65,73 @@ const DetectorMenu: React.FC = ({ detector, isOpen, onClose, return '' } const effectiveDetectorTypeCode = rawDetectorTypeCode || deriveCodeFromType() + + // Карта соответствия кодов типов детекторов их русским названиям const detectorTypeLabelMap: Record = { GA: 'Инклинометр', PE: 'Тензометр', GLE: 'Гидроуровень', } const displayDetectorTypeLabel = detectorTypeLabelMap[effectiveDetectorTypeCode] || '—' + + // Обработчик клика по кнопке "Отчет" - навигация на страницу отчетов с выбранным детектором + const handleReportsClick = () => { + const currentUrl = new URL(window.location.href) + const objectId = currentUrl.searchParams.get('objectId') || currentObject.id + const objectTitle = currentUrl.searchParams.get('objectTitle') || currentObject.title + + const detectorData = { + ...detector, + notifications: detector.notifications || [] + } + setSelectedDetector(detectorData) + + let reportsUrl = '/reports' + const params = new URLSearchParams() + + if (objectId) params.set('objectId', objectId) + if (objectTitle) params.set('objectTitle', objectTitle) + + if (params.toString()) { + reportsUrl += `?${params.toString()}` + } + + router.push(reportsUrl) + } + + // Обработчик клика по кнопке "История" - навигация на страницу истории тревог с выбранным детектором + const handleHistoryClick = () => { + const currentUrl = new URL(window.location.href) + const objectId = currentUrl.searchParams.get('objectId') || currentObject.id + const objectTitle = currentUrl.searchParams.get('objectTitle') || currentObject.title + + const detectorData = { + ...detector, + notifications: detector.notifications || [] + } + setSelectedDetector(detectorData) + + let alertsUrl = '/alerts' + const params = new URLSearchParams() + + if (objectId) params.set('objectId', objectId) + if (objectTitle) params.set('objectTitle', objectTitle) + + if (params.toString()) { + alertsUrl += `?${params.toString()}` + } + + router.push(alertsUrl) + } + + // Компонент секции деталей детектора + // Отображает информацию о датчике в компактном или полном формате const DetailsSection: React.FC<{ compact?: boolean }> = ({ compact = false }) => (
{compact ? ( + // Компактный режим: 4 строки по 2 колонки с основной информацией <> + {/* Строка 1: Маркировка и тип детектора */}
Маркировка по проекту
@@ -78,6 +142,7 @@ const DetectorMenu: React.FC = ({ detector, isOpen, onClose,
{displayDetectorTypeLabel}
+ {/* Строка 2: Местоположение и статус */}
Местоположение
@@ -88,6 +153,7 @@ const DetectorMenu: React.FC = ({ detector, isOpen, onClose,
{getStatusText(detector.status)}
+ {/* Строка 3: Временная метка и этаж */}
Временная метка
@@ -98,6 +164,7 @@ const DetectorMenu: React.FC = ({ detector, isOpen, onClose,
{detector.floor}
+ {/* Строка 4: Серийный номер */}
Серийный номер
@@ -106,7 +173,9 @@ const DetectorMenu: React.FC = ({ detector, isOpen, onClose,
) : ( + // Полный режим: 3 строки по 2 колонки с рамками между элементами <> + {/* Строка 1: Маркировка по проекту и тип детектора */}
Маркировка по проекту
@@ -117,6 +186,7 @@ const DetectorMenu: React.FC = ({ detector, isOpen, onClose,
{displayDetectorTypeLabel}
+ {/* Строка 2: Местоположение и статус */}
Местоположение
@@ -127,6 +197,7 @@ const DetectorMenu: React.FC = ({ detector, isOpen, onClose,
{getStatusText(detector.status)}
+ {/* Строка 3: Временная метка и серийный номер */}
Временная метка
@@ -142,14 +213,15 @@ const DetectorMenu: React.FC = ({ detector, isOpen, onClose,
) + // Компактный режим с якорной позицией (всплывающее окно) + // Используется для отображения информации при наведении на детектор в списке if (compact && anchor) { return (
-
+
-
Датч.{detector.name}
-
{getStatusText(detector.status)}
+
{detector.name}
- - -
+ {/* Секция с детальной информацией о детекторе */} + {/* Кнопка закрытия панели */}
) } - + export default DetectorMenu \ No newline at end of file diff --git a/frontend/components/navigation/FloorNavigation.tsx b/frontend/components/navigation/FloorNavigation.tsx index ad88661..8bc2f1a 100644 --- a/frontend/components/navigation/FloorNavigation.tsx +++ b/frontend/components/navigation/FloorNavigation.tsx @@ -1,7 +1,7 @@ 'use client' import React, { useState } from 'react' - + interface DetectorsDataType { detectors: Record } @@ -35,11 +35,12 @@ interface DetectorType { }> } -const FloorNavigation: React.FC = ({ objectId, detectorsData, onDetectorMenuClick, onClose, is3DReady = true }) => { +const FloorNavigation: React.FC = (props) => { + const { objectId, detectorsData, onDetectorMenuClick, onClose, is3DReady = true } = props const [expandedFloors, setExpandedFloors] = useState>(new Set()) const [searchTerm, setSearchTerm] = useState('') - - // конвертация детекторов в array и фильтруем по objectId и тексту запроса + + // Преобразование detectors в массив и фильтрация по objectId и поисковому запросу const detectorsArray = Object.values(detectorsData.detectors) as DetectorType[] let filteredDetectors = objectId ? detectorsArray.filter(detector => detector.object === objectId) diff --git a/frontend/components/navigation/Monitoring.tsx b/frontend/components/navigation/Monitoring.tsx index 9410d18..79407c5 100644 --- a/frontend/components/navigation/Monitoring.tsx +++ b/frontend/components/navigation/Monitoring.tsx @@ -1,6 +1,33 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useEffect, useCallback } from 'react'; import Image from 'next/image'; import useNavigationStore from '@/app/store/navigationStore'; +import type { Zone } from '@/app/types'; + +// Безопасный резолвер src изображения, чтобы избежать ошибок Invalid URL в next/image +const resolveImageSrc = (src?: string | null): string => { + if (!src || typeof src !== 'string') return '/images/test_image.png'; + let s = src.trim(); + if (!s) return '/images/test_image.png'; + s = s.replace(/\\/g, '/'); + const lower = s.toLowerCase(); + // Явный плейсхолдер test_image.png маппим на наш статический ресурс + if (lower === 'test_image.png' || lower.endsWith('/test_image.png') || lower.includes('/public/images/test_image.png')) { + return '/images/test_image.png'; + } + // Если путь содержит public/images (даже абсолютный путь ФС), переводим в относительный путь сайта + if (/\/public\/images\//i.test(s)) { + const parts = s.split(/\/public\/images\//i); + const rel = parts[1] || ''; + return `/images/${rel}`; + } + // Абсолютные URL и пути, относительные к сайту + if (s.startsWith('http://') || s.startsWith('https://')) return s; + if (s.startsWith('/')) return s; + // Нормализуем относительные имена ресурсов до путей сайта под /images + // Убираем ведущий 'public/', если он присутствует + s = s.replace(/^public\//i, ''); + return s.startsWith('images/') ? `/${s}` : `/images/${s}`; +} interface MonitoringProps { onClose?: () => void; @@ -8,49 +35,25 @@ interface MonitoringProps { } const Monitoring: React.FC = ({ onClose, onSelectModel }) => { - const [models, setModels] = useState<{ title: string; path: string }[]>([]); - const [loadError, setLoadError] = useState(null); - const PREFERRED_MODEL = useNavigationStore((state) => state.PREFERRED_MODEL); + const { currentObject, currentZones, zonesLoading, zonesError, loadZones } = useNavigationStore(); const handleSelectModel = useCallback((modelPath: string) => { console.log(`[NavigationPage] Model selected: ${modelPath}`); onSelectModel?.(modelPath); }, [onSelectModel]); - + useEffect(() => { - const fetchModels = async () => { - try { - setLoadError(null); - const res = await fetch('/api/big-models/list'); - if (!res.ok) { - const text = await res.text(); - throw new Error(text || 'Failed to fetch models list'); - } - const data = await res.json(); - const items: { name: string; path: string }[] = Array.isArray(data?.models) ? data.models : []; + const objId = currentObject?.id; + if (!objId) return; + loadZones(objId); + }, [currentObject?.id, loadZones]); - const preferredModelName = PREFERRED_MODEL.split('/').pop()?.split('.').slice(0, -1).join('.') || ''; - - const formatted = items - .map((it) => ({ title: it.name, path: it.path })) - .sort((a, b) => { - const aName = a.path.split('/').pop()?.split('.').slice(0, -1).join('.') || ''; - const bName = b.path.split('/').pop()?.split('.').slice(0, -1).join('.') || ''; - if (aName === preferredModelName) return -1; - if (bName === preferredModelName) return 1; - return a.title.localeCompare(b.title); - }); - - setModels(formatted); - } catch (error) { - console.error('[Monitoring] Error loading models list:', error); - setLoadError(error instanceof Error ? error.message : String(error)); - setModels([]); - } - }; - - fetchModels(); - }, [PREFERRED_MODEL]); + const sortedZones: Zone[] = (currentZones || []).slice().sort((a: Zone, b: Zone) => { + const oa = typeof a.order === 'number' ? a.order : 0; + const ob = typeof b.order === 'number' ? b.order : 0; + if (oa !== ob) return oa - ob; + return (a.name || '').localeCompare(b.name || ''); + }); return (
@@ -68,85 +71,86 @@ const Monitoring: React.FC = ({ onClose, onSelectModel }) => { )}
- - {loadError && ( + {/* UI зон */} + {zonesError && (
- Ошибка загрузки списка моделей: {loadError} + Ошибка загрузки зон: {zonesError}
)} - - {models.length > 0 && ( + {zonesLoading && ( +
+ Загрузка зон... +
+ )} + {sortedZones.length > 0 && ( <> - {/* Большая панорамная карточка для приоритетной модели */} - {models[0] && ( + {sortedZones[0] && ( )} - - {/* Сетка маленьких карточек для остальных моделей */} - {models.length > 1 && ( + {sortedZones.length > 1 && (
- {models.slice(1).map((model, idx) => ( + {sortedZones.slice(1).map((zone: Zone, idx: number) => ( ))}
)} )} - - {models.length === 0 && !loadError && ( + {sortedZones.length === 0 && !zonesError && !zonesLoading && (
- Список моделей пуст. Добавьте файлы в assets/big-models или проверьте API /api/big-models/list. + Зоны не найдены для выбранного объекта. Проверьте параметр objectId в API /api/get-zones.
)} diff --git a/frontend/components/notifications/NotificationDetectorInfo.tsx b/frontend/components/notifications/NotificationDetectorInfo.tsx index 0e04e2d..e9fb6d5 100644 --- a/frontend/components/notifications/NotificationDetectorInfo.tsx +++ b/frontend/components/notifications/NotificationDetectorInfo.tsx @@ -66,6 +66,15 @@ const NotificationDetectorInfo: React.FC = ({ det default: return 'text-gray-400' } } + + const getPriorityText = (priority: string) => { + switch (priority.toLowerCase()) { + case 'high': return 'высокий' + case 'medium': return 'средний' + case 'low': return 'низкий' + default: return priority + } + } const latestNotification = detectorInfo.notifications && detectorInfo.notifications.length > 0 ? detectorInfo.notifications.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())[0] @@ -87,7 +96,7 @@ const NotificationDetectorInfo: React.FC = ({ det

- Датч.{detectorInfo.name} + {detectorInfo.name}

+
- {/* Табличка с детекторами */} + {/* Табличка */}
@@ -146,7 +163,7 @@ const NotificationDetectorInfo: React.FC = ({ det
Приоритет
- {latestNotification.priority} + {getPriorityText(latestNotification.priority)}
@@ -159,14 +176,6 @@ const NotificationDetectorInfo: React.FC = ({ det )}
-
) diff --git a/frontend/components/objects/ObjectCard.tsx b/frontend/components/objects/ObjectCard.tsx index 875a5fc..cd28085 100644 --- a/frontend/components/objects/ObjectCard.tsx +++ b/frontend/components/objects/ObjectCard.tsx @@ -7,7 +7,7 @@ interface ObjectData { object_id: string title: string description: string - image: string + image: string | null location: string floors?: number area?: string @@ -45,6 +45,41 @@ const ObjectCard: React.FC = ({ object, onSelect, isSelected = // Логика редактирования объекта } + // Возврат к тестовому изображению, если src отсутствует/некорректен; нормализация относительных путей + const resolveImageSrc = (src?: string | null): string => { + if (!src || typeof src !== 'string') return '/images/test_image.png' + let s = src.trim() + if (!s) return '/images/test_image.png' + + // Нормализуем обратные слеши в стиле Windows + s = s.replace(/\\/g, '/') + const lower = s.toLowerCase() + + // Обрабатываем явный плейсхолдер test_image.png только как заглушку + if (lower === 'test_image.png' || lower.endsWith('/test_image.png') || lower.includes('/public/images/test_image.png')) { + return '/images/test_image.png' + } + + // Абсолютные URL + if (s.startsWith('http://') || s.startsWith('https://')) return s + + // Пути, относительные к сайту + if (s.startsWith('/')) { + // Преобразуем /public/images/... в /images/... + if (/\/public\/images\//i.test(s)) { + return s.replace(/\/public\/images\//i, '/images/') + } + return s + } + + // Нормализуем относительные имена ресурсов до путей сайта под /images + // Убираем ведущий 'public/', если он присутствует + s = s.replace(/^public\//i, '') + return s.startsWith('images/') ? `/${s}` : `/images/${s}` + } + + const imgSrc = resolveImageSrc(object.image) + return (
= ({ object, onSelect, isSelected = {object.title} { // Заглушка при ошибке загрузки изображения const target = e.target as HTMLImageElement - target.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDUyIiBoZWlnaHQ9IjMwMiIgdmlld0JveD0iMCAwIDQ1MiAzMDIiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSI0NTIiIGhlaWdodD0iMzAyIiBmaWxsPSIjRjFGMUYxIi8+CjxwYXRoIGQ9Ik0yMjYgMTUxTDI0NiAxMzFMMjY2IDE1MUwyNDYgMTcxTDIyNiAxNTFaIiBmaWxsPSIjOTk5OTk5Ii8+Cjx0ZXh0IHg9IjIyNiIgeT0iMTkwIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmaWxsPSIjOTk5OTk5IiBmb250LXNpemU9IjE0Ij7QntCx0YrQtdC60YI8L3RleHQ+Cjwvc3ZnPgo=' + target.src = '/images/test_image.png' }} />
diff --git a/frontend/components/reports/ReportsList.tsx b/frontend/components/reports/ReportsList.tsx index e73accd..27d87ad 100644 --- a/frontend/components/reports/ReportsList.tsx +++ b/frontend/components/reports/ReportsList.tsx @@ -43,13 +43,12 @@ interface DetectorsDataType { interface ReportsListProps { objectId?: string; detectorsData: DetectorsDataType; + initialSearchTerm?: string; } -const ReportsList: React.FC = ({ detectorsData }) => { - const [searchTerm, setSearchTerm] = useState(''); +const ReportsList: React.FC = ({ detectorsData, initialSearchTerm = '' }) => { + const [searchTerm, setSearchTerm] = useState(initialSearchTerm); const [statusFilter, setStatusFilter] = useState('all'); - const [priorityFilter] = useState('all'); - const [acknowledgedFilter] = useState('all'); const allNotifications = useMemo(() => { const notifications: NotificationType[] = []; @@ -70,20 +69,12 @@ const ReportsList: React.FC = ({ detectorsData }) => { }, [detectorsData]); const filteredDetectors = useMemo(() => { - return allNotifications.filter(notification => { - const matchesSearch = notification.detector_name.toLowerCase().includes(searchTerm.toLowerCase()) || - notification.location.toLowerCase().includes(searchTerm.toLowerCase()) || - notification.message.toLowerCase().includes(searchTerm.toLowerCase()); - - const matchesStatus = statusFilter === 'all' || notification.type === statusFilter; - const matchesPriority = priorityFilter === 'all' || notification.priority === priorityFilter; - const matchesAcknowledged = acknowledgedFilter === 'all' || - (acknowledgedFilter === 'acknowledged' && notification.acknowledged) || - (acknowledgedFilter === 'unacknowledged' && !notification.acknowledged); - - return matchesSearch && matchesStatus && matchesPriority && matchesAcknowledged; + return allNotifications.filter(notification => { + const matchesSearch = searchTerm === '' || notification.detector_id.toString() === searchTerm; + + return matchesSearch; }); - }, [allNotifications, searchTerm, statusFilter, priorityFilter, acknowledgedFilter]); + }, [allNotifications, searchTerm]); const getStatusColor = (type: string) => { switch (type) { @@ -168,7 +159,7 @@ const ReportsList: React.FC = ({ detectorsData }) => {
setSearchTerm(e.target.value)} className="bg-[#161824] text-white placeholder-gray-400 px-4 py-2 rounded-lg border border-gray-600 focus:border-blue-500 focus:outline-none w-64" diff --git a/frontend/public/images/test_image_2.png b/frontend/public/images/test_image_2.png new file mode 100644 index 0000000..b69ae33 Binary files /dev/null and b/frontend/public/images/test_image_2.png differ diff --git a/frontend/services/navigationService.ts b/frontend/services/navigationService.ts index 30b2aa8..90618d4 100644 --- a/frontend/services/navigationService.ts +++ b/frontend/services/navigationService.ts @@ -37,7 +37,7 @@ export class NavigationService { } navigateToRoute(route: MainRoutes) { - // Убираем суб-меню перед переходом на другую страницу + // Убираем подменю перед переходом на другую страницу if (route !== MainRoutes.NAVIGATION) { this.navigationStore.setCurrentSubmenu(null) } @@ -71,7 +71,10 @@ export class NavigationService { selectObjectAndGoToDashboard(objectId: string, objectTitle: string) { this.navigationStore.setCurrentObject(objectId, objectTitle) - this.navigateToRoute(MainRoutes.DASHBOARD) + // Проверяем, что подменю закрыто перед навигацией + this.navigationStore.setCurrentSubmenu(null) + const url = `${MainRoutes.DASHBOARD}?objectId=${encodeURIComponent(objectId)}&objectTitle=${encodeURIComponent(objectTitle)}` + this.router.push(url) } }