добавлен фильтр количеста вывода записей на странице История тревог
This commit is contained in:
@@ -68,7 +68,7 @@ const DetectorList: React.FC<DetectorListProps> = ({ objectId, selectedDetectors
|
||||
const [detectors, setDetectors] = useState<Detector[]>([])
|
||||
const [searchTerm, setSearchTerm] = useState<string>(initialSearchTerm)
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const itemsPerPage = 20
|
||||
const [itemsPerPage, setItemsPerPage] = useState(10)
|
||||
|
||||
useEffect(() => {
|
||||
const loadDetectors = async () => {
|
||||
@@ -80,6 +80,7 @@ const DetectorList: React.FC<DetectorListProps> = ({ objectId, selectedDetectors
|
||||
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,
|
||||
@@ -89,7 +90,8 @@ const DetectorList: React.FC<DetectorListProps> = ({ objectId, selectedDetectors
|
||||
floor: d.floor,
|
||||
checked: false,
|
||||
}))
|
||||
console.log('[DetectorList] Payload:', payload)
|
||||
console.log('[DetectorList] Payload:', payload)
|
||||
console.log('[DetectorList] Total detectors:', normalized.length)
|
||||
setDetectors(normalized)
|
||||
} catch (e) {
|
||||
console.error('Failed to load detectors:', e)
|
||||
@@ -129,6 +131,30 @@ const DetectorList: React.FC<DetectorListProps> = ({ objectId, selectedDetectors
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Выбор количества элементов */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-400">Показывать:</span>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={itemsPerPage}
|
||||
onChange={(e) => {
|
||||
setItemsPerPage(Number(e.target.value))
|
||||
setCurrentPage(1)
|
||||
}}
|
||||
className="bg-[#161824] text-white px-3 py-2 rounded-lg border border-gray-600 focus:border-blue-500 focus:outline-none text-sm font-medium appearance-none pr-8"
|
||||
style={{ fontFamily: 'Inter, sans-serif' }}
|
||||
>
|
||||
<option value="10">10</option>
|
||||
<option value="20">20</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
<svg className="w-4 h-4 absolute right-2 top-1/2 transform -translate-y-1/2 pointer-events-none text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
|
||||
@@ -151,28 +151,6 @@ const DetectorList: React.FC<DetectorListProps> = ({ objectId, selectedDetectors
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-700">
|
||||
<th className="text-left text-white font-medium py-3 w-12">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedDetectors.length === currentDetectors.length && currentDetectors.length > 0}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
currentDetectors.forEach(detector => {
|
||||
if (!selectedDetectors.includes(detector.detector_id)) {
|
||||
onDetectorSelect(detector.detector_id, true)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
currentDetectors.forEach(detector => {
|
||||
if (selectedDetectors.includes(detector.detector_id)) {
|
||||
onDetectorSelect(detector.detector_id, false)
|
||||
}
|
||||
})
|
||||
}
|
||||
}}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-700 border-gray-600 rounded focus:ring-blue-500 focus:ring-2"
|
||||
/>
|
||||
</th>
|
||||
<th className="text-left text-white font-medium py-3">Детектор</th>
|
||||
<th className="text-left text-white font-medium py-3">Статус</th>
|
||||
<th className="text-left text-white font-medium py-3">Местоположение</th>
|
||||
@@ -185,14 +163,6 @@ const DetectorList: React.FC<DetectorListProps> = ({ objectId, selectedDetectors
|
||||
|
||||
return (
|
||||
<tr key={detector.detector_id} className="border-b border-gray-800">
|
||||
<td className="py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={(e) => onDetectorSelect(detector.detector_id, e.target.checked)}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-700 border-gray-600 rounded focus:ring-blue-500 focus:ring-2"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-3 text-white text-sm">{detector.name}</td>
|
||||
<td className="py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import React from 'react'
|
||||
|
||||
interface ChartDataPoint {
|
||||
value: number
|
||||
critical?: number
|
||||
warning?: number
|
||||
label?: string
|
||||
timestamp?: string
|
||||
}
|
||||
@@ -23,19 +24,30 @@ const AreaChart: React.FC<AreaChartProps> = ({ className = '', data }) => {
|
||||
|
||||
const safeData = (Array.isArray(data) && data.length > 0)
|
||||
? data
|
||||
: Array.from({ length: 7 }, () => ({ value: 0 }))
|
||||
: Array.from({ length: 7 }, () => ({ critical: 0, warning: 0 }))
|
||||
|
||||
const maxVal = Math.max(...safeData.map(d => d.value || 0), 1)
|
||||
const maxVal = Math.max(...safeData.map(d => Math.max(d.critical || 0, d.warning || 0)), 1)
|
||||
const stepX = safeData.length > 1 ? plotWidth / (safeData.length - 1) : plotWidth
|
||||
|
||||
const points = safeData.map((d, i) => {
|
||||
// Точки для критических событий (красная линия)
|
||||
const criticalPoints = safeData.map((d, i) => {
|
||||
const x = margin.left + i * stepX
|
||||
const y = baselineY - (Math.min(d.value || 0, maxVal) / maxVal) * plotHeight
|
||||
const y = baselineY - (Math.min(d.critical || 0, maxVal) / maxVal) * plotHeight
|
||||
return { x, y }
|
||||
})
|
||||
|
||||
const linePath = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
|
||||
const areaPath = `${linePath} L${width - margin.right},${baselineY} L${margin.left},${baselineY} Z`
|
||||
// Точки для предупреждений (оранжевая линия)
|
||||
const warningPoints = safeData.map((d, i) => {
|
||||
const x = margin.left + i * stepX
|
||||
const y = baselineY - (Math.min(d.warning || 0, maxVal) / maxVal) * plotHeight
|
||||
return { x, y }
|
||||
})
|
||||
|
||||
const criticalLinePath = criticalPoints.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
|
||||
const criticalAreaPath = `${criticalLinePath} L${width - margin.right},${baselineY} L${margin.left},${baselineY} Z`
|
||||
|
||||
const warningLinePath = warningPoints.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
|
||||
const warningAreaPath = `${warningLinePath} L${width - margin.right},${baselineY} L${margin.left},${baselineY} Z`
|
||||
|
||||
// Генерируем Y-оси метки
|
||||
const ySteps = 4
|
||||
@@ -59,9 +71,13 @@ const AreaChart: React.FC<AreaChartProps> = ({ className = '', data }) => {
|
||||
<div className={`w-full h-full ${className}`}>
|
||||
<svg className="w-full h-full" viewBox={`0 0 ${width} ${height}`}>
|
||||
<defs>
|
||||
<linearGradient id="areaGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="rgb(37, 99, 235)" stopOpacity="0.3" />
|
||||
<stop offset="100%" stopColor="rgb(37, 99, 235)" stopOpacity="0" />
|
||||
<linearGradient id="criticalGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#ef4444" stopOpacity="0.3" />
|
||||
<stop offset="100%" stopColor="#ef4444" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient id="warningGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#fb923c" stopOpacity="0.3" />
|
||||
<stop offset="100%" stopColor="#fb923c" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
@@ -157,7 +173,7 @@ const AreaChart: React.FC<AreaChartProps> = ({ className = '', data }) => {
|
||||
fontFamily="Arial, sans-serif"
|
||||
transform={`rotate(-90, 20, ${margin.top + plotHeight / 2})`}
|
||||
>
|
||||
Значение
|
||||
Количество
|
||||
</text>
|
||||
|
||||
{/* Подпись оси X */}
|
||||
@@ -172,22 +188,52 @@ const AreaChart: React.FC<AreaChartProps> = ({ className = '', data }) => {
|
||||
Время
|
||||
</text>
|
||||
|
||||
{/* График */}
|
||||
<path d={areaPath} fill="url(#areaGradient)" />
|
||||
<path d={linePath} stroke="rgb(37, 99, 235)" strokeWidth="2.5" fill="none" />
|
||||
{/* График предупреждений (оранжевый) - рисуем первым чтобы был на заднем плане */}
|
||||
<path d={warningAreaPath} fill="url(#warningGradient)" />
|
||||
<path d={warningLinePath} stroke="#fb923c" strokeWidth="2.5" fill="none" />
|
||||
|
||||
{/* Точки данных */}
|
||||
{points.map((p, i) => (
|
||||
{/* Точки данных для предупреждений */}
|
||||
{warningPoints.map((p, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
key={`warning-${i}`}
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r="4"
|
||||
fill="rgb(37, 99, 235)"
|
||||
fill="#fb923c"
|
||||
stroke="rgb(15, 23, 42)"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* График критических событий (красный) - рисуем поверх */}
|
||||
<path d={criticalAreaPath} fill="url(#criticalGradient)" />
|
||||
<path d={criticalLinePath} stroke="#ef4444" strokeWidth="2.5" fill="none" />
|
||||
|
||||
{/* Точки данных для критических */}
|
||||
{criticalPoints.map((p, i) => (
|
||||
<circle
|
||||
key={`critical-${i}`}
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r="4"
|
||||
fill="#ef4444"
|
||||
stroke="rgb(15, 23, 42)"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Легенда */}
|
||||
<g transform={`translate(${width - margin.right - 120}, ${margin.top})`}>
|
||||
<circle cx="6" cy="6" r="4" fill="#ef4444" stroke="rgb(15, 23, 42)" strokeWidth="2" />
|
||||
<text x="18" y="10" fontSize="11" fill="rgb(148, 163, 184)" fontFamily="Arial, sans-serif">
|
||||
Критические
|
||||
</text>
|
||||
|
||||
<circle cx="6" cy="24" r="4" fill="#fb923c" stroke="rgb(15, 23, 42)" strokeWidth="2" />
|
||||
<text x="18" y="28" fontSize="11" fill="rgb(148, 163, 184)" fontFamily="Arial, sans-serif">
|
||||
Предупреждения
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
|
||||
196
frontend/components/dashboard/AreaChart.tsx — копия
Normal file
196
frontend/components/dashboard/AreaChart.tsx — копия
Normal file
@@ -0,0 +1,196 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
interface ChartDataPoint {
|
||||
value: number
|
||||
label?: string
|
||||
timestamp?: string
|
||||
}
|
||||
|
||||
interface AreaChartProps {
|
||||
className?: string
|
||||
data?: ChartDataPoint[]
|
||||
}
|
||||
|
||||
const AreaChart: React.FC<AreaChartProps> = ({ className = '', data }) => {
|
||||
const width = 635
|
||||
const height = 280
|
||||
const margin = { top: 20, right: 30, bottom: 50, left: 60 }
|
||||
const plotWidth = width - margin.left - margin.right
|
||||
const plotHeight = height - margin.top - margin.bottom
|
||||
const baselineY = margin.top + plotHeight
|
||||
|
||||
const safeData = (Array.isArray(data) && data.length > 0)
|
||||
? data
|
||||
: Array.from({ length: 7 }, () => ({ value: 0 }))
|
||||
|
||||
const maxVal = Math.max(...safeData.map(d => d.value || 0), 1)
|
||||
const stepX = safeData.length > 1 ? plotWidth / (safeData.length - 1) : plotWidth
|
||||
|
||||
const points = safeData.map((d, i) => {
|
||||
const x = margin.left + i * stepX
|
||||
const y = baselineY - (Math.min(d.value || 0, maxVal) / maxVal) * plotHeight
|
||||
return { x, y }
|
||||
})
|
||||
|
||||
const linePath = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
|
||||
const areaPath = `${linePath} L${width - margin.right},${baselineY} L${margin.left},${baselineY} Z`
|
||||
|
||||
// Генерируем Y-оси метки
|
||||
const ySteps = 4
|
||||
const yLabels = Array.from({ length: ySteps + 1 }, (_, i) => {
|
||||
const value = (maxVal / ySteps) * (ySteps - i)
|
||||
const y = margin.top + (i * plotHeight) / ySteps
|
||||
return { value: value.toFixed(1), y }
|
||||
})
|
||||
|
||||
// Генерируем X-оси метки (показываем каждую 2-ю или 3-ю точку)
|
||||
const xLabelStep = Math.ceil(safeData.length / 5)
|
||||
const xLabels = safeData
|
||||
.map((d, i) => {
|
||||
const x = margin.left + i * stepX
|
||||
const label = d.label || d.timestamp || `${i + 1}`
|
||||
return { label, x, index: i }
|
||||
})
|
||||
.filter((_, i) => i % xLabelStep === 0 || i === safeData.length - 1)
|
||||
|
||||
return (
|
||||
<div className={`w-full h-full ${className}`}>
|
||||
<svg className="w-full h-full" viewBox={`0 0 ${width} ${height}`}>
|
||||
<defs>
|
||||
<linearGradient id="areaGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="rgb(37, 99, 235)" stopOpacity="0.3" />
|
||||
<stop offset="100%" stopColor="rgb(37, 99, 235)" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* Сетка Y */}
|
||||
{yLabels.map((label, i) => (
|
||||
<line
|
||||
key={`grid-y-${i}`}
|
||||
x1={margin.left}
|
||||
y1={label.y}
|
||||
x2={width - margin.right}
|
||||
y2={label.y}
|
||||
stroke="rgba(148, 163, 184, 0.2)"
|
||||
strokeWidth="1"
|
||||
strokeDasharray="4,4"
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Ось X */}
|
||||
<line
|
||||
x1={margin.left}
|
||||
y1={baselineY}
|
||||
x2={width - margin.right}
|
||||
y2={baselineY}
|
||||
stroke="rgb(148, 163, 184)"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
|
||||
{/* Ось Y */}
|
||||
<line
|
||||
x1={margin.left}
|
||||
y1={margin.top}
|
||||
x2={margin.left}
|
||||
y2={baselineY}
|
||||
stroke="rgb(148, 163, 184)"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
|
||||
{/* Y-оси метки и подписи */}
|
||||
{yLabels.map((label, i) => (
|
||||
<g key={`y-label-${i}`}>
|
||||
<line
|
||||
x1={margin.left - 5}
|
||||
y1={label.y}
|
||||
x2={margin.left}
|
||||
y2={label.y}
|
||||
stroke="rgb(148, 163, 184)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<text
|
||||
x={margin.left - 10}
|
||||
y={label.y + 4}
|
||||
textAnchor="end"
|
||||
fontSize="12"
|
||||
fill="rgb(148, 163, 184)"
|
||||
fontFamily="Arial, sans-serif"
|
||||
>
|
||||
{label.value}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* X-оси метки и подписи */}
|
||||
{xLabels.map((label, i) => (
|
||||
<g key={`x-label-${i}`}>
|
||||
<line
|
||||
x1={label.x}
|
||||
y1={baselineY}
|
||||
x2={label.x}
|
||||
y2={baselineY + 5}
|
||||
stroke="rgb(148, 163, 184)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<text
|
||||
x={label.x}
|
||||
y={baselineY + 20}
|
||||
textAnchor="middle"
|
||||
fontSize="11"
|
||||
fill="rgb(148, 163, 184)"
|
||||
fontFamily="Arial, sans-serif"
|
||||
>
|
||||
{typeof label.label === 'string' ? label.label.substring(0, 10) : `${label.index + 1}`}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* Подпись оси Y */}
|
||||
<text
|
||||
x={20}
|
||||
y={margin.top + plotHeight / 2}
|
||||
textAnchor="middle"
|
||||
fontSize="13"
|
||||
fill="rgb(148, 163, 184)"
|
||||
fontFamily="Arial, sans-serif"
|
||||
transform={`rotate(-90, 20, ${margin.top + plotHeight / 2})`}
|
||||
>
|
||||
Значение
|
||||
</text>
|
||||
|
||||
{/* Подпись оси X */}
|
||||
<text
|
||||
x={margin.left + plotWidth / 2}
|
||||
y={height - 10}
|
||||
textAnchor="middle"
|
||||
fontSize="13"
|
||||
fill="rgb(148, 163, 184)"
|
||||
fontFamily="Arial, sans-serif"
|
||||
>
|
||||
Время
|
||||
</text>
|
||||
|
||||
{/* График */}
|
||||
<path d={areaPath} fill="url(#areaGradient)" />
|
||||
<path d={linePath} stroke="rgb(37, 99, 235)" strokeWidth="2.5" fill="none" />
|
||||
|
||||
{/* Точки данных */}
|
||||
{points.map((p, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r="4"
|
||||
fill="rgb(37, 99, 235)"
|
||||
stroke="rgb(15, 23, 42)"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AreaChart
|
||||
@@ -3,9 +3,9 @@
|
||||
import React from 'react'
|
||||
|
||||
interface ChartDataPoint {
|
||||
value: number
|
||||
critical?: number
|
||||
warning?: number
|
||||
label?: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
interface BarChartProps {
|
||||
@@ -22,10 +22,14 @@ const BarChart: React.FC<BarChartProps> = ({ className = '', data }) => {
|
||||
const baselineY = margin.top + plotHeight
|
||||
|
||||
const barData = (Array.isArray(data) && data.length > 0)
|
||||
? data.map(d => ({ value: d.value, label: d.label || '', color: d.color || 'rgb(37, 99, 235)' }))
|
||||
: Array.from({ length: 12 }, (_, i) => ({ value: 0, label: `${i + 1}`, color: 'rgb(37, 99, 235)' }))
|
||||
? data.map(d => ({
|
||||
critical: d.critical || 0,
|
||||
warning: d.warning || 0,
|
||||
label: d.label || ''
|
||||
}))
|
||||
: Array.from({ length: 12 }, (_, i) => ({ critical: 0, warning: 0, label: `${i + 1}` }))
|
||||
|
||||
const maxVal = Math.max(...barData.map(b => b.value || 0), 1)
|
||||
const maxVal = Math.max(...barData.map(b => (b.critical || 0) + (b.warning || 0)), 1)
|
||||
|
||||
// Генерируем Y-оси метки
|
||||
const ySteps = 4
|
||||
@@ -141,7 +145,7 @@ const BarChart: React.FC<BarChartProps> = ({ className = '', data }) => {
|
||||
fontFamily="Arial, sans-serif"
|
||||
transform={`rotate(-90, 20, ${margin.top + plotHeight / 2})`}
|
||||
>
|
||||
Значение
|
||||
Количество
|
||||
</text>
|
||||
|
||||
{/* Подпись оси X */}
|
||||
@@ -156,42 +160,98 @@ const BarChart: React.FC<BarChartProps> = ({ className = '', data }) => {
|
||||
Период
|
||||
</text>
|
||||
|
||||
{/* Столбцы */}
|
||||
{/* Столбцы - сгруппированные по критическим и предупреждениям */}
|
||||
{barData.map((bar, index) => {
|
||||
const barWidth = Math.max(30, plotWidth / barData.length - 8)
|
||||
const barSpacing = (plotWidth - barWidth * barData.length) / (barData.length - 1 || 1)
|
||||
const x = margin.left + index * (barWidth + barSpacing)
|
||||
const barHeight = (bar.value / maxVal) * plotHeight
|
||||
const y = baselineY - barHeight
|
||||
const groupX = margin.left + index * (barWidth + barSpacing)
|
||||
|
||||
const totalValue = (bar.critical || 0) + (bar.warning || 0)
|
||||
|
||||
// Ширина каждого подстолбца
|
||||
const subBarWidth = barWidth / 2 - 2
|
||||
|
||||
// Критический столбец (красный)
|
||||
const criticalHeight = (bar.critical / maxVal) * plotHeight
|
||||
const criticalY = baselineY - criticalHeight
|
||||
|
||||
// Предупреждение столбец (оранжевый)
|
||||
const warningHeight = (bar.warning / maxVal) * plotHeight
|
||||
const warningY = baselineY - warningHeight
|
||||
|
||||
return (
|
||||
<g key={`bar-${index}`}>
|
||||
<rect
|
||||
x={x}
|
||||
y={y}
|
||||
width={barWidth}
|
||||
height={barHeight}
|
||||
fill={bar.color}
|
||||
rx="4"
|
||||
ry="4"
|
||||
opacity="0.9"
|
||||
/>
|
||||
{/* Тень для глубины */}
|
||||
<rect
|
||||
x={x}
|
||||
y={y}
|
||||
width={barWidth}
|
||||
height={barHeight}
|
||||
fill="none"
|
||||
stroke={bar.color}
|
||||
strokeWidth="1"
|
||||
rx="4"
|
||||
ry="4"
|
||||
opacity="0.3"
|
||||
/>
|
||||
<g key={`bar-group-${index}`}>
|
||||
{/* Критический столбец */}
|
||||
{bar.critical > 0 && (
|
||||
<>
|
||||
<rect
|
||||
x={groupX}
|
||||
y={criticalY}
|
||||
width={subBarWidth}
|
||||
height={criticalHeight}
|
||||
fill="#ef4444"
|
||||
rx="4"
|
||||
ry="4"
|
||||
opacity="0.9"
|
||||
/>
|
||||
<rect
|
||||
x={groupX}
|
||||
y={criticalY}
|
||||
width={subBarWidth}
|
||||
height={criticalHeight}
|
||||
fill="none"
|
||||
stroke="#ef4444"
|
||||
strokeWidth="1"
|
||||
rx="4"
|
||||
ry="4"
|
||||
opacity="0.3"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Предупреждение столбец */}
|
||||
{bar.warning > 0 && (
|
||||
<>
|
||||
<rect
|
||||
x={groupX + subBarWidth + 4}
|
||||
y={warningY}
|
||||
width={subBarWidth}
|
||||
height={warningHeight}
|
||||
fill="#fb923c"
|
||||
rx="4"
|
||||
ry="4"
|
||||
opacity="0.9"
|
||||
/>
|
||||
<rect
|
||||
x={groupX + subBarWidth + 4}
|
||||
y={warningY}
|
||||
width={subBarWidth}
|
||||
height={warningHeight}
|
||||
fill="none"
|
||||
stroke="#fb923c"
|
||||
strokeWidth="1"
|
||||
rx="4"
|
||||
ry="4"
|
||||
opacity="0.3"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Легенда */}
|
||||
<g transform={`translate(${width - margin.right - 120}, ${margin.top})`}>
|
||||
<rect x="0" y="0" width="12" height="12" fill="#ef4444" rx="2" />
|
||||
<text x="18" y="10" fontSize="11" fill="rgb(148, 163, 184)" fontFamily="Arial, sans-serif">
|
||||
Критические
|
||||
</text>
|
||||
|
||||
<rect x="0" y="18" width="12" height="12" fill="#fb923c" rx="2" />
|
||||
<text x="18" y="28" fontSize="11" fill="rgb(148, 163, 184)" fontFamily="Arial, sans-serif">
|
||||
Предупреждения
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
|
||||
200
frontend/components/dashboard/BarChart.tsx — копия
Normal file
200
frontend/components/dashboard/BarChart.tsx — копия
Normal file
@@ -0,0 +1,200 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
interface ChartDataPoint {
|
||||
value: number
|
||||
label?: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
interface BarChartProps {
|
||||
className?: string
|
||||
data?: ChartDataPoint[]
|
||||
}
|
||||
|
||||
const BarChart: React.FC<BarChartProps> = ({ className = '', data }) => {
|
||||
const width = 635
|
||||
const height = 280
|
||||
const margin = { top: 20, right: 30, bottom: 50, left: 60 }
|
||||
const plotWidth = width - margin.left - margin.right
|
||||
const plotHeight = height - margin.top - margin.bottom
|
||||
const baselineY = margin.top + plotHeight
|
||||
|
||||
const barData = (Array.isArray(data) && data.length > 0)
|
||||
? data.map(d => ({ value: d.value, label: d.label || '', color: d.color || 'rgb(37, 99, 235)' }))
|
||||
: Array.from({ length: 12 }, (_, i) => ({ value: 0, label: `${i + 1}`, color: 'rgb(37, 99, 235)' }))
|
||||
|
||||
const maxVal = Math.max(...barData.map(b => b.value || 0), 1)
|
||||
|
||||
// Генерируем Y-оси метки
|
||||
const ySteps = 4
|
||||
const yLabels = Array.from({ length: ySteps + 1 }, (_, i) => {
|
||||
const value = (maxVal / ySteps) * (ySteps - i)
|
||||
const y = margin.top + (i * plotHeight) / ySteps
|
||||
return { value: value.toFixed(1), y }
|
||||
})
|
||||
|
||||
// Генерируем X-оси метки (показываем каждую 2-ю или 3-ю)
|
||||
const xLabelStep = Math.ceil(barData.length / 8)
|
||||
const xLabels = barData
|
||||
.map((d, i) => {
|
||||
const barWidth = Math.max(30, plotWidth / barData.length - 8)
|
||||
const barSpacing = (plotWidth - barWidth * barData.length) / (barData.length - 1 || 1)
|
||||
const x = margin.left + i * (barWidth + barSpacing) + barWidth / 2
|
||||
return { label: d.label || `${i + 1}`, x, index: i }
|
||||
})
|
||||
.filter((_, i) => i % xLabelStep === 0 || i === barData.length - 1)
|
||||
|
||||
return (
|
||||
<div className={`w-full h-full ${className}`}>
|
||||
<svg className="w-full h-full" viewBox={`0 0 ${width} ${height}`}>
|
||||
{/* Сетка Y */}
|
||||
{yLabels.map((label, i) => (
|
||||
<line
|
||||
key={`grid-y-${i}`}
|
||||
x1={margin.left}
|
||||
y1={label.y}
|
||||
x2={width - margin.right}
|
||||
y2={label.y}
|
||||
stroke="rgba(148, 163, 184, 0.2)"
|
||||
strokeWidth="1"
|
||||
strokeDasharray="4,4"
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Ось X */}
|
||||
<line
|
||||
x1={margin.left}
|
||||
y1={baselineY}
|
||||
x2={width - margin.right}
|
||||
y2={baselineY}
|
||||
stroke="rgb(148, 163, 184)"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
|
||||
{/* Ось Y */}
|
||||
<line
|
||||
x1={margin.left}
|
||||
y1={margin.top}
|
||||
x2={margin.left}
|
||||
y2={baselineY}
|
||||
stroke="rgb(148, 163, 184)"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
|
||||
{/* Y-оси метки и подписи */}
|
||||
{yLabels.map((label, i) => (
|
||||
<g key={`y-label-${i}`}>
|
||||
<line
|
||||
x1={margin.left - 5}
|
||||
y1={label.y}
|
||||
x2={margin.left}
|
||||
y2={label.y}
|
||||
stroke="rgb(148, 163, 184)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<text
|
||||
x={margin.left - 10}
|
||||
y={label.y + 4}
|
||||
textAnchor="end"
|
||||
fontSize="12"
|
||||
fill="rgb(148, 163, 184)"
|
||||
fontFamily="Arial, sans-serif"
|
||||
>
|
||||
{label.value}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* X-оси метки и подписи */}
|
||||
{xLabels.map((label, i) => (
|
||||
<g key={`x-label-${i}`}>
|
||||
<line
|
||||
x1={label.x}
|
||||
y1={baselineY}
|
||||
x2={label.x}
|
||||
y2={baselineY + 5}
|
||||
stroke="rgb(148, 163, 184)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<text
|
||||
x={label.x}
|
||||
y={baselineY + 20}
|
||||
textAnchor="middle"
|
||||
fontSize="11"
|
||||
fill="rgb(148, 163, 184)"
|
||||
fontFamily="Arial, sans-serif"
|
||||
>
|
||||
{typeof label.label === 'string' ? label.label.substring(0, 8) : `${label.index + 1}`}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* Подпись оси Y */}
|
||||
<text
|
||||
x={20}
|
||||
y={margin.top + plotHeight / 2}
|
||||
textAnchor="middle"
|
||||
fontSize="13"
|
||||
fill="rgb(148, 163, 184)"
|
||||
fontFamily="Arial, sans-serif"
|
||||
transform={`rotate(-90, 20, ${margin.top + plotHeight / 2})`}
|
||||
>
|
||||
Значение
|
||||
</text>
|
||||
|
||||
{/* Подпись оси X */}
|
||||
<text
|
||||
x={margin.left + plotWidth / 2}
|
||||
y={height - 10}
|
||||
textAnchor="middle"
|
||||
fontSize="13"
|
||||
fill="rgb(148, 163, 184)"
|
||||
fontFamily="Arial, sans-serif"
|
||||
>
|
||||
Период
|
||||
</text>
|
||||
|
||||
{/* Столбцы */}
|
||||
{barData.map((bar, index) => {
|
||||
const barWidth = Math.max(30, plotWidth / barData.length - 8)
|
||||
const barSpacing = (plotWidth - barWidth * barData.length) / (barData.length - 1 || 1)
|
||||
const x = margin.left + index * (barWidth + barSpacing)
|
||||
const barHeight = (bar.value / maxVal) * plotHeight
|
||||
const y = baselineY - barHeight
|
||||
|
||||
return (
|
||||
<g key={`bar-${index}`}>
|
||||
<rect
|
||||
x={x}
|
||||
y={y}
|
||||
width={barWidth}
|
||||
height={barHeight}
|
||||
fill={bar.color}
|
||||
rx="4"
|
||||
ry="4"
|
||||
opacity="0.9"
|
||||
/>
|
||||
{/* Тень для глубины */}
|
||||
<rect
|
||||
x={x}
|
||||
y={y}
|
||||
width={barWidth}
|
||||
height={barHeight}
|
||||
fill="none"
|
||||
stroke={bar.color}
|
||||
strokeWidth="1"
|
||||
rx="4"
|
||||
ry="4"
|
||||
opacity="0.3"
|
||||
/>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default BarChart
|
||||
@@ -8,7 +8,7 @@ import useNavigationStore from '../../app/store/navigationStore'
|
||||
import ChartCard from './ChartCard'
|
||||
import AreaChart from './AreaChart'
|
||||
import BarChart from './BarChart'
|
||||
import { aggregateChartDataByDays } from '../../lib/chartDataAggregator'
|
||||
import { aggregateChartDataByDays, aggregateAlertsBySeverity } from '../../lib/chartDataAggregator'
|
||||
|
||||
const Dashboard: React.FC = () => {
|
||||
const router = useRouter()
|
||||
@@ -153,10 +153,10 @@ const Dashboard: React.FC = () => {
|
||||
setSelectedTablePeriod(period)
|
||||
}
|
||||
|
||||
// Агрегируем данные графика в зависимости от периода
|
||||
// Агрегируем данные графика в зависимости от периода с разделением по severity
|
||||
const chartData = useMemo(() => {
|
||||
return aggregateChartDataByDays(rawChartData, selectedChartPeriod)
|
||||
}, [rawChartData, selectedChartPeriod])
|
||||
return aggregateAlertsBySeverity(dashboardAlerts, selectedChartPeriod)
|
||||
}, [dashboardAlerts, selectedChartPeriod])
|
||||
|
||||
const interSemiboldStyle = { fontFamily: 'Inter, sans-serif', fontWeight: 600 }
|
||||
const interRegularStyle = { fontFamily: 'Inter, sans-serif', fontWeight: 400 }
|
||||
@@ -249,7 +249,7 @@ const Dashboard: React.FC = () => {
|
||||
<ChartCard
|
||||
title="Статистика"
|
||||
>
|
||||
<BarChart data={chartData?.map((d: any) => ({ value: d.value, label: d.label }))} />
|
||||
<BarChart data={chartData} />
|
||||
</ChartCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
368
frontend/components/dashboard/Dashboard.tsx — копия 3
Normal file
368
frontend/components/dashboard/Dashboard.tsx — копия 3
Normal file
@@ -0,0 +1,368 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useState, useMemo } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Sidebar from '../ui/Sidebar'
|
||||
import AnimatedBackground from '../ui/AnimatedBackground'
|
||||
import useNavigationStore from '../../app/store/navigationStore'
|
||||
import ChartCard from './ChartCard'
|
||||
import AreaChart from './AreaChart'
|
||||
import BarChart from './BarChart'
|
||||
import { aggregateChartDataByDays } from '../../lib/chartDataAggregator'
|
||||
|
||||
const Dashboard: React.FC = () => {
|
||||
const router = useRouter()
|
||||
const { currentObject, setCurrentSubmenu, closeMonitoring, closeFloorNavigation, closeNotifications, navigateToSensor } = useNavigationStore()
|
||||
const objectTitle = currentObject?.title
|
||||
|
||||
const [dashboardAlerts, setDashboardAlerts] = useState<any[]>([])
|
||||
const [rawChartData, setRawChartData] = 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(() => {
|
||||
const loadDashboard = async () => {
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.append('time_period', selectedChartPeriod)
|
||||
|
||||
const res = await fetch(`/api/get-dashboard?${params.toString()}`, { cache: 'no-store' })
|
||||
if (!res.ok) return
|
||||
const payload = await res.json()
|
||||
console.log('[Dashboard] GET /api/get-dashboard', { status: res.status, payload })
|
||||
|
||||
let tableData = payload?.data?.table_data ?? []
|
||||
tableData = Array.isArray(tableData) ? tableData : []
|
||||
|
||||
if (objectTitle) {
|
||||
tableData = tableData.filter((a: any) => a.object === objectTitle)
|
||||
}
|
||||
|
||||
if (selectedSensorType && selectedSensorType !== '') {
|
||||
tableData = tableData.filter((a: any) => {
|
||||
return a.detector_type?.toLowerCase() === selectedSensorType.toLowerCase()
|
||||
})
|
||||
}
|
||||
|
||||
setDashboardAlerts(tableData as any[])
|
||||
|
||||
const cd = Array.isArray(payload?.data?.chart_data) ? payload.data.chart_data : []
|
||||
setRawChartData(cd as any[])
|
||||
} catch (e) {
|
||||
console.error('Failed to load dashboard:', e)
|
||||
}
|
||||
}
|
||||
loadDashboard()
|
||||
}, [objectTitle, selectedChartPeriod, selectedSensorType])
|
||||
|
||||
// Отдельный эффект для загрузки таблицы по выбранному периоду
|
||||
useEffect(() => {
|
||||
const loadTableData = async () => {
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.append('time_period', selectedTablePeriod)
|
||||
|
||||
const res = await fetch(`/api/get-dashboard?${params.toString()}`, { cache: 'no-store' })
|
||||
if (!res.ok) return
|
||||
const payload = await res.json()
|
||||
console.log('[Dashboard] GET /api/get-dashboard (table)', { status: res.status, payload })
|
||||
|
||||
let tableData = payload?.data?.table_data ?? []
|
||||
tableData = Array.isArray(tableData) ? tableData : []
|
||||
|
||||
if (objectTitle) {
|
||||
tableData = tableData.filter((a: any) => a.object === objectTitle)
|
||||
}
|
||||
|
||||
if (selectedSensorType && selectedSensorType !== '') {
|
||||
tableData = tableData.filter((a: any) => {
|
||||
return a.detector_type?.toLowerCase() === selectedSensorType.toLowerCase()
|
||||
})
|
||||
}
|
||||
|
||||
setDashboardAlerts(tableData as any[])
|
||||
} catch (e) {
|
||||
console.error('Failed to load table data:', e)
|
||||
}
|
||||
}
|
||||
loadTableData()
|
||||
}, [objectTitle, selectedTablePeriod, selectedSensorType])
|
||||
|
||||
const handleBackClick = () => {
|
||||
router.push('/objects')
|
||||
}
|
||||
|
||||
const filteredAlerts = dashboardAlerts.filter((alert: any) => {
|
||||
if (selectedSensorType === '') return true
|
||||
return alert.detector_type?.toLowerCase() === selectedSensorType.toLowerCase()
|
||||
})
|
||||
|
||||
// Статусы
|
||||
const statusCounts = filteredAlerts.reduce((acc: { critical: number; warning: number; normal: number }, a: any) => {
|
||||
if (a.severity === 'critical') acc.critical++
|
||||
else if (a.severity === 'warning') acc.warning++
|
||||
else acc.normal++
|
||||
return acc
|
||||
}, { critical: 0, warning: 0, normal: 0 })
|
||||
|
||||
const handleNavigationClick = () => {
|
||||
closeMonitoring()
|
||||
closeFloorNavigation()
|
||||
closeNotifications()
|
||||
setCurrentSubmenu(null)
|
||||
router.push('/navigation')
|
||||
}
|
||||
|
||||
const handleGoTo3D = async (alert: any, viewType: 'building' | 'floor') => {
|
||||
// Используем alert.name как идентификатор датчика (например, "GA-11")
|
||||
const sensorId = alert.serial_number || alert.name
|
||||
|
||||
if (!sensorId) {
|
||||
console.warn('[Dashboard] Alert missing sensor identifier:', alert)
|
||||
return
|
||||
}
|
||||
|
||||
const sensorSerialNumber = await navigateToSensor(
|
||||
sensorId,
|
||||
alert.floor || null,
|
||||
viewType
|
||||
)
|
||||
|
||||
if (sensorSerialNumber) {
|
||||
// Переходим на страницу навигации с параметром focusSensorId
|
||||
router.push(`/navigation?focusSensorId=${encodeURIComponent(sensorSerialNumber)}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSensorTypeChange = (sensorType: string) => {
|
||||
setSelectedSensorType(sensorType)
|
||||
}
|
||||
|
||||
const handleChartPeriodChange = (period: string) => {
|
||||
setSelectedChartPeriod(period)
|
||||
}
|
||||
|
||||
const handleTablePeriodChange = (period: string) => {
|
||||
setSelectedTablePeriod(period)
|
||||
}
|
||||
|
||||
// Агрегируем данные графика в зависимости от периода
|
||||
const chartData = useMemo(() => {
|
||||
return aggregateChartDataByDays(rawChartData, selectedChartPeriod)
|
||||
}, [rawChartData, selectedChartPeriod])
|
||||
|
||||
const interSemiboldStyle = { fontFamily: 'Inter, sans-serif', fontWeight: 600 }
|
||||
const interRegularStyle = { fontFamily: 'Inter, sans-serif', fontWeight: 400 }
|
||||
|
||||
return (
|
||||
<div className="relative flex h-screen bg-[#0e111a] overflow-hidden">
|
||||
<AnimatedBackground />
|
||||
<div className="relative z-20">
|
||||
<Sidebar
|
||||
activeItem={1} // Dashboard
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 flex-1 flex flex-col">
|
||||
<header className="bg-[#161824] border-b border-gray-700 px-6 py-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={handleBackClick}
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
aria-label="Назад к объектам"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<nav className="flex items-center gap-2 text-sm">
|
||||
<span className="text-gray-400">Объекты</span>
|
||||
<span className="text-gray-600">/</span>
|
||||
<span className="text-white">{objectTitle || 'Объект'}</span>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 p-6 overflow-auto">
|
||||
<div className="mb-6">
|
||||
<h1 style={interSemiboldStyle} className="text-white text-2xl mb-6">{objectTitle || 'Объект'}</h1>
|
||||
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="relative">
|
||||
<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"
|
||||
>
|
||||
{sensorTypes.map((type) => (
|
||||
<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" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 ml-auto">
|
||||
<button
|
||||
onClick={handleNavigationClick}
|
||||
className="rounded-[10px] px-4 py-[18px] bg-gray-600 text-gray-300 hover:bg-[rgb(22,24,36)] hover:text-white transition-colors"
|
||||
>
|
||||
<span className="text-sm font-medium">Навигация</span>
|
||||
</button>
|
||||
|
||||
<div className="relative">
|
||||
<select
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Карты-графики */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-[18px]">
|
||||
<ChartCard
|
||||
title="Показатель"
|
||||
>
|
||||
<AreaChart data={chartData} />
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard
|
||||
title="Статистика"
|
||||
>
|
||||
<BarChart data={chartData?.map((d: any) => ({ value: d.value, label: d.label }))} />
|
||||
</ChartCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Список детекторов */}
|
||||
<div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 style={interSemiboldStyle} className="text-white text-2xl">Тренды</h2>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={selectedTablePeriod}
|
||||
onChange={(e) => handleTablePeriodChange(e.target.value)}
|
||||
className="bg-[#161824] rounded-lg px-3 py-2 flex items-center gap-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="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" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Таблица */}
|
||||
<div className="bg-[#161824] rounded-[20px] p-6">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-700">
|
||||
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Детектор</th>
|
||||
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Сообщение</th>
|
||||
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Серьезность</th>
|
||||
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Дата</th>
|
||||
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Решен</th>
|
||||
<th style={interSemiboldStyle} className="text-center text-white text-sm py-3">3D Вид</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredAlerts.map((alert: any) => (
|
||||
<tr key={alert.id} className="border-b border-gray-800">
|
||||
<td style={interRegularStyle} className="py-3 text-white text-sm">{alert.name}</td>
|
||||
<td style={interRegularStyle} className="py-3 text-gray-300 text-sm">{alert.message}</td>
|
||||
<td className="py-3">
|
||||
<span style={interRegularStyle} className={`text-sm ${alert.severity === 'critical' ? 'text-red-500' : alert.severity === 'warning' ? 'text-orange-500' : 'text-green-500'}`}>
|
||||
{alert.severity === 'critical' ? 'Критическое' : alert.severity === 'warning' ? 'Предупреждение' : 'Норма'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={interRegularStyle} className="py-3 text-gray-400 text-sm">{new Date(alert.created_at).toLocaleString()}</td>
|
||||
<td className="py-3">
|
||||
{alert.resolved ? (
|
||||
<span style={interRegularStyle} className="text-sm text-green-500">Да</span>
|
||||
) : (
|
||||
<span style={interRegularStyle} className="text-sm text-gray-500">Нет</span>
|
||||
)
|
||||
}
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<button
|
||||
onClick={() => handleGoTo3D(alert, 'building')}
|
||||
className="p-1.5 rounded hover:bg-blue-600/20 transition-colors group"
|
||||
title="Показать на общей модели"
|
||||
>
|
||||
<img src="/icons/Building3D.png" alt="Здание" className="w-5 h-5 opacity-70 group-hover:opacity-100" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleGoTo3D(alert, 'floor')}
|
||||
className="p-1.5 rounded hover:bg-blue-600/20 transition-colors group"
|
||||
title="Показать на этаже"
|
||||
>
|
||||
<img
|
||||
src="/icons/Floor3D.png"
|
||||
alt="Этаж"
|
||||
className="w-5 h-5 opacity-70 group-hover:opacity-100"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Статистика */}
|
||||
<div className="mt-6 grid grid-cols-4 gap-4">
|
||||
<div className="text-center">
|
||||
<div style={interSemiboldStyle} className="text-2xl text-white">{filteredAlerts.length}</div>
|
||||
<div style={interRegularStyle} className="text-sm text-gray-400">Всего</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div style={interSemiboldStyle} className="text-2xl text-green-500">{statusCounts.normal}</div>
|
||||
<div style={interRegularStyle} className="text-sm text-gray-400">Норма</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div style={interSemiboldStyle} className="text-2xl text-orange-500">{statusCounts.warning}</div>
|
||||
<div style={interRegularStyle} className="text-sm text-gray-400">Предупреждения</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div style={interSemiboldStyle} className="text-2xl text-red-500">{statusCounts.critical}</div>
|
||||
<div style={interRegularStyle} className="text-sm text-gray-400">Критические</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Dashboard
|
||||
@@ -158,3 +158,97 @@ export function aggregateChartDataByDaysAverage(
|
||||
|
||||
return dailyData
|
||||
}
|
||||
|
||||
/**
|
||||
* Интерфейс для данных с разделением по типам событий
|
||||
*/
|
||||
export interface SeverityChartDataPoint {
|
||||
timestamp: string
|
||||
critical: number
|
||||
warning: number
|
||||
label?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Агрегирует данные тревог по дням с разделением по severity
|
||||
* @param alerts - Массив тревог с полями timestamp и severity
|
||||
* @param timePeriod - Период в часах ('24', '72', '168', '720')
|
||||
* @returns Агрегированные данные с разделением по critical/warning
|
||||
*/
|
||||
export function aggregateAlertsBySeverity(
|
||||
alerts: Array<{ timestamp?: string; created_at?: string; severity?: string }>,
|
||||
timePeriod: string
|
||||
): SeverityChartDataPoint[] {
|
||||
if (!Array.isArray(alerts) || alerts.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Группируем по дням
|
||||
const dailyMap = new Map<string, { critical: number; warning: number; date: Date }>()
|
||||
|
||||
alerts.forEach(alert => {
|
||||
const timestampField = alert.timestamp || alert.created_at
|
||||
if (!timestampField) return
|
||||
|
||||
const date = new Date(timestampField)
|
||||
const dateKey = date.toISOString().split('T')[0]
|
||||
|
||||
if (!dailyMap.has(dateKey)) {
|
||||
dailyMap.set(dateKey, { critical: 0, warning: 0, date })
|
||||
}
|
||||
|
||||
const entry = dailyMap.get(dateKey)!
|
||||
|
||||
if (alert.severity === 'critical') {
|
||||
entry.critical += 1
|
||||
} else if (alert.severity === 'warning') {
|
||||
entry.warning += 1
|
||||
}
|
||||
})
|
||||
|
||||
// Преобразуем в массив и сортируем по дате
|
||||
const dailyData = Array.from(dailyMap.entries())
|
||||
.sort((a, b) => new Date(a[0]).getTime() - new Date(b[0]).getTime())
|
||||
.map(([dateKey, data]) => {
|
||||
const date = new Date(dateKey)
|
||||
|
||||
// Форматируем подпись в зависимости от периода
|
||||
let label = ''
|
||||
if (timePeriod === '24') {
|
||||
// День - показываем время
|
||||
label = date.toLocaleDateString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit'
|
||||
})
|
||||
} else if (timePeriod === '72') {
|
||||
// 3 дня - показываем день недели и дату
|
||||
label = date.toLocaleDateString('ru-RU', {
|
||||
weekday: 'short',
|
||||
day: '2-digit',
|
||||
month: '2-digit'
|
||||
})
|
||||
} else if (timePeriod === '168') {
|
||||
// Неделя - показываем день недели
|
||||
label = date.toLocaleDateString('ru-RU', {
|
||||
weekday: 'short',
|
||||
day: '2-digit'
|
||||
})
|
||||
} else if (timePeriod === '720') {
|
||||
// Месяц - показываем дату
|
||||
label = date.toLocaleDateString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
timestamp: date.toISOString(),
|
||||
critical: data.critical,
|
||||
warning: data.warning,
|
||||
label
|
||||
}
|
||||
})
|
||||
|
||||
return dailyData
|
||||
}
|
||||
|
||||
160
frontend/lib/chartDataAggregator — копия.ts
Normal file
160
frontend/lib/chartDataAggregator — копия.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Утилита для агрегации часовых данных в дневные
|
||||
*/
|
||||
|
||||
export interface ChartDataPoint {
|
||||
timestamp: string
|
||||
value: number
|
||||
label?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Агрегирует часовые данные в дневные на основе периода
|
||||
* @param hourlyData - Массив часовых данных
|
||||
* @param timePeriod - Период в часах ('24', '72', '168', '720')
|
||||
* @returns Агрегированные дневные данные
|
||||
*/
|
||||
export function aggregateChartDataByDays(
|
||||
hourlyData: ChartDataPoint[],
|
||||
timePeriod: string
|
||||
): ChartDataPoint[] {
|
||||
if (!Array.isArray(hourlyData) || hourlyData.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Для периода в 24 часа - возвращаем часовые данные как есть
|
||||
if (timePeriod === '24') {
|
||||
return hourlyData.map(d => ({
|
||||
...d,
|
||||
label: d.label || new Date(d.timestamp).toLocaleTimeString('ru-RU', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
// Для остальных периодов - агрегируем по дням
|
||||
const dailyMap = new Map<string, { sum: number; count: number; date: Date }>()
|
||||
|
||||
hourlyData.forEach(point => {
|
||||
const date = new Date(point.timestamp)
|
||||
// Получаем дату в формате YYYY-MM-DD
|
||||
const dateKey = date.toISOString().split('T')[0]
|
||||
|
||||
if (!dailyMap.has(dateKey)) {
|
||||
dailyMap.set(dateKey, { sum: 0, count: 0, date })
|
||||
}
|
||||
|
||||
const entry = dailyMap.get(dateKey)!
|
||||
entry.sum += point.value
|
||||
entry.count += 1
|
||||
})
|
||||
|
||||
// Преобразуем в массив и сортируем по дате
|
||||
const dailyData = Array.from(dailyMap.entries())
|
||||
.sort((a, b) => new Date(a[0]).getTime() - new Date(b[0]).getTime())
|
||||
.map(([dateKey, data]) => {
|
||||
const date = new Date(dateKey)
|
||||
|
||||
// Форматируем подпись в зависимости от периода
|
||||
let label = ''
|
||||
if (timePeriod === '72') {
|
||||
// 3 дня - показываем день недели и дату
|
||||
label = date.toLocaleDateString('ru-RU', {
|
||||
weekday: 'short',
|
||||
day: '2-digit',
|
||||
month: '2-digit'
|
||||
})
|
||||
} else if (timePeriod === '168') {
|
||||
// Неделя - показываем день недели
|
||||
label = date.toLocaleDateString('ru-RU', {
|
||||
weekday: 'short',
|
||||
day: '2-digit'
|
||||
})
|
||||
} else if (timePeriod === '720') {
|
||||
// Месяц - показываем дату
|
||||
label = date.toLocaleDateString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
timestamp: date.toISOString(),
|
||||
value: data.sum,
|
||||
label
|
||||
}
|
||||
})
|
||||
|
||||
return dailyData
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает среднее значение за день (для альтернативной агрегации)
|
||||
*/
|
||||
export function aggregateChartDataByDaysAverage(
|
||||
hourlyData: ChartDataPoint[],
|
||||
timePeriod: string
|
||||
): ChartDataPoint[] {
|
||||
if (!Array.isArray(hourlyData) || hourlyData.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (timePeriod === '24') {
|
||||
return hourlyData.map(d => ({
|
||||
...d,
|
||||
label: d.label || new Date(d.timestamp).toLocaleTimeString('ru-RU', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
const dailyMap = new Map<string, { sum: number; count: number; date: Date }>()
|
||||
|
||||
hourlyData.forEach(point => {
|
||||
const date = new Date(point.timestamp)
|
||||
const dateKey = date.toISOString().split('T')[0]
|
||||
|
||||
if (!dailyMap.has(dateKey)) {
|
||||
dailyMap.set(dateKey, { sum: 0, count: 0, date })
|
||||
}
|
||||
|
||||
const entry = dailyMap.get(dateKey)!
|
||||
entry.sum += point.value
|
||||
entry.count += 1
|
||||
})
|
||||
|
||||
const dailyData = Array.from(dailyMap.entries())
|
||||
.sort((a, b) => new Date(a[0]).getTime() - new Date(b[0]).getTime())
|
||||
.map(([dateKey, data]) => {
|
||||
const date = new Date(dateKey)
|
||||
|
||||
let label = ''
|
||||
if (timePeriod === '72') {
|
||||
label = date.toLocaleDateString('ru-RU', {
|
||||
weekday: 'short',
|
||||
day: '2-digit',
|
||||
month: '2-digit'
|
||||
})
|
||||
} else if (timePeriod === '168') {
|
||||
label = date.toLocaleDateString('ru-RU', {
|
||||
weekday: 'short',
|
||||
day: '2-digit'
|
||||
})
|
||||
} else if (timePeriod === '720') {
|
||||
label = date.toLocaleDateString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
timestamp: date.toISOString(),
|
||||
value: Math.round(data.sum / data.count), // Среднее значение
|
||||
label
|
||||
}
|
||||
})
|
||||
|
||||
return dailyData
|
||||
}
|
||||
Reference in New Issue
Block a user