AEB-71: Added 3D navigation in monitoring zones
This commit is contained in:
@@ -234,7 +234,7 @@ const AlertsPage: React.FC = () => {
|
||||
</td>
|
||||
<td className="py-4">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
item.acknowledged ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
||||
item.acknowledged ? 'bg-green-600/20 text-green-300 ring-1 ring-green-600/40' : 'bg-red-600/20 text-red-300 ring-1 ring-red-600/40'
|
||||
}`}>
|
||||
{item.acknowledged ? 'Да' : 'Нет'}
|
||||
</span>
|
||||
|
||||
@@ -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<string>('')
|
||||
|
||||
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 = () => {
|
||||
<div className="flex-1 flex flex-col relative">
|
||||
|
||||
{showMonitoring && (
|
||||
<div className="absolute left-0 top-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]" style={{height: 'calc(100% - 73px)', top: '73px'}}>
|
||||
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
||||
<div className="h-full overflow-auto p-4">
|
||||
<Monitoring
|
||||
objectId={objectId || undefined}
|
||||
onClose={closeMonitoring}
|
||||
onSelectModel={(path) => {
|
||||
setSelectedModelPath(path)
|
||||
setModelError(null)
|
||||
setIsModelReady(false)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showFloorNavigation && (
|
||||
<div className="absolute left-0 top-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]" style={{height: 'calc(100% - 73px)', top: '73px'}}>
|
||||
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
||||
<div className="h-full overflow-auto p-4">
|
||||
<FloorNavigation
|
||||
objectId={objectId || undefined}
|
||||
@@ -216,7 +230,7 @@ const NavigationPage: React.FC = () => {
|
||||
)}
|
||||
|
||||
{showNotifications && (
|
||||
<div className="absolute left-0 top-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]" style={{height: 'calc(100% - 73px)', top: '73px'}}>
|
||||
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
||||
<div className="h-full overflow-auto p-4">
|
||||
<Notifications
|
||||
objectId={objectId || undefined}
|
||||
@@ -236,7 +250,7 @@ const NavigationPage: React.FC = () => {
|
||||
detector => detector.detector_id === selectedNotification.detector_id
|
||||
);
|
||||
return detectorData ? (
|
||||
<div className="absolute left-[500px] top-0 bg-[#161824] border-r border-gray-700 z-30 w-[454px]" style={{height: 'calc(100% - 73px)', top: '73px'}}>
|
||||
<div className="absolute left-[500px] top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-30 w-[454px]">
|
||||
<div className="h-full overflow-auto p-4">
|
||||
<NotificationDetectorInfo
|
||||
detectorData={detectorData}
|
||||
@@ -251,8 +265,8 @@ const NavigationPage: React.FC = () => {
|
||||
null
|
||||
)}
|
||||
|
||||
<header className="bg-[#161824] border-b border-gray-700 px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<header className="bg-[#161824] border-b border-gray-700 px-6 h-[73px] flex items-center">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={handleBackClick}
|
||||
@@ -292,7 +306,7 @@ const NavigationPage: React.FC = () => {
|
||||
</div>
|
||||
) : (
|
||||
<ModelViewer
|
||||
modelPath='/static-models/AerBIM_Monitor_ASM_HT_Viewer_Expo2017Astana_Level_+1430_custom_prop.glb'
|
||||
modelPath={selectedModelPath}
|
||||
onModelLoaded={handleModelLoaded}
|
||||
onError={handleModelError}
|
||||
focusSensorId={selectedDetector?.serial_number ?? null}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 { useRouter } from 'next/navigation'
|
||||
|
||||
// Универсальная функция для преобразования объекта из бэкенда в ObjectData
|
||||
const transformRawToObjectData = (raw: any): ObjectData => {
|
||||
@@ -27,6 +28,7 @@ const ObjectsPage: React.FC = () => {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [selectedObjectId, setSelectedObjectId] = useState<string | null>(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)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
47
frontend/app/api/big-models/list/route.ts
Normal file
47
frontend/app/api/big-models/list/route.ts
Normal file
@@ -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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}) : []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface DetectorType {
|
||||
object: string
|
||||
status: string
|
||||
type: string
|
||||
detector_type: string
|
||||
location: string
|
||||
floor: number
|
||||
checked: boolean
|
||||
|
||||
Reference in New Issue
Block a user