From ce7e39debfc8cb8a572bdca4e4fd5ad2a88c353d Mon Sep 17 00:00:00 2001 From: iv_vuytsik Date: Thu, 25 Dec 2025 03:10:21 +0300 Subject: [PATCH] New api and zone management; highligh occlusion and highlighAll functionality; improved search in reports and alerts history + autofill; refactored alert panel --- .gitignore | 1 + .../account/serializers/alert_serializers.py | 9 +- .../serializers/objects_serializers.py | 2 +- backend/api/account/urls.py | 3 + backend/api/account/views/alert_views.py | 39 +- backend/api/account/views/dashboard_views.py | 5 +- backend/api/account/views/zones_views.py | 66 ++++ backend/sitemanagement/admin.py | 6 +- ...one_image_path_zone_model_path_and_more.py | 32 ++ .../migrations/0009_alter_zone_image_path.py | 18 + ...r_zone_image_path_alter_zone_model_path.py | 23 ++ backend/sitemanagement/models.py | 5 +- frontend/app/(protected)/alerts/page.tsx | 105 ++---- frontend/app/(protected)/navigation/page.tsx | 166 +++++++-- frontend/app/(protected)/objects/page.tsx | 20 +- frontend/app/(protected)/reports/page.tsx | 27 +- frontend/app/api/get-alerts/route.ts | 1 + frontend/app/api/get-reports/route.ts | 11 +- frontend/app/api/get-zones/route.ts | 184 ++++++++++ frontend/app/store/navigationStore.ts | 141 ++++++-- frontend/app/types.ts | 4 +- frontend/app/types/index.ts | 15 + frontend/components/alerts/AlertsList.tsx | 145 ++++++++ frontend/components/alerts/DetectorList.tsx | 60 +--- frontend/components/dashboard/Dashboard.tsx | 12 +- frontend/components/model/ModelViewer.tsx | 178 ++++++++- frontend/components/model/SceneToolbar.tsx | 56 ++- frontend/components/navigation/AlertMenu.tsx | 339 +++++++++++++----- .../components/navigation/DetectorMenu.tsx | 104 +++++- .../components/navigation/FloorNavigation.tsx | 9 +- frontend/components/navigation/Monitoring.tsx | 142 ++++---- .../NotificationDetectorInfo.tsx | 31 +- frontend/components/objects/ObjectCard.tsx | 41 ++- frontend/components/reports/ReportsList.tsx | 27 +- frontend/public/images/test_image_2.png | Bin 0 -> 39742 bytes frontend/services/navigationService.ts | 7 +- 36 files changed, 1562 insertions(+), 472 deletions(-) create mode 100644 backend/api/account/views/zones_views.py create mode 100644 backend/sitemanagement/migrations/0008_alter_zone_options_zone_image_path_zone_model_path_and_more.py create mode 100644 backend/sitemanagement/migrations/0009_alter_zone_image_path.py create mode 100644 backend/sitemanagement/migrations/0010_alter_zone_image_path_alter_zone_model_path.py create mode 100644 frontend/app/api/get-zones/route.ts create mode 100644 frontend/components/alerts/AlertsList.tsx create mode 100644 frontend/public/images/test_image_2.png 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 0000000000000000000000000000000000000000..b69ae334b9ca2d6d1d77c949e0817bcb5e3af78e GIT binary patch literal 39742 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 Q?*T7I+cP#VtbG&z31IIl4FCWD literal 0 HcmV?d00001 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) } }