Merge branch 'AEB-71/navigation_and_dashboard' into 'main'
AEB-71: Added 3D navigation in monitoring zones See merge request wedeving/aerbim-www!13
This commit is contained in:
@@ -8,11 +8,11 @@ class AlertSerializer(serializers.ModelSerializer):
|
|||||||
name = serializers.SerializerMethodField()
|
name = serializers.SerializerMethodField()
|
||||||
object = serializers.SerializerMethodField()
|
object = serializers.SerializerMethodField()
|
||||||
metric_value = serializers.SerializerMethodField()
|
metric_value = serializers.SerializerMethodField()
|
||||||
sensor_type_name = serializers.SerializerMethodField()
|
detector_type = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Alert
|
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)
|
@extend_schema_field(OpenApiTypes.STR)
|
||||||
def get_name(self, obj) -> str:
|
def get_name(self, obj) -> str:
|
||||||
@@ -22,14 +22,17 @@ class AlertSerializer(serializers.ModelSerializer):
|
|||||||
def get_object(self, obj) -> Optional[str]:
|
def get_object(self, obj) -> Optional[str]:
|
||||||
zone = obj.sensor.zones.first()
|
zone = obj.sensor.zones.first()
|
||||||
return zone.object.title if zone else None
|
return zone.object.title if zone else None
|
||||||
|
|
||||||
@extend_schema_field(OpenApiTypes.STR)
|
@extend_schema_field(OpenApiTypes.STR)
|
||||||
def get_metric_value(self, obj) -> str:
|
def get_metric_value(self, obj) -> str:
|
||||||
if obj.metric.value is not None:
|
if obj.metric.value is not None:
|
||||||
unit = obj.sensor.signal_format.unit if obj.sensor.signal_format else ''
|
unit = obj.sensor.signal_format.unit if obj.sensor.signal_format else ''
|
||||||
return f"{obj.metric.value} {unit}".strip()
|
return f"{obj.metric.value} {unit}".strip()
|
||||||
return obj.metric.raw_value
|
return obj.metric.raw_value
|
||||||
|
|
||||||
@extend_schema_field(OpenApiTypes.STR)
|
@extend_schema_field(OpenApiTypes.STR)
|
||||||
def get_sensor_type_name(self, obj) -> str:
|
def get_detector_type(self, obj) -> str:
|
||||||
return obj.sensor_type.name
|
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()
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class NotificationSerializer(serializers.ModelSerializer):
|
|||||||
class DetectorSerializer(serializers.ModelSerializer):
|
class DetectorSerializer(serializers.ModelSerializer):
|
||||||
detector_id = serializers.SerializerMethodField()
|
detector_id = serializers.SerializerMethodField()
|
||||||
type = serializers.SerializerMethodField()
|
type = serializers.SerializerMethodField()
|
||||||
|
detector_type = serializers.SerializerMethodField()
|
||||||
name = serializers.SerializerMethodField()
|
name = serializers.SerializerMethodField()
|
||||||
object = serializers.SerializerMethodField()
|
object = serializers.SerializerMethodField()
|
||||||
status = serializers.SerializerMethodField()
|
status = serializers.SerializerMethodField()
|
||||||
@@ -32,22 +33,28 @@ class DetectorSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Sensor
|
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):
|
def get_detector_id(self, obj):
|
||||||
return obj.name or f"{obj.sensor_type.code}-{obj.id}"
|
return obj.name or f"{obj.sensor_type.code}-{obj.id}"
|
||||||
|
|
||||||
def get_type(self, obj):
|
def get_type(self, obj):
|
||||||
# маппинг типов сенсоров на нужные значения
|
|
||||||
sensor_type_mapping = {
|
sensor_type_mapping = {
|
||||||
'GA': 'fire_detector',
|
'GA': 'Инклинометр',
|
||||||
'PE': 'pressure_sensor',
|
'PE': 'Тензометр',
|
||||||
'GLE': 'gas_detector'
|
'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):
|
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):
|
def get_object(self, obj):
|
||||||
# получаем первую зону датчика и её объект
|
# получаем первую зону датчика и её объект
|
||||||
@@ -64,17 +71,12 @@ class DetectorSerializer(serializers.ModelSerializer):
|
|||||||
return 'normal'
|
return 'normal'
|
||||||
|
|
||||||
def get_zone(self, obj):
|
def get_zone(self, obj):
|
||||||
zone = obj.zones.first()
|
first_zone = obj.zones.first()
|
||||||
if zone:
|
return first_zone.name if first_zone else None
|
||||||
return zone.name
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_floor(self, obj):
|
def get_floor(self, obj):
|
||||||
# получаем этаж из зоны
|
first_zone = obj.zones.first()
|
||||||
zone = obj.zones.first()
|
return getattr(first_zone, 'floor', None)
|
||||||
if zone:
|
|
||||||
return zone.floor
|
|
||||||
return None # если датчик не привязали к зоне
|
|
||||||
|
|
||||||
class DetectorsResponseSerializer(serializers.Serializer):
|
class DetectorsResponseSerializer(serializers.Serializer):
|
||||||
detectors = serializers.SerializerMethodField()
|
detectors = serializers.SerializerMethodField()
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ const AlertsPage: React.FC = () => {
|
|||||||
</td>
|
</td>
|
||||||
<td className="py-4">
|
<td className="py-4">
|
||||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
<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 ? 'Да' : 'Нет'}
|
{item.acknowledged ? 'Да' : 'Нет'}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ interface DetectorType {
|
|||||||
status: string
|
status: string
|
||||||
checked: boolean
|
checked: boolean
|
||||||
type: string
|
type: string
|
||||||
|
detector_type: string
|
||||||
location: string
|
location: string
|
||||||
floor: number
|
floor: number
|
||||||
notifications: Array<{
|
notifications: Array<{
|
||||||
@@ -86,6 +87,7 @@ const NavigationPage: React.FC = () => {
|
|||||||
const urlObjectTitle = searchParams.get('objectTitle')
|
const urlObjectTitle = searchParams.get('objectTitle')
|
||||||
const objectId = currentObject.id || urlObjectId
|
const objectId = currentObject.id || urlObjectId
|
||||||
const objectTitle = currentObject.title || urlObjectTitle
|
const objectTitle = currentObject.title || urlObjectTitle
|
||||||
|
const [selectedModelPath, setSelectedModelPath] = useState<string>('')
|
||||||
|
|
||||||
const handleModelLoaded = useCallback(() => {
|
const handleModelLoaded = useCallback(() => {
|
||||||
setIsModelReady(true)
|
setIsModelReady(true)
|
||||||
@@ -173,12 +175,19 @@ const NavigationPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getStatusText = (status: string) => {
|
const getStatusText = (status: string) => {
|
||||||
switch (status) {
|
const s = (status || '').toLowerCase()
|
||||||
case 'active': return 'Активен'
|
switch (s) {
|
||||||
case 'inactive': return 'Неактивен'
|
case '#b3261e':
|
||||||
case 'error': return 'Ошибка'
|
case 'critical':
|
||||||
case 'maintenance': return 'Обслуживание'
|
return 'Критический'
|
||||||
default: 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">
|
<div className="flex-1 flex flex-col relative">
|
||||||
|
|
||||||
{showMonitoring && (
|
{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">
|
<div className="h-full overflow-auto p-4">
|
||||||
<Monitoring
|
<Monitoring
|
||||||
objectId={objectId || undefined}
|
objectId={objectId || undefined}
|
||||||
onClose={closeMonitoring}
|
onClose={closeMonitoring}
|
||||||
|
onSelectModel={(path) => {
|
||||||
|
setSelectedModelPath(path)
|
||||||
|
setModelError(null)
|
||||||
|
setIsModelReady(false)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showFloorNavigation && (
|
{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">
|
<div className="h-full overflow-auto p-4">
|
||||||
<FloorNavigation
|
<FloorNavigation
|
||||||
objectId={objectId || undefined}
|
objectId={objectId || undefined}
|
||||||
@@ -216,7 +230,7 @@ const NavigationPage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{showNotifications && (
|
{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">
|
<div className="h-full overflow-auto p-4">
|
||||||
<Notifications
|
<Notifications
|
||||||
objectId={objectId || undefined}
|
objectId={objectId || undefined}
|
||||||
@@ -236,7 +250,7 @@ const NavigationPage: React.FC = () => {
|
|||||||
detector => detector.detector_id === selectedNotification.detector_id
|
detector => detector.detector_id === selectedNotification.detector_id
|
||||||
);
|
);
|
||||||
return detectorData ? (
|
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">
|
<div className="h-full overflow-auto p-4">
|
||||||
<NotificationDetectorInfo
|
<NotificationDetectorInfo
|
||||||
detectorData={detectorData}
|
detectorData={detectorData}
|
||||||
@@ -251,8 +265,8 @@ const NavigationPage: React.FC = () => {
|
|||||||
null
|
null
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<header className="bg-[#161824] border-b border-gray-700 px-6 py-4">
|
<header className="bg-[#161824] border-b border-gray-700 px-6 h-[73px] flex items-center">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between w-full">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<button
|
<button
|
||||||
onClick={handleBackClick}
|
onClick={handleBackClick}
|
||||||
@@ -292,7 +306,7 @@ const NavigationPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ModelViewer
|
<ModelViewer
|
||||||
modelPath='/static-models/AerBIM_Monitor_ASM_HT_Viewer_Expo2017Astana_Level_+1430_custom_prop.glb'
|
modelPath={selectedModelPath}
|
||||||
onModelLoaded={handleModelLoaded}
|
onModelLoaded={handleModelLoaded}
|
||||||
onError={handleModelError}
|
onError={handleModelError}
|
||||||
focusSensorId={selectedDetector?.serial_number ?? null}
|
focusSensorId={selectedDetector?.serial_number ?? null}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import React, { useState, useEffect } from 'react'
|
|||||||
import ObjectGallery from '../../../components/objects/ObjectGallery'
|
import ObjectGallery from '../../../components/objects/ObjectGallery'
|
||||||
import { ObjectData } from '../../../components/objects/ObjectCard'
|
import { ObjectData } from '../../../components/objects/ObjectCard'
|
||||||
import Sidebar from '../../../components/ui/Sidebar'
|
import Sidebar from '../../../components/ui/Sidebar'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
|
||||||
// Универсальная функция для преобразования объекта из бэкенда в ObjectData
|
// Универсальная функция для преобразования объекта из бэкенда в ObjectData
|
||||||
const transformRawToObjectData = (raw: any): ObjectData => {
|
const transformRawToObjectData = (raw: any): ObjectData => {
|
||||||
@@ -27,6 +28,7 @@ const ObjectsPage: React.FC = () => {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [selectedObjectId, setSelectedObjectId] = useState<string | null>(null)
|
const [selectedObjectId, setSelectedObjectId] = useState<string | null>(null)
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
@@ -41,7 +43,15 @@ const ObjectsPage: React.FC = () => {
|
|||||||
console.log('[ObjectsPage] GET /api/get-objects', { status: res.status, payload })
|
console.log('[ObjectsPage] GET /api/get-objects', { status: res.status, payload })
|
||||||
|
|
||||||
if (!res.ok) {
|
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
|
const data = (payload?.data ?? payload) as any
|
||||||
@@ -66,7 +76,7 @@ const ObjectsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
loadData()
|
loadData()
|
||||||
}, [])
|
}, [router])
|
||||||
|
|
||||||
const handleObjectSelect = (objectId: string) => {
|
const handleObjectSelect = (objectId: string) => {
|
||||||
console.log('Object selected:', objectId)
|
console.log('Object selected:', objectId)
|
||||||
|
|||||||
@@ -18,10 +18,20 @@ export async function GET(
|
|||||||
const stat = fs.statSync(filePath);
|
const stat = fs.statSync(filePath);
|
||||||
const stream = fs.createReadStream(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, {
|
return new Response(stream as unknown as ReadableStream, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Length': stat.size.toString(),
|
'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
|
return true
|
||||||
}) : list
|
}) : list
|
||||||
|
|
||||||
const transformed = filtered.map((a) => ({
|
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,
|
id: a.id,
|
||||||
detector_name: a.name,
|
detector_name: a.name || a.detector_name,
|
||||||
message: a.message,
|
message: a.message,
|
||||||
type: a.severity,
|
type,
|
||||||
location: a.object,
|
location: a.object,
|
||||||
priority: a.severity,
|
priority,
|
||||||
acknowledged: a.resolved,
|
acknowledged: typeof a.acknowledged === 'boolean' ? a.acknowledged : !!a.resolved,
|
||||||
timestamp: a.created_at,
|
timestamp: a.timestamp || a.created_at,
|
||||||
}))
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({ success: true, data: transformed })
|
return NextResponse.json({ success: true, data: transformed })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -38,8 +38,14 @@ export async function GET(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const url = new URL(req.url)
|
const url = new URL(req.url)
|
||||||
const timePeriod = url.searchParams.get('time_period')
|
const timePeriodRaw = url.searchParams.get('time_period')
|
||||||
const qs = timePeriod ? `?time_period=${encodeURIComponent(timePeriod)}` : ''
|
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}`, {
|
const res = await fetch(`${backendUrl}/account/get-dashboard/${qs}`, {
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -67,6 +67,20 @@ export async function GET() {
|
|||||||
checked: sensor.checked ?? false,
|
checked: sensor.checked ?? false,
|
||||||
location: sensor.zone ?? '',
|
location: sensor.zone ?? '',
|
||||||
serial_number: sensor.serial_number ?? sensor.name ?? '',
|
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' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ refresh: refreshToken }),
|
body: JSON.stringify({ refresh: refreshToken }),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (refreshRes.ok) {
|
if (refreshRes.ok) {
|
||||||
const refreshed = await refreshRes.json()
|
const refreshed = await refreshRes.json()
|
||||||
accessToken = refreshed.access
|
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)
|
||||||
}
|
}
|
||||||
} catch {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!accessToken) {
|
if (!accessToken) {
|
||||||
@@ -53,6 +74,20 @@ export async function GET(req: NextRequest) {
|
|||||||
try { payload = JSON.parse(payloadText) } catch { payload = payloadText }
|
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)
|
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: false, error: `Backend objects error: ${err}` }, { status: objectsRes.status })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export interface DetectorType {
|
|||||||
object: string
|
object: string
|
||||||
status: string
|
status: string
|
||||||
type: string
|
type: string
|
||||||
|
detector_type: string
|
||||||
location: string
|
location: string
|
||||||
floor: number
|
floor: number
|
||||||
checked: boolean
|
checked: boolean
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface RawDetector {
|
|||||||
object: string
|
object: string
|
||||||
status: string
|
status: string
|
||||||
type: string
|
type: string
|
||||||
|
detector_type: string
|
||||||
location: string
|
location: string
|
||||||
floor: number
|
floor: number
|
||||||
notifications: Array<{
|
notifications: Array<{
|
||||||
|
|||||||
@@ -22,15 +22,7 @@ const AreaChart: React.FC<AreaChartProps> = ({ className = '', data }) => {
|
|||||||
|
|
||||||
const safeData = (Array.isArray(data) && data.length > 0)
|
const safeData = (Array.isArray(data) && data.length > 0)
|
||||||
? data
|
? data
|
||||||
: [
|
: Array.from({ length: 7 }, () => ({ value: 0 }))
|
||||||
{ value: 5 },
|
|
||||||
{ value: 3 },
|
|
||||||
{ value: 7 },
|
|
||||||
{ value: 2 },
|
|
||||||
{ value: 6 },
|
|
||||||
{ value: 4 },
|
|
||||||
{ value: 8 }
|
|
||||||
]
|
|
||||||
|
|
||||||
const maxVal = Math.max(...safeData.map(d => d.value || 0), 1)
|
const maxVal = Math.max(...safeData.map(d => d.value || 0), 1)
|
||||||
const stepX = safeData.length > 1 ? width / (safeData.length - 1) : width
|
const stepX = safeData.length > 1 ? width / (safeData.length - 1) : width
|
||||||
|
|||||||
@@ -14,24 +14,9 @@ interface BarChartProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const BarChart: React.FC<BarChartProps> = ({ className = '', data }) => {
|
const BarChart: React.FC<BarChartProps> = ({ 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)
|
const barData = (Array.isArray(data) && data.length > 0)
|
||||||
? data.map(d => ({ value: d.value, color: d.color || 'rgb(42, 157, 144)' }))
|
? 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)
|
const maxVal = Math.max(...barData.map(b => b.value || 0), 1)
|
||||||
|
|
||||||
|
|||||||
@@ -7,28 +7,49 @@ import useNavigationStore from '../../app/store/navigationStore'
|
|||||||
import ChartCard from './ChartCard'
|
import ChartCard from './ChartCard'
|
||||||
import AreaChart from './AreaChart'
|
import AreaChart from './AreaChart'
|
||||||
import BarChart from './BarChart'
|
import BarChart from './BarChart'
|
||||||
import DetectorChart from './DetectorChart'
|
|
||||||
|
|
||||||
const Dashboard: React.FC = () => {
|
const Dashboard: React.FC = () => {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { currentObject, setCurrentSubmenu, closeMonitoring, closeFloorNavigation, closeNotifications } = useNavigationStore()
|
const { currentObject, setCurrentSubmenu, closeMonitoring, closeFloorNavigation, closeNotifications } = useNavigationStore()
|
||||||
const objectId = currentObject?.id
|
|
||||||
const objectTitle = currentObject?.title
|
const objectTitle = currentObject?.title
|
||||||
|
|
||||||
const [dashboardAlerts, setDashboardAlerts] = useState<any[]>([])
|
const [dashboardAlerts, setDashboardAlerts] = useState<any[]>([])
|
||||||
const [chartData, setChartData] = useState<{ timestamp: string; value: number }[]>([])
|
const [chartData, setChartData] = useState<{ timestamp: string; value: number }[]>([])
|
||||||
|
const [sensorTypes] = useState<Array<{code: string, name: string}>>([
|
||||||
|
{ code: '', name: 'Все датчики' },
|
||||||
|
{ code: 'GA', name: 'Инклинометр' },
|
||||||
|
{ code: 'PE', name: 'Танзометр' },
|
||||||
|
{ code: 'GLE', name: 'Гидроуровень' }
|
||||||
|
])
|
||||||
|
const [selectedSensorType, setSelectedSensorType] = useState<string>('')
|
||||||
|
const [selectedChartPeriod, setSelectedChartPeriod] = useState<string>('168')
|
||||||
|
const [selectedTablePeriod, setSelectedTablePeriod] = useState<string>('168')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadDashboard = async () => {
|
const loadDashboard = async () => {
|
||||||
try {
|
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
|
if (!res.ok) return
|
||||||
const payload = await res.json()
|
const payload = await res.json()
|
||||||
console.log('[Dashboard] GET /api/get-dashboard', { status: res.status, payload })
|
console.log('[Dashboard] GET /api/get-dashboard', { status: res.status, payload })
|
||||||
const tableData = payload?.data?.table_data ?? []
|
|
||||||
const arr = (Array.isArray(tableData) ? tableData : [])
|
let tableData = payload?.data?.table_data ?? []
|
||||||
.filter((a: any) => (objectTitle ? a.object === objectTitle : true))
|
tableData = Array.isArray(tableData) ? tableData : []
|
||||||
setDashboardAlerts(arr as any[])
|
|
||||||
|
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 : []
|
const cd = Array.isArray(payload?.data?.chart_data) ? payload.data.chart_data : []
|
||||||
setChartData(cd as any[])
|
setChartData(cd as any[])
|
||||||
@@ -37,14 +58,52 @@ const Dashboard: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
loadDashboard()
|
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 = () => {
|
const handleBackClick = () => {
|
||||||
router.push('/objects')
|
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++
|
if (a.severity === 'critical') acc.critical++
|
||||||
else if (a.severity === 'warning') acc.warning++
|
else if (a.severity === 'warning') acc.warning++
|
||||||
else acc.normal++
|
else acc.normal++
|
||||||
@@ -59,6 +118,18 @@ const Dashboard: React.FC = () => {
|
|||||||
router.push('/navigation')
|
router.push('/navigation')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSensorTypeChange = (sensorType: string) => {
|
||||||
|
setSelectedSensorType(sensorType)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChartPeriodChange = (period: string) => {
|
||||||
|
setSelectedChartPeriod(period)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTablePeriodChange = (period: string) => {
|
||||||
|
setSelectedTablePeriod(period)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-[#0e111a]">
|
<div className="flex h-screen bg-[#0e111a]">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
@@ -87,17 +158,25 @@ const Dashboard: React.FC = () => {
|
|||||||
|
|
||||||
<div className="flex-1 p-6 overflow-auto">
|
<div className="flex-1 p-6 overflow-auto">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="text-white text-2xl font-semibold mb-6">Объект {objectId?.replace('object_', '')}</h1>
|
<h1 className="text-white text-2xl font-semibold mb-6">{objectTitle || 'Объект'}</h1>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex items-center gap-3 mb-6">
|
||||||
<button
|
<div className="relative">
|
||||||
className="flex items-center gap-6 rounded-[10px] px-4 py-[18px] bg-[rgb(22,24,36)] text-white"
|
<select
|
||||||
|
value={selectedSensorType}
|
||||||
|
onChange={(e) => handleSensorTypeChange(e.target.value)}
|
||||||
|
className="flex items-center gap-6 rounded-[10px] px-4 py-[18px] bg-[rgb(22,24,36)] text-white appearance-none pr-8"
|
||||||
>
|
>
|
||||||
<span className="text-sm font-medium">Датчики : все</span>
|
{sensorTypes.map((type) => (
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<option key={type.code} value={type.code}>
|
||||||
|
{type.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<svg className="w-4 h-4 absolute right-3 top-1/2 transform -translate-y-1/2 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 ml-auto">
|
<div className="flex items-center gap-3 ml-auto">
|
||||||
<button
|
<button
|
||||||
@@ -107,12 +186,20 @@ const Dashboard: React.FC = () => {
|
|||||||
<span className="text-sm font-medium">Навигация</span>
|
<span className="text-sm font-medium">Навигация</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 bg-[rgb(22,24,36)] rounded-lg px-3 py-2">
|
<div className="relative">
|
||||||
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<select
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
value={selectedChartPeriod}
|
||||||
|
onChange={(e) => handleChartPeriodChange(e.target.value)}
|
||||||
|
className="flex items-center gap-2 bg-[rgb(22,24,36)] rounded-lg px-3 py-2 text-white appearance-none pr-8"
|
||||||
|
>
|
||||||
|
<option value="24">День</option>
|
||||||
|
<option value="72">3 дня</option>
|
||||||
|
<option value="168">Неделя</option>
|
||||||
|
<option value="720">Месяц</option>
|
||||||
|
</select>
|
||||||
|
<svg className="w-4 h-4 absolute right-3 top-1/2 transform -translate-y-1/2 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-white text-sm font-medium">Период</span>
|
|
||||||
<div className="w-2 h-2 bg-white rounded-full"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -121,14 +208,14 @@ const Dashboard: React.FC = () => {
|
|||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-[18px]">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-[18px]">
|
||||||
<ChartCard
|
<ChartCard
|
||||||
title="Показатель"
|
title="Показатель"
|
||||||
subtitle="За последние 6 месяцев"
|
// subtitle removed
|
||||||
>
|
>
|
||||||
<AreaChart data={chartData} />
|
<AreaChart data={chartData} />
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
<ChartCard
|
<ChartCard
|
||||||
title="Статистика"
|
title="Статистика"
|
||||||
subtitle="Данные за период"
|
// subtitle removed
|
||||||
>
|
>
|
||||||
<BarChart data={chartData?.map((d: any) => ({ value: d.value }))} />
|
<BarChart data={chartData?.map((d: any) => ({ value: d.value }))} />
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
@@ -140,12 +227,18 @@ const Dashboard: React.FC = () => {
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h2 className="text-white text-2xl font-semibold">Тренды</h2>
|
<h2 className="text-white text-2xl font-semibold">Тренды</h2>
|
||||||
<div className="bg-[#161824] rounded-lg px-3 py-2 flex items-center gap-2">
|
<div className="relative">
|
||||||
<svg className="w-4 h-4 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
<select
|
||||||
<path fillRule="evenodd" d="M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" clipRule="evenodd" />
|
value={selectedTablePeriod}
|
||||||
</svg>
|
onChange={(e) => handleTablePeriodChange(e.target.value)}
|
||||||
<span className="text-white text-sm font-medium">Месяц</span>
|
className="bg-[#161824] rounded-lg px-3 py-2 flex items-center gap-2 text-white appearance-none pr-8"
|
||||||
<svg className="w-4 h-4 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
>
|
||||||
|
<option value="24">День</option>
|
||||||
|
<option value="72">3 дня</option>
|
||||||
|
<option value="168">Неделя</option>
|
||||||
|
<option value="720">Месяц</option>
|
||||||
|
</select>
|
||||||
|
<svg className="w-4 h-4 absolute right-3 top-1/2 transform -translate-y-1/2 pointer-events-none" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
|
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
@@ -165,7 +258,7 @@ const Dashboard: React.FC = () => {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{dashboardAlerts.map((alert: any) => (
|
{filteredAlerts.map((alert: any) => (
|
||||||
<tr key={alert.id} className="border-b border-gray-800">
|
<tr key={alert.id} className="border-b border-gray-800">
|
||||||
<td className="py-3 text-white text-sm">{alert.name}</td>
|
<td className="py-3 text-white text-sm">{alert.name}</td>
|
||||||
<td className="py-3 text-gray-300 text-sm">{alert.message}</td>
|
<td className="py-3 text-gray-300 text-sm">{alert.message}</td>
|
||||||
@@ -191,7 +284,7 @@ const Dashboard: React.FC = () => {
|
|||||||
{/* Статы */}
|
{/* Статы */}
|
||||||
<div className="mt-6 grid grid-cols-4 gap-4">
|
<div className="mt-6 grid grid-cols-4 gap-4">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-2xl font-bold text-white">{dashboardAlerts.length}</div>
|
<div className="text-2xl font-bold text-white">{filteredAlerts.length}</div>
|
||||||
<div className="text-sm text-gray-400">Всего</div>
|
<div className="text-sm text-gray-400">Всего</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
@@ -208,37 +301,6 @@ const Dashboard: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Графики с аналитикой */}
|
|
||||||
<div className="mt-6 grid grid-cols-1 lg:grid-cols-4 gap-[18px]">
|
|
||||||
<ChartCard
|
|
||||||
title="Тренды детекторов"
|
|
||||||
subtitle="За последний месяц"
|
|
||||||
>
|
|
||||||
<DetectorChart type="line" data={chartData?.map((d: any) => ({ value: d.value }))} />
|
|
||||||
</ChartCard>
|
|
||||||
|
|
||||||
<ChartCard
|
|
||||||
title="Статистика по месяцам"
|
|
||||||
subtitle="Активность детекторов"
|
|
||||||
>
|
|
||||||
<DetectorChart type="bar" data={chartData?.map((d: any) => ({ value: d.value }))} />
|
|
||||||
</ChartCard>
|
|
||||||
|
|
||||||
<ChartCard
|
|
||||||
title="Анализ производительности"
|
|
||||||
subtitle="Эффективность работы"
|
|
||||||
>
|
|
||||||
<DetectorChart type="line" data={chartData?.map((d: any) => ({ value: d.value }))} />
|
|
||||||
</ChartCard>
|
|
||||||
|
|
||||||
<ChartCard
|
|
||||||
title="Сводка по статусам"
|
|
||||||
subtitle="Распределение состояний"
|
|
||||||
>
|
|
||||||
<DetectorChart type="bar" data={chartData?.map((d: any) => ({ value: d.value }))} />
|
|
||||||
</ChartCard>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -249,7 +249,6 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
|||||||
|
|
||||||
const allMeshes = importedMeshesRef.current || []
|
const allMeshes = importedMeshesRef.current || []
|
||||||
|
|
||||||
// Safeguard: Check if we have any meshes at all
|
|
||||||
if (allMeshes.length === 0) {
|
if (allMeshes.length === 0) {
|
||||||
console.warn('[ModelViewer] No meshes available for sensor matching')
|
console.warn('[ModelViewer] No meshes available for sensor matching')
|
||||||
highlightLayerRef.current?.removeAllMeshes()
|
highlightLayerRef.current?.removeAllMeshes()
|
||||||
@@ -378,15 +377,15 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
|||||||
<div className="w-full h-screen relative bg-gray-900 overflow-hidden">
|
<div className="w-full h-screen relative bg-gray-900 overflow-hidden">
|
||||||
{!modelPath ? (
|
{!modelPath ? (
|
||||||
<div className="h-full flex items-center justify-center">
|
<div className="h-full flex items-center justify-center">
|
||||||
<div className="text-center p-8 bg-[#161824] rounded-lg border border-gray-700 max-w-md">
|
<div className="text-center p-8 bg-[#161824] rounded-lg border border-gray-700 max-w-md shadow-xl">
|
||||||
<div className="text-amber-400 text-lg font-semibold mb-4">
|
<div className="text-amber-400 text-lg font-semibold mb-2">
|
||||||
3D модель недоступна
|
3D модель не выбрана
|
||||||
</div>
|
</div>
|
||||||
<div className="text-gray-300 mb-4">
|
<div className="text-gray-300 mb-4">
|
||||||
Путь к 3D модели не задан
|
Выберите модель в панели «Зоны мониторинга», чтобы начать просмотр
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-400">
|
<div className="text-sm text-gray-400">
|
||||||
Обратитесь к администратору для настройки модели
|
Если список пуст, добавьте файлы в каталог assets/big-models или проверьте API
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,8 +10,17 @@ interface DetectorType {
|
|||||||
status: string
|
status: string
|
||||||
checked: boolean
|
checked: boolean
|
||||||
type: string
|
type: string
|
||||||
|
detector_type: string
|
||||||
location: string
|
location: string
|
||||||
floor: number
|
floor: number
|
||||||
|
notifications?: Array<{
|
||||||
|
id: number
|
||||||
|
type: string
|
||||||
|
message: string
|
||||||
|
timestamp: string
|
||||||
|
acknowledged: boolean
|
||||||
|
priority: string
|
||||||
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DetectorMenuProps {
|
interface DetectorMenuProps {
|
||||||
@@ -26,6 +35,35 @@ interface DetectorMenuProps {
|
|||||||
const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose, getStatusText, compact = false, anchor = null }) => {
|
const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose, getStatusText, compact = false, anchor = null }) => {
|
||||||
if (!isOpen) return 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<string, string> = {
|
||||||
|
GA: 'Инклинометр',
|
||||||
|
PE: 'Тензометр',
|
||||||
|
GLE: 'Гидроуровень',
|
||||||
|
}
|
||||||
|
const displayDetectorTypeLabel = detectorTypeLabelMap[effectiveDetectorTypeCode] || '—'
|
||||||
const DetailsSection: React.FC<{ compact?: boolean }> = ({ compact = false }) => (
|
const DetailsSection: React.FC<{ compact?: boolean }> = ({ compact = false }) => (
|
||||||
<div className={compact ? 'mt-2 space-y-1' : 'space-y-0 border border-[rgb(30,31,36)] rounded-lg overflow-hidden'}>
|
<div className={compact ? 'mt-2 space-y-1' : 'space-y-0 border border-[rgb(30,31,36)] rounded-lg overflow-hidden'}>
|
||||||
{compact ? (
|
{compact ? (
|
||||||
@@ -37,7 +75,7 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[rgb(113,113,122)] text-[11px]">Тип детектора</div>
|
<div className="text-[rgb(113,113,122)] text-[11px]">Тип детектора</div>
|
||||||
<div className="text-white text-xs truncate">{detector.type}</div>
|
<div className="text-white text-xs truncate">{displayDetectorTypeLabel}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
@@ -53,13 +91,19 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
|
|||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[rgb(113,113,122)] text-[11px]">Временная метка</div>
|
<div className="text-[rgb(113,113,122)] text-[11px]">Временная метка</div>
|
||||||
<div className="text-white text-xs truncate">Сегодня, 14:30</div>
|
<div className="text-white text-xs truncate">{formattedTimestamp}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[rgb(113,113,122)] text-[11px]">Этаж</div>
|
<div className="text-[rgb(113,113,122)] text-[11px]">Этаж</div>
|
||||||
<div className="text-white text-xs truncate">{detector.floor}</div>
|
<div className="text-white text-xs truncate">{detector.floor}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-[rgb(113,113,122)] text-[11px]">Серийный номер</div>
|
||||||
|
<div className="text-white text-xs truncate">{detector.serial_number}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -70,7 +114,7 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1 p-4">
|
<div className="flex-1 p-4">
|
||||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Тип детектора</div>
|
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Тип детектора</div>
|
||||||
<div className="text-white text-sm">{detector.type}</div>
|
<div className="text-white text-sm">{displayDetectorTypeLabel}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex border-t border-[rgb(30,31,36)]">
|
<div className="flex border-t border-[rgb(30,31,36)]">
|
||||||
@@ -86,10 +130,11 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
|
|||||||
<div className="flex border-t border-[rgb(30,31,36)]">
|
<div className="flex border-t border-[rgb(30,31,36)]">
|
||||||
<div className="flex-1 p-4 border-r border-[rgb(30,31,36)]">
|
<div className="flex-1 p-4 border-r border-[rgb(30,31,36)]">
|
||||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Временная метка</div>
|
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Временная метка</div>
|
||||||
<div className="text-white text-sm">Сегодня, 14:30</div>
|
<div className="text-white text-sm">{formattedTimestamp}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 p-4">
|
<div className="flex-1 p-4">
|
||||||
<div className="text-white text-sm text-right">Вчера</div>
|
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Серийный номер</div>
|
||||||
|
<div className="text-white text-sm">{detector.serial_number}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -121,7 +166,7 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
|
|||||||
</button>
|
</button>
|
||||||
<button className="bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] text-white px-2 py-1 rounded-[8px] text-xs font-medium transition-colors flex items-center gap-1">
|
<button className="bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] text-white px-2 py-1 rounded-[8px] text-xs font-medium transition-colors flex items-center gap-1">
|
||||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4л3 3м6-3а9 9 0 11-18 0 9 9 0 0118 0з" />
|
||||||
</svg>
|
</svg>
|
||||||
История
|
История
|
||||||
</button>
|
</button>
|
||||||
@@ -133,7 +178,7 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<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-0 bg-[#161824] border-r border-gray-700 з-30 w-[454px]" style={{height: 'calc(100% - 73px)', top: '73px'}}>
|
||||||
<div className="h-full overflow-auto p-5">
|
<div className="h-full overflow-auto p-5">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="text-white text-lg font-medium">
|
<h3 className="text-white text-lg font-medium">
|
||||||
@@ -142,13 +187,13 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button className="bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] text-white px-3 py-2 rounded-[10px] text-sm font-medium transition-colors flex items-center gap-2">
|
<button className="bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] text-white px-3 py-2 rounded-[10px] text-sm font-medium transition-colors flex items-center gap-2">
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6м2 5H7а2 2 0 01-2-2V5а2 2 0 012-2h5.586а1 1 0 01.707.293л5.414 5.414а1 1 0 01.293.707V19а2 2 0 01-2 2з" />
|
||||||
</svg>
|
</svg>
|
||||||
Отчет
|
Отчет
|
||||||
</button>
|
</button>
|
||||||
<button className="bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] text-white px-3 py-2 rounded-[10px] text-sm font-medium transition-colors flex items-center gap-2">
|
<button className="bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] text-white px-3 py-2 rounded-[10px] text-sm font-medium transition-colors flex items-center gap-2">
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4л3 3м6-3а9 9 0 11-18 0 9 9 0 0118 0з" />
|
||||||
</svg>
|
</svg>
|
||||||
История
|
История
|
||||||
</button>
|
</button>
|
||||||
@@ -162,7 +207,7 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
|
|||||||
className="absolute top-4 right-4 text-gray-400 hover:text-white transition-colors"
|
className="absolute top-4 right-4 text-gray-400 hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6л12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ interface DetectorType {
|
|||||||
status: string
|
status: string
|
||||||
checked: boolean
|
checked: boolean
|
||||||
type: string
|
type: string
|
||||||
|
detector_type: string
|
||||||
location: string
|
location: string
|
||||||
floor: number
|
floor: number
|
||||||
notifications: Array<{
|
notifications: Array<{
|
||||||
|
|||||||
@@ -1,13 +1,51 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
|
||||||
interface MonitoringProps {
|
interface MonitoringProps {
|
||||||
objectId?: string;
|
objectId?: string;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
|
onSelectModel?: (modelPath: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Monitoring: React.FC<MonitoringProps> = ({ onClose }) => {
|
const Monitoring: React.FC<MonitoringProps> = ({ onClose, onSelectModel }) => {
|
||||||
const [objectImageError, setObjectImageError] = useState(false);
|
const [objectImageError, setObjectImageError] = useState(false);
|
||||||
|
const [models, setModels] = useState<{ title: string; path: string }[]>([]);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(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 (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
@@ -51,13 +89,26 @@ const Monitoring: React.FC<MonitoringProps> = ({ onClose }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{loadError && (
|
||||||
|
<div className="rounded-lg bg-red-600/20 border border-red-600/40 text-red-200 text-xs px-3 py-2">
|
||||||
|
Ошибка загрузки списка моделей: {loadError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
{[1, 2, 3, 4, 5, 6].map((zone) => (
|
{models.length > 0 ? (
|
||||||
<div key={zone} className="flex-1 bg-gray-300 rounded-lg h-[120px] flex items-center justify-center">
|
models.map((model, idx) => (
|
||||||
|
<button
|
||||||
|
key={`${model.path}-${idx}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelectModel?.(model.path)}
|
||||||
|
className="relative flex-1 bg-gray-300 rounded-lg h-[120px] flex items-center justify-center hover:bg-gray-400 transition-colors"
|
||||||
|
title={`Загрузить модель: ${model.title}`}
|
||||||
|
>
|
||||||
<div className="w-full h-full bg-gray-200 rounded flex items-center justify-center">
|
<div className="w-full h-full bg-gray-200 rounded flex items-center justify-center">
|
||||||
<Image
|
<Image
|
||||||
src="/images/test_image.png"
|
src="/images/test_image.png"
|
||||||
alt={`Зона ${zone}`}
|
alt={model.title}
|
||||||
width={120}
|
width={120}
|
||||||
height={120}
|
height={120}
|
||||||
className="max-w-full max-h-full object-contain opacity-50"
|
className="max-w-full max-h-full object-contain opacity-50"
|
||||||
@@ -68,8 +119,18 @@ const Monitoring: React.FC<MonitoringProps> = ({ onClose }) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="absolute bottom-1 left-1 right-1 text-[10px] text-gray-700 bg-white/70 rounded px-2 py-0.5 truncate">
|
||||||
|
{model.title}
|
||||||
</div>
|
</div>
|
||||||
))}
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="col-span-2">
|
||||||
|
<div className="rounded-lg bg-gray-200 text-gray-700 text-xs px-3 py-2 border border-gray-300">
|
||||||
|
Список моделей пуст. Добавьте файлы в assets/big-models или проверьте API /api/big-models/list.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ interface DetectorInfoType {
|
|||||||
object: string
|
object: string
|
||||||
status: string
|
status: string
|
||||||
type: string
|
type: string
|
||||||
|
detector_type: string
|
||||||
location: string
|
location: string
|
||||||
floor: number
|
floor: number
|
||||||
checked: boolean
|
checked: boolean
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ interface DetectorType {
|
|||||||
object: string
|
object: string
|
||||||
status: string
|
status: string
|
||||||
type: string
|
type: string
|
||||||
|
detector_type: string
|
||||||
location: string
|
location: string
|
||||||
floor: number
|
floor: number
|
||||||
checked: boolean
|
checked: boolean
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ interface DetectorType {
|
|||||||
object: string
|
object: string
|
||||||
status: string
|
status: string
|
||||||
type: string
|
type: string
|
||||||
|
detector_type: string
|
||||||
location: string
|
location: string
|
||||||
floor: number
|
floor: number
|
||||||
checked: boolean
|
checked: boolean
|
||||||
@@ -231,8 +232,8 @@ const ReportsList: React.FC<ReportsListProps> = ({ detectorsData }) => {
|
|||||||
<td className="py-4">
|
<td className="py-4">
|
||||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||||
detector.acknowledged
|
detector.acknowledged
|
||||||
? 'bg-green-100 text-green-800'
|
? 'bg-green-600/20 text-green-300 ring-1 ring-green-600/40'
|
||||||
: 'bg-red-100 text-red-800'
|
: 'bg-red-600/20 text-red-300 ring-1 ring-red-600/40'
|
||||||
}`}>
|
}`}>
|
||||||
{detector.acknowledged ? 'Да' : 'Нет'}
|
{detector.acknowledged ? 'Да' : 'Нет'}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -456,7 +456,7 @@ const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="relative w-4 h-4 aspect-[1] p-1 rounded hover:bg-gray-700 focus:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors duration-200"
|
className="!relative !w-8 !h-8 p-1.5 rounded-lg bg-gray-800/60 border border-gray-600/40 shadow-lg hover:shadow-xl hover:bg-gray-700 focus:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all duration-200"
|
||||||
aria-label="Logout"
|
aria-label="Logout"
|
||||||
title="Выйти"
|
title="Выйти"
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -60,7 +60,24 @@ async function refreshAccessToken(token: GoogleToken): Promise<GoogleToken> {
|
|||||||
} catch {
|
} catch {
|
||||||
errorData = { error: errorText }
|
errorData = { error: errorText }
|
||||||
}
|
}
|
||||||
throw new Error(errorData.error || 'Token refresh failed')
|
|
||||||
|
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')
|
||||||
|
return {
|
||||||
|
...token,
|
||||||
|
error: 'RefreshTokenExpired',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Token refresh failed:', errorData.error || 'Token refresh failed')
|
||||||
|
return {
|
||||||
|
...token,
|
||||||
|
error: 'RefreshAccessTokenError',
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const refreshedTokens = await response.json()
|
const refreshedTokens = await response.json()
|
||||||
@@ -176,6 +193,11 @@ export const authOptions: NextAuthOptions = {
|
|||||||
if (token) {
|
if (token) {
|
||||||
session.accessToken = token.accessToken as string
|
session.accessToken = token.accessToken as string
|
||||||
session.refreshToken = token.refreshToken as string
|
session.refreshToken = token.refreshToken as string
|
||||||
|
|
||||||
|
if (token.error === 'RefreshTokenExpired') {
|
||||||
|
session.accessToken = undefined
|
||||||
|
session.refreshToken = undefined
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return session
|
return session
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export interface DetectorType {
|
|||||||
status: string
|
status: string
|
||||||
checked: boolean
|
checked: boolean
|
||||||
type: string
|
type: string
|
||||||
|
detector_type: string
|
||||||
location: string
|
location: string
|
||||||
floor: number
|
floor: number
|
||||||
notifications: NotificationType[]
|
notifications: NotificationType[]
|
||||||
|
|||||||
Reference in New Issue
Block a user