добавлен фильтр количеста вывода записей на странице История тревог

This commit is contained in:
2026-02-03 21:46:20 +03:00
parent 5e58f6ef76
commit 79e4845870
10 changed files with 1209 additions and 89 deletions

View File

@@ -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,
@@ -90,6 +91,7 @@ const DetectorList: React.FC<DetectorListProps> = ({ objectId, selectedDetectors
checked: false,
}))
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"

View File

@@ -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">

View File

@@ -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>
)

View 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

View File

@@ -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}`}>
<g key={`bar-group-${index}`}>
{/* Критический столбец */}
{bar.critical > 0 && (
<>
<rect
x={x}
y={y}
width={barWidth}
height={barHeight}
fill={bar.color}
x={groupX}
y={criticalY}
width={subBarWidth}
height={criticalHeight}
fill="#ef4444"
rx="4"
ry="4"
opacity="0.9"
/>
{/* Тень для глубины */}
<rect
x={x}
y={y}
width={barWidth}
height={barHeight}
x={groupX}
y={criticalY}
width={subBarWidth}
height={criticalHeight}
fill="none"
stroke={bar.color}
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>
)

View 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

View File

@@ -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>

View 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

View File

@@ -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
}

View 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
}