diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b692b6a..1b622d9 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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: diff --git a/frontend/index.html b/frontend/index.html index 072a57e..294bea9 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,9 +2,9 @@ - + - frontend + iotDashboard
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7d1702f..c27c801 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index ecf0cb4..ad81d87 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/App.css b/frontend/src/App.css index 1bce4b7..4948159 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -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)); +} diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 7272084..96d9a56 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -59,6 +59,19 @@ export const telemetryApi = { apiClient.get>('/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 diff --git a/frontend/src/components/AddWidgetModal.tsx b/frontend/src/components/AddWidgetModal.tsx new file mode 100644 index 0000000..fb18cc2 --- /dev/null +++ b/frontend/src/components/AddWidgetModal.tsx @@ -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('stat') + const [title, setTitle] = useState('') + const [selectedDevices, setSelectedDevices] = useState([]) + const [selectedMetrics, setSelectedMetrics] = useState([]) + 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 ( +
+
+

Add Widget

+ + {/* Progress steps */} +
    +
  • = 1 ? 'step-primary' : ''}`}>Type
  • +
  • = 2 ? 'step-primary' : ''}`}>Data Source
  • +
  • = 3 ? 'step-primary' : ''}`}>Configure
  • +
+ + {/* Step 1: Widget Type */} + {step === 1 && ( +
+
+ +
+ + + + + + + +
+
+ +
+ + +
+
+ )} + + {/* Step 2: Data Source */} + {step === 2 && ( +
+
+ +
+ {devices.length === 0 ? ( +
No devices found
+ ) : ( + devices.map((device) => ( + + )) + )} +
+
+ +
+ + {selectedDevices.length === 0 ? ( +
+ Please select a device first +
+ ) : ( + <> + {(['stat', 'gauge'].includes(widgetType)) && ( +
+ + + + This widget type supports only one metric +
+ )} +
+ {availableMetrics.length === 0 ? ( +
+ No metrics found for this device +
+ ) : ( + availableMetrics.map((metric) => ( + + )) + )} +
+ + )} +
+ +
+ + +
+
+ )} + + {/* Step 3: Configure */} + {step === 3 && ( +
+
+ + setTitle(e.target.value)} + /> +
+ +
+ + +
+ +
+
+ + +
+ +
+ + +
+
+ +
+ + + +
+
Widget Summary
+
Type: {widgetType}
+
Device: {devices.find(d => d.id === selectedDevices[0])?.name}
+
Metrics: {selectedMetrics.join(', ')}
+
Size: {widgetWidth} × {widgetHeight}
+
+
+ +
+ + +
+
+ )} +
+
+
+ ) +} diff --git a/frontend/src/components/EditWidgetModal.tsx b/frontend/src/components/EditWidgetModal.tsx new file mode 100644 index 0000000..570791b --- /dev/null +++ b/frontend/src/components/EditWidgetModal.tsx @@ -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) => void +} + +export default function EditWidgetModal({ isOpen, widget, onClose, onSave }: EditWidgetModalProps) { + const [title, setTitle] = useState('') + const [selectedMetrics, setSelectedMetrics] = useState([]) + 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 ( +
+
+

Edit Widget

+ +
+ {/* Widget Type (read-only) */} +
+ +
{widget.type}
+
+ + {/* Title */} +
+ + setTitle(e.target.value)} + /> +
+ + {/* Metrics */} +
+ + {(['stat', 'gauge'].includes(widget.type)) && ( +
+ + + + This widget type supports only one metric +
+ )} +
+ {availableMetrics.length === 0 ? ( +
+ Loading metrics... +
+ ) : ( + availableMetrics.map((metric) => ( + + )) + )} +
+
+ + {/* Time Range */} +
+ + +
+ + {/* Size */} +
+
+ + +
+ +
+ + +
+
+ +
+ + +
+
+
+
+
+ ) +} diff --git a/frontend/src/components/dashboard/TelemetryTrendCard.tsx b/frontend/src/components/dashboard/TelemetryTrendCard.tsx index 70731e7..e4563f3 100644 --- a/frontend/src/components/dashboard/TelemetryTrendCard.tsx +++ b/frontend/src/components/dashboard/TelemetryTrendCard.tsx @@ -49,14 +49,14 @@ export default function TelemetryTrendCard({ title, data, unit, accentColor = '# 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)" /> formatValue(value, unit)} diff --git a/frontend/src/components/widgets/AiInsightWidget.tsx b/frontend/src/components/widgets/AiInsightWidget.tsx new file mode 100644 index 0000000..31b9837 --- /dev/null +++ b/frontend/src/components/widgets/AiInsightWidget.tsx @@ -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 ( +
+
+
+

{title || 'AI Environmental Insights'}

+
+ + +
+
+ + {isLoading && ( +
+
+ +

Analyzing environmental data...

+
+
+ )} + + {error && ( +
+ + + +
+
Analysis Failed
+
{(error as Error)?.message || 'Could not connect to GPT service'}
+
+
+ )} + + {analysis && showAnalysis && !isLoading && ( +
+
+
+ {promptType === 'trend_summary' ? 'Trend Analysis' : 'Anomaly Detection'} +
+
+ {analysis.data_points_analyzed} data points analyzed +
+
+
+
+
+ {analysis.analysis} +
+
+
+ + +
+
+ )} + + {!showAnalysis && ( +
+ + + +

Click Analyze to get AI-powered environmental insights

+
+ )} +
+
+ ) +} diff --git a/frontend/src/components/widgets/GaugeWidget.tsx b/frontend/src/components/widgets/GaugeWidget.tsx new file mode 100644 index 0000000..320669a --- /dev/null +++ b/frontend/src/components/widgets/GaugeWidget.tsx @@ -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 ( +
+
+
+ ) + } + + if (error) { + return ( +
+
+

Error: {error.message}

+
+
+ ) + } + + return ( +
+
+

{title || metric}

+
+ + + + +
+
+
+ {value.toFixed(1)} +
+ {latest?.unit && ( +
{latest.unit}
+ )} +
+
+
+ {latest && ( +
+ Updated {new Date(latest.time).toLocaleTimeString()} +
+ )} +
+
+ ) +} diff --git a/frontend/src/components/widgets/LineChartWidget.tsx b/frontend/src/components/widgets/LineChartWidget.tsx new file mode 100644 index 0000000..40a1bbc --- /dev/null +++ b/frontend/src/components/widgets/LineChartWidget.tsx @@ -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>() + + 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 ( +
+ +
+ ) + } + + if (error) { + return ( +
+ + + + Error loading data: {error.message} +
+ ) + } + + if (chartData.length === 0) { + return ( +
+ + + + No data available for this metric +
+ ) + } + + return ( +
+
+

+ {config.title || metricIds.map(formatMetricName).join(' & ')} +

+ + + {visualization?.showGrid !== false && ( + + )} + + + { + // 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 && } + {metricIds.map((metric, index) => ( + + ))} + + +
+
+ ) +} diff --git a/frontend/src/components/widgets/StatWidget.tsx b/frontend/src/components/widgets/StatWidget.tsx new file mode 100644 index 0000000..8457471 --- /dev/null +++ b/frontend/src/components/widgets/StatWidget.tsx @@ -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 ( +
+
+
+ ) + } + + if (error) { + return ( +
+
+

Error: {error.message}

+
+
+ ) + } + + return ( +
+
+
+ {title || formatMetricName(metric)} +
+
+ {latest ? latest.value.toFixed(1) : '—'} + {latest?.unit && {latest.unit}} +
+
+
+
Min
+
{min.toFixed(1)}
+
+
+
Avg
+
{average.toFixed(1)}
+
+
+
Max
+
{max.toFixed(1)}
+
+
+
+ {data.length} readings in last {timeframe.hours || 24}h +
+
+
+ ) +} diff --git a/frontend/src/components/widgets/WidgetContainer.tsx b/frontend/src/components/widgets/WidgetContainer.tsx new file mode 100644 index 0000000..b140f17 --- /dev/null +++ b/frontend/src/components/widgets/WidgetContainer.tsx @@ -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 ( +
+
+

Unknown widget type: {config.type}

+
+
+ ) + } + + return ( +
+ {/* Drag handle and actions */} +
+
+ + + + Drag +
+
+ {onEdit && ( + + )} + {onRemove && ( + + )} +
+
+ + {/* Actual widget */} +
+ +
+
+ ) +} diff --git a/frontend/src/components/widgets/index.ts b/frontend/src/components/widgets/index.ts new file mode 100644 index 0000000..6a8d435 --- /dev/null +++ b/frontend/src/components/widgets/index.ts @@ -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' diff --git a/frontend/src/components/widgets/registry.ts b/frontend/src/components/widgets/registry.ts new file mode 100644 index 0000000..f859bd6 --- /dev/null +++ b/frontend/src/components/widgets/registry.ts @@ -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> = { + 'line-chart': LineChartWidget, + 'stat': StatWidget, + 'gauge': GaugeWidget, + 'ai-insight': AiInsightWidget, + 'bar-chart': LineChartWidget, // Placeholder - implement later +} diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts new file mode 100644 index 0000000..6c9cd0b --- /dev/null +++ b/frontend/src/hooks/index.ts @@ -0,0 +1,4 @@ +export { useTelemetrySeries } from './useTelemetrySeries' +export { useDeviceMetrics, useAllMetrics } from './useDeviceMetrics' +export { useDashboardConfig } from './useDashboardConfig' +export type { WidgetConfig, DashboardConfig, WidgetType } from './useDashboardConfig' diff --git a/frontend/src/hooks/useDashboardConfig.ts b/frontend/src/hooks/useDashboardConfig.ts new file mode 100644 index 0000000..f2e2304 --- /dev/null +++ b/frontend/src/hooks/useDashboardConfig.ts @@ -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(() => { + 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) => { + 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, + } +} diff --git a/frontend/src/hooks/useDeviceMetrics.ts b/frontend/src/hooks/useDeviceMetrics.ts new file mode 100644 index 0000000..da172d3 --- /dev/null +++ b/frontend/src/hooks/useDeviceMetrics.ts @@ -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, + } +} diff --git a/frontend/src/hooks/useTelemetrySeries.ts b/frontend/src/hooks/useTelemetrySeries.ts new file mode 100644 index 0000000..df938c4 --- /dev/null +++ b/frontend/src/hooks/useTelemetrySeries.ts @@ -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, + } +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 361a511..1d1a167 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -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 => { - 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(null) + const [gridWidth, setGridWidth] = useState(1200) + const gridContainerRef = useRef(null) - const { - data: telemetryFeed, - isLoading: telemetryLoading, - isFetching: telemetryFetching, - refetch: refetchTelemetry, - } = useQuery({ - queryKey: ['telemetry', 'feed', { page_size: 200 }], - queryFn: async (): Promise => { - const response = await telemetryApi.query({ page_size: 200 }) - return response.data - }, - refetchInterval: 15000, - }) - - const telemetrySamples = useMemo(() => { - 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(() => { - if (!telemetrySamples.length) { - return [] - } - - const groups = new Map() - - 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(() => { - 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 ( -
- -
- ) + 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) => { + 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 ( -
+
-

Environment Overview

+

Dashboard

- 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

- + Export + + + +
- {/* Environmental Snapshot */} -
-

Environmental Snapshot

- {telemetryLoading && !metricSummaries.length ? ( -
- {[1, 2, 3].map((key) => ( -
-
-
- ))} -
- ) : metricSummaries.length ? ( -
- {metricSummaries.slice(0, 3).map((metric) => ( -
-
-
- {metric.label} -
-
- {formatValue(metric.latest?.value, metric.unit)} -
-
- Avg (last {metric.count}) - {formatValue(metric.average, metric.unit)} -
- {metric.change !== undefined && metric.change !== 0 && ( -
0 ? 'text-warning' : 'text-success' - }`} - > - {metric.change > 0 ? '+' : ''} - {formatValue(metric.change, metric.unit)} since first sample -
- )} -
-
- ))} -
- ) : ( -
-
- No telemetry ingested yet. Connect devices or publish MQTT data to see environmental metrics. -
-
- )} -
- - {/* Featured Trend */} - {primaryMetric && ( -
-

Featured Trend

- -
- )} - - {/* Stats Grid */} -
-

System Health

-
-
-
-
- - - -
-
Total Devices
-
{overview?.total_devices ?? 0}
-
Registered in system
-
-
- -
-
-
- - - -
-
Active Devices
-
{overview?.active_devices ?? 0}
-
Currently online
-
-
- -
-
-
- - - -
-
MQTT Devices
-
{overview?.mqtt_devices ?? 0}
-
Using mTLS
-
-
- -
-
-
- - - -
-
Expiring Soon
-
- {overview?.certificates_expiring_soon ?? 0} -
-
Certificates need renewal
-
+ {config.widgets.length === 0 ? ( +
+
+ + + +

No Widgets Yet

+

+ Get started by adding your first widget. Choose from line charts, stat cards, gauges, or AI insights. +

+
-
+ ) : ( +
+ + {config.widgets.map((widget) => ( +
+ removeWidget(widget.id)} + onEdit={() => setEditingWidget(widget.id)} + /> +
+ ))} +
+
+ )} - {/* Recent Telemetry */} - {overview?.recent_telemetry?.length ? ( -
-

Recent Telemetry

-
- - - - - - - - - - - {overview.recent_telemetry.map((t, idx) => ( - - - - - - - ))} - -
DeviceMetricValueTime
-
{t.device_name}
-
{t.device_id}
-
-
{t.metric}
-
- {formatValue(t.value, t.unit)} - - {new Date(t.time).toLocaleString()} -
-
-
- ) : null} + setIsModalOpen(false)} + onAdd={(widget) => { + addWidget(widget) + setIsModalOpen(false) + }} + /> + + w.id === editingWidget) || null} + onClose={() => setEditingWidget(null)} + onSave={(widgetId, updates) => { + updateWidget(widgetId, updates) + setEditingWidget(null) + }} + />
) } - diff --git a/frontend/src/pages/DeviceList.tsx b/frontend/src/pages/DeviceList.tsx index 1190068..f54ca43 100644 --- a/frontend/src/pages/DeviceList.tsx +++ b/frontend/src/pages/DeviceList.tsx @@ -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(null) const [revokeDevice, setRevokeDevice] = useState(null) const [renewDevice, setRenewDevice] = useState(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 => { + const response = await dashboardApi.getOverview() + return response.data + }, + staleTime: 5000, + }) + const devices = devicesData?.results || [] - if (isLoading) { + if (devicesLoading) { return (
@@ -41,6 +50,120 @@ export default function DeviceList() {
+
+

System Health

+ {overview ? ( +
+
+
+
+ + + +
+
Total Devices
+
{overview.total_devices}
+
Registered in system
+
+
+ +
+
+
+ + + +
+
Active Devices
+
{overview.active_devices}
+
Currently online
+
+
+ +
+
+
+ + + +
+
MQTT Devices
+
{overview.mqtt_devices}
+
Using mTLS
+
+
+ +
+
+
+ + + +
+
Expiring Soon
+
+ {overview.certificates_expiring_soon} +
+
Certificates need renewal
+
+
+
+ ) : ( +
+ {[1, 2, 3, 4].map((key) => ( +
+
+
+
+
+
+
+
+ ))} +
+ )} +
+
diff --git a/frontend/src/utils/formatters.ts b/frontend/src/utils/formatters.ts new file mode 100644 index 0000000..350e6aa --- /dev/null +++ b/frontend/src/utils/formatters.ts @@ -0,0 +1,64 @@ +/** + * Format metric names for display + */ +export function formatMetricName(metric: string): string { + // Known metric mappings + const metricNames: Record = { + '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 + } +} diff --git a/iotDashboard/api/views.py b/iotDashboard/api/views.py index 235d72f..6c5e0fc 100644 --- a/iotDashboard/api/views.py +++ b/iotDashboard/api/views.py @@ -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, ) @@ -247,6 +245,91 @@ class TelemetryViewSet(viewsets.ReadOnlyModelViewSet): .distinct() ) 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): diff --git a/iotDashboard/gpt_service_client.py b/iotDashboard/gpt_service_client.py new file mode 100644 index 0000000..a304876 --- /dev/null +++ b/iotDashboard/gpt_service_client.py @@ -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 diff --git a/iotDashboard/urls.py b/iotDashboard/urls.py index fef87c2..dd835c7 100644 --- a/iotDashboard/urls.py +++ b/iotDashboard/urls.py @@ -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"), diff --git a/iotDashboard/views.py b/iotDashboard/views.py index 6c38d7c..b75931b 100644 --- a/iotDashboard/views.py +++ b/iotDashboard/views.py @@ -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 + ) + diff --git a/pyproject.toml b/pyproject.toml index 0a16b60..73b691e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..c83850d --- /dev/null +++ b/scripts/README.md @@ -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 +``` + +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. diff --git a/services/gpt_service/.env.sample b/services/gpt_service/.env.sample index a6ef261..a37bd10 100644 --- a/services/gpt_service/.env.sample +++ b/services/gpt_service/.env.sample @@ -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 diff --git a/services/gpt_service/METRICS_REFERENCE.md b/services/gpt_service/METRICS_REFERENCE.md new file mode 100644 index 0000000..ffec2b4 --- /dev/null +++ b/services/gpt_service/METRICS_REFERENCE.md @@ -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! diff --git a/services/gpt_service/README.md b/services/gpt_service/README.md index e69de29..be7eca2 100644 --- a/services/gpt_service/README.md +++ b/services/gpt_service/README.md @@ -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 diff --git a/services/gpt_service/gpt_service.py b/services/gpt_service/gpt_service.py index a776402..9d04ab1 100644 --- a/services/gpt_service/gpt_service.py +++ b/services/gpt_service/gpt_service.py @@ -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}") - \ No newline at end of file + 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 diff --git a/services/gpt_service/main.py b/services/gpt_service/main.py index fe65d76..15fbeb4 100644 --- a/services/gpt_service/main.py +++ b/services/gpt_service/main.py @@ -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() \ No newline at end of file +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)}") \ No newline at end of file diff --git a/services/gpt_service/pyproject.toml b/services/gpt_service/pyproject.toml index 465b17e..57e50de 100644 --- a/services/gpt_service/pyproject.toml +++ b/services/gpt_service/pyproject.toml @@ -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", ] diff --git a/uv.lock b/uv.lock index e0f3c0d..21907c9 100644 --- a/uv.lock +++ b/uv.lock @@ -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"