diff --git a/backend/src/plugins/websocket.ts b/backend/src/plugins/websocket.ts index 8a13ebb..2c31019 100644 --- a/backend/src/plugins/websocket.ts +++ b/backend/src/plugins/websocket.ts @@ -22,7 +22,7 @@ export async function websocketPlugin(fastify: FastifyInstance) { /** * WebSocket endpoint for real-time alerts */ - fastify.get('/ws/alerts', { websocket: true }, (connection, request) => { + fastify.get('/api/ws/alerts', { websocket: true }, (connection, request) => { const clientId = `${Date.now()}-${Math.random().toString(36).slice(2)}`; // Get the raw WebSocket from the SocketStream diff --git a/backend/src/routes/pulse.routes.ts b/backend/src/routes/pulse.routes.ts index 9446da8..99938d3 100644 --- a/backend/src/routes/pulse.routes.ts +++ b/backend/src/routes/pulse.routes.ts @@ -147,6 +147,28 @@ export async function pulseRoutes(fastify: FastifyInstance) { } }); + /** + * GET /pulse/sparklines + * Get sparkline data for all devices + */ + fastify.get('/sparklines', { + handler: async (request, reply) => { + const pulse = getPulseService(); + + if (!pulse) { + return reply.status(503).send({ error: 'Pulse not configured' }); + } + + try { + const sparklines = await pulse.getSparklines(); + return { sparklines }; + } catch (error: any) { + fastify.log.error(error); + return reply.status(500).send({ error: error.message }); + } + } + }); + /** * GET /pulse/devices/:id/history * Get historical readings for a device diff --git a/backend/src/services/pulse.service.ts b/backend/src/services/pulse.service.ts index 776b364..c6de106 100644 --- a/backend/src/services/pulse.service.ts +++ b/backend/src/services/pulse.service.ts @@ -160,6 +160,30 @@ export class PulseService { })); } + /** + * Get sparkline data (last 1 hour) for all devices + */ + async getSparklines(): Promise> { + const devices = await this.getDevices(); + const results: Record = {}; + + await Promise.all(devices.map(async (device) => { + try { + // Get 1 hour of history + const history = await this.getHistory(device.id, 1); + // Sort by timestamp ascending + results[device.id] = history.sort((a, b) => + a.timestamp.getTime() - b.timestamp.getTime() + ); + } catch (error) { + console.warn(`Failed to fetch history for sparkline (device ${device.id})`, error); + results[device.id] = []; + } + })); + + return results; + } + /** * Test connection to Pulse API */ diff --git a/frontend/src/components/dashboard/PulseSensorCard.tsx b/frontend/src/components/dashboard/PulseSensorCard.tsx new file mode 100644 index 0000000..4a9ab1e --- /dev/null +++ b/frontend/src/components/dashboard/PulseSensorCard.tsx @@ -0,0 +1,134 @@ + +import { motion } from 'framer-motion'; +import { LucideIcon, Thermometer, Droplets, Wind, CloudFog, Sun, Activity } from 'lucide-react'; +import { cn } from '../../lib/utils'; +import { useNavigate } from 'react-router-dom'; + +interface PulseSensorCardProps { + reading: { + deviceId: string; + deviceName: string; + temperature: number; + humidity: number; + vpd: number; + dewpoint: number; + timestamp: Date; + }; + history?: { + temperature: number[]; + // we can add other metrics history later + }; + onClick?: () => void; +} + +export function PulseSensorCard({ reading, history, onClick }: PulseSensorCardProps) { + const navigate = useNavigate(); + + // Determine status color based on VPD (gold standard for crop health) + const getStatusColor = (vpd: number) => { + if (vpd < 0.8 || vpd > 1.2) return 'text-amber-500'; // Warning + if (vpd < 0.4 || vpd > 1.6) return 'text-rose-500'; // Critical + return 'text-emerald-500'; // Good + }; + + const statusColor = getStatusColor(reading.vpd); + + // Simple Sparkline + const Sparkline = ({ data, color = "#10b981", width = 120, height = 40 }: { data: number[], color?: string, width?: number, height?: number }) => { + if (!data || data.length < 2) return null; + + const min = Math.min(...data); + const max = Math.max(...data); + const range = max - min || 1; + + const points = data.map((val, i) => { + const x = (i / (data.length - 1)) * width; + const y = height - ((val - min) / range) * height; + return `${x},${y}`; + }).join(' '); + + return ( + + + + ); + }; + + const handleClick = () => { + if (onClick) onClick(); + else navigate(`/pulse`); // Default to pulse dashboard + }; + + return ( + +
+
+
+ +
+
+

+ {reading.deviceName} +

+

Pulse Grow

+
+
+ +
+ {reading.timestamp ? 'LIVE' : 'OFFLINE'} +
+
+ +
+
+
+ + Temp +
+

+ {reading.temperature.toFixed(1)}° +

+
+ +
+
+ + RH +
+

+ {reading.humidity.toFixed(0)}% +

+
+ +
+
+ + VPD +
+

+ {reading.vpd.toFixed(2)} +

+
+ +
+ {history && history.temperature && ( +
+ 80 ? '#f43f5e' : '#10b981'} /> +
+ )} +
+
+
+ ); +} diff --git a/frontend/src/hooks/useNotifications.ts b/frontend/src/hooks/useNotifications.ts index df7b30d..a4b524c 100644 --- a/frontend/src/hooks/useNotifications.ts +++ b/frontend/src/hooks/useNotifications.ts @@ -31,7 +31,7 @@ export function useNotifications(options: UseNotificationsOptions = {}) { // Determine WebSocket URL based on current location const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const host = window.location.host; - const wsUrl = `${protocol}//${host}/ws/alerts`; + const wsUrl = `${protocol}//${host}/api/ws/alerts`; try { const ws = new WebSocket(wsUrl); diff --git a/frontend/src/lib/navigation.ts b/frontend/src/lib/navigation.ts index 8b68c18..5b95574 100644 --- a/frontend/src/lib/navigation.ts +++ b/frontend/src/lib/navigation.ts @@ -84,6 +84,7 @@ export const NAV_SECTIONS: NavSection[] = [ { id: 'batches', label: 'Batches', path: '/batches', icon: Sprout }, { id: 'ipm', label: 'IPM Dashboard', shortLabel: 'IPM', path: '/ipm', icon: Shield }, { id: 'environment', label: 'Environment', path: '/environment', icon: Thermometer, minRole: 'STAFF' }, + { id: 'pulse', label: 'Pulse Monitor', path: '/pulse', icon: Grid3X3, minRole: 'STAFF' }, ] }, { @@ -128,8 +129,6 @@ export const NAV_SECTIONS: NavSection[] = [ { id: 'audit', label: 'Audit Log', path: '/compliance/audit', icon: ClipboardList }, { id: 'documents', label: 'SOP Library', path: '/compliance/documents', icon: FileText }, { id: 'metrc', label: 'METRC Integration', path: '/metrc', icon: Cloud, minRole: 'MANAGER' }, - { id: 'facility-3d', label: '3D Facility View', path: '/facility/3d', icon: Grid3X3, minRole: 'MANAGER' }, - { id: 'layout', label: 'Layout Designer', path: '/layout-designer', icon: Grid3X3, minRole: 'ADMIN' }, ] }, { diff --git a/frontend/src/pages/EnvironmentDashboard.tsx b/frontend/src/pages/EnvironmentDashboard.tsx index 59a0f3b..be37bde 100644 --- a/frontend/src/pages/EnvironmentDashboard.tsx +++ b/frontend/src/pages/EnvironmentDashboard.tsx @@ -6,6 +6,7 @@ import { import api from '../lib/api'; import { PageHeader, MetricCard, EmptyState, CardSkeleton } from '../components/ui/LinearPrimitives'; import { useNotifications } from '../hooks/useNotifications'; +import { PulseSensorCard } from '../components/dashboard/PulseSensorCard'; interface SensorData { id: string; @@ -77,6 +78,7 @@ export default function EnvironmentDashboard() { const [dashboard, setDashboard] = useState(null); const [sensors, setSensors] = useState([]); const [pulseReadings, setPulseReadings] = useState([]); + const [sparklines, setSparklines] = useState>({}); const [pulseConnected, setPulseConnected] = useState(false); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); @@ -106,11 +108,17 @@ export default function EnvironmentDashboard() { setPulseConnected(pulseStatusRes.data.connected); if (pulseStatusRes.data.connected) { - const pulseReadingsRes = await api.get('/pulse/readings'); - setPulseReadings(pulseReadingsRes.data.readings || []); + // Fetch readings and sparklines in parallel + // Use catch for sparklines to avoid failing the whole dash if rate limited + const [readingsRes, sparklinesRes] = await Promise.all([ + api.get('/pulse/readings'), + api.get('/pulse/sparklines').catch(() => ({ data: { sparklines: {} } })) + ]); + setPulseReadings(readingsRes.data.readings || []); + setSparklines(sparklinesRes.data.sparklines || {}); } } catch (pulseError) { - console.log('Pulse not configured:', pulseError); + console.log('Pulse not configured or error:', pulseError); setPulseConnected(false); } } catch (error) { @@ -126,18 +134,6 @@ export default function EnvironmentDashboard() { setRefreshing(false); }; - const getTempColor = (temp: number) => { - if (temp < 65) return 'text-blue-500'; - if (temp > 82) return 'text-red-500'; - return 'text-green-500'; - }; - - const getVpdColor = (vpd: number) => { - if (vpd < 0.8) return 'text-blue-500'; - if (vpd > 1.2) return 'text-red-500'; - return 'text-green-500'; - }; - const getStatusClass = (value: number, min?: number, max?: number): string => { if (min && value < min) return 'text-accent'; if (max && value > max) return 'text-destructive'; @@ -267,97 +263,14 @@ export default function EnvironmentDashboard() {
- {pulseReadings.map((reading, index) => ( -
- {/* Device Header */} -
-

- {reading.deviceName || `Device ${reading.deviceId}`} -

-
-
- - {/* Metrics Grid */} -
- {/* Temperature */} -
-
- - Temp -
-

- {reading.temperature.toFixed(1)}°F -

-
- - {/* Humidity */} -
-
- - Humidity -
-

- {reading.humidity.toFixed(1)}% -

-
- - {/* VPD */} -
-
- - VPD -
-

- {reading.vpd.toFixed(2)} kPa -

-
- - {/* Dewpoint */} -
-
- - Dewpoint -
-

- {reading.dewpoint.toFixed(1)}°F -

-
- - {/* CO2 (if available) */} - {reading.co2 !== undefined && ( -
-
- - CO2 -
-

- {reading.co2} ppm -

-
- )} - - {/* Light (if available) */} - {reading.light !== undefined && ( -
-
- - Light -
-

- {reading.light.toFixed(0)} lux -

-
- )} -
- - {/* Timestamp */} -

- {new Date(reading.timestamp).toLocaleString()} -

-
+ {pulseReadings.map((reading) => ( + r.temperature) + }} + /> ))}