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

@@ -3,6 +3,8 @@ The following concise instructions help AI coding agents become productive in th
Purpose Purpose
- This repo is a microservices-based IoT platform for device management, data ingestion, and telemetry storage. The system uses MQTT with mTLS authentication, Redis streams for message queuing, and PostgreSQL/TimescaleDB for persistent storage. - This repo is a microservices-based IoT platform for device management, data ingestion, and telemetry storage. The system uses MQTT with mTLS authentication, Redis streams for message queuing, and PostgreSQL/TimescaleDB for persistent storage.
Never forget to involve human developers for more complex tasks and decisions. You are encouraged to ask more.
Big Picture Big Picture
- Architecture: Device → MQTT (mTLS) → mqtt_ingestion → Redis → db_write → PostgreSQL/TimescaleDB - Architecture: Device → MQTT (mTLS) → mqtt_ingestion → Redis → db_write → PostgreSQL/TimescaleDB
- Components: - Components:

View File

@@ -2,9 +2,9 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <!-- <link rel="icon" type="image/svg+xml" href="/vite.svg" /> -->
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title> <title>iotDashboard</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -13,12 +13,14 @@
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-toast": "^1.2.15", "@radix-ui/react-toast": "^1.2.15",
"@tanstack/react-query": "^5.62.8", "@tanstack/react-query": "^5.62.8",
"@types/react-grid-layout": "^1.3.5",
"axios": "^1.7.9", "axios": "^1.7.9",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"lucide-react": "^0.462.0", "lucide-react": "^0.462.0",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-grid-layout": "^1.5.2",
"react-hook-form": "^7.54.2", "react-hook-form": "^7.54.2",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"react-router": "^7.1.0", "react-router": "^7.1.0",
@@ -2518,7 +2520,6 @@
"version": "19.2.2", "version": "19.2.2",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.0.2" "csstype": "^3.0.2"
@@ -2534,6 +2535,15 @@
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
}, },
"node_modules/@types/react-grid-layout": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/react-grid-layout/-/react-grid-layout-1.3.5.tgz",
"integrity": "sha512-WH/po1gcEcoR6y857yAnPGug+ZhkF4PaTUxgAbwfeSH/QOgVSakKHBXoPGad/sEznmkiaK3pqHk+etdWisoeBQ==",
"license": "MIT",
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.46.3", "version": "8.46.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.3.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.3.tgz",
@@ -4953,6 +4963,44 @@
"react": "^19.2.0" "react": "^19.2.0"
} }
}, },
"node_modules/react-draggable": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.5.0.tgz",
"integrity": "sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw==",
"license": "MIT",
"dependencies": {
"clsx": "^2.1.1",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"react": ">= 16.3.0",
"react-dom": ">= 16.3.0"
}
},
"node_modules/react-grid-layout": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-1.5.2.tgz",
"integrity": "sha512-vT7xmQqszTT+sQw/LfisrEO4le1EPNnSEMVHy6sBZyzS3yGkMywdOd+5iEFFwQwt0NSaGkxuRmYwa1JsP6OJdw==",
"license": "MIT",
"dependencies": {
"clsx": "^2.1.1",
"fast-equals": "^4.0.3",
"prop-types": "^15.8.1",
"react-draggable": "^4.4.6",
"react-resizable": "^3.0.5",
"resize-observer-polyfill": "^1.5.1"
},
"peerDependencies": {
"react": ">= 16.3.0",
"react-dom": ">= 16.3.0"
}
},
"node_modules/react-grid-layout/node_modules/fast-equals": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz",
"integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==",
"license": "MIT"
},
"node_modules/react-hook-form": { "node_modules/react-hook-form": {
"version": "7.66.0", "version": "7.66.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz",
@@ -5049,6 +5097,19 @@
} }
} }
}, },
"node_modules/react-resizable": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.0.5.tgz",
"integrity": "sha512-vKpeHhI5OZvYn82kXOs1bC8aOXktGU5AmKAgaZS4F5JPburCtbmDPqE7Pzp+1kN4+Wb81LlF33VpGwWwtXem+w==",
"license": "MIT",
"dependencies": {
"prop-types": "15.x",
"react-draggable": "^4.0.3"
},
"peerDependencies": {
"react": ">= 16.3"
}
},
"node_modules/react-router": { "node_modules/react-router": {
"version": "7.9.5", "version": "7.9.5",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.5.tgz", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.5.tgz",
@@ -5172,6 +5233,12 @@
"decimal.js-light": "^2.4.1" "decimal.js-light": "^2.4.1"
} }
}, },
"node_modules/resize-observer-polyfill": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==",
"license": "MIT"
},
"node_modules/resolve-from": { "node_modules/resolve-from": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",

View File

@@ -15,12 +15,14 @@
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-toast": "^1.2.15", "@radix-ui/react-toast": "^1.2.15",
"@tanstack/react-query": "^5.62.8", "@tanstack/react-query": "^5.62.8",
"@types/react-grid-layout": "^1.3.5",
"axios": "^1.7.9", "axios": "^1.7.9",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"lucide-react": "^0.462.0", "lucide-react": "^0.462.0",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-grid-layout": "^1.5.2",
"react-hook-form": "^7.54.2", "react-hook-form": "^7.54.2",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"react-router": "^7.1.0", "react-router": "^7.1.0",

View File

@@ -87,3 +87,79 @@
transform: rotate(360deg); 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 }), apiClient.get<PaginatedResponse<Telemetry>>('/telemetry/latest/', { params }),
getMetrics: () => apiClient.get<{ metrics: string[] }>('/telemetry/metrics/'), 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 // 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 <XAxis
dataKey="time" dataKey="time"
tickFormatter={formatTimeLabel} tickFormatter={formatTimeLabel}
tick={{ fontSize: 12 }} tick={{ fontSize: 12, fill: '#ffffff' }}
stroke="hsl(var(--bc) / 0.3)" stroke="rgba(255, 255, 255, 0.3)"
/> />
<YAxis <YAxis
tickFormatter={(val) => formatValue(val, unit)} tickFormatter={(val) => formatValue(val, unit)}
width={48} width={48}
tick={{ fontSize: 12 }} tick={{ fontSize: 12, fill: '#ffffff' }}
stroke="hsl(var(--bc) / 0.3)" stroke="rgba(255, 255, 255, 0.3)"
/> />
<Tooltip <Tooltip
formatter={(value: number) => formatValue(value, unit)} 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 { useState, useRef, useEffect } from 'react'
import { useQuery } from '@tanstack/react-query' import GridLayout from 'react-grid-layout'
import { dashboardApi, telemetryApi } from '../api' import 'react-grid-layout/css/styles.css'
import TelemetryTrendCard from '../components/dashboard/TelemetryTrendCard' import { useDashboardConfig } from '../hooks'
import type { DashboardOverview, Telemetry } from '../types/api' import { WidgetContainer } from '../components/widgets'
import AddWidgetModal from '../components/AddWidgetModal'
type TelemetryQueryResult = Telemetry[] | { results?: Telemetry[] } import EditWidgetModal from '../components/EditWidgetModal'
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
}
export default function Dashboard() { export default function Dashboard() {
const { const { config, addWidget, removeWidget, updateWidget, exportConfig, importConfig } = useDashboardConfig()
data: overview, const [isModalOpen, setIsModalOpen] = useState(false)
isLoading: overviewLoading, const [editingWidget, setEditingWidget] = useState<string | null>(null)
isFetching: overviewFetching, const [gridWidth, setGridWidth] = useState(1200)
refetch: refetchOverview, const gridContainerRef = useRef<HTMLDivElement>(null)
} = useQuery({
queryKey: ['dashboard', 'overview'],
queryFn: async (): Promise<DashboardOverview> => {
const response = await dashboardApi.getOverview()
return response.data
},
refetchInterval: 5000,
})
const { // Update grid width on resize
data: telemetryFeed, useEffect(() => {
isLoading: telemetryLoading, const updateWidth = () => {
isFetching: telemetryFetching, if (gridContainerRef.current) {
refetch: refetchTelemetry, setGridWidth(gridContainerRef.current.offsetWidth)
} = 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 []
} }
if (Array.isArray(telemetryFeed)) { updateWidth()
return telemetryFeed window.addEventListener('resize', updateWidth)
} return () => window.removeEventListener('resize', updateWidth)
}, [])
const maybeResults = telemetryFeed.results const handleLayoutChange = (newLayout: GridLayout.Layout[]) => {
if (Array.isArray(maybeResults)) { // Update widget positions when layout changes
return maybeResults newLayout.forEach((item) => {
} const widget = config.widgets.find((w) => w.id === item.i)
if (widget) {
return [] updateWidget(item.i, {
}, [telemetryFeed]) position: {
x: item.x,
const metricSummaries = useMemo<MetricSummary[]>(() => { y: item.y,
if (!telemetrySamples.length) { w: item.w,
return [] h: item.h,
} },
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,
}) })
} }
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) { const layout = config.widgets.map((widget) => ({
return ( i: widget.id,
<div className="flex items-center justify-center min-h-screen"> x: widget.position?.x || 0,
<span className="loading loading-spinner loading-lg"></span> y: widget.position?.y || 0,
</div> 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 ( 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 className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div> <div>
<h1 className="text-3xl font-bold">Environment Overview</h1> <h1 className="text-3xl font-bold">Dashboard</h1>
<p className="text-base-content/70"> <p className="text-base-content/70">
Live snapshot of workplace telemetry and system health. Focus on environmental Customize your view with modular widgets
trendsdevice controls are just a click away.
</p> </p>
</div> </div>
<button <div className="flex gap-2">
className="btn btn-outline btn-sm w-full sm:w-auto" <button
onClick={() => { className="btn btn-outline btn-sm"
refetchOverview() onClick={handleExport}
refetchTelemetry() >
}} <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
disabled={overviewFetching || telemetryFetching} <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" />
>
{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"
/>
</svg> </svg>
)} Export
<span className="ml-2">Refresh</span> </button>
</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> </div>
{/* Environmental Snapshot */} {config.widgets.length === 0 ? (
<section className="space-y-4"> <div className="card bg-base-200 shadow-lg">
<h2 className="text-xl font-semibold">Environmental Snapshot</h2> <div className="card-body items-center text-center py-16">
{telemetryLoading && !metricSummaries.length ? ( <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">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"> <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" />
{[1, 2, 3].map((key) => ( </svg>
<div key={key} className="card bg-base-200 animate-pulse"> <h2 className="text-2xl font-bold mt-4">No Widgets Yet</h2>
<div className="card-body h-32"></div> <p className="text-base-content/60 max-w-md">
</div> Get started by adding your first widget. Choose from line charts, stat cards, gauges, or AI insights.
))} </p>
</div> <button
) : metricSummaries.length ? ( className="btn btn-primary mt-6"
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"> onClick={() => setIsModalOpen(true)}
{metricSummaries.slice(0, 3).map((metric) => ( >
<div key={metric.metricKey} className="card bg-base-100 shadow"> <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<div className="card-body"> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
<div className="text-sm uppercase tracking-wide text-base-content/60"> </svg>
{metric.label} Add Your First Widget
</div> </button>
<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>
</div> </div>
</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 */} <AddWidgetModal
{overview?.recent_telemetry?.length ? ( isOpen={isModalOpen}
<section className="space-y-4"> onClose={() => setIsModalOpen(false)}
<h2 className="text-2xl font-bold">Recent Telemetry</h2> onAdd={(widget) => {
<div className="overflow-x-auto"> addWidget(widget)
<table className="table table-zebra"> setIsModalOpen(false)
<thead> }}
<tr> />
<th>Device</th>
<th>Metric</th> <EditWidgetModal
<th>Value</th> isOpen={editingWidget !== null}
<th>Time</th> widget={config.widgets.find((w) => w.id === editingWidget) || null}
</tr> onClose={() => setEditingWidget(null)}
</thead> onSave={(widgetId, updates) => {
<tbody> updateWidget(widgetId, updates)
{overview.recent_telemetry.map((t, idx) => ( setEditingWidget(null)
<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}
</div> </div>
) )
} }

View File

@@ -1,8 +1,8 @@
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useState } from 'react' import { useState } from 'react'
import { devicesApi } from '../api' import { dashboardApi, devicesApi } from '../api'
import type { Device } from '../types/api' import type { DashboardOverview, Device } from '../types/api'
import DeleteDeviceDialog from '../components/DeleteDeviceDialog' import DeleteDeviceDialog from '../components/DeleteDeviceDialog'
import RevokeDialog from '../components/RevokeDialog' import RevokeDialog from '../components/RevokeDialog'
import RenewDialog from '../components/RenewDialog' import RenewDialog from '../components/RenewDialog'
@@ -11,7 +11,7 @@ export default function DeviceList() {
const [deleteDevice, setDeleteDevice] = useState<Device | null>(null) const [deleteDevice, setDeleteDevice] = useState<Device | null>(null)
const [revokeDevice, setRevokeDevice] = useState<Device | null>(null) const [revokeDevice, setRevokeDevice] = useState<Device | null>(null)
const [renewDevice, setRenewDevice] = useState<Device | null>(null) const [renewDevice, setRenewDevice] = useState<Device | null>(null)
const { data: devicesData, isLoading } = useQuery({ const { data: devicesData, isLoading: devicesLoading } = useQuery({
queryKey: ['devices'], queryKey: ['devices'],
queryFn: async () => { queryFn: async () => {
const response = await devicesApi.getAll() 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 || [] const devices = devicesData?.results || []
if (isLoading) { if (devicesLoading) {
return ( return (
<div className="flex items-center justify-center min-h-screen"> <div className="flex items-center justify-center min-h-screen">
<span className="loading loading-spinner loading-lg"></span> <span className="loading loading-spinner loading-lg"></span>
@@ -41,6 +50,120 @@ export default function DeviceList() {
</Link> </Link>
</div> </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"> <div className="overflow-x-auto">
<table className="table table-zebra w-full"> <table className="table table-zebra w-full">
<thead> <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
}
}

View File

@@ -2,24 +2,22 @@
from datetime import timedelta from datetime import timedelta
from django.utils import timezone from django.utils import timezone
from django.db.models import Q, Count
from rest_framework import viewsets, status from rest_framework import viewsets, status
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated from asgiref.sync import async_to_sync
from iotDashboard.models import Device, DeviceCertificate, Telemetry from iotDashboard.models import Device, DeviceCertificate, Telemetry
from iotDashboard.device_manager_client import ( from iotDashboard.device_manager_client import (
DeviceManagerClient, DeviceManagerClient,
DeviceManagerAPIError DeviceManagerAPIError
) )
from iotDashboard import gpt_service_client
from .serializers import ( from .serializers import (
DeviceSerializer, DeviceSerializer,
DeviceCreateSerializer, DeviceCreateSerializer,
DeviceCertificateSerializer,
TelemetrySerializer, TelemetrySerializer,
DashboardOverviewSerializer, DashboardOverviewSerializer,
DeviceMetricsSerializer,
) )
@@ -248,6 +246,91 @@ class TelemetryViewSet(viewsets.ReadOnlyModelViewSet):
) )
return Response({'metrics': list(metrics)}) return Response({'metrics': list(metrics)})
@action(detail=False, methods=['post'])
def analyze(self, request):
"""Analyze telemetry data using GPT service."""
# Parse request parameters
device_id = request.data.get('device_id')
metric = request.data.get('metric')
hours = int(request.data.get('hours', 24))
limit = int(request.data.get('limit', 100))
prompt_type = request.data.get('prompt_type', 'trend_summary')
custom_prompt = request.data.get('custom_prompt')
# Validate device_id
if not device_id:
return Response(
{'error': 'device_id is required'},
status=status.HTTP_400_BAD_REQUEST
)
try:
device = Device.objects.get(id=device_id)
except Device.DoesNotExist:
return Response(
{'error': f'Device {device_id} not found'},
status=status.HTTP_404_NOT_FOUND
)
# Query telemetry data
queryset = Telemetry.objects.filter(
device_id=device_id,
time__gte=timezone.now() - timedelta(hours=hours)
)
if metric:
queryset = queryset.filter(metric=metric)
telemetry = queryset.order_by('-time')[:limit]
if not telemetry:
return Response(
{'error': 'No telemetry data found for specified parameters'},
status=status.HTTP_404_NOT_FOUND
)
# Format data for GPT service
telemetry_data = [
{
'device_id': str(t.device_id),
'metric': t.metric,
'value': float(t.value),
'timestamp': t.time.isoformat()
}
for t in telemetry
]
# Device context
device_info = {
'name': device.name,
'location': device.location,
'protocol': device.protocol,
}
# Call GPT service
try:
result = async_to_sync(gpt_service_client.analyze_telemetry)(
telemetry_data=telemetry_data,
device_info=device_info,
prompt_type=prompt_type,
custom_prompt=custom_prompt
)
return Response({
'analysis': result.analysis,
'prompt_type': result.prompt_type,
'data_points_analyzed': result.data_points_analyzed
})
except gpt_service_client.GPTServiceError as e:
return Response(
{
'error': e.message,
'details': e.details,
'gpt_service_available': False
},
status=e.status_code or status.HTTP_503_SERVICE_UNAVAILABLE
)
class DashboardViewSet(viewsets.ViewSet): class DashboardViewSet(viewsets.ViewSet):
"""ViewSet for dashboard overview data.""" """ViewSet for dashboard overview data."""

View File

@@ -0,0 +1,114 @@
"""Client for GPT Service API."""
import httpx
from typing import List, Dict, Any, Optional, Literal
from dataclasses import dataclass
import logging
logger = logging.getLogger(__name__)
GPT_SERVICE_URL = "http://localhost:8001"
@dataclass
class AnalysisResponse:
"""Response from GPT service analysis."""
analysis: str
prompt_type: str
data_points_analyzed: int
class GPTServiceError(Exception):
"""Exception raised for GPT service API errors."""
def __init__(self, message: str, status_code: int = None, details: Any = None):
self.message = message
self.status_code = status_code
self.details = details
super().__init__(self.message)
async def analyze_telemetry(
telemetry_data: List[Dict[str, Any]],
device_info: Optional[Dict[str, Any]] = None,
prompt_type: Literal["anomaly_detection", "trend_summary", "custom"] = "trend_summary",
custom_prompt: Optional[str] = None
) -> AnalysisResponse:
"""
Analyze telemetry data using GPT service.
Args:
telemetry_data: List of dicts with device_id, metric, value, timestamp
device_info: Optional device metadata for context
prompt_type: Type of analysis (anomaly_detection, trend_summary, custom)
custom_prompt: Custom prompt for 'custom' type
Returns:
AnalysisResponse with analysis, prompt_type, and data_points_analyzed
Raises:
GPTServiceError: If the API request fails
"""
payload = {
"telemetry_data": telemetry_data,
"device_info": device_info or {},
"prompt_type": prompt_type,
"custom_prompt": custom_prompt
}
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
f"{GPT_SERVICE_URL}/analyze",
json=payload
)
if response.status_code == 200:
data = response.json()
return AnalysisResponse(
analysis=data['analysis'],
prompt_type=data['prompt_type'],
data_points_analyzed=data['data_points_analyzed']
)
else:
error_data = response.json() if response.text else {}
raise GPTServiceError(
message=error_data.get('detail', 'GPT service request failed'),
status_code=response.status_code,
details=error_data
)
except httpx.TimeoutException:
raise GPTServiceError(
message="GPT service request timed out",
status_code=504
)
except httpx.ConnectError:
raise GPTServiceError(
message="Could not connect to GPT service. Is it running on port 8001?",
status_code=503
)
except Exception as e:
if isinstance(e, GPTServiceError):
raise
logger.error(f"Unexpected error calling GPT service: {str(e)}")
raise GPTServiceError(
message=f"Unexpected error: {str(e)}",
status_code=500
)
async def health_check() -> bool:
"""
Check if GPT service is healthy.
Returns:
True if service is healthy, False otherwise
"""
try:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(f"{GPT_SERVICE_URL}/health")
return response.status_code == 200
except Exception as e:
logger.warning(f"GPT service health check failed: {str(e)}")
return False

View File

@@ -41,6 +41,7 @@ urlpatterns = [
# Telemetry data API # Telemetry data API
path("fetch_device_data/", views.fetch_device_data, name="fetch_device_data"), path("fetch_device_data/", views.fetch_device_data, name="fetch_device_data"),
path("analyze_data/", views.analyze_data, name="analyze_data"),
# Legacy/utility endpoints # Legacy/utility endpoints
path("devices_api/", views.devices_api, name="devices_api"), path("devices_api/", views.devices_api, name="devices_api"),

View File

@@ -8,14 +8,6 @@ from iotDashboard.device_manager_client import DeviceManagerClient, DeviceManage
device_manager = DeviceManagerClient() device_manager = DeviceManagerClient()
# def index(request):
# """Redirect to chart page."""
# if request.user.is_authenticated:
# return redirect("/chart/")
# return HttpResponse("NOT AUTHENTICATED!!!")
def chart(request): def chart(request):
"""Main dashboard showing telemetry charts.""" """Main dashboard showing telemetry charts."""
try: try:
@@ -261,3 +253,95 @@ def devices_api(request):
"""JSON API endpoint for devices.""" """JSON API endpoint for devices."""
devices = list(Device.objects.all().values("id", "name", "protocol", "location")) devices = list(Device.objects.all().values("id", "name", "protocol", "location"))
return JsonResponse(devices, safe=False) return JsonResponse(devices, safe=False)
def analyze_data(request):
"""Calling the GPT Service to analyze the data."""
from asgiref.sync import async_to_sync
from iotDashboard import gpt_service_client
from datetime import timedelta
from django.utils import timezone
if request.method != "POST":
return JsonResponse({"error": "POST method required"}, status=405)
try:
data = json.loads(request.body)
except json.JSONDecodeError:
return JsonResponse({"error": "Invalid JSON"}, status=400)
# Parse parameters
device_id = data.get('device_id')
metric = data.get('metric')
hours = int(data.get('hours', 24))
limit = int(data.get('limit', 100))
prompt_type = data.get('prompt_type', 'trend_summary')
custom_prompt = data.get('custom_prompt')
# Validate device_id
if not device_id:
return JsonResponse({"error": "device_id is required"}, status=400)
try:
device = Device.objects.get(id=device_id)
except Device.DoesNotExist:
return JsonResponse({"error": f"Device {device_id} not found"}, status=404)
# Query telemetry data
queryset = Telemetry.objects.filter(
device_id=device_id,
time__gte=timezone.now() - timedelta(hours=hours)
)
if metric:
queryset = queryset.filter(metric=metric)
telemetry = queryset.order_by('-time')[:limit]
if not telemetry:
return JsonResponse(
{"error": "No telemetry data found for specified parameters"},
status=404
)
# Format data for GPT service
telemetry_data = [
{
'device_id': str(t.device_id),
'metric': t.metric,
'value': float(t.value),
'timestamp': t.time.isoformat()
}
for t in telemetry
]
# Device context
device_info = {
'name': device.name,
'location': device.location,
'protocol': device.protocol,
}
# Call GPT service
try:
result = async_to_sync(gpt_service_client.analyze_telemetry)(
telemetry_data=telemetry_data,
device_info=device_info,
prompt_type=prompt_type,
custom_prompt=custom_prompt
)
return JsonResponse({
'analysis': result.analysis,
'prompt_type': result.prompt_type,
'data_points_analyzed': result.data_points_analyzed
})
except gpt_service_client.GPTServiceError as e:
return JsonResponse(
{
'error': e.message,
'details': e.details,
'gpt_service_available': False
},
status=e.status_code or 503
)

View File

@@ -11,6 +11,7 @@ dependencies = [
"django-rest>=0.8.7", "django-rest>=0.8.7",
"djangorestframework>=3.16.1", "djangorestframework>=3.16.1",
"djangorestframework-simplejwt>=5.5.1", "djangorestframework-simplejwt>=5.5.1",
"httpx>=0.28.1",
"openai>=2.6.1", "openai>=2.6.1",
"paho-mqtt>=2.1.0", "paho-mqtt>=2.1.0",
"psycopg2-binary>=2.9.11", "psycopg2-binary>=2.9.11",

248
scripts/README.md Normal file
View File

@@ -0,0 +1,248 @@
# MQTT Data Generator
A Python script that simulates realistic environmental sensor data and publishes it to your MQTT broker for testing the IoT Dashboard.
## Features
**8 Environmental Metrics**: Temperature, Humidity, CO2, Pressure, Light, Noise, PM2.5, VOC
**Realistic Patterns**: Daily cycles (office hours, night time)
**Random Walk**: Natural sensor drift and variations
**Anomaly Injection**: 5% chance of anomalies for testing alerts
**Self-Correcting**: Values drift back toward optimal ranges (simulates HVAC)
**TLS/mTLS Support**: Secure connections with certificates
## Installation
```bash
# Install dependencies
pip install paho-mqtt
# Or using uv
uv pip install paho-mqtt
```
## Quick Start
### 1. Basic Usage (Localhost, No TLS)
```bash
python scripts/mqtt_data_generator.py --device-id office-sensor-01 --interval 5
```
### 2. With Specific Metrics
```bash
python scripts/mqtt_data_generator.py \
--device-id lab-sensor \
--metrics temperature humidity co2 \
--interval 10
```
### 3. With TLS (Port 8883)
```bash
python scripts/mqtt_data_generator.py \
--device-id secure-sensor \
--broker localhost \
--port 8883 \
--tls \
--ca-cert infrastructure/mosquitto/certs/ca.crt \
--client-cert path/to/device.crt \
--client-key path/to/device.key \
--interval 5
```
### 4. Limited Duration (Testing)
```bash
# Run for 5 minutes
python scripts/mqtt_data_generator.py \
--device-id test-sensor \
--duration 300 \
--interval 2
```
## Command Line Arguments
| Argument | Required | Default | Description |
|----------|----------|---------|-------------|
| `--broker` | No | localhost | MQTT broker hostname |
| `--port` | No | 1883 | MQTT broker port (8883 for TLS) |
| `--device-id` | **Yes** | - | Device ID for MQTT topics |
| `--metrics` | No | all | Specific metrics to publish |
| `--interval` | No | 5 | Publish interval in seconds |
| `--duration` | No | 0 | Run duration in seconds (0 = infinite) |
| `--tls` | No | False | Enable TLS/SSL encryption |
| `--ca-cert` | No | - | Path to CA certificate |
| `--client-cert` | No | - | Path to client certificate (mTLS) |
| `--client-key` | No | - | Path to client private key (mTLS) |
## Available Metrics
All metrics follow the standards in `services/gpt_service/METRICS_REFERENCE.md`:
- **temperature** - Indoor temperature (15-28°C)
- **humidity** - Relative humidity (20-70%)
- **co2** - Carbon dioxide concentration (400-1500 ppm)
- **pressure** - Atmospheric pressure (1000-1030 hPa)
- **light** - Illuminance (200-1000 lux)
- **noise** - Sound level (30-70 dB)
- **pm25** - Fine particulate matter (0-50 µg/m³)
- **voc** - Volatile organic compounds (0-500 ppb)
## MQTT Topic Format
Published to standard format: `devices/{device_id}/{metric}`
Examples:
- `devices/office-sensor-01/temperature`
- `devices/office-sensor-01/humidity`
- `devices/lab-sensor/co2`
## Data Patterns
### Daily Cycles
- **Business hours (9-17)**: Higher CO2, temperature, noise
- **Night time (22-6)**: Lower light, CO2, noise
- **All day**: Natural variations within ranges
### Realistic Behavior
- **Random walk**: Small incremental changes
- **Self-correcting**: HVAC-like drift toward optimal ranges
- **Anomalies**: 5% chance of spikes/drops for alert testing
### Example Values
```
[14:23:45] Iteration 1
📊 temperature: 21.34 °C -> devices/office-sensor-01/temperature
📊 humidity: 45.67 % -> devices/office-sensor-01/humidity
📊 co2: 678.0 ppm -> devices/office-sensor-01/co2
📊 pressure: 1015.23 hPa -> devices/office-sensor-01/pressure
📊 light: 456.0 lux -> devices/office-sensor-01/light
📊 noise: 42.5 dB -> devices/office-sensor-01/noise
📊 pm25: 8.3 µg/m³ -> devices/office-sensor-01/pm25
📊 voc: 120.0 ppb -> devices/office-sensor-01/voc
```
## Multi-Device Simulation
Run multiple instances with different device IDs:
```bash
# Terminal 1 - Office sensor
python scripts/mqtt_data_generator.py --device-id office-01 &
# Terminal 2 - Lab sensor
python scripts/mqtt_data_generator.py --device-id lab-01 &
# Terminal 3 - Warehouse sensor
python scripts/mqtt_data_generator.py --device-id warehouse-01 &
```
## Testing Scenarios
### Normal Operations
```bash
python scripts/mqtt_data_generator.py \
--device-id normal-sensor \
--interval 5
```
### High-Frequency Monitoring
```bash
python scripts/mqtt_data_generator.py \
--device-id fast-sensor \
--interval 1
```
### Limited Metrics (CO2 monitoring)
```bash
python scripts/mqtt_data_generator.py \
--device-id co2-monitor \
--metrics co2 temperature humidity \
--interval 10
```
## Integration with IoT Dashboard
1. **Register device** (if using device_manager):
```bash
curl -X POST http://localhost:8000/devices/register \
-H "Content-Type: application/json" \
-d '{"name":"Office Sensor","location":"Building A - Floor 2"}'
```
2. **Start data generator** with the device ID:
```bash
python scripts/mqtt_data_generator.py --device-id <device_id>
```
3. **View in dashboard**: Data will appear in the frontend automatically
4. **Test AI analysis**: Use the AI Insights widget to analyze patterns
## Troubleshooting
### Connection Refused
- Check MQTT broker is running: `docker compose -f infrastructure/compose.yml ps`
- Verify port: 1883 (plain) or 8883 (TLS)
### TLS Certificate Errors
- Ensure CA certificate path is correct
- For mTLS, verify client cert/key match device registration
- Check certificate hasn't expired
### No Data in Dashboard
- Verify mqtt_ingestion service is running
- Check Redis stream: `redis-cli XLEN mqtt:ingestion`
- Verify db_write service is running
- Check device_id matches registered device
### High CPU Usage
- Increase `--interval` value
- Reduce number of metrics
- Run fewer instances
## Performance
- **CPU**: ~1-2% per instance at 5s interval
- **Memory**: ~20MB per instance
- **Network**: ~100 bytes per metric per publish
- **Recommended**: Max 10 instances on a single machine
## Example Output
```
============================================================
🌡️ MQTT Environmental Sensor Data Generator
============================================================
Device ID: office-sensor-01
Metrics: temperature, humidity, co2, pressure, light, noise, pm25, voc
Interval: 5s
Duration: Infinite
============================================================
✓ Connected to MQTT broker at localhost:1883
[14:23:45] Iteration 1
📊 temperature: 21.34 °C -> devices/office-sensor-01/temperature
📊 humidity: 45.67 % -> devices/office-sensor-01/humidity
📊 co2: 678.0 ppm -> devices/office-sensor-01/co2
📊 pressure: 1015.23 hPa -> devices/office-sensor-01/pressure
📊 light: 456.0 lux -> devices/office-sensor-01/light
📊 noise: 42.5 dB -> devices/office-sensor-01/noise
📊 pm25: 8.3 µg/m³ -> devices/office-sensor-01/pm25
📊 voc: 120.0 ppb -> devices/office-sensor-01/voc
[14:23:50] Iteration 2
...
```
## Stopping the Generator
- **Ctrl+C**: Graceful shutdown
- **Automatic**: If `--duration` specified
## License
Part of the IoT Dashboard project.

View File

@@ -1,6 +1,6 @@
API_KEY=your_api_key_here API_KEY=your_api_key_here
PROVIDER_NAME=openai PROVIDER_NAME=openai
MODEL_NAME=gpt-4.1 MODEL_NAME=gpt-4o-mini
HOST_URL= http://localhost:8000 HOST_URL= http://localhost:8000
LOG_LEVEL=INFO LOG_LEVEL=INFO

View File

@@ -0,0 +1,136 @@
# Environmental Monitoring Standards Reference
This document provides the industry-standard optimal ranges used by the GPT service for environmental analysis.
## Supported Metrics
### Temperature (°C)
- **Optimal Range**: 18-24°C
- **Comfort Zone**: 20-22°C
- **Critical Low**: <15°C
- **Critical High**: >28°C
- **Standards**: ASHRAE 55, ISO 7730
- **Key Concerns**:
- Worker comfort and productivity
- Equipment operating conditions
- Energy efficiency
- HVAC system performance
### Humidity (%)
- **Optimal Range**: 30-60%
- **Comfort Zone**: 40-50%
- **Critical Low**: <20%
- **Critical High**: >70%
- **Standards**: ASHRAE 55, WHO guidelines
- **Key Concerns**:
- Mold and mildew growth (>60%)
- Static electricity and equipment damage (<30%)
- Respiratory health and comfort
- Material degradation
### CO2 (ppm)
- **Optimal Range**: 400-1000ppm
- **Comfort Zone**: 400-800ppm
- **Critical Low**: <350ppm (unusual indoors)
- **Critical High**: >1500ppm
- **Standards**: ASHRAE 62.1, WHO Air Quality Guidelines
- **Key Concerns**:
- Air quality and ventilation effectiveness
- Cognitive performance (>1000ppm affects decision-making)
- Occupant health and alertness
- HVAC system efficiency
- **Impact**: Studies show 15% decline in cognitive function at 1400ppm
### Atmospheric Pressure (hPa)
- **Optimal Range**: 1013-1023hPa
- **Comfort Zone**: 1013-1020hPa
- **Critical Low**: <980hPa
- **Critical High**: >1050hPa
- **Key Concerns**:
- Weather changes and ventilation
- Building pressurization
- Equipment calibration
- Occupant comfort
### Light / Illuminance (lux)
- **Optimal Range**: 300-500 lux
- **Comfort Zone**: 400-500 lux
- **Critical Low**: <200 lux
- **Critical High**: >1000 lux
- **Standards**: EN 12464-1, IESNA recommendations
- **Key Concerns**:
- Visual comfort and eye strain
- Productivity and task performance
- Energy consumption
- Circadian rhythm regulation
- **Note**: Higher levels (750-1000 lux) for detailed work
### Noise (dB)
- **Optimal Range**: 30-50dB
- **Comfort Zone**: 35-45dB
- **Critical Low**: <20dB (unusual indoors)
- **Critical High**: >70dB
- **Standards**: WHO Noise Guidelines, OSHA
- **Key Concerns**:
- Acoustic comfort and concentration
- Speech intelligibility
- Stress and productivity impact
- Hearing protection requirements (>85dB)
- **Impact**: 40-45dB ideal for office work, <35dB for focused tasks
### PM2.5 - Fine Particulate Matter (µg/m³)
- **Optimal Range**: 0-12 µg/m³
- **Comfort Zone**: 0-10 µg/m³
- **Critical Low**: 0 µg/m³ (best)
- **Critical High**: >35 µg/m³
- **Standards**: EPA Air Quality Index, WHO guidelines
- **Key Concerns**:
- Air quality and health risk
- Respiratory system impact
- Filter maintenance requirements
- Outdoor air quality correlation
- **Impact**: >35 µg/m³ = Unhealthy for sensitive groups
### VOC - Volatile Organic Compounds (ppb)
- **Optimal Range**: 0-220ppb
- **Comfort Zone**: 0-150ppb
- **Critical Low**: 0ppb (best)
- **Critical High**: >500ppb
- **Standards**: Various indoor air quality standards
- **Key Concerns**:
- Indoor air quality
- Off-gassing from materials
- Ventilation effectiveness
- Occupant health symptoms (headaches, irritation)
## Analysis Approach
The GPT service uses these standards to:
1. **Assess Current Conditions**: Compare measurements against optimal ranges
2. **Identify Issues**: Flag deviations with severity levels
3. **Provide Context**: Explain health/productivity impacts
4. **Recommend Actions**: Suggest specific interventions (HVAC, ventilation, etc.)
5. **Predict Trends**: Forecast potential issues based on patterns
## Multi-Metric Correlation
When analyzing multiple metrics together, the service looks for:
- **HVAC Performance**: Temperature + Humidity + CO2 trends
- **Ventilation Effectiveness**: CO2 + PM2.5 + VOC levels
- **Occupancy Impact**: CO2 rise + Temperature increase + Humidity changes
- **Seasonal Patterns**: Pressure + Temperature + Humidity correlations
## References
- ASHRAE Standard 55: Thermal Environmental Conditions for Human Occupancy
- ASHRAE Standard 62.1: Ventilation for Acceptable Indoor Air Quality
- WHO Air Quality Guidelines
- ISO 7730: Ergonomics of the thermal environment
- EN 12464-1: Light and lighting of work places
- EPA Air Quality Index
- OSHA Occupational Noise Exposure Standards
## Usage in Prompts
The service automatically includes relevant standards in analysis prompts based on detected metrics. No manual configuration needed - just send your telemetry data!

View File

@@ -0,0 +1,240 @@
# GPT Service
FastAPI microservice for AI-powered environmental telemetry analysis using OpenAI GPT models.
## Purpose
Provides intelligent analysis of IoT environmental sensor data to ensure optimal working conditions. The service specializes in monitoring indoor environmental quality metrics (temperature, humidity, CO2, etc.) with context-aware insights based on industry standards (ASHRAE, WHO, OSHA).
**Goal**: Smart IoT dashboard that maintains healthy, comfortable, and productive work environments through AI-driven insights.
## Architecture
- **Input**: Telemetry data + device context from Django
- **Processing**: OpenAI GPT API with specialized prompts
- **Output**: Actionable insights and analysis
- **State**: Stateless - no database, pure text processing
## Setup
1. **Install dependencies**:
```bash
uv sync
```
2. **Configure environment**:
```bash
cp .env.sample .env
# Edit .env with your OpenAI API key
```
3. **Run the service**:
```bash
uv run uvicorn main:app --reload --port 8001
```
## API Endpoints
### POST /analyze
Analyze telemetry data with AI.
**Request**:
```json
{
"telemetry_data": [
{
"device_id": "abc123",
"metric": "temperature",
"value": 23.5,
"timestamp": "2024-01-15T10:30:00Z"
}
],
"device_info": {
"name": "Sensor-01",
"location": "Lab A"
},
"prompt_type": "trend_summary",
"custom_prompt": null
}
```
**Prompt Types**:
- `trend_summary`: Environmental quality trends with comfort assessment
- `anomaly_detection`: Identify deviations from optimal ranges with severity
- `custom`: Use custom_prompt field
**Supported Metrics** (with industry-standard optimal ranges):
- `temperature`: Indoor temperature (18-24°C optimal)
- `humidity`: Relative humidity (30-60% optimal)
- `co2`: Carbon dioxide concentration (400-1000ppm optimal)
- `pressure`: Atmospheric pressure (1013-1023hPa optimal)
- `light`: Illuminance level (300-500 lux optimal)
- `noise`: Sound level (30-50dB optimal)
- `pm25`: Fine particulate matter (0-12 µg/m³ optimal)
- `voc`: Volatile organic compounds (0-220ppb optimal)
Each metric includes:
- Optimal and comfort ranges
- Critical thresholds
- Health/productivity impact assessment
- Specific concerns (e.g., mold growth for humidity, cognitive impact for CO2)
**Response**:
```json
{
"analysis": "The temperature data shows...",
"prompt_type": "trend_summary",
"data_points_analyzed": 100
}
```
### GET /health
Health check endpoint.
**Response**:
```json
{
"status": "healthy",
"service": "gpt_service"
}
```
## Environment Variables
- `API_KEY`: OpenAI API key (required)
- `PROVIDER_NAME`: AI provider (default: "openai")
- `MODEL_NAME`: OpenAI model (default: "gpt-4o-mini")
- `HOST_URL`: Service URL (default: "http://localhost:8001")
- `LOG_LEVEL`: Logging level (default: "INFO")
## Integration with Django
Django backend should:
1. Query telemetry from PostgreSQL/TimescaleDB
2. Format data as array of `{device_id, metric, value, timestamp}`
3. Add device context in `device_info`
4. POST to `/analyze` endpoint
5. Return analysis to frontend
Example Django integration:
```python
import httpx
async def get_telemetry_insights(device_id: str, metric: str = None):
# Query telemetry
telemetry = Telemetry.objects.filter(device_id=device_id)
if metric:
telemetry = telemetry.filter(metric=metric)
# Format data
data = [
{
"device_id": t.device_id,
"metric": t.metric,
"value": t.value,
"timestamp": t.timestamp.isoformat()
}
for t in telemetry[:100] # Limit to last 100 points
]
# Get device info
device = Device.objects.get(device_id=device_id)
device_info = {
"name": device.name,
"location": device.location
}
# Call GPT service
async with httpx.AsyncClient() as client:
response = await client.post(
"http://localhost:8001/analyze",
json={
"telemetry_data": data,
"device_info": device_info,
"prompt_type": "trend_summary"
}
)
return response.json()
```
## Testing
```bash
# Health check
curl http://localhost:8001/health
# Test analysis
curl -X POST http://localhost:8001/analyze \
-H "Content-Type: application/json" \
-d '{
"telemetry_data": [
{"device_id": "test", "metric": "temperature", "value": 23.5, "timestamp": "2024-01-15T10:30:00Z"}
],
"prompt_type": "trend_summary"
}'
```
## Development
- **Stateless design** - No database required
- **Metric-aware** - Specialized prompts for 8 environmental metrics
- **Standards-based** - Uses ASHRAE, WHO, OSHA guidelines
- **Context-rich** - Includes optimal ranges, thresholds, and impact assessments
- **Async/await** - Non-blocking OpenAI API calls
- **Error handling** - Proper HTTP exceptions with logging
- **Type safety** - Pydantic models and type hints throughout
## Analysis Features
### Metric-Specific Intelligence
The service automatically detects which metrics are in your telemetry data and provides specialized analysis:
**Temperature Analysis**:
- Optimal range: 18-24°C (comfort zone: 20-22°C)
- Assesses impact on worker productivity and equipment
- Identifies HVAC performance issues
- Recommends energy efficiency improvements
**Humidity Analysis**:
- Optimal range: 30-60% (comfort zone: 40-50%)
- Warns about mold risk (>60%) and static electricity (<30%)
- Evaluates respiratory health impact
- Suggests ventilation adjustments
**CO2 Analysis**:
- Optimal range: 400-1000ppm (comfort zone: 400-800ppm)
- Links high CO2 to cognitive performance decline
- Assesses ventilation effectiveness
- Recommends occupancy adjustments
**And more** for pressure, light, noise, PM2.5, and VOC metrics.
### Analysis Types
1. **Trend Summary** (`prompt_type: "trend_summary"`):
- Overall environmental quality rating
- Time spent in optimal vs suboptimal ranges
- Daily patterns and correlations
- Predictive insights and optimization opportunities
2. **Anomaly Detection** (`prompt_type: "anomaly_detection"`):
- Identifies deviations from optimal ranges
- Severity assessment (low/medium/high/critical)
- Root cause analysis (HVAC, occupancy, external factors)
- Prioritized action items
3. **Custom Analysis** (`prompt_type: "custom"`):
- Use your own prompt
- Still includes metric context and standards
- Flexible for specific use cases
## Notes
- Service is stateless by design
- Django provides all data context
- No direct database access
- Focuses on text transformation only
- Aligns with microservices architecture pattern

View File

@@ -1,4 +1,5 @@
from openai import OpenAI from openai import OpenAI
from typing import List, Dict, Any
from config import API_KEY, MODEL_NAME, PROVIDER_NAME, HOST_URL, LOG_LEVEL from config import API_KEY, MODEL_NAME, PROVIDER_NAME, HOST_URL, LOG_LEVEL
import logging import logging
@@ -20,4 +21,354 @@ class GPTService:
self.logger.error(f"Unsupported provider: {self.provider_name}") self.logger.error(f"Unsupported provider: {self.provider_name}")
raise ValueError(f"Unsupported provider: {self.provider_name}") raise ValueError(f"Unsupported provider: {self.provider_name}")
def _get_metric_specific_context(self, metric: str) -> Dict[str, Any]:
"""Get metric-specific optimal ranges and context for environmental monitoring."""
metric_contexts = {
"temperature": {
"unit": "°C",
"optimal_range": (18, 24),
"comfort_range": (20, 22),
"critical_low": 15,
"critical_high": 28,
"context": "indoor environment temperature",
"concerns": [
"Worker comfort and productivity",
"Equipment operating conditions",
"Energy efficiency",
"HVAC system performance"
]
},
"humidity": {
"unit": "%",
"optimal_range": (30, 60),
"comfort_range": (40, 50),
"critical_low": 20,
"critical_high": 70,
"context": "relative humidity",
"concerns": [
"Mold and mildew growth (>60%)",
"Static electricity and equipment damage (<30%)",
"Respiratory health and comfort",
"Material degradation"
]
},
"co2": {
"unit": "ppm",
"optimal_range": (400, 1000),
"comfort_range": (400, 800),
"critical_low": 350,
"critical_high": 1500,
"context": "carbon dioxide concentration",
"concerns": [
"Air quality and ventilation effectiveness",
"Cognitive performance (>1000ppm affects decision-making)",
"Occupant health and alertness",
"HVAC system efficiency"
]
},
"pressure": {
"unit": "hPa",
"optimal_range": (1013, 1023),
"comfort_range": (1013, 1020),
"critical_low": 980,
"critical_high": 1050,
"context": "atmospheric pressure",
"concerns": [
"Weather changes and ventilation",
"Building pressurization",
"Equipment calibration",
"Occupant comfort"
]
},
"light": {
"unit": "lux",
"optimal_range": (300, 500),
"comfort_range": (400, 500),
"critical_low": 200,
"critical_high": 1000,
"context": "illuminance level",
"concerns": [
"Visual comfort and eye strain",
"Productivity and task performance",
"Energy consumption",
"Circadian rhythm regulation"
]
},
"noise": {
"unit": "dB",
"optimal_range": (30, 50),
"comfort_range": (35, 45),
"critical_low": 20,
"critical_high": 70,
"context": "noise level",
"concerns": [
"Acoustic comfort and concentration",
"Speech intelligibility",
"Stress and productivity impact",
"Hearing protection requirements (>85dB)"
]
},
"pm25": {
"unit": "µg/m³",
"optimal_range": (0, 12),
"comfort_range": (0, 10),
"critical_low": 0,
"critical_high": 35,
"context": "fine particulate matter (PM2.5)",
"concerns": [
"Air quality and health risk",
"Respiratory system impact",
"Filter maintenance requirements",
"Outdoor air quality correlation"
]
},
"voc": {
"unit": "ppb",
"optimal_range": (0, 220),
"comfort_range": (0, 150),
"critical_low": 0,
"critical_high": 500,
"context": "volatile organic compounds",
"concerns": [
"Indoor air quality",
"Off-gassing from materials",
"Ventilation effectiveness",
"Occupant health symptoms"
]
}
}
# Default for unknown metrics
default = {
"unit": "",
"optimal_range": None,
"comfort_range": None,
"critical_low": None,
"critical_high": None,
"context": f"{metric} measurement",
"concerns": ["Monitor for unexpected changes", "Verify sensor accuracy"]
}
return metric_contexts.get(metric.lower(), default)
def _build_prompt(
self,
telemetry_data: List[Dict[str, Any]],
device_info: Dict[str, Any],
prompt_type: str,
custom_prompt: str | None = None
) -> str:
"""Build analysis prompt based on type with metric-specific context."""
# Format telemetry data for prompt
data_summary = self._format_telemetry_summary(telemetry_data)
device_context = self._format_device_info(device_info)
# Get metrics present in data
metrics_in_data = set(point.get("metric", "").lower() for point in telemetry_data)
# Build metric-specific context
metric_context_lines = []
for metric in metrics_in_data:
if metric:
ctx = self._get_metric_specific_context(metric)
metric_context_lines.append(f"\n**{metric.upper()}** ({ctx['context']}):")
if ctx['optimal_range']:
metric_context_lines.append(f" - Optimal Range: {ctx['optimal_range'][0]}-{ctx['optimal_range'][1]} {ctx['unit']}")
if ctx['comfort_range']:
metric_context_lines.append(f" - Comfort Zone: {ctx['comfort_range'][0]}-{ctx['comfort_range'][1]} {ctx['unit']}")
if ctx['critical_high']:
metric_context_lines.append(f" - Critical Thresholds: <{ctx['critical_low']} or >{ctx['critical_high']} {ctx['unit']}")
metric_context_lines.append(" - Key Concerns:")
for concern in ctx['concerns']:
metric_context_lines.append(f"{concern}")
metric_context = "\n".join(metric_context_lines) if metric_context_lines else ""
prompts = {
"anomaly_detection": f"""You are an IoT environmental monitoring specialist. Analyze the telemetry data to detect anomalies and issues.
{device_context}
Environmental Standards:
{metric_context}
Recent Measurements:
{data_summary}
Respond in this EXACT JSON format (no markdown, just valid JSON):
{{
"status": "normal|warning|critical",
"summary": "Brief 1-2 sentence overview",
"anomalies": [
{{
"metric": "metric name",
"severity": "low|medium|high|critical",
"description": "What's wrong",
"value": "current value",
"expected": "expected range"
}}
],
"impacts": ["Impact 1", "Impact 2"],
"actions": ["Action 1", "Action 2"],
"root_causes": ["Cause 1", "Cause 2"]
}}
Keep summary under 50 words, each item under 20 words.""",
"trend_summary": f"""You are an IoT environmental monitoring specialist. Analyze the measurement trends and patterns.
{device_context}
Environmental Standards:
{metric_context}
Measurement History:
{data_summary}
Respond in this EXACT JSON format (no markdown, just valid JSON):
{{
"status": "excellent|good|fair|poor",
"summary": "Brief 1-2 sentence overview of conditions",
"trends": [
{{
"metric": "metric name",
"direction": "improving|stable|degrading",
"description": "What's happening"
}}
],
"comfort_score": {{
"rating": 85,
"description": "Brief assessment"
}},
"patterns": ["Pattern 1", "Pattern 2"],
"recommendations": ["Recommendation 1", "Recommendation 2"],
"forecast": "Brief prediction based on trends"
}}
Keep all text concise: summary under 50 words, each item under 20 words.""",
"custom": custom_prompt or "Analyze the provided telemetry data."
}
base_prompt = prompts.get(prompt_type, prompts["trend_summary"])
if prompt_type == "custom" and custom_prompt:
base_prompt = f"{custom_prompt}\n\n{device_context}\n\n{metric_context}\n\nTelemetry Data:\n{data_summary}"
return base_prompt
def _format_telemetry_summary(self, telemetry_data: List[Dict[str, Any]]) -> str:
"""Format telemetry data into readable summary with full data points."""
if not telemetry_data:
return "No data available"
# Group by metric
metrics = {}
for point in telemetry_data:
metric = point.get("metric", "unknown")
if metric not in metrics:
metrics[metric] = []
metrics[metric].append({
"value": point.get("value"),
"timestamp": point.get("timestamp"),
"device_id": point.get("device_id")
})
# Build summary with full data
lines = []
for metric, points in metrics.items():
values = [p["value"] for p in points if p["value"] is not None]
if values:
lines.append(f"\n{metric}:")
lines.append(f" Summary Statistics:")
lines.append(f" - Data points: {len(values)}")
lines.append(f" - Min: {min(values):.2f}")
lines.append(f" - Max: {max(values):.2f}")
lines.append(f" - Average: {sum(values)/len(values):.2f}")
lines.append(f" - Latest: {points[-1]['value']:.2f} at {points[-1]['timestamp']}")
# Include all individual readings for AI analysis
lines.append(f" Full Time Series Data:")
for point in points:
lines.append(f" - {point['timestamp']}: {point['value']:.2f}")
return "\n".join(lines)
def _format_device_info(self, device_info: Dict[str, Any]) -> str:
"""Format device information for prompt."""
if not device_info:
return "Device Context: Not provided"
lines = ["Device Context:"]
for key, value in device_info.items():
lines.append(f" - {key}: {value}")
return "\n".join(lines)
async def analyze(
self,
telemetry_data: List[Dict[str, Any]],
device_info: Dict[str, Any] = None,
prompt_type: str = "trend_summary",
custom_prompt: str | None = None
) -> str:
"""
Analyze telemetry data using OpenAI GPT model.
Args:
telemetry_data: List of telemetry data points with device_id, metric, value, timestamp
device_info: Optional device metadata for context
prompt_type: Type of analysis (anomaly_detection, trend_summary, custom)
custom_prompt: Custom prompt for 'custom' type
Returns:
Analysis result as string
"""
try:
device_info = device_info or {}
# Build prompt
prompt = self._build_prompt(telemetry_data, device_info, prompt_type, custom_prompt)
system_prompt = "You are an expert IoT environmental monitoring specialist with deep knowledge of indoor environmental quality standards (ASHRAE, WHO, OSHA guidelines). Your goal is to help maintain optimal working conditions for occupant health, comfort, and productivity. Provide clear, actionable insights with specific metric values and recommendations. Focus on environmental factors that impact human performance and wellbeing."
# Log the complete prompt for debugging/review
self.logger.info("="*80)
self.logger.info(f"PROMPT LOGGING - Analysis Type: {prompt_type}")
self.logger.info("="*80)
self.logger.info("\n[SYSTEM PROMPT]")
self.logger.info(system_prompt)
self.logger.info("\n" + "-"*80)
self.logger.info("[USER PROMPT]")
self.logger.info(prompt)
self.logger.info("="*80)
self.logger.info(f"Sending analysis request to {self.model_name}")
# Call OpenAI API
response = self.client.chat.completions.create(
model=self.model_name,
messages=[
{
"role": "system",
"content": system_prompt
},
{
"role": "user",
"content": prompt
}
],
temperature=0.7,
max_tokens=1500
)
analysis = response.choices[0].message.content
self.logger.info(f"Analysis completed successfully. Tokens used: {response.usage.total_tokens}")
return analysis
except Exception as e:
self.logger.error(f"Analysis failed: {str(e)}")
raise

View File

@@ -1,10 +1,93 @@
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
from typing import List, Dict, Any, Literal
from contextlib import asynccontextmanager
from gpt_service import GPTService from gpt_service import GPTService
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
gpt_service = None
def main(): @asynccontextmanager
GPTService() async def lifespan(app: FastAPI):
print("Hello from gpt-service!") """Lifespan event handler for startup and shutdown."""
global gpt_service
# Startup
logger.info("Initializing GPT Service...")
gpt_service = GPTService()
logger.info("GPT Service initialized successfully")
yield
# Shutdown (cleanup if needed)
logger.info("Shutting down GPT Service...")
if __name__ == "__main__": app = FastAPI(lifespan=lifespan)
main()
class TelemetryDataPoint(BaseModel):
device_id: str
metric: str
value: float
timestamp: str
class AnalyzeRequest(BaseModel):
telemetry_data: List[TelemetryDataPoint] = Field(..., description="Array of telemetry data points")
device_info: Dict[str, Any] = Field(default_factory=dict, description="Device metadata")
prompt_type: Literal["anomaly_detection", "trend_summary", "custom"] = Field(
default="trend_summary",
description="Type of analysis to perform"
)
custom_prompt: str | None = Field(None, description="Custom prompt for 'custom' type")
class AnalyzeResponse(BaseModel):
analysis: str
prompt_type: str
data_points_analyzed: int
@app.get("/health")
async def health_check():
return {"status": "healthy", "service": "gpt_service"}
@app.post("/analyze", response_model=AnalyzeResponse)
async def analyze_telemetry(request: AnalyzeRequest):
"""
Analyze telemetry data using GPT model.
Receives formatted telemetry data from Django and returns AI insights.
"""
try:
if not gpt_service:
raise HTTPException(status_code=503, detail="GPT service not initialized")
if not request.telemetry_data:
raise HTTPException(status_code=400, detail="No telemetry data provided")
logger.info(f"Analyzing {len(request.telemetry_data)} telemetry points with prompt_type={request.prompt_type}")
# Convert Pydantic models to dicts for GPTService
telemetry_dicts = [point.model_dump() for point in request.telemetry_data]
# Call GPT service analysis
analysis_result = await gpt_service.analyze(
telemetry_data=telemetry_dicts,
device_info=request.device_info,
prompt_type=request.prompt_type,
custom_prompt=request.custom_prompt
)
return AnalyzeResponse(
analysis=analysis_result,
prompt_type=request.prompt_type,
data_points_analyzed=len(request.telemetry_data)
)
except ValueError as e:
logger.error(f"Validation error: {str(e)}")
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Analysis failed: {str(e)}")
raise HTTPException(status_code=500, detail=f"Analysis failed: {str(e)}")

View File

@@ -5,6 +5,8 @@ description = "Add your description here"
readme = "README.md" readme = "README.md"
requires-python = ">=3.13" requires-python = ">=3.13"
dependencies = [ dependencies = [
"fastapi>=0.121.0",
"openai>=2.6.1", "openai>=2.6.1",
"pydantic>=2.12.3",
"python-dotenv>=1.2.1", "python-dotenv>=1.2.1",
] ]

46
uv.lock generated
View File

@@ -24,6 +24,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/44/1f/38e29b06bfed7818ebba1f84904afdc8153ef7b6c7e0d8f3bc6643f5989c/alembic-1.17.0-py3-none-any.whl", hash = "sha256:80523bc437d41b35c5db7e525ad9d908f79de65c27d6a5a5eab6df348a352d99", size = 247449, upload-time = "2025-10-11T18:40:16.288Z" }, { url = "https://files.pythonhosted.org/packages/44/1f/38e29b06bfed7818ebba1f84904afdc8153ef7b6c7e0d8f3bc6643f5989c/alembic-1.17.0-py3-none-any.whl", hash = "sha256:80523bc437d41b35c5db7e525ad9d908f79de65c27d6a5a5eab6df348a352d99", size = 247449, upload-time = "2025-10-11T18:40:16.288Z" },
] ]
[[package]]
name = "annotated-doc"
version = "0.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/a6/dc46877b911e40c00d395771ea710d5e77b6de7bacd5fdcd78d70cc5a48f/annotated_doc-0.0.3.tar.gz", hash = "sha256:e18370014c70187422c33e945053ff4c286f453a984eba84d0dbfa0c935adeda", size = 5535, upload-time = "2025-10-24T14:57:10.718Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/02/b7/cf592cb5de5cb3bade3357f8d2cf42bf103bbe39f459824b4939fd212911/annotated_doc-0.0.3-py3-none-any.whl", hash = "sha256:348ec6664a76f1fd3be81f43dffbee4c7e8ce931ba71ec67cc7f4ade7fbbb580", size = 5488, upload-time = "2025-10-24T14:57:09.462Z" },
]
[[package]] [[package]]
name = "annotated-types" name = "annotated-types"
version = "0.7.0" version = "0.7.0"
@@ -206,18 +215,37 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/60/94/fdfb7b2f0b16cd3ed4d4171c55c1c07a2d1e3b106c5978c8ad0c15b4a48b/djangorestframework_simplejwt-5.5.1-py3-none-any.whl", hash = "sha256:2c30f3707053d384e9f315d11c2daccfcb548d4faa453111ca19a542b732e469", size = 107674, upload-time = "2025-07-21T16:52:07.493Z" }, { url = "https://files.pythonhosted.org/packages/60/94/fdfb7b2f0b16cd3ed4d4171c55c1c07a2d1e3b106c5978c8ad0c15b4a48b/djangorestframework_simplejwt-5.5.1-py3-none-any.whl", hash = "sha256:2c30f3707053d384e9f315d11c2daccfcb548d4faa453111ca19a542b732e469", size = 107674, upload-time = "2025-07-21T16:52:07.493Z" },
] ]
[[package]]
name = "fastapi"
version = "0.121.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8c/e3/77a2df0946703973b9905fd0cde6172c15e0781984320123b4f5079e7113/fastapi-0.121.0.tar.gz", hash = "sha256:06663356a0b1ee93e875bbf05a31fb22314f5bed455afaaad2b2dad7f26e98fa", size = 342412, upload-time = "2025-11-03T10:25:54.818Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dd/2c/42277afc1ba1a18f8358561eee40785d27becab8f80a1f945c0a3051c6eb/fastapi-0.121.0-py3-none-any.whl", hash = "sha256:8bdf1b15a55f4e4b0d6201033da9109ea15632cb76cf156e7b8b4019f2172106", size = 109183, upload-time = "2025-11-03T10:25:53.27Z" },
]
[[package]] [[package]]
name = "gpt-service" name = "gpt-service"
version = "0.1.0" version = "0.1.0"
source = { virtual = "services/gpt_service" } source = { virtual = "services/gpt_service" }
dependencies = [ dependencies = [
{ name = "fastapi" },
{ name = "openai" }, { name = "openai" },
{ name = "pydantic" },
{ name = "python-dotenv" }, { name = "python-dotenv" },
] ]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "fastapi", specifier = ">=0.121.0" },
{ name = "openai", specifier = ">=2.6.1" }, { name = "openai", specifier = ">=2.6.1" },
{ name = "pydantic", specifier = ">=2.12.3" },
{ name = "python-dotenv", specifier = ">=1.2.1" }, { name = "python-dotenv", specifier = ">=1.2.1" },
] ]
@@ -235,6 +263,8 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" }, { url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" },
{ url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" }, { url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" },
{ url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" }, { url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" },
{ url = "https://files.pythonhosted.org/packages/1c/53/f9c440463b3057485b8594d7a638bed53ba531165ef0ca0e6c364b5cc807/greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b", size = 1564759, upload-time = "2025-11-04T12:42:19.395Z" },
{ url = "https://files.pythonhosted.org/packages/47/e4/3bb4240abdd0a8d23f4f88adec746a3099f0d86bfedb623f063b2e3b4df0/greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929", size = 1634288, upload-time = "2025-11-04T12:42:21.174Z" },
{ url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" }, { url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" },
{ url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" }, { url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" },
{ url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" }, { url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" },
@@ -242,6 +272,8 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" }, { url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" },
{ url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" }, { url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" },
{ url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" }, { url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" },
{ url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508, upload-time = "2025-11-04T12:42:23.427Z" },
{ url = "https://files.pythonhosted.org/packages/0d/da/343cd760ab2f92bac1845ca07ee3faea9fe52bee65f7bcb19f16ad7de08b/greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681", size = 1680760, upload-time = "2025-11-04T12:42:25.341Z" },
{ url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" }, { url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" },
] ]
@@ -302,6 +334,7 @@ dependencies = [
{ name = "django-rest" }, { name = "django-rest" },
{ name = "djangorestframework" }, { name = "djangorestframework" },
{ name = "djangorestframework-simplejwt" }, { name = "djangorestframework-simplejwt" },
{ name = "httpx" },
{ name = "openai" }, { name = "openai" },
{ name = "paho-mqtt" }, { name = "paho-mqtt" },
{ name = "psycopg2-binary" }, { name = "psycopg2-binary" },
@@ -323,6 +356,7 @@ requires-dist = [
{ name = "django-rest", specifier = ">=0.8.7" }, { name = "django-rest", specifier = ">=0.8.7" },
{ name = "djangorestframework", specifier = ">=3.16.1" }, { name = "djangorestframework", specifier = ">=3.16.1" },
{ name = "djangorestframework-simplejwt", specifier = ">=5.5.1" }, { name = "djangorestframework-simplejwt", specifier = ">=5.5.1" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "openai", specifier = ">=2.6.1" }, { name = "openai", specifier = ">=2.6.1" },
{ name = "paho-mqtt", specifier = ">=2.1.0" }, { name = "paho-mqtt", specifier = ">=2.1.0" },
{ name = "psycopg2-binary", specifier = ">=2.9.11" }, { name = "psycopg2-binary", specifier = ">=2.9.11" },
@@ -687,6 +721,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/5c/bfd6bd0bf979426d405cc6e71eceb8701b148b16c21d2dc3c261efc61c7b/sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca", size = 44415, upload-time = "2024-12-10T12:05:27.824Z" }, { url = "https://files.pythonhosted.org/packages/a9/5c/bfd6bd0bf979426d405cc6e71eceb8701b148b16c21d2dc3c261efc61c7b/sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca", size = 44415, upload-time = "2024-12-10T12:05:27.824Z" },
] ]
[[package]]
name = "starlette"
version = "0.49.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/de/1a/608df0b10b53b0beb96a37854ee05864d182ddd4b1156a22f1ad3860425a/starlette-0.49.3.tar.gz", hash = "sha256:1c14546f299b5901a1ea0e34410575bc33bbd741377a10484a54445588d00284", size = 2655031, upload-time = "2025-11-01T15:12:26.13Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a3/e0/021c772d6a662f43b63044ab481dc6ac7592447605b5b35a957785363122/starlette-0.49.3-py3-none-any.whl", hash = "sha256:b579b99715fdc2980cf88c8ec96d3bf1ce16f5a8051a7c2b84ef9b1cdecaea2f", size = 74340, upload-time = "2025-11-01T15:12:24.387Z" },
]
[[package]] [[package]]
name = "tqdm" name = "tqdm"
version = "4.67.1" version = "4.67.1"