From 88653cb07c11cc3f889794acffdf533aca011903 Mon Sep 17 00:00:00 2001 From: iv_vuytsik Date: Tue, 11 Nov 2025 10:07:38 +0300 Subject: [PATCH] AEB-71: Added 3D navigation in monitoring zones --- .../account/serializers/alert_serializers.py | 15 +- .../account/serializers/sensor_serializers.py | 34 ++-- backend/api/account/views/sensors_views.py | 2 +- frontend/app/(protected)/alerts/page.tsx | 2 +- frontend/app/(protected)/navigation/page.tsx | 42 ++-- frontend/app/(protected)/objects/page.tsx | 14 +- .../app/api/big-models/[...path]/route.ts | 12 +- frontend/app/api/big-models/list/route.ts | 47 +++++ frontend/app/api/get-alerts/route.ts | 25 ++- frontend/app/api/get-dashboard/route.ts | 12 +- frontend/app/api/get-detectors/route.ts | 14 ++ frontend/app/api/get-objects/route.ts | 39 +++- frontend/app/store/navigationStore.ts | 1 + frontend/components/alerts/DetectorList.tsx | 1 + frontend/components/dashboard/AreaChart.tsx | 10 +- frontend/components/dashboard/BarChart.tsx | 17 +- frontend/components/dashboard/Dashboard.tsx | 190 ++++++++++++------ frontend/components/model/ModelViewer.tsx | 13 +- .../components/navigation/DetectorMenu.tsx | 65 +++++- .../components/navigation/FloorNavigation.tsx | 1 + frontend/components/navigation/Monitoring.tsx | 97 +++++++-- .../NotificationDetectorInfo.tsx | 1 + .../notifications/Notifications.tsx | 1 + frontend/components/reports/ReportsList.tsx | 5 +- frontend/components/ui/Sidebar.tsx | 2 +- frontend/lib/auth.ts | 24 ++- frontend/types/detectors.ts | 1 + 27 files changed, 503 insertions(+), 184 deletions(-) create mode 100644 frontend/app/api/big-models/list/route.ts diff --git a/backend/api/account/serializers/alert_serializers.py b/backend/api/account/serializers/alert_serializers.py index 1104bef..2f1fd49 100644 --- a/backend/api/account/serializers/alert_serializers.py +++ b/backend/api/account/serializers/alert_serializers.py @@ -8,11 +8,11 @@ class AlertSerializer(serializers.ModelSerializer): name = serializers.SerializerMethodField() object = serializers.SerializerMethodField() metric_value = serializers.SerializerMethodField() - sensor_type_name = serializers.SerializerMethodField() + detector_type = serializers.SerializerMethodField() class Meta: model = Alert - fields = ('id', 'name', 'object', 'metric_value', 'sensor_type_name', 'message', 'severity', 'created_at', 'resolved') + fields = ('id', 'name', 'object', 'metric_value', 'detector_type', 'message', 'severity', 'created_at', 'resolved') @extend_schema_field(OpenApiTypes.STR) def get_name(self, obj) -> str: @@ -22,14 +22,17 @@ class AlertSerializer(serializers.ModelSerializer): def get_object(self, obj) -> Optional[str]: zone = obj.sensor.zones.first() return zone.object.title if zone else None - @extend_schema_field(OpenApiTypes.STR) def get_metric_value(self, obj) -> str: if obj.metric.value is not None: unit = obj.sensor.signal_format.unit if obj.sensor.signal_format else '' return f"{obj.metric.value} {unit}".strip() return obj.metric.raw_value - @extend_schema_field(OpenApiTypes.STR) - def get_sensor_type_name(self, obj) -> str: - return obj.sensor_type.name \ No newline at end of file + def get_detector_type(self, obj) -> str: + sensor_type = getattr(obj, 'sensor_type', None) + if sensor_type is None and hasattr(obj, 'sensor') and obj.sensor: + sensor_type = getattr(obj.sensor, 'sensor_type', None) + if sensor_type is None: + return '' + return (getattr(sensor_type, 'code', '') or '').upper() diff --git a/backend/api/account/serializers/sensor_serializers.py b/backend/api/account/serializers/sensor_serializers.py index 8ebd7f7..376d5e0 100644 --- a/backend/api/account/serializers/sensor_serializers.py +++ b/backend/api/account/serializers/sensor_serializers.py @@ -23,6 +23,7 @@ class NotificationSerializer(serializers.ModelSerializer): class DetectorSerializer(serializers.ModelSerializer): detector_id = serializers.SerializerMethodField() type = serializers.SerializerMethodField() + detector_type = serializers.SerializerMethodField() name = serializers.SerializerMethodField() object = serializers.SerializerMethodField() status = serializers.SerializerMethodField() @@ -32,22 +33,28 @@ class DetectorSerializer(serializers.ModelSerializer): class Meta: model = Sensor - fields = ('detector_id', 'type', 'serial_number', 'name', 'object', 'status', 'zone', 'floor', 'notifications') + fields = ('detector_id', 'type', 'detector_type', 'serial_number', 'name', 'object', 'status', 'zone', 'floor', 'notifications') def get_detector_id(self, obj): return obj.name or f"{obj.sensor_type.code}-{obj.id}" def get_type(self, obj): - # маппинг типов сенсоров на нужные значения sensor_type_mapping = { - 'GA': 'fire_detector', - 'PE': 'pressure_sensor', - 'GLE': 'gas_detector' + 'GA': 'Инклинометр', + 'PE': 'Тензометр', + 'GLE': 'Гидроуровень', } - return sensor_type_mapping.get(obj.sensor_type.code, 'unknown') + code = (getattr(obj.sensor_type, 'code', '') or '').upper() + return sensor_type_mapping.get(code, (getattr(obj.sensor_type, 'name', '') or '')) + + def get_detector_type(self, obj): + return (getattr(obj.sensor_type, 'code', '') or '').upper() def get_name(self, obj): - return f"{obj.sensor_type.name} {obj.serial_number}" + sensor_type = getattr(obj, 'sensor_type', None) or getattr(obj, 'sensor_type', None) + serial = getattr(obj, 'serial_number', '') or '' + base_name = getattr(obj, 'name', '') or '' + return base_name or f"{getattr(obj.sensor_type, 'code', '')}-{serial}".strip('-') def get_object(self, obj): # получаем первую зону датчика и её объект @@ -64,17 +71,12 @@ class DetectorSerializer(serializers.ModelSerializer): return 'normal' def get_zone(self, obj): - zone = obj.zones.first() - if zone: - return zone.name - return None + first_zone = obj.zones.first() + return first_zone.name if first_zone else None def get_floor(self, obj): - # получаем этаж из зоны - zone = obj.zones.first() - if zone: - return zone.floor - return None # если датчик не привязали к зоне + first_zone = obj.zones.first() + return getattr(first_zone, 'floor', None) class DetectorsResponseSerializer(serializers.Serializer): detectors = serializers.SerializerMethodField() diff --git a/backend/api/account/views/sensors_views.py b/backend/api/account/views/sensors_views.py index 135d055..75ae12d 100644 --- a/backend/api/account/views/sensors_views.py +++ b/backend/api/account/views/sensors_views.py @@ -59,4 +59,4 @@ class SensorView(APIView): except Sensor.DoesNotExist: return Response( {"error": "Датчики не найдены"}, - status=status.HTTP_404_NOT_FOUND) \ No newline at end of file + status=status.HTTP_404_NOT_FOUND) diff --git a/frontend/app/(protected)/alerts/page.tsx b/frontend/app/(protected)/alerts/page.tsx index 4709ac1..61adfd5 100644 --- a/frontend/app/(protected)/alerts/page.tsx +++ b/frontend/app/(protected)/alerts/page.tsx @@ -234,7 +234,7 @@ const AlertsPage: React.FC = () => { {item.acknowledged ? 'Да' : 'Нет'} diff --git a/frontend/app/(protected)/navigation/page.tsx b/frontend/app/(protected)/navigation/page.tsx index b2b527e..69b5c03 100644 --- a/frontend/app/(protected)/navigation/page.tsx +++ b/frontend/app/(protected)/navigation/page.tsx @@ -29,6 +29,7 @@ interface DetectorType { status: string checked: boolean type: string + detector_type: string location: string floor: number notifications: Array<{ @@ -86,7 +87,8 @@ const NavigationPage: React.FC = () => { const urlObjectTitle = searchParams.get('objectTitle') const objectId = currentObject.id || urlObjectId const objectTitle = currentObject.title || urlObjectTitle - + const [selectedModelPath, setSelectedModelPath] = useState('') + const handleModelLoaded = useCallback(() => { setIsModelReady(true) setModelError(null) @@ -173,12 +175,19 @@ const NavigationPage: React.FC = () => { } const getStatusText = (status: string) => { - switch (status) { - case 'active': return 'Активен' - case 'inactive': return 'Неактивен' - case 'error': return 'Ошибка' - case 'maintenance': return 'Обслуживание' - default: return 'Неизвестно' + const s = (status || '').toLowerCase() + switch (s) { + case '#b3261e': + case 'critical': + return 'Критический' + case '#fd7c22': + case 'warning': + return 'Предупреждение' + case '#00ff00': + case 'normal': + return 'Норма' + default: + return 'Неизвестно' } } @@ -191,18 +200,23 @@ const NavigationPage: React.FC = () => {
{showMonitoring && ( -
+
{ + setSelectedModelPath(path) + setModelError(null) + setIsModelReady(false) + }} />
)} {showFloorNavigation && ( -
+
{ )} {showNotifications && ( -
+
{ detector => detector.detector_id === selectedNotification.detector_id ); return detectorData ? ( -
+
{ null )} -
-
+
+
) : ( { @@ -27,6 +28,7 @@ const ObjectsPage: React.FC = () => { const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [selectedObjectId, setSelectedObjectId] = useState(null) + const router = useRouter() useEffect(() => { const loadData = async () => { @@ -41,7 +43,15 @@ const ObjectsPage: React.FC = () => { console.log('[ObjectsPage] GET /api/get-objects', { status: res.status, payload }) if (!res.ok) { - throw new Error(typeof payload === 'string' ? payload : (payload?.error || 'Не удалось получить данные объектов')) + const errorMessage = typeof payload === 'string' ? payload : (payload?.error || 'Не удалось получить данные объектов') + + if (errorMessage.includes('Authentication required') || res.status === 401) { + console.log('[ObjectsPage] Authentication required, redirecting to login') + router.push('/login') + return + } + + throw new Error(errorMessage) } const data = (payload?.data ?? payload) as any @@ -66,7 +76,7 @@ const ObjectsPage: React.FC = () => { } loadData() - }, []) + }, [router]) const handleObjectSelect = (objectId: string) => { console.log('Object selected:', objectId) diff --git a/frontend/app/api/big-models/[...path]/route.ts b/frontend/app/api/big-models/[...path]/route.ts index 06ed5b6..dd98f1b 100644 --- a/frontend/app/api/big-models/[...path]/route.ts +++ b/frontend/app/api/big-models/[...path]/route.ts @@ -17,11 +17,21 @@ export async function GET( const stat = fs.statSync(filePath); const stream = fs.createReadStream(filePath); + + const ext = path.extname(fileName).toLowerCase(); + let contentType = 'application/octet-stream'; + if (ext === '.glb') contentType = 'model/gltf-binary'; + else if (ext === '.gltf') contentType = 'model/gltf+json'; + else if (ext === '.bin') contentType = 'application/octet-stream'; + else if (ext === '.png') contentType = 'image/png'; + else if (ext === '.jpg' || ext === '.jpeg') contentType = 'image/jpeg'; + else if (ext === '.webp') contentType = 'image/webp'; + else if (ext === '.ktx2') contentType = 'image/ktx2'; return new Response(stream as unknown as ReadableStream, { headers: { 'Content-Length': stat.size.toString(), - 'Content-Type': 'model/gltf-binary', + 'Content-Type': contentType, }, }); } \ No newline at end of file diff --git a/frontend/app/api/big-models/list/route.ts b/frontend/app/api/big-models/list/route.ts new file mode 100644 index 0000000..c5c99cf --- /dev/null +++ b/frontend/app/api/big-models/list/route.ts @@ -0,0 +1,47 @@ +export const dynamic = 'force-static'; + +import fs from 'fs'; +import path from 'path'; + +export async function GET() { + try { + const dirPath = path.join(process.cwd(), 'assets', 'big-models'); + if (!fs.existsSync(dirPath)) { + return new Response(JSON.stringify({ models: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const files = fs.readdirSync(dirPath, { withFileTypes: true }); + const models = files + .filter((ent) => ent.isFile() && (ent.name.toLowerCase().endsWith('.glb') || ent.name.toLowerCase().endsWith('.gltf'))) + .map((ent) => { + const filename = ent.name; + const base = filename.replace(/\.(glb|gltf)$/i, ''); + let title = base; + title = title.replace(/^AerBIM-Monitor_ASM-HT-Viewer_/i, ''); + title = title.replace(/_/g, ' '); + title = title.replace(/\bLevel\b/gi, 'Уровень'); + title = title.replace(/\bcustom\s*prop\b/gi, ''); + title = title.replace(/\bcustom\b/gi, ''); + title = title.replace(/\bprop\b/gi, ''); + title = title.replace(/\s{2,}/g, ' ').trim(); + + const pathUrl = `/static-models/${filename}`; + return { name: title, path: pathUrl }; + }); + + return new Response(JSON.stringify({ models }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } catch (error) { + console.error('[big-models/list] Error listing models:', error); + const msg = error instanceof Error ? error.message : String(error); + return new Response(JSON.stringify({ error: msg, models: [] }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } +} \ No newline at end of file diff --git a/frontend/app/api/get-alerts/route.ts b/frontend/app/api/get-alerts/route.ts index 936c7ca..9355c19 100644 --- a/frontend/app/api/get-alerts/route.ts +++ b/frontend/app/api/get-alerts/route.ts @@ -89,16 +89,21 @@ export async function GET(req: NextRequest) { return true }) : list - const transformed = filtered.map((a) => ({ - id: a.id, - detector_name: a.name, - message: a.message, - type: a.severity, - location: a.object, - priority: a.severity, - acknowledged: a.resolved, - timestamp: a.created_at, - })) + const transformed = filtered.map((a) => { + const severity = String(a?.severity || a?.type || '').toLowerCase() + const type = severity === 'critical' ? 'critical' : severity === 'warning' ? 'warning' : 'info' + const priority = severity === 'critical' ? 'high' : severity === 'warning' ? 'medium' : 'low' + return { + id: a.id, + detector_name: a.name || a.detector_name, + message: a.message, + type, + location: a.object, + priority, + acknowledged: typeof a.acknowledged === 'boolean' ? a.acknowledged : !!a.resolved, + timestamp: a.timestamp || a.created_at, + } + }) return NextResponse.json({ success: true, data: transformed }) } catch (error) { diff --git a/frontend/app/api/get-dashboard/route.ts b/frontend/app/api/get-dashboard/route.ts index 69f3e07..5456ada 100644 --- a/frontend/app/api/get-dashboard/route.ts +++ b/frontend/app/api/get-dashboard/route.ts @@ -36,10 +36,16 @@ export async function GET(req: NextRequest) { if (!backendUrl) { return NextResponse.json({ success: false, error: 'BACKEND_URL is not configured' }, { status: 500 }) } - + const url = new URL(req.url) - const timePeriod = url.searchParams.get('time_period') - const qs = timePeriod ? `?time_period=${encodeURIComponent(timePeriod)}` : '' + const timePeriodRaw = url.searchParams.get('time_period') + const allowedPeriods = new Set([24, 72, 168, 720]) + let timePeriodNum = timePeriodRaw ? Number(timePeriodRaw) : undefined + if (Number.isNaN(timePeriodNum)) { + timePeriodNum = undefined + } + const finalTimePeriod = timePeriodNum && allowedPeriods.has(timePeriodNum) ? String(timePeriodNum) : '168' + const qs = `?time_period=${encodeURIComponent(finalTimePeriod)}` const res = await fetch(`${backendUrl}/account/get-dashboard/${qs}`, { headers: { diff --git a/frontend/app/api/get-detectors/route.ts b/frontend/app/api/get-detectors/route.ts index 3ce0ce0..f50ac4c 100644 --- a/frontend/app/api/get-detectors/route.ts +++ b/frontend/app/api/get-detectors/route.ts @@ -67,6 +67,20 @@ export async function GET() { checked: sensor.checked ?? false, location: sensor.zone ?? '', serial_number: sensor.serial_number ?? sensor.name ?? '', + detector_type: sensor.detector_type ?? '', + notifications: Array.isArray(sensor.notifications) ? sensor.notifications.map((n: any) => { + const severity = String(n?.severity || n?.type || '').toLowerCase() + const type = severity === 'critical' ? 'critical' : severity === 'warning' ? 'warning' : 'info' + const priority = severity === 'critical' ? 'high' : severity === 'warning' ? 'medium' : 'low' + return { + id: n.id, + type, + message: n.message, + timestamp: n.timestamp || n.created_at, + acknowledged: typeof n.acknowledged === 'boolean' ? n.acknowledged : !!n.resolved, + priority, + } + }) : [] } } diff --git a/frontend/app/api/get-objects/route.ts b/frontend/app/api/get-objects/route.ts index 11e7067..eaf5e37 100644 --- a/frontend/app/api/get-objects/route.ts +++ b/frontend/app/api/get-objects/route.ts @@ -23,11 +23,32 @@ export async function GET(req: NextRequest) { 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 {} + } catch (error) { + console.error('Error during token refresh:', error) + } } if (!accessToken) { @@ -52,7 +73,21 @@ export async function GET(req: NextRequest) { let payload: any try { payload = JSON.parse(payloadText) } catch { payload = payloadText } - if (!objectsRes.ok) { + if (!objectsRes.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 }) + } + } + const err = typeof payload === 'string' ? payload : JSON.stringify(payload) return NextResponse.json({ success: false, error: `Backend objects error: ${err}` }, { status: objectsRes.status }) } diff --git a/frontend/app/store/navigationStore.ts b/frontend/app/store/navigationStore.ts index cfafd20..383d659 100644 --- a/frontend/app/store/navigationStore.ts +++ b/frontend/app/store/navigationStore.ts @@ -8,6 +8,7 @@ export interface DetectorType { object: string status: string type: string + detector_type: string location: string floor: number checked: boolean diff --git a/frontend/components/alerts/DetectorList.tsx b/frontend/components/alerts/DetectorList.tsx index 056c3f2..f45a63a 100644 --- a/frontend/components/alerts/DetectorList.tsx +++ b/frontend/components/alerts/DetectorList.tsx @@ -18,6 +18,7 @@ interface RawDetector { object: string status: string type: string + detector_type: string location: string floor: number notifications: Array<{ diff --git a/frontend/components/dashboard/AreaChart.tsx b/frontend/components/dashboard/AreaChart.tsx index f2c76f4..72ae21e 100644 --- a/frontend/components/dashboard/AreaChart.tsx +++ b/frontend/components/dashboard/AreaChart.tsx @@ -22,15 +22,7 @@ const AreaChart: React.FC = ({ className = '', data }) => { const safeData = (Array.isArray(data) && data.length > 0) ? data - : [ - { value: 5 }, - { value: 3 }, - { value: 7 }, - { value: 2 }, - { value: 6 }, - { value: 4 }, - { value: 8 } - ] + : Array.from({ length: 7 }, () => ({ value: 0 })) const maxVal = Math.max(...safeData.map(d => d.value || 0), 1) const stepX = safeData.length > 1 ? width / (safeData.length - 1) : width diff --git a/frontend/components/dashboard/BarChart.tsx b/frontend/components/dashboard/BarChart.tsx index 8e90e54..6cf6c8a 100644 --- a/frontend/components/dashboard/BarChart.tsx +++ b/frontend/components/dashboard/BarChart.tsx @@ -14,24 +14,9 @@ interface BarChartProps { } const BarChart: React.FC = ({ className = '', data }) => { - const defaultData = [ - { value: 80, color: 'rgb(42, 157, 144)' }, - { value: 65, color: 'rgb(42, 157, 144)' }, - { value: 90, color: 'rgb(42, 157, 144)' }, - { value: 45, color: 'rgb(42, 157, 144)' }, - { value: 75, color: 'rgb(42, 157, 144)' }, - { value: 55, color: 'rgb(42, 157, 144)' }, - { value: 85, color: 'rgb(42, 157, 144)' }, - { value: 70, color: 'rgb(42, 157, 144)' }, - { value: 60, color: 'rgb(42, 157, 144)' }, - { value: 95, color: 'rgb(42, 157, 144)' }, - { value: 40, color: 'rgb(42, 157, 144)' }, - { value: 80, color: 'rgb(42, 157, 144)' } - ] - const barData = (Array.isArray(data) && data.length > 0) ? data.map(d => ({ value: d.value, color: d.color || 'rgb(42, 157, 144)' })) - : defaultData + : Array.from({ length: 12 }, () => ({ value: 0, color: 'rgb(42, 157, 144)' })) const maxVal = Math.max(...barData.map(b => b.value || 0), 1) diff --git a/frontend/components/dashboard/Dashboard.tsx b/frontend/components/dashboard/Dashboard.tsx index 35a8b31..eea95b8 100644 --- a/frontend/components/dashboard/Dashboard.tsx +++ b/frontend/components/dashboard/Dashboard.tsx @@ -7,28 +7,49 @@ import useNavigationStore from '../../app/store/navigationStore' import ChartCard from './ChartCard' import AreaChart from './AreaChart' import BarChart from './BarChart' -import DetectorChart from './DetectorChart' - + const Dashboard: React.FC = () => { const router = useRouter() const { currentObject, setCurrentSubmenu, closeMonitoring, closeFloorNavigation, closeNotifications } = useNavigationStore() - const objectId = currentObject?.id const objectTitle = currentObject?.title const [dashboardAlerts, setDashboardAlerts] = useState([]) const [chartData, setChartData] = useState<{ timestamp: string; value: number }[]>([]) + const [sensorTypes] = useState>([ + { code: '', name: 'Все датчики' }, + { code: 'GA', name: 'Инклинометр' }, + { code: 'PE', name: 'Танзометр' }, + { code: 'GLE', name: 'Гидроуровень' } + ]) + const [selectedSensorType, setSelectedSensorType] = useState('') + const [selectedChartPeriod, setSelectedChartPeriod] = useState('168') + const [selectedTablePeriod, setSelectedTablePeriod] = useState('168') useEffect(() => { const loadDashboard = async () => { try { - const res = await fetch('/api/get-dashboard', { cache: 'no-store' }) + const params = new URLSearchParams() + params.append('time_period', selectedChartPeriod) + + const res = await fetch(`/api/get-dashboard?${params.toString()}`, { cache: 'no-store' }) if (!res.ok) return const payload = await res.json() console.log('[Dashboard] GET /api/get-dashboard', { status: res.status, payload }) - const tableData = payload?.data?.table_data ?? [] - const arr = (Array.isArray(tableData) ? tableData : []) - .filter((a: any) => (objectTitle ? a.object === objectTitle : true)) - setDashboardAlerts(arr as any[]) + + let tableData = payload?.data?.table_data ?? [] + tableData = Array.isArray(tableData) ? tableData : [] + + if (objectTitle) { + tableData = tableData.filter((a: any) => a.object === objectTitle) + } + + if (selectedSensorType && selectedSensorType !== '') { + tableData = tableData.filter((a: any) => { + return a.detector_type?.toLowerCase() === selectedSensorType.toLowerCase() + }) + } + + setDashboardAlerts(tableData as any[]) const cd = Array.isArray(payload?.data?.chart_data) ? payload.data.chart_data : [] setChartData(cd as any[]) @@ -37,14 +58,52 @@ const Dashboard: React.FC = () => { } } loadDashboard() - }, [objectTitle]) + }, [objectTitle, selectedChartPeriod, selectedSensorType]) + + // Separate effect for table data based on table period + useEffect(() => { + const loadTableData = async () => { + try { + const params = new URLSearchParams() + params.append('time_period', selectedTablePeriod) + + const res = await fetch(`/api/get-dashboard?${params.toString()}`, { cache: 'no-store' }) + if (!res.ok) return + const payload = await res.json() + console.log('[Dashboard] GET /api/get-dashboard (table)', { status: res.status, payload }) + + let tableData = payload?.data?.table_data ?? [] + tableData = Array.isArray(tableData) ? tableData : [] + + if (objectTitle) { + tableData = tableData.filter((a: any) => a.object === objectTitle) + } + + if (selectedSensorType && selectedSensorType !== '') { + tableData = tableData.filter((a: any) => { + return a.detector_type?.toLowerCase() === selectedSensorType.toLowerCase() + }) + } + + setDashboardAlerts(tableData as any[]) + } catch (e) { + console.error('Failed to load table data:', e) + } + } + loadTableData() + }, [objectTitle, selectedTablePeriod, selectedSensorType]) const handleBackClick = () => { router.push('/objects') } - + + const filteredAlerts = dashboardAlerts.filter((alert: any) => { + if (selectedSensorType === '') return true + return alert.detector_type?.toLowerCase() === selectedSensorType.toLowerCase() + }) + // Статусы - const statusCounts = dashboardAlerts.reduce((acc: { critical: number; warning: number; normal: number }, a: any) => { + const statusCounts = filteredAlerts.reduce((acc: { critical: number; warning: number; normal: number }, a: any) => { if (a.severity === 'critical') acc.critical++ else if (a.severity === 'warning') acc.warning++ else acc.normal++ @@ -58,6 +117,18 @@ const Dashboard: React.FC = () => { setCurrentSubmenu(null) router.push('/navigation') } + + const handleSensorTypeChange = (sensorType: string) => { + setSelectedSensorType(sensorType) + } + + const handleChartPeriodChange = (period: string) => { + setSelectedChartPeriod(period) + } + + const handleTablePeriodChange = (period: string) => { + setSelectedTablePeriod(period) + } return (
@@ -87,17 +158,25 @@ const Dashboard: React.FC = () => {
-

Объект {objectId?.replace('object_', '')}

+

{objectTitle || 'Объект'}

- +
-
- - +
+ + + - Период -
@@ -121,14 +208,14 @@ const Dashboard: React.FC = () => {
({ value: d.value }))} /> @@ -140,12 +227,18 @@ const Dashboard: React.FC = () => {

Тренды

-
- - - - Месяц - +
+ +
@@ -165,7 +258,7 @@ const Dashboard: React.FC = () => { - {dashboardAlerts.map((alert: any) => ( + {filteredAlerts.map((alert: any) => ( {alert.name} {alert.message} @@ -191,7 +284,7 @@ const Dashboard: React.FC = () => { {/* Статы */}
-
{dashboardAlerts.length}
+
{filteredAlerts.length}
Всего
@@ -208,37 +301,6 @@ const Dashboard: React.FC = () => {
- - {/* Графики с аналитикой */} -
- - ({ value: d.value }))} /> - - - - ({ value: d.value }))} /> - - - - ({ value: d.value }))} /> - - - - ({ value: d.value }))} /> - -
diff --git a/frontend/components/model/ModelViewer.tsx b/frontend/components/model/ModelViewer.tsx index ab8756d..d64a212 100644 --- a/frontend/components/model/ModelViewer.tsx +++ b/frontend/components/model/ModelViewer.tsx @@ -248,8 +248,7 @@ const ModelViewer: React.FC = ({ } const allMeshes = importedMeshesRef.current || [] - - // Safeguard: Check if we have any meshes at all + if (allMeshes.length === 0) { console.warn('[ModelViewer] No meshes available for sensor matching') highlightLayerRef.current?.removeAllMeshes() @@ -378,15 +377,15 @@ const ModelViewer: React.FC = ({
{!modelPath ? (
-
-
- 3D модель недоступна +
+
+ 3D модель не выбрана
- Путь к 3D модели не задан + Выберите модель в панели «Зоны мониторинга», чтобы начать просмотр
- Обратитесь к администратору для настройки модели + Если список пуст, добавьте файлы в каталог assets/big-models или проверьте API
diff --git a/frontend/components/navigation/DetectorMenu.tsx b/frontend/components/navigation/DetectorMenu.tsx index dfecef6..2c6da2a 100644 --- a/frontend/components/navigation/DetectorMenu.tsx +++ b/frontend/components/navigation/DetectorMenu.tsx @@ -10,8 +10,17 @@ interface DetectorType { status: string checked: boolean type: string + detector_type: string location: string floor: number + notifications?: Array<{ + id: number + type: string + message: string + timestamp: string + acknowledged: boolean + priority: string + }> } interface DetectorMenuProps { @@ -26,6 +35,35 @@ interface DetectorMenuProps { const DetectorMenu: React.FC = ({ detector, isOpen, onClose, getStatusText, compact = false, anchor = null }) => { if (!isOpen) return null + // Получаем самую свежую временную метку из уведомлений + const latestTimestamp = (() => { + const list = detector.notifications ?? [] + if (!Array.isArray(list) || list.length === 0) return null + const dates = list.map(n => new Date(n.timestamp)).filter(d => !isNaN(d.getTime())) + if (dates.length === 0) return null + dates.sort((a, b) => b.getTime() - a.getTime()) + return dates[0] + })() + const formattedTimestamp = latestTimestamp + ? 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() + if (!t) return '' + if (t.includes('инклинометр')) return 'GA' + if (t.includes('тензометр')) return 'PE' + if (t.includes('гидроуров')) return 'GLE' + return '' + } + const effectiveDetectorTypeCode = rawDetectorTypeCode || deriveCodeFromType() + const detectorTypeLabelMap: Record = { + GA: 'Инклинометр', + PE: 'Тензометр', + GLE: 'Гидроуровень', + } + const displayDetectorTypeLabel = detectorTypeLabelMap[effectiveDetectorTypeCode] || '—' const DetailsSection: React.FC<{ compact?: boolean }> = ({ compact = false }) => (
{compact ? ( @@ -37,7 +75,7 @@ const DetectorMenu: React.FC = ({ detector, isOpen, onClose,
Тип детектора
-
{detector.type}
+
{displayDetectorTypeLabel}
@@ -53,13 +91,19 @@ const DetectorMenu: React.FC = ({ detector, isOpen, onClose,
Временная метка
-
Сегодня, 14:30
+
{formattedTimestamp}
Этаж
{detector.floor}
+
+
+
Серийный номер
+
{detector.serial_number}
+
+
) : ( <> @@ -70,7 +114,7 @@ const DetectorMenu: React.FC = ({ detector, isOpen, onClose,
Тип детектора
-
{detector.type}
+
{displayDetectorTypeLabel}
@@ -86,10 +130,11 @@ const DetectorMenu: React.FC = ({ detector, isOpen, onClose,
Временная метка
-
Сегодня, 14:30
+
{formattedTimestamp}
-
Вчера
+
Серийный номер
+
{detector.serial_number}
@@ -121,7 +166,7 @@ const DetectorMenu: React.FC = ({ detector, isOpen, onClose, @@ -133,7 +178,7 @@ const DetectorMenu: React.FC = ({ detector, isOpen, onClose, } return ( -
+

@@ -142,13 +187,13 @@ const DetectorMenu: React.FC = ({ detector, isOpen, onClose,
@@ -162,7 +207,7 @@ const DetectorMenu: React.FC = ({ detector, isOpen, onClose, className="absolute top-4 right-4 text-gray-400 hover:text-white transition-colors" > - +
diff --git a/frontend/components/navigation/FloorNavigation.tsx b/frontend/components/navigation/FloorNavigation.tsx index 941cf9e..ad88661 100644 --- a/frontend/components/navigation/FloorNavigation.tsx +++ b/frontend/components/navigation/FloorNavigation.tsx @@ -22,6 +22,7 @@ interface DetectorType { status: string checked: boolean type: string + detector_type: string location: string floor: number notifications: Array<{ diff --git a/frontend/components/navigation/Monitoring.tsx b/frontend/components/navigation/Monitoring.tsx index 210555e..5ebebf4 100644 --- a/frontend/components/navigation/Monitoring.tsx +++ b/frontend/components/navigation/Monitoring.tsx @@ -1,13 +1,51 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import Image from 'next/image'; interface MonitoringProps { objectId?: string; onClose?: () => void; + onSelectModel?: (modelPath: string) => void; } -const Monitoring: React.FC = ({ onClose }) => { +const Monitoring: React.FC = ({ onClose, onSelectModel }) => { const [objectImageError, setObjectImageError] = useState(false); + const [models, setModels] = useState<{ title: string; path: string }[]>([]); + const [loadError, setLoadError] = useState(null); + + // Загружаем список доступных моделей из assets/big-models через API + 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 preferred = 'AerBIM-Monitor_ASM-HT-Viewer_Expo2017Astana_20250910'; + const formatted = items + .map((it) => ({ title: it.name, path: it.path })) + .sort((a, b) => { + const ap = a.path.includes(preferred) ? -1 : 0; + const bp = b.path.includes(preferred) ? -1 : 0; + if (ap !== bp) return ap - bp; + 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(); + }, []); return (
@@ -51,25 +89,48 @@ const Monitoring: React.FC = ({ onClose }) => {

+ {loadError && ( +
+ Ошибка загрузки списка моделей: {loadError} +
+ )} +
- {[1, 2, 3, 4, 5, 6].map((zone) => ( -
-
- {`Зона { - const target = e.target as HTMLImageElement; - target.style.display = 'none'; - }} - /> + {models.length > 0 ? ( + models.map((model, idx) => ( + + )) + ) : ( +
+
+ Список моделей пуст. Добавьте файлы в assets/big-models или проверьте API /api/big-models/list.
- ))} + )}
diff --git a/frontend/components/notifications/NotificationDetectorInfo.tsx b/frontend/components/notifications/NotificationDetectorInfo.tsx index cde4cc7..0e04e2d 100644 --- a/frontend/components/notifications/NotificationDetectorInfo.tsx +++ b/frontend/components/notifications/NotificationDetectorInfo.tsx @@ -8,6 +8,7 @@ interface DetectorInfoType { object: string status: string type: string + detector_type: string location: string floor: number checked: boolean diff --git a/frontend/components/notifications/Notifications.tsx b/frontend/components/notifications/Notifications.tsx index 3c73d20..93e3a33 100644 --- a/frontend/components/notifications/Notifications.tsx +++ b/frontend/components/notifications/Notifications.tsx @@ -24,6 +24,7 @@ interface DetectorType { object: string status: string type: string + detector_type: string location: string floor: number checked: boolean diff --git a/frontend/components/reports/ReportsList.tsx b/frontend/components/reports/ReportsList.tsx index 2d41192..e73accd 100644 --- a/frontend/components/reports/ReportsList.tsx +++ b/frontend/components/reports/ReportsList.tsx @@ -22,6 +22,7 @@ interface DetectorType { object: string status: string type: string + detector_type: string location: string floor: number checked: boolean @@ -231,8 +232,8 @@ const ReportsList: React.FC = ({ detectorsData }) => { {detector.acknowledged ? 'Да' : 'Нет'} diff --git a/frontend/components/ui/Sidebar.tsx b/frontend/components/ui/Sidebar.tsx index 92ce88f..d26927f 100644 --- a/frontend/components/ui/Sidebar.tsx +++ b/frontend/components/ui/Sidebar.tsx @@ -456,7 +456,7 @@ const Sidebar: React.FC = ({