feat: Pulse sparklines, sidebar updates, and WS fix
This commit is contained in:
parent
01b6c18f58
commit
1abb972d37
7 changed files with 202 additions and 110 deletions
|
|
@ -22,7 +22,7 @@ export async function websocketPlugin(fastify: FastifyInstance) {
|
||||||
/**
|
/**
|
||||||
* WebSocket endpoint for real-time alerts
|
* 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)}`;
|
const clientId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
|
|
||||||
// Get the raw WebSocket from the SocketStream
|
// Get the raw WebSocket from the SocketStream
|
||||||
|
|
|
||||||
|
|
@ -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 /pulse/devices/:id/history
|
||||||
* Get historical readings for a device
|
* Get historical readings for a device
|
||||||
|
|
|
||||||
|
|
@ -160,6 +160,30 @@ export class PulseService {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get sparkline data (last 1 hour) for all devices
|
||||||
|
*/
|
||||||
|
async getSparklines(): Promise<Record<string, PulseReading[]>> {
|
||||||
|
const devices = await this.getDevices();
|
||||||
|
const results: Record<string, PulseReading[]> = {};
|
||||||
|
|
||||||
|
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
|
* Test connection to Pulse API
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
134
frontend/src/components/dashboard/PulseSensorCard.tsx
Normal file
134
frontend/src/components/dashboard/PulseSensorCard.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<svg width={width} height={height} className="overflow-visible opacity-50">
|
||||||
|
<polyline
|
||||||
|
points={points}
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (onClick) onClick();
|
||||||
|
else navigate(`/pulse`); // Default to pulse dashboard
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
whileHover={{ y: -2 }}
|
||||||
|
onClick={handleClick}
|
||||||
|
className="group relative overflow-hidden bg-white dark:bg-[#0C0C0C] border border-[var(--color-border-subtle)]/60 p-5 rounded-2xl transition-all hover:shadow-2xl hover:shadow-indigo-500/5 cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start mb-4">
|
||||||
|
<div className="flex gap-3 items-center">
|
||||||
|
<div className="p-2.5 rounded-xl bg-emerald-500/10 text-emerald-500 ring-1 ring-emerald-500/20">
|
||||||
|
<Activity size={18} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-[11px] font-bold text-[var(--color-text-tertiary)] uppercase tracking-widest leading-none mb-1">
|
||||||
|
{reading.deviceName}
|
||||||
|
</h3>
|
||||||
|
<p className="text-[10px] text-[var(--color-text-tertiary)] opacity-60">Pulse Grow</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={cn("text-[10px] font-bold px-2 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-slate-700", statusColor)}>
|
||||||
|
{reading.timestamp ? 'LIVE' : 'OFFLINE'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-1.5 mb-0.5 text-[var(--color-text-tertiary)]">
|
||||||
|
<Thermometer size={12} />
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-wider">Temp</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-[var(--color-text-primary)]">
|
||||||
|
{reading.temperature.toFixed(1)}°
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-1.5 mb-0.5 text-[var(--color-text-tertiary)]">
|
||||||
|
<Droplets size={12} />
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-wider">RH</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-[var(--color-text-primary)]">
|
||||||
|
{reading.humidity.toFixed(0)}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-1.5 mb-0.5 text-[var(--color-text-tertiary)]">
|
||||||
|
<CloudFog size={12} />
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-wider">VPD</span>
|
||||||
|
</div>
|
||||||
|
<p className={cn("text-2xl font-bold", statusColor)}>
|
||||||
|
{reading.vpd.toFixed(2)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-end justify-end">
|
||||||
|
{history && history.temperature && (
|
||||||
|
<div className="mb-1">
|
||||||
|
<Sparkline data={history.temperature} color={reading.temperature > 80 ? '#f43f5e' : '#10b981'} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -31,7 +31,7 @@ export function useNotifications(options: UseNotificationsOptions = {}) {
|
||||||
// Determine WebSocket URL based on current location
|
// Determine WebSocket URL based on current location
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
const host = window.location.host;
|
const host = window.location.host;
|
||||||
const wsUrl = `${protocol}//${host}/ws/alerts`;
|
const wsUrl = `${protocol}//${host}/api/ws/alerts`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const ws = new WebSocket(wsUrl);
|
const ws = new WebSocket(wsUrl);
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,7 @@ export const NAV_SECTIONS: NavSection[] = [
|
||||||
{ id: 'batches', label: 'Batches', path: '/batches', icon: Sprout },
|
{ id: 'batches', label: 'Batches', path: '/batches', icon: Sprout },
|
||||||
{ id: 'ipm', label: 'IPM Dashboard', shortLabel: 'IPM', path: '/ipm', icon: Shield },
|
{ id: 'ipm', label: 'IPM Dashboard', shortLabel: 'IPM', path: '/ipm', icon: Shield },
|
||||||
{ id: 'environment', label: 'Environment', path: '/environment', icon: Thermometer, minRole: 'STAFF' },
|
{ 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: 'audit', label: 'Audit Log', path: '/compliance/audit', icon: ClipboardList },
|
||||||
{ id: 'documents', label: 'SOP Library', path: '/compliance/documents', icon: FileText },
|
{ id: 'documents', label: 'SOP Library', path: '/compliance/documents', icon: FileText },
|
||||||
{ id: 'metrc', label: 'METRC Integration', path: '/metrc', icon: Cloud, minRole: 'MANAGER' },
|
{ 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' },
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
import api from '../lib/api';
|
import api from '../lib/api';
|
||||||
import { PageHeader, MetricCard, EmptyState, CardSkeleton } from '../components/ui/LinearPrimitives';
|
import { PageHeader, MetricCard, EmptyState, CardSkeleton } from '../components/ui/LinearPrimitives';
|
||||||
import { useNotifications } from '../hooks/useNotifications';
|
import { useNotifications } from '../hooks/useNotifications';
|
||||||
|
import { PulseSensorCard } from '../components/dashboard/PulseSensorCard';
|
||||||
|
|
||||||
interface SensorData {
|
interface SensorData {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -77,6 +78,7 @@ export default function EnvironmentDashboard() {
|
||||||
const [dashboard, setDashboard] = useState<DashboardData | null>(null);
|
const [dashboard, setDashboard] = useState<DashboardData | null>(null);
|
||||||
const [sensors, setSensors] = useState<SensorData[]>([]);
|
const [sensors, setSensors] = useState<SensorData[]>([]);
|
||||||
const [pulseReadings, setPulseReadings] = useState<PulseReading[]>([]);
|
const [pulseReadings, setPulseReadings] = useState<PulseReading[]>([]);
|
||||||
|
const [sparklines, setSparklines] = useState<Record<string, PulseReading[]>>({});
|
||||||
const [pulseConnected, setPulseConnected] = useState(false);
|
const [pulseConnected, setPulseConnected] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
@ -106,11 +108,17 @@ export default function EnvironmentDashboard() {
|
||||||
setPulseConnected(pulseStatusRes.data.connected);
|
setPulseConnected(pulseStatusRes.data.connected);
|
||||||
|
|
||||||
if (pulseStatusRes.data.connected) {
|
if (pulseStatusRes.data.connected) {
|
||||||
const pulseReadingsRes = await api.get('/pulse/readings');
|
// Fetch readings and sparklines in parallel
|
||||||
setPulseReadings(pulseReadingsRes.data.readings || []);
|
// 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) {
|
} catch (pulseError) {
|
||||||
console.log('Pulse not configured:', pulseError);
|
console.log('Pulse not configured or error:', pulseError);
|
||||||
setPulseConnected(false);
|
setPulseConnected(false);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -126,18 +134,6 @@ export default function EnvironmentDashboard() {
|
||||||
setRefreshing(false);
|
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 => {
|
const getStatusClass = (value: number, min?: number, max?: number): string => {
|
||||||
if (min && value < min) return 'text-accent';
|
if (min && value < min) return 'text-accent';
|
||||||
if (max && value > max) return 'text-destructive';
|
if (max && value > max) return 'text-destructive';
|
||||||
|
|
@ -267,97 +263,14 @@ export default function EnvironmentDashboard() {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{pulseReadings.map((reading, index) => (
|
{pulseReadings.map((reading) => (
|
||||||
<div
|
<PulseSensorCard
|
||||||
key={reading.deviceId || index}
|
key={reading.deviceId}
|
||||||
className="card p-5 space-y-4"
|
reading={reading}
|
||||||
>
|
history={{
|
||||||
{/* Device Header */}
|
temperature: (sparklines[reading.deviceId] || []).map(r => r.temperature)
|
||||||
<div className="flex items-center justify-between">
|
}}
|
||||||
<h3 className="font-semibold text-primary">
|
/>
|
||||||
{reading.deviceName || `Device ${reading.deviceId}`}
|
|
||||||
</h3>
|
|
||||||
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Metrics Grid */}
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
{/* Temperature */}
|
|
||||||
<div className="p-3 rounded-lg bg-[var(--color-bg-tertiary)]">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<Thermometer className={`w-4 h-4 ${getTempColor(reading.temperature)}`} />
|
|
||||||
<span className="text-xs text-tertiary">Temp</span>
|
|
||||||
</div>
|
|
||||||
<p className={`text-xl font-bold ${getTempColor(reading.temperature)}`}>
|
|
||||||
{reading.temperature.toFixed(1)}°F
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Humidity */}
|
|
||||||
<div className="p-3 rounded-lg bg-[var(--color-bg-tertiary)]">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<Droplets className="w-4 h-4 text-blue-500" />
|
|
||||||
<span className="text-xs text-tertiary">Humidity</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xl font-bold text-blue-500">
|
|
||||||
{reading.humidity.toFixed(1)}%
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* VPD */}
|
|
||||||
<div className="p-3 rounded-lg bg-[var(--color-bg-tertiary)]">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<Wind className={`w-4 h-4 ${getVpdColor(reading.vpd)}`} />
|
|
||||||
<span className="text-xs text-tertiary">VPD</span>
|
|
||||||
</div>
|
|
||||||
<p className={`text-xl font-bold ${getVpdColor(reading.vpd)}`}>
|
|
||||||
{reading.vpd.toFixed(2)} kPa
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Dewpoint */}
|
|
||||||
<div className="p-3 rounded-lg bg-[var(--color-bg-tertiary)]">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<Droplets className="w-4 h-4 text-cyan-500" />
|
|
||||||
<span className="text-xs text-tertiary">Dewpoint</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xl font-bold text-cyan-500">
|
|
||||||
{reading.dewpoint.toFixed(1)}°F
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* CO2 (if available) */}
|
|
||||||
{reading.co2 !== undefined && (
|
|
||||||
<div className="p-3 rounded-lg bg-[var(--color-bg-tertiary)]">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<Wind className="w-4 h-4 text-purple-500" />
|
|
||||||
<span className="text-xs text-tertiary">CO2</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xl font-bold text-purple-500">
|
|
||||||
{reading.co2} ppm
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Light (if available) */}
|
|
||||||
{reading.light !== undefined && (
|
|
||||||
<div className="p-3 rounded-lg bg-[var(--color-bg-tertiary)]">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<Sun className="w-4 h-4 text-yellow-500" />
|
|
||||||
<span className="text-xs text-tertiary">Light</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xl font-bold text-yellow-500">
|
|
||||||
{reading.light.toFixed(0)} lux
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Timestamp */}
|
|
||||||
<p className="text-xs text-tertiary text-right">
|
|
||||||
{new Date(reading.timestamp).toLocaleString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue