Introduced GPT/AI service, added dashboard widgets, drag and drop, export import.

This commit is contained in:
2025-11-09 23:56:13 +01:00
parent 8e98f5ad7d
commit 7921049f56
36 changed files with 3498 additions and 403 deletions

View File

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

View File

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

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

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

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