mirror of
https://github.com/ferdzo/iotDashboard.git
synced 2026-04-05 17:16:26 +00:00
Introduced GPT/AI service, added dashboard widgets, drag and drop, export import.
This commit is contained in:
@@ -87,3 +87,79 @@
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* React Grid Layout Overrides */
|
||||
.react-grid-layout {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.react-grid-item {
|
||||
transition: all 200ms ease;
|
||||
transition-property: left, top, width, height;
|
||||
}
|
||||
|
||||
.react-grid-item img {
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.react-grid-item > .react-resizable-handle {
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.react-grid-item > .react-resizable-handle::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: 3px;
|
||||
bottom: 3px;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-right: 2px solid hsl(var(--bc) / 0.4);
|
||||
border-bottom: 2px solid hsl(var(--bc) / 0.4);
|
||||
}
|
||||
|
||||
.react-resizable-hide > .react-resizable-handle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.react-grid-item > .react-resizable-handle.react-resizable-handle-se {
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
cursor: se-resize;
|
||||
}
|
||||
|
||||
.react-grid-item.resizing {
|
||||
transition: none;
|
||||
z-index: 100;
|
||||
will-change: width, height;
|
||||
}
|
||||
|
||||
.react-grid-item.react-draggable-dragging {
|
||||
transition: none;
|
||||
z-index: 100;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.react-grid-item.dropping {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.react-grid-item.react-grid-placeholder {
|
||||
background: hsl(var(--p) / 0.2);
|
||||
opacity: 0.2;
|
||||
transition-duration: 100ms;
|
||||
z-index: 2;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
-o-user-select: none;
|
||||
user-select: none;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.react-grid-item > .react-resizable-handle::after {
|
||||
border-right: 2px solid hsl(var(--p));
|
||||
border-bottom: 2px solid hsl(var(--p));
|
||||
}
|
||||
|
||||
@@ -59,6 +59,19 @@ export const telemetryApi = {
|
||||
apiClient.get<PaginatedResponse<Telemetry>>('/telemetry/latest/', { params }),
|
||||
|
||||
getMetrics: () => apiClient.get<{ metrics: string[] }>('/telemetry/metrics/'),
|
||||
|
||||
analyze: (data: {
|
||||
device_id: string;
|
||||
metric?: string;
|
||||
hours?: number;
|
||||
limit?: number;
|
||||
prompt_type?: 'anomaly_detection' | 'trend_summary' | 'custom';
|
||||
custom_prompt?: string;
|
||||
}) => apiClient.post<{
|
||||
analysis: string;
|
||||
prompt_type: string;
|
||||
data_points_analyzed: number;
|
||||
}>('/telemetry/analyze/', data),
|
||||
};
|
||||
|
||||
// Dashboard API
|
||||
|
||||
398
frontend/src/components/AddWidgetModal.tsx
Normal file
398
frontend/src/components/AddWidgetModal.tsx
Normal file
@@ -0,0 +1,398 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { devicesApi } from '../api'
|
||||
import type { WidgetType, WidgetConfig } from '../hooks'
|
||||
import { createDefaultWidgetTitle } from '../utils/formatters'
|
||||
|
||||
interface AddWidgetModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onAdd: (widget: WidgetConfig) => void
|
||||
}
|
||||
|
||||
export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModalProps) {
|
||||
const [step, setStep] = useState(1)
|
||||
const [widgetType, setWidgetType] = useState<WidgetType>('stat')
|
||||
const [title, setTitle] = useState('')
|
||||
const [selectedDevices, setSelectedDevices] = useState<string[]>([])
|
||||
const [selectedMetrics, setSelectedMetrics] = useState<string[]>([])
|
||||
const [timeframeHours, setTimeframeHours] = useState(24)
|
||||
const [widgetWidth, setWidgetWidth] = useState(1) // Default to 1 column (small)
|
||||
const [widgetHeight, setWidgetHeight] = useState(2) // Default to 2 rows (medium)
|
||||
|
||||
// Fetch devices
|
||||
const { data: devicesData } = useQuery({
|
||||
queryKey: ['devices'],
|
||||
queryFn: async () => {
|
||||
const response = await devicesApi.getAll()
|
||||
return response.data
|
||||
},
|
||||
})
|
||||
|
||||
// Fetch metrics for selected device(s)
|
||||
const { data: deviceMetricsData } = useQuery({
|
||||
queryKey: ['device-metrics', selectedDevices[0]],
|
||||
queryFn: async () => {
|
||||
if (selectedDevices.length === 0) return []
|
||||
const response = await devicesApi.getMetrics(selectedDevices[0])
|
||||
return response.data.metrics
|
||||
},
|
||||
enabled: selectedDevices.length > 0,
|
||||
})
|
||||
|
||||
const devices = devicesData?.results || []
|
||||
const availableMetrics = deviceMetricsData || []
|
||||
|
||||
// Reset form when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setStep(1)
|
||||
setWidgetType('stat')
|
||||
setTitle('')
|
||||
setSelectedDevices([])
|
||||
setSelectedMetrics([])
|
||||
setTimeframeHours(24)
|
||||
setWidgetWidth(1)
|
||||
setWidgetHeight(2)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
// Reset metrics when device changes
|
||||
useEffect(() => {
|
||||
setSelectedMetrics([])
|
||||
}, [selectedDevices])
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (selectedDevices.length === 0 || selectedMetrics.length === 0) {
|
||||
alert('Please select at least one device and one metric')
|
||||
return
|
||||
}
|
||||
|
||||
const selectedDevice = devices.find(d => d.id === selectedDevices[0])
|
||||
const defaultTitle = createDefaultWidgetTitle(widgetType, selectedDevice?.name, selectedMetrics)
|
||||
|
||||
const newWidget: WidgetConfig = {
|
||||
id: `widget-${Date.now()}`,
|
||||
type: widgetType,
|
||||
title: title || defaultTitle,
|
||||
deviceIds: selectedDevices,
|
||||
metricIds: selectedMetrics,
|
||||
timeframe: {
|
||||
hours: timeframeHours,
|
||||
},
|
||||
visualization: {
|
||||
showLegend: true,
|
||||
showGrid: true,
|
||||
height: widgetType === 'line-chart' ? 300 : undefined,
|
||||
},
|
||||
position: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: widgetWidth,
|
||||
h: widgetHeight,
|
||||
},
|
||||
}
|
||||
|
||||
onAdd(newWidget)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const toggleDevice = (deviceId: string) => {
|
||||
// Only allow single device selection for better UX
|
||||
setSelectedDevices([deviceId])
|
||||
}
|
||||
|
||||
const toggleMetric = (metric: string) => {
|
||||
// Stat and gauge widgets only allow one metric
|
||||
const singleMetricWidgets = ['stat', 'gauge']
|
||||
const maxMetrics = singleMetricWidgets.includes(widgetType) ? 1 : 5
|
||||
|
||||
setSelectedMetrics((prev) => {
|
||||
if (prev.includes(metric)) {
|
||||
return prev.filter((m) => m !== metric)
|
||||
}
|
||||
|
||||
// If adding would exceed max, replace last or prevent
|
||||
if (prev.length >= maxMetrics) {
|
||||
if (maxMetrics === 1) {
|
||||
return [metric] // Replace for single-metric widgets
|
||||
}
|
||||
return prev // Don't add more for multi-metric widgets
|
||||
}
|
||||
|
||||
return [...prev, metric]
|
||||
})
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="modal modal-open">
|
||||
<div className="modal-box max-w-2xl">
|
||||
<h3 className="font-bold text-lg mb-4">Add Widget</h3>
|
||||
|
||||
{/* Progress steps */}
|
||||
<ul className="steps w-full mb-6">
|
||||
<li className={`step ${step >= 1 ? 'step-primary' : ''}`}>Type</li>
|
||||
<li className={`step ${step >= 2 ? 'step-primary' : ''}`}>Data Source</li>
|
||||
<li className={`step ${step >= 3 ? 'step-primary' : ''}`}>Configure</li>
|
||||
</ul>
|
||||
|
||||
{/* Step 1: Widget Type */}
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Select Widget Type</span>
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
className={`btn ${widgetType === 'stat' ? 'btn-primary' : 'btn-outline'} justify-start`}
|
||||
onClick={() => setWidgetType('stat')}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z" />
|
||||
</svg>
|
||||
<div className="text-left">
|
||||
<div className="font-semibold">Stat Card</div>
|
||||
<div className="text-xs opacity-70">Min/Max/Avg</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`btn ${widgetType === 'line-chart' ? 'btn-primary' : 'btn-outline'} justify-start`}
|
||||
onClick={() => setWidgetType('line-chart')}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
<div className="text-left">
|
||||
<div className="font-semibold">Line Chart</div>
|
||||
<div className="text-xs opacity-70">Time series</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`btn ${widgetType === 'gauge' ? 'btn-primary' : 'btn-outline'} justify-start`}
|
||||
onClick={() => setWidgetType('gauge')}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="text-left">
|
||||
<div className="font-semibold">Gauge</div>
|
||||
<div className="text-xs opacity-70">Circular meter</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`btn ${widgetType === 'ai-insight' ? 'btn-primary' : 'btn-outline'} justify-start`}
|
||||
onClick={() => setWidgetType('ai-insight')}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
<div className="text-left">
|
||||
<div className="font-semibold">AI Insight</div>
|
||||
<div className="text-xs opacity-70">GPT analysis</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-action">
|
||||
<button className="btn btn-ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={() => setStep(2)}>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Data Source */}
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Select Device</span>
|
||||
<span className="label-text-alt">{selectedDevices.length} selected</span>
|
||||
</label>
|
||||
<div className="border rounded-lg p-2 max-h-48 overflow-y-auto space-y-1">
|
||||
{devices.length === 0 ? (
|
||||
<div className="text-center text-base-content/60 py-4">No devices found</div>
|
||||
) : (
|
||||
devices.map((device) => (
|
||||
<label key={device.id} className="flex items-center gap-2 p-2 hover:bg-base-200 rounded cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="device"
|
||||
className="radio radio-sm"
|
||||
checked={selectedDevices.includes(device.id)}
|
||||
onChange={() => toggleDevice(device.id)}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{device.name}</div>
|
||||
<div className="text-xs text-base-content/60">{device.location || 'No location'}</div>
|
||||
</div>
|
||||
<div className="badge badge-sm">{device.protocol}</div>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Select Metric(s)</span>
|
||||
<span className="label-text-alt">{selectedMetrics.length} selected</span>
|
||||
</label>
|
||||
{selectedDevices.length === 0 ? (
|
||||
<div className="border rounded-lg p-4 text-center text-base-content/60">
|
||||
Please select a device first
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{(['stat', 'gauge'].includes(widgetType)) && (
|
||||
<div className="alert alert-info mb-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="stroke-current shrink-0 w-5 h-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<span className="text-sm">This widget type supports only one metric</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="border rounded-lg p-2 max-h-48 overflow-y-auto space-y-1">
|
||||
{availableMetrics.length === 0 ? (
|
||||
<div className="text-center text-base-content/60 py-4">
|
||||
No metrics found for this device
|
||||
</div>
|
||||
) : (
|
||||
availableMetrics.map((metric) => (
|
||||
<label key={metric} className="flex items-center gap-2 p-2 hover:bg-base-200 rounded cursor-pointer">
|
||||
<input
|
||||
type={(['stat', 'gauge'].includes(widgetType)) ? 'radio' : 'checkbox'}
|
||||
name={(['stat', 'gauge'].includes(widgetType)) ? 'single-metric' : undefined}
|
||||
className={(['stat', 'gauge'].includes(widgetType)) ? 'radio radio-sm' : 'checkbox checkbox-sm'}
|
||||
checked={selectedMetrics.includes(metric)}
|
||||
onChange={() => toggleMetric(metric)}
|
||||
/>
|
||||
<span className="capitalize">{metric.replace(/_/g, ' ')}</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="modal-action">
|
||||
<button className="btn btn-ghost" onClick={() => setStep(1)}>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => setStep(3)}
|
||||
disabled={selectedDevices.length === 0 || selectedMetrics.length === 0}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Configure */}
|
||||
{step === 3 && (
|
||||
<div className="space-y-4">
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Widget Title (Optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-bordered"
|
||||
placeholder="Auto-generated if empty"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Time Range</span>
|
||||
</label>
|
||||
<select
|
||||
className="select select-bordered"
|
||||
value={timeframeHours}
|
||||
onChange={(e) => setTimeframeHours(Number(e.target.value))}
|
||||
>
|
||||
<option value={1}>Last 1 hour</option>
|
||||
<option value={6}>Last 6 hours</option>
|
||||
<option value={24}>Last 24 hours</option>
|
||||
<option value={168}>Last 7 days</option>
|
||||
<option value={720}>Last 30 days</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Width</span>
|
||||
</label>
|
||||
<select
|
||||
className="select select-bordered"
|
||||
value={widgetWidth}
|
||||
onChange={(e) => setWidgetWidth(Number(e.target.value))}
|
||||
>
|
||||
<option value={1}>Small (1 column)</option>
|
||||
<option value={2}>Medium (2 columns)</option>
|
||||
<option value={3}>Large (3 columns)</option>
|
||||
<option value={4}>Full Width (4 columns)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Height</span>
|
||||
</label>
|
||||
<select
|
||||
className="select select-bordered"
|
||||
value={widgetHeight}
|
||||
onChange={(e) => setWidgetHeight(Number(e.target.value))}
|
||||
>
|
||||
<option value={1}>Short</option>
|
||||
<option value={2}>Medium</option>
|
||||
<option value={3}>Tall</option>
|
||||
<option value={4}>Extra Tall</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="alert">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="stroke-info shrink-0 w-6 h-6">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<div className="text-sm">
|
||||
<div className="font-semibold">Widget Summary</div>
|
||||
<div>Type: <span className="badge badge-sm">{widgetType}</span></div>
|
||||
<div>Device: {devices.find(d => d.id === selectedDevices[0])?.name}</div>
|
||||
<div>Metrics: {selectedMetrics.join(', ')}</div>
|
||||
<div>Size: {widgetWidth} × {widgetHeight}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-action">
|
||||
<button className="btn btn-ghost" onClick={() => setStep(2)}>
|
||||
Back
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={handleSubmit}>
|
||||
Add Widget
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-backdrop" onClick={onClose}></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
221
frontend/src/components/EditWidgetModal.tsx
Normal file
221
frontend/src/components/EditWidgetModal.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { devicesApi } from '../api'
|
||||
import type { WidgetConfig } from '../hooks'
|
||||
|
||||
interface EditWidgetModalProps {
|
||||
isOpen: boolean
|
||||
widget: WidgetConfig | null
|
||||
onClose: () => void
|
||||
onSave: (widgetId: string, updates: Partial<WidgetConfig>) => void
|
||||
}
|
||||
|
||||
export default function EditWidgetModal({ isOpen, widget, onClose, onSave }: EditWidgetModalProps) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [selectedMetrics, setSelectedMetrics] = useState<string[]>([])
|
||||
const [timeframeHours, setTimeframeHours] = useState(24)
|
||||
const [widgetWidth, setWidgetWidth] = useState(1)
|
||||
const [widgetHeight, setWidgetHeight] = useState(2)
|
||||
|
||||
// Fetch metrics for the widget's device
|
||||
const { data: deviceMetricsData } = useQuery({
|
||||
queryKey: ['device-metrics', widget?.deviceIds[0]],
|
||||
queryFn: async () => {
|
||||
if (!widget?.deviceIds[0]) return []
|
||||
const response = await devicesApi.getMetrics(widget.deviceIds[0])
|
||||
return response.data.metrics
|
||||
},
|
||||
enabled: !!widget?.deviceIds[0] && isOpen,
|
||||
})
|
||||
|
||||
const availableMetrics = deviceMetricsData || []
|
||||
|
||||
// Load widget data when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen && widget) {
|
||||
setTitle(widget.title || '')
|
||||
setSelectedMetrics(widget.metricIds || [])
|
||||
setTimeframeHours(widget.timeframe?.hours || 24)
|
||||
setWidgetWidth(widget.position?.w || 1)
|
||||
setWidgetHeight(widget.position?.h || 2)
|
||||
}
|
||||
}, [isOpen, widget])
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!widget || selectedMetrics.length === 0) {
|
||||
alert('Please select at least one metric')
|
||||
return
|
||||
}
|
||||
|
||||
onSave(widget.id, {
|
||||
title,
|
||||
metricIds: selectedMetrics,
|
||||
timeframe: {
|
||||
hours: timeframeHours,
|
||||
},
|
||||
position: {
|
||||
...widget.position,
|
||||
x: widget.position?.x || 0,
|
||||
y: widget.position?.y || 0,
|
||||
w: widgetWidth,
|
||||
h: widgetHeight,
|
||||
},
|
||||
})
|
||||
onClose()
|
||||
}
|
||||
|
||||
const toggleMetric = (metric: string) => {
|
||||
// Stat and gauge widgets only allow one metric
|
||||
const singleMetricWidgets = ['stat', 'gauge']
|
||||
const maxMetrics = widget && singleMetricWidgets.includes(widget.type) ? 1 : 5
|
||||
|
||||
setSelectedMetrics((prev) => {
|
||||
if (prev.includes(metric)) {
|
||||
return prev.filter((m) => m !== metric)
|
||||
}
|
||||
|
||||
// If adding would exceed max, replace last or prevent
|
||||
if (prev.length >= maxMetrics) {
|
||||
if (maxMetrics === 1) {
|
||||
return [metric] // Replace for single-metric widgets
|
||||
}
|
||||
return prev // Don't add more for multi-metric widgets
|
||||
}
|
||||
|
||||
return [...prev, metric]
|
||||
})
|
||||
}
|
||||
|
||||
if (!isOpen || !widget) return null
|
||||
|
||||
return (
|
||||
<div className="modal modal-open">
|
||||
<div className="modal-box max-w-2xl">
|
||||
<h3 className="font-bold text-lg mb-4">Edit Widget</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Widget Type (read-only) */}
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Widget Type</span>
|
||||
</label>
|
||||
<div className="badge badge-lg badge-primary">{widget.type}</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Widget Title</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-bordered"
|
||||
placeholder="Auto-generated if empty"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Metrics */}
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Select Metric(s)</span>
|
||||
<span className="label-text-alt">{selectedMetrics.length} selected</span>
|
||||
</label>
|
||||
{(['stat', 'gauge'].includes(widget.type)) && (
|
||||
<div className="alert alert-info mb-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="stroke-current shrink-0 w-5 h-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<span className="text-sm">This widget type supports only one metric</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="border rounded-lg p-2 max-h-48 overflow-y-auto space-y-1">
|
||||
{availableMetrics.length === 0 ? (
|
||||
<div className="text-center text-base-content/60 py-4">
|
||||
Loading metrics...
|
||||
</div>
|
||||
) : (
|
||||
availableMetrics.map((metric) => (
|
||||
<label key={metric} className="flex items-center gap-2 p-2 hover:bg-base-200 rounded cursor-pointer">
|
||||
<input
|
||||
type={(['stat', 'gauge'].includes(widget.type)) ? 'radio' : 'checkbox'}
|
||||
name={(['stat', 'gauge'].includes(widget.type)) ? 'single-metric' : undefined}
|
||||
className={(['stat', 'gauge'].includes(widget.type)) ? 'radio radio-sm' : 'checkbox checkbox-sm'}
|
||||
checked={selectedMetrics.includes(metric)}
|
||||
onChange={() => toggleMetric(metric)}
|
||||
/>
|
||||
<span className="capitalize">{metric.replace(/_/g, ' ')}</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time Range */}
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Time Range</span>
|
||||
</label>
|
||||
<select
|
||||
className="select select-bordered"
|
||||
value={timeframeHours}
|
||||
onChange={(e) => setTimeframeHours(Number(e.target.value))}
|
||||
>
|
||||
<option value={1}>Last 1 hour</option>
|
||||
<option value={6}>Last 6 hours</option>
|
||||
<option value={24}>Last 24 hours</option>
|
||||
<option value={168}>Last 7 days</option>
|
||||
<option value={720}>Last 30 days</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Size */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Width</span>
|
||||
</label>
|
||||
<select
|
||||
className="select select-bordered"
|
||||
value={widgetWidth}
|
||||
onChange={(e) => setWidgetWidth(Number(e.target.value))}
|
||||
>
|
||||
<option value={1}>Small (1 column)</option>
|
||||
<option value={2}>Medium (2 columns)</option>
|
||||
<option value={3}>Large (3 columns)</option>
|
||||
<option value={4}>Full Width (4 columns)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Height</span>
|
||||
</label>
|
||||
<select
|
||||
className="select select-bordered"
|
||||
value={widgetHeight}
|
||||
onChange={(e) => setWidgetHeight(Number(e.target.value))}
|
||||
>
|
||||
<option value={1}>Short</option>
|
||||
<option value={2}>Medium</option>
|
||||
<option value={3}>Tall</option>
|
||||
<option value={4}>Extra Tall</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-action">
|
||||
<button className="btn btn-ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={handleSubmit}>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-backdrop" onClick={onClose}></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -49,14 +49,14 @@ export default function TelemetryTrendCard({ title, data, unit, accentColor = '#
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tickFormatter={formatTimeLabel}
|
||||
tick={{ fontSize: 12 }}
|
||||
stroke="hsl(var(--bc) / 0.3)"
|
||||
tick={{ fontSize: 12, fill: '#ffffff' }}
|
||||
stroke="rgba(255, 255, 255, 0.3)"
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={(val) => formatValue(val, unit)}
|
||||
width={48}
|
||||
tick={{ fontSize: 12 }}
|
||||
stroke="hsl(var(--bc) / 0.3)"
|
||||
tick={{ fontSize: 12, fill: '#ffffff' }}
|
||||
stroke="rgba(255, 255, 255, 0.3)"
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number) => formatValue(value, unit)}
|
||||
|
||||
144
frontend/src/components/widgets/AiInsightWidget.tsx
Normal file
144
frontend/src/components/widgets/AiInsightWidget.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { telemetryApi } from '../../api'
|
||||
import type { WidgetConfig } from '../../hooks'
|
||||
|
||||
interface AiInsightWidgetProps {
|
||||
config: WidgetConfig
|
||||
}
|
||||
|
||||
export default function AiInsightWidget({ config }: AiInsightWidgetProps) {
|
||||
const { deviceIds, metricIds, timeframe, title } = config
|
||||
const [promptType, setPromptType] = useState<'trend_summary' | 'anomaly_detection'>('trend_summary')
|
||||
const [showAnalysis, setShowAnalysis] = useState(false)
|
||||
|
||||
const deviceId = deviceIds[0]
|
||||
const metric = metricIds[0]
|
||||
|
||||
const {
|
||||
data: analysis,
|
||||
isLoading,
|
||||
refetch,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ['ai-insight', deviceId, metric, promptType, timeframe],
|
||||
queryFn: async () => {
|
||||
const response = await telemetryApi.analyze({
|
||||
device_id: deviceId,
|
||||
metric,
|
||||
hours: timeframe.hours || 240,
|
||||
limit: 200,
|
||||
prompt_type: promptType,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
enabled: false, // Manual trigger
|
||||
})
|
||||
|
||||
const handleAnalyze = () => {
|
||||
setShowAnalysis(true)
|
||||
refetch()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card bg-base-100 shadow">
|
||||
<div className="card-body">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="card-title text-base">{title || 'AI Environmental Insights'}</h3>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
className="select select-bordered select-sm"
|
||||
value={promptType}
|
||||
onChange={(e) => setPromptType(e.target.value as 'trend_summary' | 'anomaly_detection')}
|
||||
>
|
||||
<option value="trend_summary">Trend Summary</option>
|
||||
<option value="anomaly_detection">Anomaly Detection</option>
|
||||
</select>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={handleAnalyze}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<span className="loading loading-spinner loading-xs"></span>
|
||||
Analyzing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
Analyze
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center space-y-4">
|
||||
<span className="loading loading-spinner loading-lg text-primary"></span>
|
||||
<p className="text-sm text-base-content/60">Analyzing environmental data...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="alert alert-error mt-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<div className="font-bold">Analysis Failed</div>
|
||||
<div className="text-sm">{(error as Error)?.message || 'Could not connect to GPT service'}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{analysis && showAnalysis && !isLoading && (
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="badge badge-primary badge-lg">
|
||||
{promptType === 'trend_summary' ? 'Trend Analysis' : 'Anomaly Detection'}
|
||||
</div>
|
||||
<div className="text-xs text-base-content/50">
|
||||
{analysis.data_points_analyzed} data points analyzed
|
||||
</div>
|
||||
</div>
|
||||
<div className="divider my-2"></div>
|
||||
<div className="prose max-w-none">
|
||||
<div className="whitespace-pre-wrap text-sm leading-relaxed max-h-96 overflow-y-auto">
|
||||
{analysis.analysis}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => setShowAnalysis(false)}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
Refresh Analysis
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!showAnalysis && (
|
||||
<div className="text-center py-8 text-base-content/60">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-12 w-12 mx-auto mb-4 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
<p className="text-sm">Click Analyze to get AI-powered environmental insights</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
103
frontend/src/components/widgets/GaugeWidget.tsx
Normal file
103
frontend/src/components/widgets/GaugeWidget.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useTelemetrySeries } from '../../hooks'
|
||||
import type { WidgetConfig } from '../../hooks'
|
||||
|
||||
interface GaugeWidgetProps {
|
||||
config: WidgetConfig
|
||||
}
|
||||
|
||||
export default function GaugeWidget({ config }: GaugeWidgetProps) {
|
||||
const { deviceIds, metricIds, timeframe, title } = config
|
||||
|
||||
const deviceId = deviceIds[0]
|
||||
const metric = metricIds[0]
|
||||
|
||||
const { data, isLoading, error } = useTelemetrySeries({
|
||||
deviceId,
|
||||
metric,
|
||||
hours: timeframe.hours,
|
||||
startTime: timeframe.startTime,
|
||||
endTime: timeframe.endTime,
|
||||
limit: 1,
|
||||
})
|
||||
|
||||
const latest = data[0]
|
||||
const value = latest?.value || 0
|
||||
|
||||
// Simple gauge ranges (could be configured per metric)
|
||||
const ranges = {
|
||||
low: 33,
|
||||
medium: 66,
|
||||
high: 100,
|
||||
}
|
||||
|
||||
const percentage = Math.min(100, Math.max(0, value))
|
||||
let color = 'text-success'
|
||||
if (percentage > ranges.medium) color = 'text-error'
|
||||
else if (percentage > ranges.low) color = 'text-warning'
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="card bg-base-200 animate-pulse">
|
||||
<div className="card-body h-48"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="card bg-error/10">
|
||||
<div className="card-body">
|
||||
<p className="text-error text-sm">Error: {error.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card bg-base-100 shadow">
|
||||
<div className="card-body items-center text-center">
|
||||
<h3 className="card-title text-base">{title || metric}</h3>
|
||||
<div className="relative w-32 h-32 mt-4">
|
||||
<svg className="w-full h-full transform -rotate-90">
|
||||
<circle
|
||||
cx="64"
|
||||
cy="64"
|
||||
r="56"
|
||||
stroke="currentColor"
|
||||
strokeWidth="8"
|
||||
fill="none"
|
||||
className="text-base-300"
|
||||
/>
|
||||
<circle
|
||||
cx="64"
|
||||
cy="64"
|
||||
r="56"
|
||||
stroke="currentColor"
|
||||
strokeWidth="8"
|
||||
fill="none"
|
||||
strokeDasharray={`${2 * Math.PI * 56}`}
|
||||
strokeDashoffset={`${2 * Math.PI * 56 * (1 - percentage / 100)}`}
|
||||
className={color}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div>
|
||||
<div className={`text-3xl font-bold ${color}`}>
|
||||
{value.toFixed(1)}
|
||||
</div>
|
||||
{latest?.unit && (
|
||||
<div className="text-sm text-base-content/60">{latest.unit}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{latest && (
|
||||
<div className="text-xs text-base-content/50 mt-4">
|
||||
Updated {new Date(latest.time).toLocaleTimeString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
235
frontend/src/components/widgets/LineChartWidget.tsx
Normal file
235
frontend/src/components/widgets/LineChartWidget.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import { useMemo } from 'react'
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
import { useTelemetrySeries } from '../../hooks'
|
||||
import type { WidgetConfig } from '../../hooks'
|
||||
import { formatMetricName } from '../../utils/formatters'
|
||||
|
||||
interface LineChartWidgetProps {
|
||||
config: WidgetConfig
|
||||
}
|
||||
|
||||
// Helper component to fetch data for a single metric
|
||||
function useMultiMetricData(deviceId: string, metricIds: string[], timeframe: WidgetConfig['timeframe']) {
|
||||
// Fetch data for each metric (React allows hooks in arrays when count is stable)
|
||||
const metric1 = useTelemetrySeries({
|
||||
deviceId,
|
||||
metric: metricIds[0] || '',
|
||||
hours: timeframe.hours,
|
||||
startTime: timeframe.startTime,
|
||||
endTime: timeframe.endTime,
|
||||
})
|
||||
|
||||
const metric2 = useTelemetrySeries({
|
||||
deviceId,
|
||||
metric: metricIds[1] || '',
|
||||
hours: timeframe.hours,
|
||||
startTime: timeframe.startTime,
|
||||
endTime: timeframe.endTime,
|
||||
})
|
||||
|
||||
const metric3 = useTelemetrySeries({
|
||||
deviceId,
|
||||
metric: metricIds[2] || '',
|
||||
hours: timeframe.hours,
|
||||
startTime: timeframe.startTime,
|
||||
endTime: timeframe.endTime,
|
||||
})
|
||||
|
||||
const metric4 = useTelemetrySeries({
|
||||
deviceId,
|
||||
metric: metricIds[3] || '',
|
||||
hours: timeframe.hours,
|
||||
startTime: timeframe.startTime,
|
||||
endTime: timeframe.endTime,
|
||||
})
|
||||
|
||||
const metric5 = useTelemetrySeries({
|
||||
deviceId,
|
||||
metric: metricIds[4] || '',
|
||||
hours: timeframe.hours,
|
||||
startTime: timeframe.startTime,
|
||||
endTime: timeframe.endTime,
|
||||
})
|
||||
|
||||
const queries = [metric1, metric2, metric3, metric4, metric5].slice(0, metricIds.length)
|
||||
|
||||
return { queries, metricIds }
|
||||
}
|
||||
|
||||
export default function LineChartWidget({ config }: LineChartWidgetProps) {
|
||||
const { deviceIds, metricIds, timeframe, visualization } = config
|
||||
const deviceId = deviceIds[0]
|
||||
|
||||
const { queries } = useMultiMetricData(deviceId, metricIds, timeframe)
|
||||
|
||||
const isLoading = queries.some((q) => q.isLoading)
|
||||
const error = queries.find((q) => q.error)?.error
|
||||
|
||||
// Combine data from all metrics into a single chart dataset
|
||||
const chartData = useMemo(() => {
|
||||
if (queries.length === 0 || !queries[0]?.data || queries[0].data.length === 0) return []
|
||||
|
||||
// Create a map of time -> { time, fullDateTime, metric1, metric2, ... }
|
||||
const timeMap = new Map<string, Record<string, number | string>>()
|
||||
|
||||
queries.forEach((query, index) => {
|
||||
const metric = metricIds[index]
|
||||
if (!query.data) return
|
||||
|
||||
query.data.forEach((point) => {
|
||||
const date = new Date(point.time)
|
||||
|
||||
// Short time for X-axis display (just time)
|
||||
const timeStr = date.toLocaleString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
|
||||
// Full date/time for tooltip
|
||||
const fullDateTime = date.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
|
||||
const key = date.getTime().toString()
|
||||
|
||||
if (!timeMap.has(key)) {
|
||||
timeMap.set(key, {
|
||||
time: timeStr,
|
||||
fullDateTime: fullDateTime,
|
||||
timestamp: date.getTime()
|
||||
})
|
||||
}
|
||||
|
||||
const entry = timeMap.get(key)!
|
||||
entry[metric] = point.value
|
||||
})
|
||||
})
|
||||
|
||||
// Sort by timestamp
|
||||
return Array.from(timeMap.values()).sort((a, b) => {
|
||||
return (a.timestamp as number) - (b.timestamp as number)
|
||||
})
|
||||
}, [queries, metricIds])
|
||||
|
||||
const colors = visualization?.colors || [
|
||||
'#3b82f6', // blue
|
||||
'#10b981', // green
|
||||
'#f59e0b', // amber
|
||||
'#ef4444', // red
|
||||
'#8b5cf6', // purple
|
||||
'#ec4899', // pink
|
||||
]
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<span className="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="alert alert-error">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="stroke-current shrink-0 h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Error loading data: {error.message}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (chartData.length === 0) {
|
||||
return (
|
||||
<div className="alert alert-info">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
className="stroke-current shrink-0 w-6 h-6"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path>
|
||||
</svg>
|
||||
<span>No data available for this metric</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card bg-base-100 h-full overflow-hidden">
|
||||
<div className="card-body p-4">
|
||||
<h3 className="card-title text-sm mb-2">
|
||||
{config.title || metricIds.map(formatMetricName).join(' & ')}
|
||||
</h3>
|
||||
<ResponsiveContainer width="100%" height={visualization?.height || 280}>
|
||||
<LineChart data={chartData} margin={{ top: 5, right: 20, left: 0, bottom: 50 }}>
|
||||
{visualization?.showGrid !== false && (
|
||||
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
|
||||
)}
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tick={{ fontSize: 11 }}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={50}
|
||||
/>
|
||||
<YAxis tick={{ fontSize: 12 }} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
color: 'white',
|
||||
}}
|
||||
labelFormatter={(label, payload) => {
|
||||
// Use fullDateTime from the data point for tooltip
|
||||
return payload && payload[0] ? payload[0].payload.fullDateTime : label
|
||||
}}
|
||||
formatter={(value: number) => [value.toFixed(2)]}
|
||||
/>
|
||||
{visualization?.showLegend !== false && <Legend />}
|
||||
{metricIds.map((metric, index) => (
|
||||
<Line
|
||||
key={metric}
|
||||
type="monotone"
|
||||
dataKey={metric}
|
||||
stroke={colors[index % colors.length]}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
name={formatMetricName(metric)}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
78
frontend/src/components/widgets/StatWidget.tsx
Normal file
78
frontend/src/components/widgets/StatWidget.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useTelemetrySeries } from '../../hooks'
|
||||
import type { WidgetConfig } from '../../hooks'
|
||||
import { formatMetricName } from '../../utils/formatters'
|
||||
|
||||
interface StatWidgetProps {
|
||||
config: WidgetConfig
|
||||
}
|
||||
|
||||
export default function StatWidgetProps({ config }: StatWidgetProps) {
|
||||
const { deviceIds, metricIds, timeframe, title } = config
|
||||
|
||||
const deviceId = deviceIds[0]
|
||||
const metric = metricIds[0]
|
||||
|
||||
const { data, isLoading, error } = useTelemetrySeries({
|
||||
deviceId,
|
||||
metric,
|
||||
hours: timeframe.hours,
|
||||
startTime: timeframe.startTime,
|
||||
endTime: timeframe.endTime,
|
||||
limit: 100,
|
||||
})
|
||||
|
||||
const latest = data[0]
|
||||
const values = data.map((d) => d.value)
|
||||
const average = values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0
|
||||
const min = values.length > 0 ? Math.min(...values) : 0
|
||||
const max = values.length > 0 ? Math.max(...values) : 0
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="card bg-base-200 animate-pulse">
|
||||
<div className="card-body h-32"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="card bg-error/10">
|
||||
<div className="card-body">
|
||||
<p className="text-error text-sm">Error: {error.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card bg-base-100 shadow">
|
||||
<div className="card-body">
|
||||
<div className="text-sm uppercase tracking-wide text-base-content/60">
|
||||
{title || formatMetricName(metric)}
|
||||
</div>
|
||||
<div className="text-4xl font-bold text-primary">
|
||||
{latest ? latest.value.toFixed(1) : '—'}
|
||||
{latest?.unit && <span className="text-xl ml-2">{latest.unit}</span>}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 mt-4 text-sm">
|
||||
<div>
|
||||
<div className="text-base-content/60">Min</div>
|
||||
<div className="font-semibold">{min.toFixed(1)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-base-content/60">Avg</div>
|
||||
<div className="font-semibold">{average.toFixed(1)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-base-content/60">Max</div>
|
||||
<div className="font-semibold">{max.toFixed(1)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-base-content/50 mt-2">
|
||||
{data.length} readings in last {timeframe.hours || 24}h
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
98
frontend/src/components/widgets/WidgetContainer.tsx
Normal file
98
frontend/src/components/widgets/WidgetContainer.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { WidgetConfig } from '../../hooks'
|
||||
import { widgetRegistry } from './registry'
|
||||
|
||||
interface WidgetContainerProps {
|
||||
config: WidgetConfig
|
||||
onRemove?: (id: string) => void
|
||||
onEdit?: (id: string) => void
|
||||
}
|
||||
|
||||
export default function WidgetContainer({ config, onRemove, onEdit }: WidgetContainerProps) {
|
||||
const WidgetComponent = widgetRegistry[config.type]
|
||||
|
||||
if (!WidgetComponent) {
|
||||
return (
|
||||
<div className="card bg-error/10">
|
||||
<div className="card-body">
|
||||
<p className="text-error">Unknown widget type: {config.type}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative group h-full">
|
||||
{/* Drag handle and actions */}
|
||||
<div className="absolute top-0 left-0 right-0 z-10 flex items-center justify-between p-2 opacity-0 group-hover:opacity-100 transition-opacity bg-gradient-to-b from-base-300/90 to-transparent">
|
||||
<div className="drag-handle cursor-move flex items-center gap-1 px-2 py-1 rounded bg-base-100/80 text-xs">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 8h16M4 16h16"
|
||||
/>
|
||||
</svg>
|
||||
Drag
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{onEdit && (
|
||||
<button
|
||||
className="btn btn-xs btn-circle btn-ghost bg-base-100/80"
|
||||
onClick={() => onEdit(config.id)}
|
||||
title="Edit widget"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{onRemove && (
|
||||
<button
|
||||
className="btn btn-xs btn-circle btn-ghost bg-base-100/80"
|
||||
onClick={() => onRemove(config.id)}
|
||||
title="Remove widget"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actual widget */}
|
||||
<div className="h-full">
|
||||
<WidgetComponent config={config} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
6
frontend/src/components/widgets/index.ts
Normal file
6
frontend/src/components/widgets/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { default as WidgetContainer } from './WidgetContainer'
|
||||
export { default as LineChartWidget } from './LineChartWidget'
|
||||
export { default as StatWidget } from './StatWidget'
|
||||
export { default as GaugeWidget } from './GaugeWidget'
|
||||
export { default as AiInsightWidget } from './AiInsightWidget'
|
||||
export { widgetRegistry } from './registry'
|
||||
19
frontend/src/components/widgets/registry.ts
Normal file
19
frontend/src/components/widgets/registry.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { ComponentType } from 'react'
|
||||
import type { WidgetConfig, WidgetType } from '../../hooks'
|
||||
|
||||
import LineChartWidget from './LineChartWidget'
|
||||
import StatWidget from './StatWidget'
|
||||
import GaugeWidget from './GaugeWidget'
|
||||
import AiInsightWidget from './AiInsightWidget'
|
||||
|
||||
interface WidgetProps {
|
||||
config: WidgetConfig
|
||||
}
|
||||
|
||||
export const widgetRegistry: Record<WidgetType, ComponentType<WidgetProps>> = {
|
||||
'line-chart': LineChartWidget,
|
||||
'stat': StatWidget,
|
||||
'gauge': GaugeWidget,
|
||||
'ai-insight': AiInsightWidget,
|
||||
'bar-chart': LineChartWidget, // Placeholder - implement later
|
||||
}
|
||||
4
frontend/src/hooks/index.ts
Normal file
4
frontend/src/hooks/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { useTelemetrySeries } from './useTelemetrySeries'
|
||||
export { useDeviceMetrics, useAllMetrics } from './useDeviceMetrics'
|
||||
export { useDashboardConfig } from './useDashboardConfig'
|
||||
export type { WidgetConfig, DashboardConfig, WidgetType } from './useDashboardConfig'
|
||||
127
frontend/src/hooks/useDashboardConfig.ts
Normal file
127
frontend/src/hooks/useDashboardConfig.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export type WidgetType = 'line-chart' | 'gauge' | 'stat' | 'ai-insight' | 'bar-chart'
|
||||
|
||||
export interface WidgetConfig {
|
||||
id: string
|
||||
type: WidgetType
|
||||
title: string
|
||||
deviceIds: string[]
|
||||
metricIds: string[]
|
||||
timeframe: {
|
||||
hours?: number
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
}
|
||||
visualization?: {
|
||||
colors?: string[]
|
||||
showLegend?: boolean
|
||||
showGrid?: boolean
|
||||
height?: number
|
||||
}
|
||||
position?: {
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface DashboardConfig {
|
||||
widgets: WidgetConfig[]
|
||||
layout: 'grid' | 'freeform'
|
||||
refreshInterval?: number
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: DashboardConfig = {
|
||||
widgets: [],
|
||||
layout: 'grid',
|
||||
refreshInterval: 30000,
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'iot-dashboard-config'
|
||||
|
||||
/**
|
||||
* Hook to manage dashboard configuration with localStorage persistence
|
||||
*/
|
||||
export function useDashboardConfig() {
|
||||
const [config, setConfig] = useState<DashboardConfig>(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored) {
|
||||
return JSON.parse(stored) as DashboardConfig
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load dashboard config:', error)
|
||||
}
|
||||
return DEFAULT_CONFIG
|
||||
})
|
||||
|
||||
// Persist to localStorage whenever config changes
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(config))
|
||||
} catch (error) {
|
||||
console.error('Failed to save dashboard config:', error)
|
||||
}
|
||||
}, [config])
|
||||
|
||||
const addWidget = (widget: WidgetConfig) => {
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
widgets: [...prev.widgets, widget],
|
||||
}))
|
||||
}
|
||||
|
||||
const updateWidget = (id: string, updates: Partial<WidgetConfig>) => {
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
widgets: prev.widgets.map((w) => (w.id === id ? { ...w, ...updates } : w)),
|
||||
}))
|
||||
}
|
||||
|
||||
const removeWidget = (id: string) => {
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
widgets: prev.widgets.filter((w) => w.id !== id),
|
||||
}))
|
||||
}
|
||||
|
||||
const reorderWidgets = (widgets: WidgetConfig[]) => {
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
widgets,
|
||||
}))
|
||||
}
|
||||
|
||||
const resetConfig = () => {
|
||||
setConfig(DEFAULT_CONFIG)
|
||||
}
|
||||
|
||||
const exportConfig = (): string => {
|
||||
return JSON.stringify(config, null, 2)
|
||||
}
|
||||
|
||||
const importConfig = (configOrJson: DashboardConfig | string) => {
|
||||
try {
|
||||
const imported = typeof configOrJson === 'string'
|
||||
? JSON.parse(configOrJson) as DashboardConfig
|
||||
: configOrJson
|
||||
setConfig(imported)
|
||||
} catch (error) {
|
||||
console.error('Failed to import config:', error)
|
||||
throw new Error('Invalid configuration')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
config,
|
||||
addWidget,
|
||||
updateWidget,
|
||||
removeWidget,
|
||||
reorderWidgets,
|
||||
resetConfig,
|
||||
exportConfig,
|
||||
importConfig,
|
||||
}
|
||||
}
|
||||
54
frontend/src/hooks/useDeviceMetrics.ts
Normal file
54
frontend/src/hooks/useDeviceMetrics.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { devicesApi, telemetryApi } from '../api'
|
||||
|
||||
interface DeviceMetricsResult {
|
||||
deviceId: string
|
||||
deviceName: string
|
||||
metrics: string[]
|
||||
isLoading: boolean
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch available metrics for a specific device
|
||||
*/
|
||||
export function useDeviceMetrics(deviceId: string | undefined): DeviceMetricsResult {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['device', deviceId, 'metrics'],
|
||||
queryFn: async () => {
|
||||
if (!deviceId) return null
|
||||
const response = await devicesApi.getMetrics(deviceId)
|
||||
return response.data
|
||||
},
|
||||
enabled: !!deviceId,
|
||||
staleTime: 60000, // Cache for 1 minute
|
||||
})
|
||||
|
||||
return {
|
||||
deviceId: data?.device_id || '',
|
||||
deviceName: data?.device_name || '',
|
||||
metrics: data?.metrics || [],
|
||||
isLoading,
|
||||
error: error as Error | null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch all available metrics across all devices
|
||||
*/
|
||||
export function useAllMetrics() {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['telemetry', 'metrics'],
|
||||
queryFn: async () => {
|
||||
const response = await telemetryApi.getMetrics()
|
||||
return response.data.metrics
|
||||
},
|
||||
staleTime: 60000,
|
||||
})
|
||||
|
||||
return {
|
||||
metrics: data || [],
|
||||
isLoading,
|
||||
error: error as Error | null,
|
||||
}
|
||||
}
|
||||
75
frontend/src/hooks/useTelemetrySeries.ts
Normal file
75
frontend/src/hooks/useTelemetrySeries.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { telemetryApi } from '../api'
|
||||
import type { Telemetry } from '../types/api'
|
||||
|
||||
interface TelemetrySeriesParams {
|
||||
deviceId?: string
|
||||
metric?: string
|
||||
hours?: number
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
limit?: number
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
interface TelemetrySeries {
|
||||
data: Telemetry[]
|
||||
isLoading: boolean
|
||||
isFetching: boolean
|
||||
error: Error | null
|
||||
refetch: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch telemetry data series with flexible filtering
|
||||
*/
|
||||
export function useTelemetrySeries({
|
||||
deviceId,
|
||||
metric,
|
||||
hours = 24,
|
||||
startTime,
|
||||
endTime,
|
||||
limit = 1000,
|
||||
enabled = true,
|
||||
}: TelemetrySeriesParams): TelemetrySeries {
|
||||
const { data, isLoading, isFetching, error, refetch } = useQuery({
|
||||
queryKey: ['telemetry', 'series', { deviceId, metric, hours, startTime, endTime, limit }],
|
||||
queryFn: async () => {
|
||||
const params: {
|
||||
device_id?: string
|
||||
metric?: string
|
||||
hours?: number
|
||||
start_time?: string
|
||||
end_time?: string
|
||||
page_size: number
|
||||
} = {
|
||||
page_size: limit,
|
||||
}
|
||||
|
||||
if (deviceId) params.device_id = deviceId
|
||||
if (metric) params.metric = metric
|
||||
if (startTime) params.start_time = startTime
|
||||
if (endTime) params.end_time = endTime
|
||||
if (!startTime && !endTime && hours) params.hours = hours
|
||||
|
||||
const response = await telemetryApi.query(params)
|
||||
|
||||
// Handle paginated response
|
||||
if ('results' in response.data) {
|
||||
return response.data.results
|
||||
}
|
||||
|
||||
return response.data as Telemetry[]
|
||||
},
|
||||
enabled,
|
||||
refetchInterval: 30000, // Refresh every 30 seconds
|
||||
})
|
||||
|
||||
return {
|
||||
data: data || [],
|
||||
isLoading,
|
||||
isFetching,
|
||||
error: error as Error | null,
|
||||
refetch,
|
||||
}
|
||||
}
|
||||
@@ -1,401 +1,198 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { dashboardApi, telemetryApi } from '../api'
|
||||
import TelemetryTrendCard from '../components/dashboard/TelemetryTrendCard'
|
||||
import type { DashboardOverview, Telemetry } from '../types/api'
|
||||
|
||||
type TelemetryQueryResult = Telemetry[] | { results?: Telemetry[] }
|
||||
|
||||
type MetricSummary = {
|
||||
metricKey: string
|
||||
label: string
|
||||
unit?: string
|
||||
samples: Array<{ time: string; value: number }>
|
||||
latest?: { time: string; value: number }
|
||||
earliest?: { time: string; value: number }
|
||||
average: number
|
||||
change?: number
|
||||
count: number
|
||||
}
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import GridLayout from 'react-grid-layout'
|
||||
import 'react-grid-layout/css/styles.css'
|
||||
import { useDashboardConfig } from '../hooks'
|
||||
import { WidgetContainer } from '../components/widgets'
|
||||
import AddWidgetModal from '../components/AddWidgetModal'
|
||||
import EditWidgetModal from '../components/EditWidgetModal'
|
||||
|
||||
export default function Dashboard() {
|
||||
const {
|
||||
data: overview,
|
||||
isLoading: overviewLoading,
|
||||
isFetching: overviewFetching,
|
||||
refetch: refetchOverview,
|
||||
} = useQuery({
|
||||
queryKey: ['dashboard', 'overview'],
|
||||
queryFn: async (): Promise<DashboardOverview> => {
|
||||
const response = await dashboardApi.getOverview()
|
||||
return response.data
|
||||
},
|
||||
refetchInterval: 5000,
|
||||
})
|
||||
const { config, addWidget, removeWidget, updateWidget, exportConfig, importConfig } = useDashboardConfig()
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [editingWidget, setEditingWidget] = useState<string | null>(null)
|
||||
const [gridWidth, setGridWidth] = useState(1200)
|
||||
const gridContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const {
|
||||
data: telemetryFeed,
|
||||
isLoading: telemetryLoading,
|
||||
isFetching: telemetryFetching,
|
||||
refetch: refetchTelemetry,
|
||||
} = useQuery({
|
||||
queryKey: ['telemetry', 'feed', { page_size: 200 }],
|
||||
queryFn: async (): Promise<TelemetryQueryResult> => {
|
||||
const response = await telemetryApi.query({ page_size: 200 })
|
||||
return response.data
|
||||
},
|
||||
refetchInterval: 15000,
|
||||
})
|
||||
|
||||
const telemetrySamples = useMemo<Telemetry[]>(() => {
|
||||
if (!telemetryFeed) {
|
||||
return []
|
||||
// Update grid width on resize
|
||||
useEffect(() => {
|
||||
const updateWidth = () => {
|
||||
if (gridContainerRef.current) {
|
||||
setGridWidth(gridContainerRef.current.offsetWidth)
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(telemetryFeed)) {
|
||||
return telemetryFeed
|
||||
}
|
||||
updateWidth()
|
||||
window.addEventListener('resize', updateWidth)
|
||||
return () => window.removeEventListener('resize', updateWidth)
|
||||
}, [])
|
||||
|
||||
const maybeResults = telemetryFeed.results
|
||||
if (Array.isArray(maybeResults)) {
|
||||
return maybeResults
|
||||
}
|
||||
|
||||
return []
|
||||
}, [telemetryFeed])
|
||||
|
||||
const metricSummaries = useMemo<MetricSummary[]>(() => {
|
||||
if (!telemetrySamples.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
const groups = new Map<string, MetricSummary>()
|
||||
|
||||
telemetrySamples.forEach((sample) => {
|
||||
const metricKey = sample.metric.toLowerCase()
|
||||
if (!groups.has(metricKey)) {
|
||||
const label = sample.metric
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/\b\w/g, (char) => char.toUpperCase())
|
||||
|
||||
groups.set(metricKey, {
|
||||
metricKey,
|
||||
label,
|
||||
unit: sample.unit,
|
||||
samples: [],
|
||||
average: 0,
|
||||
count: 0,
|
||||
const handleLayoutChange = (newLayout: GridLayout.Layout[]) => {
|
||||
// Update widget positions when layout changes
|
||||
newLayout.forEach((item) => {
|
||||
const widget = config.widgets.find((w) => w.id === item.i)
|
||||
if (widget) {
|
||||
updateWidget(item.i, {
|
||||
position: {
|
||||
x: item.x,
|
||||
y: item.y,
|
||||
w: item.w,
|
||||
h: item.h,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
groups.get(metricKey)!.samples.push({ time: sample.time, value: sample.value })
|
||||
})
|
||||
|
||||
return Array.from(groups.values())
|
||||
.map((group) => {
|
||||
const ordered = [...group.samples].sort(
|
||||
(a, b) => new Date(a.time).getTime() - new Date(b.time).getTime(),
|
||||
)
|
||||
|
||||
const total = ordered.reduce((acc, cur) => acc + Number(cur.value), 0)
|
||||
const average = total / ordered.length
|
||||
const latest = ordered.at(-1)
|
||||
const earliest = ordered[0]
|
||||
const change = latest && earliest ? latest.value - earliest.value : undefined
|
||||
|
||||
return {
|
||||
...group,
|
||||
samples: ordered,
|
||||
latest,
|
||||
earliest,
|
||||
average,
|
||||
change,
|
||||
count: ordered.length,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => b.count - a.count)
|
||||
}, [telemetrySamples])
|
||||
|
||||
const primaryMetric = useMemo<MetricSummary | undefined>(() => {
|
||||
if (!metricSummaries.length) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const prefersTrend = metricSummaries.find(
|
||||
(metric) => metric.count > 1 && metric.metricKey.includes('temp'),
|
||||
)
|
||||
|
||||
if (prefersTrend) {
|
||||
return prefersTrend
|
||||
}
|
||||
|
||||
const anyWithTrend = metricSummaries.find((metric) => metric.count > 1)
|
||||
if (anyWithTrend) {
|
||||
return anyWithTrend
|
||||
}
|
||||
|
||||
return metricSummaries[0]
|
||||
}, [metricSummaries])
|
||||
|
||||
const isLoading = overviewLoading && telemetryLoading
|
||||
|
||||
const formatValue = (value?: number, unit?: string) => {
|
||||
if (value === undefined || Number.isNaN(value)) {
|
||||
return '—'
|
||||
}
|
||||
|
||||
const rounded = Number.isInteger(value) ? value : Number(value.toFixed(1))
|
||||
return unit ? `${rounded} ${unit}` : `${rounded}`
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<span className="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
)
|
||||
const layout = config.widgets.map((widget) => ({
|
||||
i: widget.id,
|
||||
x: widget.position?.x || 0,
|
||||
y: widget.position?.y || 0,
|
||||
w: widget.position?.w || 1,
|
||||
h: widget.position?.h || 2,
|
||||
minW: 1,
|
||||
minH: 1,
|
||||
maxW: 4,
|
||||
}))
|
||||
|
||||
const handleExport = () => {
|
||||
const json = exportConfig()
|
||||
const blob = new Blob([json], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `dashboard-config-${new Date().toISOString().split('T')[0]}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const handleImport = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => {
|
||||
try {
|
||||
const text = event.target?.result as string
|
||||
const parsed = JSON.parse(text)
|
||||
importConfig(parsed)
|
||||
alert('Dashboard configuration imported successfully!')
|
||||
} catch (error) {
|
||||
alert('Failed to import configuration')
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
reader.readAsText(file)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-10">
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Environment Overview</h1>
|
||||
<h1 className="text-3xl font-bold">Dashboard</h1>
|
||||
<p className="text-base-content/70">
|
||||
Live snapshot of workplace telemetry and system health. Focus on environmental
|
||||
trends—device controls are just a click away.
|
||||
Customize your view with modular widgets
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-outline btn-sm w-full sm:w-auto"
|
||||
onClick={() => {
|
||||
refetchOverview()
|
||||
refetchTelemetry()
|
||||
}}
|
||||
disabled={overviewFetching || telemetryFetching}
|
||||
>
|
||||
{overviewFetching || telemetryFetching ? (
|
||||
<span className="loading loading-spinner loading-xs"></span>
|
||||
) : (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9M20 20v-5h-.581m-15.357-2a8.003 8.003 0 0115.357 2"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={handleExport}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
)}
|
||||
<span className="ml-2">Refresh</span>
|
||||
</button>
|
||||
Export
|
||||
</button>
|
||||
<label className="btn btn-outline btn-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
Import
|
||||
<input
|
||||
type="file"
|
||||
accept="application/json"
|
||||
className="hidden"
|
||||
onChange={handleImport}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add Widget
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Environmental Snapshot */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">Environmental Snapshot</h2>
|
||||
{telemetryLoading && !metricSummaries.length ? (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[1, 2, 3].map((key) => (
|
||||
<div key={key} className="card bg-base-200 animate-pulse">
|
||||
<div className="card-body h-32"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : metricSummaries.length ? (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{metricSummaries.slice(0, 3).map((metric) => (
|
||||
<div key={metric.metricKey} className="card bg-base-100 shadow">
|
||||
<div className="card-body">
|
||||
<div className="text-sm uppercase tracking-wide text-base-content/60">
|
||||
{metric.label}
|
||||
</div>
|
||||
<div className="text-4xl font-bold text-primary">
|
||||
{formatValue(metric.latest?.value, metric.unit)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm text-base-content/60">
|
||||
<span>Avg (last {metric.count})</span>
|
||||
<span>{formatValue(metric.average, metric.unit)}</span>
|
||||
</div>
|
||||
{metric.change !== undefined && metric.change !== 0 && (
|
||||
<div
|
||||
className={`text-sm font-medium ${
|
||||
metric.change > 0 ? 'text-warning' : 'text-success'
|
||||
}`}
|
||||
>
|
||||
{metric.change > 0 ? '+' : ''}
|
||||
{formatValue(metric.change, metric.unit)} since first sample
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="card bg-base-200">
|
||||
<div className="card-body text-sm text-base-content/70">
|
||||
No telemetry ingested yet. Connect devices or publish MQTT data to see environmental metrics.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Featured Trend */}
|
||||
{primaryMetric && (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">Featured Trend</h2>
|
||||
<TelemetryTrendCard
|
||||
title={primaryMetric.label}
|
||||
data={primaryMetric.samples}
|
||||
unit={primaryMetric.unit}
|
||||
subtitle={`Latest ${primaryMetric.count} readings`}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Stats Grid */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">System Health</h2>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="stats shadow">
|
||||
<div className="stat">
|
||||
<div className="stat-figure text-primary">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
className="inline-block w-8 h-8 stroke-current"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="stat-title">Total Devices</div>
|
||||
<div className="stat-value text-primary">{overview?.total_devices ?? 0}</div>
|
||||
<div className="stat-desc">Registered in system</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stats shadow">
|
||||
<div className="stat">
|
||||
<div className="stat-figure text-success">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
className="inline-block w-8 h-8 stroke-current"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="stat-title">Active Devices</div>
|
||||
<div className="stat-value text-success">{overview?.active_devices ?? 0}</div>
|
||||
<div className="stat-desc">Currently online</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stats shadow">
|
||||
<div className="stat">
|
||||
<div className="stat-figure text-secondary">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
className="inline-block w-8 h-8 stroke-current"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-15.857 21.213 0"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="stat-title">MQTT Devices</div>
|
||||
<div className="stat-value text-secondary">{overview?.mqtt_devices ?? 0}</div>
|
||||
<div className="stat-desc">Using mTLS</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stats shadow">
|
||||
<div className="stat">
|
||||
<div className="stat-figure text-warning">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
className="inline-block w-8 h-8 stroke-current"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="stat-title">Expiring Soon</div>
|
||||
<div className="stat-value text-warning">
|
||||
{overview?.certificates_expiring_soon ?? 0}
|
||||
</div>
|
||||
<div className="stat-desc">Certificates need renewal</div>
|
||||
</div>
|
||||
{config.widgets.length === 0 ? (
|
||||
<div className="card bg-base-200 shadow-lg">
|
||||
<div className="card-body items-center text-center py-16">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-24 w-24 text-base-content/20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
<h2 className="text-2xl font-bold mt-4">No Widgets Yet</h2>
|
||||
<p className="text-base-content/60 max-w-md">
|
||||
Get started by adding your first widget. Choose from line charts, stat cards, gauges, or AI insights.
|
||||
</p>
|
||||
<button
|
||||
className="btn btn-primary mt-6"
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add Your First Widget
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<div className="w-full" ref={gridContainerRef}>
|
||||
<GridLayout
|
||||
className="layout"
|
||||
layout={layout}
|
||||
cols={4}
|
||||
rowHeight={150}
|
||||
width={gridWidth}
|
||||
onLayoutChange={handleLayoutChange}
|
||||
draggableHandle=".drag-handle"
|
||||
compactType="vertical"
|
||||
preventCollision={false}
|
||||
isResizable={true}
|
||||
isDraggable={true}
|
||||
>
|
||||
{config.widgets.map((widget) => (
|
||||
<div key={widget.id}>
|
||||
<WidgetContainer
|
||||
config={widget}
|
||||
onRemove={() => removeWidget(widget.id)}
|
||||
onEdit={() => setEditingWidget(widget.id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</GridLayout>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Telemetry */}
|
||||
{overview?.recent_telemetry?.length ? (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-2xl font-bold">Recent Telemetry</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Device</th>
|
||||
<th>Metric</th>
|
||||
<th>Value</th>
|
||||
<th>Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{overview.recent_telemetry.map((t, idx) => (
|
||||
<tr key={`${t.device_id}-${t.metric}-${idx}`} className="hover">
|
||||
<td>
|
||||
<div className="font-bold">{t.device_name}</div>
|
||||
<div className="text-sm opacity-50">{t.device_id}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="badge badge-ghost">{t.metric}</div>
|
||||
</td>
|
||||
<td className="font-mono font-semibold">
|
||||
{formatValue(t.value, t.unit)}
|
||||
</td>
|
||||
<td className="text-sm opacity-70">
|
||||
{new Date(t.time).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
<AddWidgetModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onAdd={(widget) => {
|
||||
addWidget(widget)
|
||||
setIsModalOpen(false)
|
||||
}}
|
||||
/>
|
||||
|
||||
<EditWidgetModal
|
||||
isOpen={editingWidget !== null}
|
||||
widget={config.widgets.find((w) => w.id === editingWidget) || null}
|
||||
onClose={() => setEditingWidget(null)}
|
||||
onSave={(widgetId, updates) => {
|
||||
updateWidget(widgetId, updates)
|
||||
setEditingWidget(null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { devicesApi } from '../api'
|
||||
import type { Device } from '../types/api'
|
||||
import { dashboardApi, devicesApi } from '../api'
|
||||
import type { DashboardOverview, Device } from '../types/api'
|
||||
import DeleteDeviceDialog from '../components/DeleteDeviceDialog'
|
||||
import RevokeDialog from '../components/RevokeDialog'
|
||||
import RenewDialog from '../components/RenewDialog'
|
||||
@@ -11,7 +11,7 @@ export default function DeviceList() {
|
||||
const [deleteDevice, setDeleteDevice] = useState<Device | null>(null)
|
||||
const [revokeDevice, setRevokeDevice] = useState<Device | null>(null)
|
||||
const [renewDevice, setRenewDevice] = useState<Device | null>(null)
|
||||
const { data: devicesData, isLoading } = useQuery({
|
||||
const { data: devicesData, isLoading: devicesLoading } = useQuery({
|
||||
queryKey: ['devices'],
|
||||
queryFn: async () => {
|
||||
const response = await devicesApi.getAll()
|
||||
@@ -19,9 +19,18 @@ export default function DeviceList() {
|
||||
},
|
||||
})
|
||||
|
||||
const { data: overview } = useQuery({
|
||||
queryKey: ['dashboard', 'overview'],
|
||||
queryFn: async (): Promise<DashboardOverview> => {
|
||||
const response = await dashboardApi.getOverview()
|
||||
return response.data
|
||||
},
|
||||
staleTime: 5000,
|
||||
})
|
||||
|
||||
const devices = devicesData?.results || []
|
||||
|
||||
if (isLoading) {
|
||||
if (devicesLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<span className="loading loading-spinner loading-lg"></span>
|
||||
@@ -41,6 +50,120 @@ export default function DeviceList() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<section className="mb-8 space-y-4">
|
||||
<h2 className="text-xl font-semibold">System Health</h2>
|
||||
{overview ? (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="stats shadow">
|
||||
<div className="stat">
|
||||
<div className="stat-figure text-primary">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
className="inline-block w-8 h-8 stroke-current"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="stat-title">Total Devices</div>
|
||||
<div className="stat-value text-primary">{overview.total_devices}</div>
|
||||
<div className="stat-desc">Registered in system</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stats shadow">
|
||||
<div className="stat">
|
||||
<div className="stat-figure text-success">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
className="inline-block w-8 h-8 stroke-current"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="stat-title">Active Devices</div>
|
||||
<div className="stat-value text-success">{overview.active_devices}</div>
|
||||
<div className="stat-desc">Currently online</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stats shadow">
|
||||
<div className="stat">
|
||||
<div className="stat-figure text-secondary">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
className="inline-block w-8 h-8 stroke-current"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-15.857 21.213 0"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="stat-title">MQTT Devices</div>
|
||||
<div className="stat-value text-secondary">{overview.mqtt_devices}</div>
|
||||
<div className="stat-desc">Using mTLS</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stats shadow">
|
||||
<div className="stat">
|
||||
<div className="stat-figure text-warning">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
className="inline-block w-8 h-8 stroke-current"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="stat-title">Expiring Soon</div>
|
||||
<div className="stat-value text-warning">
|
||||
{overview.certificates_expiring_soon}
|
||||
</div>
|
||||
<div className="stat-desc">Certificates need renewal</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{[1, 2, 3, 4].map((key) => (
|
||||
<div key={key} className="stats shadow animate-pulse">
|
||||
<div className="stat">
|
||||
<div className="stat-figure w-8 h-8 rounded-full bg-base-200"></div>
|
||||
<div className="stat-title bg-base-200 h-4 w-24 rounded"></div>
|
||||
<div className="stat-value bg-base-200 h-6 w-20 rounded mt-2"></div>
|
||||
<div className="stat-desc bg-base-200 h-4 w-28 rounded mt-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="table table-zebra w-full">
|
||||
<thead>
|
||||
|
||||
64
frontend/src/utils/formatters.ts
Normal file
64
frontend/src/utils/formatters.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Format metric names for display
|
||||
*/
|
||||
export function formatMetricName(metric: string): string {
|
||||
// Known metric mappings
|
||||
const metricNames: Record<string, string> = {
|
||||
'temperature': 'Temperature',
|
||||
'humidity': 'Humidity',
|
||||
'co2': 'CO₂',
|
||||
'pressure': 'Pressure',
|
||||
'light': 'Light Level',
|
||||
'noise': 'Noise Level',
|
||||
'pm25': 'PM2.5',
|
||||
'voc': 'VOC',
|
||||
}
|
||||
|
||||
// Return mapped name or capitalize the metric
|
||||
return metricNames[metric.toLowerCase()] ||
|
||||
metric.replace(/_/g, ' ')
|
||||
.split(' ')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Format device name for display
|
||||
*/
|
||||
export function formatDeviceName(deviceName: string): string {
|
||||
return deviceName
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/-/g, ' ')
|
||||
.split(' ')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a default widget title based on type, device, and metrics
|
||||
*/
|
||||
export function createDefaultWidgetTitle(
|
||||
type: string,
|
||||
deviceName: string | undefined,
|
||||
metrics: string[]
|
||||
): string {
|
||||
const formattedMetrics = metrics.map(formatMetricName).join(' & ')
|
||||
|
||||
switch (type) {
|
||||
case 'line-chart':
|
||||
if (metrics.length > 1) {
|
||||
return `${formattedMetrics}`
|
||||
}
|
||||
return `${formattedMetrics}`
|
||||
case 'stat':
|
||||
return `${formattedMetrics}`
|
||||
case 'gauge':
|
||||
return `${formattedMetrics}`
|
||||
case 'ai-insight':
|
||||
return deviceName
|
||||
? `AI Insights - ${formatDeviceName(deviceName)}`
|
||||
: 'AI Insights'
|
||||
default:
|
||||
return formattedMetrics
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user