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