feat: Pulse sparklines, sidebar updates, and WS fix
Some checks are pending
Test / backend-test (push) Waiting to run
Test / frontend-test (push) Waiting to run

This commit is contained in:
fullsizemalt 2026-01-05 22:45:37 -08:00
parent 01b6c18f58
commit 1abb972d37
7 changed files with 202 additions and 110 deletions

View file

@ -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

View file

@ -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

View file

@ -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
*/ */

View 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>
);
}

View file

@ -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);

View file

@ -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' },
] ]
}, },
{ {

View file

@ -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>