diff --git a/.gitignore b/.gitignore index 37696e5..1d2078f 100644 --- a/.gitignore +++ b/.gitignore @@ -92,4 +92,8 @@ docker-compose.override.yml .DS_Store # Cached mesh data files -frontend/data/*.json \ No newline at end of file +frontend/data/*.json + +# Demo seed data: ignore demo mux outputs and the seeding command +backend/api/management/commands/seed_demo_data.py +backend/data/multiplexors/DemoMux.csv \ No newline at end of file diff --git a/frontend/app/(protected)/alerts/page.tsx b/frontend/app/(protected)/alerts/page.tsx index 96d3a2b..7db801b 100644 --- a/frontend/app/(protected)/alerts/page.tsx +++ b/frontend/app/(protected)/alerts/page.tsx @@ -6,12 +6,28 @@ import Sidebar from '../../../components/ui/Sidebar' import useNavigationStore from '../../store/navigationStore' import DetectorList from '../../../components/alerts/DetectorList' 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 [selectedDetectors, setSelectedDetectors] = useState([]) + const { data: session } = useSession() + + type AlertItem = { + id: number + type: string + message: string + timestamp: string + acknowledged: boolean + priority: string + detector_id?: number + detector_name?: string + location?: string + object?: string + } + const [alerts, setAlerts] = useState([]) const urlObjectId = searchParams.get('objectId') const urlObjectTitle = searchParams.get('objectTitle') @@ -24,6 +40,30 @@ const AlertsPage: React.FC = () => { } }, [urlObjectId, urlObjectTitle, currentObject.id, setCurrentObject]) + useEffect(() => { + const loadAlerts = async () => { + try { + const params = new URLSearchParams() + if (currentObject.id) params.set('objectId', currentObject.id) + const res = await fetch(`/api/get-alerts?${params.toString()}`, { cache: 'no-store' }) + if (!res.ok) return + const payload = await res.json() + console.log('[AlertsPage] GET /api/get-alerts', { + url: `/api/get-alerts?${params.toString()}`, + status: res.status, + payload, + }) + const data = Array.isArray(payload?.data) ? payload.data : (payload?.data?.alerts || []) + console.log('[AlertsPage] parsed alerts:', data) + setAlerts(data as AlertItem[]) + } catch (e) { + console.error('Failed to load alerts:', e) + } + } + loadAlerts() + + }, [currentObject.id]) + const handleBackClick = () => { router.push('/dashboard') } @@ -36,13 +76,39 @@ const AlertsPage: React.FC = () => { } } - const handleExport = (format: 'csv' | 'pdf') => { - // TODO: добавить функционал по экспорту - console.log(`Exporting ${selectedDetectors.length} items as ${format}`) + const handleExport = async (format: 'csv' | 'pdf', hours: number) => { + try { + const res = await fetch(`/api/get-reports`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(session?.accessToken ? { Authorization: `Bearer ${session.accessToken}` } : {}), + }, + body: JSON.stringify({ format: format, hours }), + credentials: 'include', + }) + if (!res.ok) { + const errText = await res.text() + throw new Error(`Export failed: ${res.status} ${errText}`) + } + const blob = await res.blob() + const contentDisposition = res.headers.get('Content-Disposition') || res.headers.get('content-disposition') + const filenameMatch = contentDisposition?.match(/filename="(.+)"/) + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').replace(/T/g, '_') + const filename = filenameMatch ? filenameMatch[1] : `alerts_report_${timestamp}.${format}` + const url = window.URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = filename + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + window.URL.revokeObjectURL(url) + } catch (error) { + console.error('Error:', error) + } } - - return (
{

Уведомления и тревоги

- {/* Кол-во выбранных объектов */} {selectedDetectors.length > 0 && ( Выбрано: {selectedDetectors.length} @@ -93,6 +158,81 @@ const AlertsPage: React.FC = () => { selectedDetectors={selectedDetectors} onDetectorSelect={handleDetectorSelect} /> + + {/* История тревог */} +
+
+

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

+ Всего: {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 2e6ce11..9097120 100644 --- a/frontend/app/(protected)/navigation/page.tsx +++ b/frontend/app/(protected)/navigation/page.tsx @@ -1,6 +1,6 @@ 'use client' -import React, { useEffect, useCallback } from 'react' +import React, { useEffect, useCallback, useState } from 'react' import { useRouter, useSearchParams } from 'next/navigation' import Sidebar from '../../../components/ui/Sidebar' import useNavigationStore from '../../store/navigationStore' @@ -9,8 +9,16 @@ import FloorNavigation from '../../../components/navigation/FloorNavigation' import DetectorMenu from '../../../components/navigation/DetectorMenu' import Notifications from '../../../components/notifications/Notifications' import NotificationDetectorInfo from '../../../components/notifications/NotificationDetectorInfo' -import ModelViewer from '../../../components/model/ModelViewer' -import detectorsData from '../../../data/detectors.json' +import dynamic from 'next/dynamic' + +const ModelViewer = dynamic(() => import('../../../components/model/ModelViewer'), { + ssr: false, + loading: () => ( +
+
Загрузка 3D-модуля…
+
+ ), +}) interface DetectorType { detector_id: number @@ -66,7 +74,10 @@ const NavigationPage: React.FC = () => { setSelectedNotification, setShowNotificationDetectorInfo } = useNavigationStore() - + + const [detectorsData, setDetectorsData] = useState<{ detectors: Record }>({ detectors: {} }) + const [detectorsError, setDetectorsError] = useState(null) + const urlObjectId = searchParams.get('objectId') const urlObjectTitle = searchParams.get('objectTitle') const objectId = currentObject.id || urlObjectId @@ -85,6 +96,27 @@ const NavigationPage: React.FC = () => { } }, [urlObjectId, urlObjectTitle, currentObject.id, setCurrentObject]) + useEffect(() => { + const loadDetectors = async () => { + try { + setDetectorsError(null) + const res = await fetch('/api/get-detectors', { cache: 'no-store' }) + const text = await res.text() + let payload: any + try { payload = JSON.parse(text) } catch { payload = text } + console.log('[NavigationPage] GET /api/get-detectors', { status: res.status, payload }) + if (!res.ok) throw new Error(typeof payload === 'string' ? payload : (payload?.error || 'Не удалось получить детекторов')) + const data = payload?.data ?? payload + const detectors = (data?.detectors ?? {}) as Record + setDetectorsData({ detectors }) + } catch (e: any) { + console.error('Ошибка загрузки детекторов:', e) + setDetectorsError(e?.message || 'Ошибка при загрузке детекторов') + } + } + loadDetectors() + }, []) + const handleBackClick = () => { router.push('/dashboard') } @@ -170,6 +202,9 @@ const NavigationPage: React.FC = () => { onNotificationClick={handleNotificationClick} onClose={closeNotifications} /> + {detectorsError && ( +
{detectorsError}
+ )} )} @@ -225,7 +260,7 @@ const NavigationPage: React.FC = () => {
diff --git a/frontend/app/(protected)/objects/page.tsx b/frontend/app/(protected)/objects/page.tsx index 4d99ca5..f4dd0f7 100644 --- a/frontend/app/(protected)/objects/page.tsx +++ b/frontend/app/(protected)/objects/page.tsx @@ -4,39 +4,21 @@ import React, { useState, useEffect } from 'react' import ObjectGallery from '../../../components/objects/ObjectGallery' import { ObjectData } from '../../../components/objects/ObjectCard' import Sidebar from '../../../components/ui/Sidebar' -import detectorsData from '../../../data/detectors.json' -// Интерфейс для данных объекта из JSON -interface RawObjectData { - name: string - title: string - description: string - image?: string - location: string - address: string - floors: number - area: number - type?: string - status?: string - zones: Array<{ - zone_id: string - name: string - detectors: number[] - }> -} - -// Функция для преобразования данных объекта из JSON -const transformObjectToObjectData = (objectId: string, objectData: RawObjectData): ObjectData => { +// Универсальная функция для преобразования объекта из бэкенда в ObjectData +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 ?? '') return { - object_id: objectId, - title: objectData.title || `Объект ${objectId}`, - description: objectData.description || `Описание объекта ${objectData.title || objectId}`, - image: objectData.image || '/images/default-object.jpg', - location: objectData.location || 'Не указано', - floors: objectData.floors, - area: objectData.area.toString(), - type: objectData.type || 'object', - status: objectData.status || 'active' + object_id, + title: raw?.title ?? `Объект ${object_id}`, + description: raw?.description ?? `Описание объекта ${raw?.title ?? object_id}`, + image: raw?.image ?? '/images/test_image.png', + location: raw?.location ?? raw?.address ?? 'Не указано', + floors: Number(raw?.floors ?? 0), + area: String(raw?.area ?? ''), + type: raw?.type ?? 'object', + status: raw?.status ?? 'active', } } @@ -47,21 +29,37 @@ const ObjectsPage: React.FC = () => { const [selectedObjectId, setSelectedObjectId] = useState(null) useEffect(() => { - const loadData = () => { + const loadData = async () => { + setLoading(true) + setError(null) try { - setLoading(true) - - if (detectorsData.objects) { - const transformedObjects = Object.entries(detectorsData.objects).map( - ([objectId, objectData]) => transformObjectToObjectData(objectId, objectData) - ) - setObjects(transformedObjects) - } else { - throw new Error('Не удалось получить данные объектов') + const url = '/api/get-objects' + const res = await fetch(url, { cache: 'no-store' }) + const payloadText = await res.text() + let payload: any + try { payload = JSON.parse(payloadText) } catch { payload = payloadText } + console.log('[ObjectsPage] GET /api/get-objects', { status: res.status, payload }) + + if (!res.ok) { + throw new Error(typeof payload === 'string' ? payload : (payload?.error || 'Не удалось получить данные объектов')) } - } catch (err) { - console.error('Ошибка при загрузке данных:', err) - setError(err instanceof Error ? err.message : 'Произошла неизвестная ошибка') + + const data = (payload?.data ?? payload) as any + let rawObjectsArray: any[] = [] + if (Array.isArray(data)) { + rawObjectsArray = data + } else if (Array.isArray(data?.objects)) { + rawObjectsArray = data.objects + } else if (data && typeof data === 'object') { + // если приходит как map { id: obj } + rawObjectsArray = Object.values(data) + } + + const transformedObjects = rawObjectsArray.map(transformRawToObjectData) + setObjects(transformedObjects) + } catch (err: any) { + console.error('Ошибка при загрузке данных объектов:', err) + setError(err?.message || 'Произошла неизвестная ошибка') } finally { setLoading(false) } diff --git a/frontend/app/(protected)/reports/page.tsx b/frontend/app/(protected)/reports/page.tsx index 112910c..aa68bc8 100644 --- a/frontend/app/(protected)/reports/page.tsx +++ b/frontend/app/(protected)/reports/page.tsx @@ -1,18 +1,21 @@ 'use client' -import React, { useEffect } from 'react' +import React, { useEffect, useState } from 'react' import { useRouter, useSearchParams } from 'next/navigation' import Sidebar from '../../../components/ui/Sidebar' import useNavigationStore from '../../store/navigationStore' import ReportsList from '../../../components/reports/ReportsList' import ExportMenu from '../../../components/ui/ExportMenu' -import detectorsData from '../../../data/detectors.json' -import axios from 'axios' +import { useSession } from 'next-auth/react' +import DetectorList from '../../../components/alerts/DetectorList' const ReportsPage: React.FC = () => { const router = useRouter() const searchParams = useSearchParams() + const { data: session } = useSession() const { currentObject, setCurrentObject } = useNavigationStore() + const [selectedDetectors, setSelectedDetectors] = useState([]) + const [detectorsData, setDetectorsData] = useState({ detectors: {} }) const urlObjectId = searchParams.get('objectId') const urlObjectTitle = searchParams.get('objectTitle') @@ -25,36 +28,70 @@ const ReportsPage: React.FC = () => { } }, [urlObjectId, urlObjectTitle, currentObject.id, setCurrentObject]) + useEffect(() => { + const loadDetectors = async () => { + try { + const params = new URLSearchParams() + if (currentObject.id) params.set('objectId', currentObject.id) + const res = await fetch(`/api/get-detectors?${params.toString()}`, { cache: 'no-store' }) + if (!res.ok) return + const payload = await res.json() + const data = payload?.data || { detectors: {} } + console.log('[ReportsPage] GET /api/get-detectors', { + url: `/api/get-detectors?${params.toString()}`, + status: res.status, + payload, + parsed: data, + }) + setDetectorsData(data) + } catch (e) { + console.error('Failed to load detectors data:', e) + } + } + loadDetectors() + + }, [currentObject.id]) + const handleBackClick = () => { router.push('/dashboard') } - const handleExport = async (format: 'csv' | 'pdf') => { - try { - const response = await axios.post( - `${process.env.NEXT_PUBLIC_API_URL}/account/get-reports/`, - { report_format: format }, - { - responseType: 'blob', - headers: { - 'Content-Type': 'application/json', - }, - } - ) + const handleDetectorSelect = (detectorId: number, selected: boolean) => { + if (selected) { + setSelectedDetectors(prev => [...prev, detectorId]) + } else { + setSelectedDetectors(prev => prev.filter(id => id !== detectorId)) + } + } - const contentDisposition = response.headers['content-disposition'] + const handleExport = async (format: 'csv' | 'pdf', hours: number) => { + try { + const res = await fetch(`/api/get-reports`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(session?.accessToken ? { Authorization: `Bearer ${session.accessToken}` } : {}), + }, + body: JSON.stringify({ format: format, hours }), + credentials: 'include', + }) + if (!res.ok) { + const errText = await res.text() + console.error('[ReportsPage] POST /api/get-reports failed', { status: res.status, body: errText }) + throw new Error(`Export failed: ${res.status} ${errText}`) + } + console.log('[ReportsPage] POST /api/get-reports success', { status: res.status, headers: Object.fromEntries(res.headers.entries()) }) + const blob = await res.blob() + const contentDisposition = res.headers.get('Content-Disposition') || res.headers.get('content-disposition') const filenameMatch = contentDisposition?.match(/filename="(.+)"/) const timestamp = new Date().toISOString().replace(/[:.]/g, '-').replace(/T/g, '_') - const filename = filenameMatch ? filenameMatch[1] : `alerts_report_${timestamp}.${format}` - - const url = window.URL.createObjectURL(new Blob([response.data])) + const filename = filenameMatch ? filenameMatch[1] : `detectors_report_${timestamp}.${format}` + const url = window.URL.createObjectURL(blob) const link = document.createElement('a') link.href = url link.download = filename - document.body.appendChild(link) link.click() - document.body.removeChild(link) window.URL.revokeObjectURL(url) } catch (error) { @@ -63,12 +100,12 @@ const ReportsPage: React.FC = () => { } return ( -
- + - -
+ +
- + {/* Selection table to choose detectors to include in the report */} +
+ +
+ + {/* Existing notifications-based list */} +
+ +
diff --git a/frontend/app/api/big-models/[...path]/route.ts b/frontend/app/api/big-models/[...path]/route.ts new file mode 100644 index 0000000..06ed5b6 --- /dev/null +++ b/frontend/app/api/big-models/[...path]/route.ts @@ -0,0 +1,27 @@ +export const dynamic = 'force-static'; + +import fs from 'fs'; +import path from 'path'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ path: string[] }> } +) { + const { path: pathParts } = await params; + const fileName = pathParts.join('/'); + const filePath = path.join(process.cwd(), 'assets', 'big-models', fileName); + + if (!fs.existsSync(filePath)) { + return new Response('Not Found', { status: 404 }); + } + + const stat = fs.statSync(filePath); + const stream = fs.createReadStream(filePath); + + return new Response(stream as unknown as ReadableStream, { + headers: { + 'Content-Length': stat.size.toString(), + 'Content-Type': 'model/gltf-binary', + }, + }); +} \ No newline at end of file diff --git a/frontend/app/api/get-alerts/route.ts b/frontend/app/api/get-alerts/route.ts new file mode 100644 index 0000000..936c7ca --- /dev/null +++ b/frontend/app/api/get-alerts/route.ts @@ -0,0 +1,114 @@ +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 = await getToken({ req, secret }).catch(() => null) + + let accessToken = session?.accessToken || bearer || (token as any)?.accessToken + const refreshToken = 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 + } + } catch {} + } + + 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 url = new URL(req.url) + const objectId = url.searchParams.get('objectId') + + const objectsRes = await fetch(`${backendUrl}/account/get-objects/`, { + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + cache: 'no-store', + }) + + const alertsRes = await fetch(`${backendUrl}/account/get-alerts/`, { + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + cache: 'no-store', + }) + + const payloadText = await alertsRes.text() + let payload: any + try { payload = JSON.parse(payloadText) } catch { payload = payloadText } + + if (!alertsRes.ok) { + const err = typeof payload === 'string' ? payload : JSON.stringify(payload) + return NextResponse.json({ success: false, error: `Backend alerts error: ${err}` }, { status: alertsRes.status }) + } + + const objectsText = await objectsRes.text() + let objectsPayload: any + try { objectsPayload = JSON.parse(objectsText) } catch { objectsPayload = [] } + + const titleToIdMap: Record = {} + if (Array.isArray(objectsPayload)) { + for (const obj of objectsPayload) { + if (obj && typeof obj.title === 'string' && typeof obj.id === 'number') { + titleToIdMap[obj.title] = `object_${obj.id}` + } + } + } + + const list: any[] = Array.isArray(payload) ? payload : [] + const filtered = objectId ? list.filter(a => { + if (a.object && typeof a.object === 'string') { + const mappedId = titleToIdMap[a.object] || a.object + return mappedId === objectId + } + 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, + })) + + return NextResponse.json({ success: true, data: transformed }) + } catch (error) { + console.error('Error fetching alerts data:', error) + return NextResponse.json( + { + success: false, + error: 'Failed to fetch alerts data', + }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/frontend/app/api/get-detectors-data/route.ts b/frontend/app/api/get-detectors-data/route.ts deleted file mode 100644 index 3a4a45d..0000000 --- a/frontend/app/api/get-detectors-data/route.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { NextResponse } from 'next/server' -import { readFile } from 'fs/promises' -import { join } from 'path' - -export async function GET() { - try { - // Имитация полученных данных в формате json (данные в data/detectors.json) - const filePath = join(process.cwd(), 'frontend', 'data', 'detectors.json') - const fileContent = await readFile(filePath, 'utf8') - const allData = JSON.parse(fileContent) - - return NextResponse.json({ - success: true, - data: allData, - objectsCount: Object.keys(allData.objects || {}).length, - detectorsCount: Object.keys(allData.detectors || {}).length - }) - } catch (error) { - console.error('Error fetching detectors data:', error) - return NextResponse.json( - { - success: false, - error: 'Failed to fetch detectors data' - }, - { status: 500 } - ) - } -} \ No newline at end of file diff --git a/frontend/app/api/get-detectors/route.ts b/frontend/app/api/get-detectors/route.ts new file mode 100644 index 0000000..4392acf --- /dev/null +++ b/frontend/app/api/get-detectors/route.ts @@ -0,0 +1,88 @@ +import { NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' + +export async function GET() { + try { + const session = await getServerSession(authOptions) + if (!session?.accessToken) { + return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) + } + + const backendUrl = process.env.BACKEND_URL + + const [detectorsRes, objectsRes] = await Promise.all([ + fetch(`${backendUrl}/account/get-detectors/`, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${session.accessToken}`, + }, + cache: 'no-store', + }), + fetch(`${backendUrl}/account/get-objects/`, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${session.accessToken}`, + }, + cache: 'no-store', + }), + ]) + + if (!detectorsRes.ok) { + const err = await detectorsRes.text() + return NextResponse.json({ success: false, error: `Backend detectors error: ${err}` }, { status: detectorsRes.status }) + } + if (!objectsRes.ok) { + const err = await objectsRes.text() + return NextResponse.json({ success: false, error: `Backend objects error: ${err}` }, { status: objectsRes.status }) + } + + const detectorsPayload = await detectorsRes.json() + const objectsPayload = await objectsRes.json() + + const titleToIdMap: Record = {} + if (Array.isArray(objectsPayload)) { + for (const obj of objectsPayload) { + if (obj && typeof obj.title === 'string' && typeof obj.id === 'number') { + titleToIdMap[obj.title] = `object_${obj.id}` + } + } + } + + const statusToColor: Record = { + critical: '#b3261e', + warning: '#fd7c22', + normal: '#00ff00', + } + + const transformedDetectors: Record = {} + const detectorsObj = detectorsPayload?.detectors ?? {} + for (const [key, sensor] of Object.entries(detectorsObj)) { + const color = statusToColor[sensor.status] ?? '#00ff00' + const objectId = titleToIdMap[sensor.object] || sensor.object + transformedDetectors[key] = { + ...sensor, + status: color, + object: objectId, + checked: sensor.checked ?? false, + location: sensor.zone ?? '', + } + } + + return NextResponse.json({ + success: true, + data: { detectors: transformedDetectors }, + objectsCount: Array.isArray(objectsPayload) ? objectsPayload.length : 0, + detectorsCount: Object.keys(transformedDetectors).length, + }) + } catch (error) { + console.error('Error fetching detectors data:', error) + return NextResponse.json( + { + success: false, + error: 'Failed to fetch detectors data', + }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/frontend/app/api/get-objects/route.ts b/frontend/app/api/get-objects/route.ts new file mode 100644 index 0000000..11e7067 --- /dev/null +++ b/frontend/app/api/get-objects/route.ts @@ -0,0 +1,71 @@ +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 + } + } catch {} + } + + 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 objectsRes = await fetch(`${backendUrl}/account/get-objects/`, { + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + cache: 'no-store', + }) + + const payloadText = await objectsRes.text() + let payload: any + try { payload = JSON.parse(payloadText) } catch { payload = payloadText } + + if (!objectsRes.ok) { + const err = typeof payload === 'string' ? payload : JSON.stringify(payload) + return NextResponse.json({ success: false, error: `Backend objects error: ${err}` }, { status: objectsRes.status }) + } + + return NextResponse.json({ success: true, data: payload }) + } catch (error) { + console.error('Error fetching objects data:', error) + return NextResponse.json( + { + success: false, + error: 'Failed to fetch objects data', + }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/frontend/app/api/get-reports/route.ts b/frontend/app/api/get-reports/route.ts new file mode 100644 index 0000000..849c214 --- /dev/null +++ b/frontend/app/api/get-reports/route.ts @@ -0,0 +1,109 @@ +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { NextRequest } from 'next/server' +import { getToken } from 'next-auth/jwt' + +export async function POST(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 = await getToken({ req, secret }).catch(() => null) + + let accessToken = session?.accessToken || bearer || (token as any)?.accessToken + const refreshToken = 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 + } + } catch {} + } + + if (!accessToken) { + return new Response(JSON.stringify({ success: false, error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }) + } + + const backendUrl = process.env.BACKEND_URL + if (!backendUrl) { + return new Response(JSON.stringify({ success: false, error: 'BACKEND_URL is not configured' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }) + } + + const body = await req.json().catch(() => ({ })) as { format?: 'csv' | 'pdf', hours?: 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 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) + + let backendRes = await fetch(`${backendUrl}/account/get-reports/`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ report_format: finalFormat, hours: finalHours }), + }) + + if (!backendRes.ok && backendRes.status === 404) { + backendRes = await fetch(`${backendUrl}/account/get-report/`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ report_format: finalFormat, hours: finalHours }), + }) + } + + if (!backendRes.ok) { + const errText = await backendRes.text().catch(() => '') + const contentType = backendRes.headers.get('Content-Type') || 'text/plain' + return new Response(errText || 'Error generating report', { + status: backendRes.status, + headers: { + 'Content-Type': contentType, + }, + }) + } + + const arrayBuffer = await backendRes.arrayBuffer() + const contentType = backendRes.headers.get('Content-Type') || 'application/octet-stream' + const contentDisposition = backendRes.headers.get('Content-Disposition') || backendRes.headers.get('content-disposition') + + const headers: Record = { 'Content-Type': contentType } + if (contentDisposition) { + headers['Content-Disposition'] = contentDisposition + } else if (finalFormat) { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').replace(/T/g, '_') + headers['Content-Disposition'] = `attachment; filename="alerts_report_${timestamp}.${finalFormat}"` + } + + return new Response(arrayBuffer, { status: 200, headers }) + } catch (error) { + console.error('Error proxying report export:', error) + return new Response(JSON.stringify({ success: false, error: 'Failed to export report' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }) + } +} \ No newline at end of file diff --git a/frontend/app/hooks/useClientFetch.ts b/frontend/app/hooks/useClientFetch.ts index ebca539..1f35c23 100644 --- a/frontend/app/hooks/useClientFetch.ts +++ b/frontend/app/hooks/useClientFetch.ts @@ -32,7 +32,7 @@ export function useClientFetch { const { method = 'GET', config = {}, queryOptions = {}, mutationOptions = {} } = options - const API_URL = process.env.NEXT_PUBLIC_API_URL + const API_URL = process.env.NEXT_PUBLIC_API_URL ?? `${window.location.origin}/api/v1` const fullUrl = url.startsWith('http') ? url : `${API_URL}${url}` // всегда вызываем оба хука diff --git a/frontend/app/providers/AuthProvider.tsx b/frontend/app/providers/AuthProvider.tsx index 6f800ad..cc2fa7f 100644 --- a/frontend/app/providers/AuthProvider.tsx +++ b/frontend/app/providers/AuthProvider.tsx @@ -15,20 +15,29 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { setAuthenticated(false) return } - - const API_URL = process.env.NEXT_PUBLIC_API_URL + + const base = process.env.NEXT_PUBLIC_API_URL || '/api/v1' + const baseClean = base.replace(/\/+$/, '') + const url = `${baseClean}/account/user/` + console.log('[AuthProvider] Fetching user from:', url) try { - const response = await fetch(`${API_URL}/account/user/`, { + const response = await fetch(url, { headers: { Authorization: `Bearer ${session.accessToken}`, - 'Content-Type': 'application/json', + Accept: 'application/json', }, + cache: 'no-store', + redirect: 'follow', }) if (!response.ok) { const errorText = await response.text() - console.error('Error response:', errorText) + console.error('Error fetching user data:', { + status: response.status, + url: url, + body: errorText, + }) throw new Error(`Error fetching user data: ${response.status} ${errorText}`) } @@ -39,7 +48,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { name: userData.name || session.user.name || '', surname: userData.surname || '', email: userData.email || session.user.email || '', - image: userData.image, + image: userData.imageURL || userData.image, account_type: userData.account_type, login: userData.login, uuid: userData.uuid, @@ -47,6 +56,8 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { setAuthenticated(true) } catch (error) { console.error('Error in fetchUserData:', error) + setAuthenticated(false) + setUser(null) } } diff --git a/frontend/app/types.ts b/frontend/app/types.ts new file mode 100644 index 0000000..5c8ae6d --- /dev/null +++ b/frontend/app/types.ts @@ -0,0 +1,9 @@ + export interface ValidationRules { + required?: boolean + minLength?: number + pattern?: RegExp +} +export type ValidationErrors = Record + +export type User = object +export type UserState = object \ No newline at end of file diff --git a/frontend/components/alerts/DetectorList.tsx b/frontend/components/alerts/DetectorList.tsx index 74c3269..056c3f2 100644 --- a/frontend/components/alerts/DetectorList.tsx +++ b/frontend/components/alerts/DetectorList.tsx @@ -1,7 +1,6 @@ 'use client' import React, { useState, useEffect } from 'react' -import detectorsData from '../../data/detectors.json' interface Detector { detector_id: number @@ -45,12 +44,31 @@ const DetectorList: React.FC = ({ objectId, selectedDetectors const [searchTerm, setSearchTerm] = useState('') useEffect(() => { - const detectorsArray = Object.values(detectorsData.detectors).filter( - (detector: RawDetector) => objectId ? detector.object === objectId : true - ) - setDetectors(detectorsArray as Detector[]) - - + const loadDetectors = async () => { + try { + const res = await fetch('/api/get-detectors', { cache: 'no-store' }) + if (!res.ok) return + const payload = await res.json() + const detectorsData: Record = payload?.data?.detectors ?? {} + const rawArray: RawDetector[] = Object.values(detectorsData).filter( + (detector) => (objectId ? detector.object === objectId : true) + ) + const normalized: Detector[] = rawArray.map((d) => ({ + detector_id: d.detector_id, + name: d.name, + location: d.location, + status: d.status, + object: d.object, + floor: d.floor, + checked: false, + })) + console.log('[DetectorList] Payload:', payload) + setDetectors(normalized) + } catch (e) { + console.error('Failed to load detectors:', e) + } + } + loadDetectors() }, [objectId]) const filteredDetectors = detectors.filter(detector => { diff --git a/frontend/components/dashboard/Dashboard.tsx b/frontend/components/dashboard/Dashboard.tsx index 7f26cf5..bc1d1f9 100644 --- a/frontend/components/dashboard/Dashboard.tsx +++ b/frontend/components/dashboard/Dashboard.tsx @@ -1,6 +1,6 @@ 'use client' -import React from 'react' +import React, { useEffect, useState } from 'react' import { useRouter } from 'next/navigation' import Sidebar from '../ui/Sidebar' import useNavigationStore from '../../app/store/navigationStore' @@ -8,14 +8,34 @@ import ChartCard from './ChartCard' import AreaChart from './AreaChart' import BarChart from './BarChart' import DetectorChart from './DetectorChart' -import detectorsData from '../../data/detectors.json' - + const Dashboard: React.FC = () => { const router = useRouter() const { currentObject, setCurrentSubmenu, closeMonitoring, closeFloorNavigation, closeNotifications } = useNavigationStore() const objectId = currentObject?.id const objectTitle = currentObject?.title + const [detectorsArray, setDetectorsArray] = useState([]) + + useEffect(() => { + const loadDetectors = async () => { + try { + const res = await fetch('/api/get-detectors', { cache: 'no-store' }) + if (!res.ok) return + const payload = await res.json() + console.log('[Dashboard] GET /api/get-detectors', { status: res.status, payload }) + const detectorsData = payload?.data?.detectors ?? {} + const arr = Object.values(detectorsData).filter( + (detector: any) => (objectId ? detector.object === objectId : true) + ) + setDetectorsArray(arr as any[]) + } catch (e) { + console.error('Failed to load detectors:', e) + } + } + loadDetectors() + }, [objectId]) + const handleBackClick = () => { router.push('/objects') } @@ -39,10 +59,6 @@ const Dashboard: React.FC = () => { }> } - const detectorsArray = Object.values(detectorsData.detectors).filter( - (detector: DetectorData) => objectId ? detector.object === objectId : true - ) - // Статусы const statusCounts = detectorsArray.reduce((acc: { critical: number; warning: number; normal: number }, detector: DetectorData) => { if (detector.status === '#b3261e') acc.critical++ diff --git a/frontend/components/model/ModelViewer.tsx b/frontend/components/model/ModelViewer.tsx index 6ac9ebe..85b3c6f 100644 --- a/frontend/components/model/ModelViewer.tsx +++ b/frontend/components/model/ModelViewer.tsx @@ -152,11 +152,32 @@ const ModelViewer: React.FC = ({ setLoadingProgress(100) console.log('GLTF Model loaded successfully!') - console.log('\n=== Complete Model Object ===') - console.log(result) - console.log('\n=== Structure Overview ===') - console.log('Meshes:', result.meshes?.length || 0) - console.log('Transform Nodes:', result.transformNodes?.length || 0) + console.log('\n=== IfcSensor Meshes ===') + const sensorMeshes = (result.meshes || []).filter(m => (m.id ?? '').includes('IfcSensor')) + console.log('IfcSensor Mesh count:', sensorMeshes.length) + sensorMeshes.forEach(m => { + const meta: any = (m as any).metadata + const extras = meta?.extras ?? meta?.gltf?.extras + console.group(`IfcSensor Mesh: ${m.id || m.name}`) + console.log('id:', m.id) + console.log('name:', m.name) + console.log('uniqueId:', m.uniqueId) + console.log('class:', typeof (m as any).getClassName === 'function' ? (m as any).getClassName() : 'Mesh') + console.log('material:', m.material?.name) + console.log('parent:', m.parent?.name) + console.log('metadata:', meta) + if (extras) console.log('extras:', extras) + const bi = m.getBoundingInfo?.() + const bb = bi?.boundingBox + if (bb) { + console.log('bounding.center:', bb.center) + console.log('bounding.extendSize:', bb.extendSize) + } + const verts = (m as any).getTotalVertices?.() + if (typeof verts === 'number') console.log('vertices:', verts) + console.groupEnd() + }) + if (result.meshes.length > 0) { const boundingBox = result.meshes[0].getHierarchyBoundingVectors() diff --git a/frontend/components/ui/Button.tsx b/frontend/components/ui/Button.tsx index 91461b1..8cea6b8 100644 --- a/frontend/components/ui/Button.tsx +++ b/frontend/components/ui/Button.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { ButtonProps } from '@/app/types' +import { ButtonProps } from '@/types' const Button = ({ onClick, diff --git a/frontend/components/ui/ExportMenu.tsx b/frontend/components/ui/ExportMenu.tsx index ee7e3ae..3b3b497 100644 --- a/frontend/components/ui/ExportMenu.tsx +++ b/frontend/components/ui/ExportMenu.tsx @@ -3,14 +3,15 @@ import React, { useState } from 'react' interface ExportMenuProps { - onExport: (format: 'csv' | 'pdf') => void + onExport: (format: 'csv' | 'pdf', hours: number) => void } const ExportMenu: React.FC = ({ onExport }) => { const [selectedFormat, setSelectedFormat] = useState<'csv' | 'pdf'>('csv') + const [selectedHours, setSelectedHours] = useState(168) // default: week const handleExport = () => { - onExport(selectedFormat) + onExport(selectedFormat, selectedHours) } return ( @@ -40,6 +41,20 @@ const ExportMenu: React.FC = ({ onExport }) => { PDF
+ +
+ Период: + +