diff --git a/use-cases/Iot-monitoring-sample/README.md b/use-cases/Iot-monitoring-sample/README.md new file mode 100644 index 0000000..219f893 --- /dev/null +++ b/use-cases/Iot-monitoring-sample/README.md @@ -0,0 +1,271 @@ +# Industrial IoT Monitoring Dashboard + +## Repository Description + +This repository demonstrates a real-time **Industrial IoT Monitoring Dashboard** built with **React**, **Syncfusion React Components**, +**Socket.IO**, and **Node.js/Express**. It showcases enterprise-style remote data binding, live device monitoring, anomaly detection, reporting, and server-side data processing. + +## Overview + +The sample demonstrates: + +- Syncfusion React Grid with Remote Custom Binding +- Socket.IO live updates +- Server-side paging, filtering, sorting, and searching +- Real-time dashboard statistics +- Live anomaly detection + +## Features + +### Dashboard + +- Real-time Summary Cards +- Live Device Monitoring Grid +- Remote Custom Binding +- Server-side Paging +- Server-side Filtering +- Server-side Sorting +- Server-side Searching +- Cell Editing +- Advanced Filter Panel +- Column Chooser +- Responsive Layout + +### Real-Time Monitoring + +- Socket.IO Integration +- Incremental Row Updates +- Live Summary Updates +- Automatic Grid Refresh +- Multi-client Synchronization + +### Alerts + +- Historical Alerts Grid +- Temperature Threshold Detection +- Battery Low Detection +- Date Range Filtering +- Alert Severity Management + +### Reports + +- Device Summary Reports +- Excel Export +- PDF Export +- Sensor Analytics + +## Prerequisites + +- Node.js 18+ +- npm +- React +- TypeScript +- Vite + +## Installation + +``` bash +cd iot-monitoring-sample + +cd server +npm install + +cd ../client +npm install +``` + +## Running the Sample + +### Start Backend + +``` bash +cd server +npm run dev +``` + +### Start Frontend + +``` bash +cd client +npm run dev +``` + +## Architecture + +``` text +Client (React + Syncfusion) + │ + ▼ + Remote Custom Binding + │ + ▼ + Node.js + Express + │ + ▼ + Device Service + │ + ▼ + In-Memory Data Store + ▲ + │ + Socket.IO + │ + ▼ + Connected Clients +``` + +## Project Structure + + +``` +iot-monitoring-sample/ +│ +├── client/ # React Frontend (Vite) +│ ├── src/ +│ │ ├── components/ # Reusable UI components +│ │ │ ├── Header.tsx +│ │ │ ├── Sidebar.tsx +│ │ │ ├── SummaryCard.tsx +│ │ │ ├── StatusBadge.tsx +│ │ │ ├── LoadingSkeleton.tsx +│ │ │ ├── ErrorBoundary.tsx +│ │ │ └── ToastNotification.tsx +│ │ ├── pages/ # Page components +│ │ │ ├── Dashboard.tsx +│ │ │ ├── Alerts.tsx +│ │ │ └── Reports.tsx +│ │ ├── services/ # API and Socket.IO services +│ │ │ ├── socketService.ts +│ │ │ └── apiService.ts +│ │ ├── hooks/ # Custom React hooks +│ │ │ ├── useSocket.ts +│ │ │ └── useGridData.ts +│ │ ├── context/ # React Context for state +│ │ │ └── AppContext.tsx +│ │ ├── utils/ # Utility functions +│ │ │ ├── dateUtils.ts +│ │ │ └── constants.ts +│ │ ├── App.tsx # Root component +│ │ └── main.tsx # Entry point +│ ├── package.json +│ └── vite.config.ts +│ +├── server/ # Node.js + Express Backend +│ ├── models/ # Data models +│ │ └── Device.js +│ ├── services/ # Business logic +│ │ ├── DeviceService.js +│ │ └── SocketService.js +│ ├── controllers/ # Request handlers +│ │ └── DeviceController.js +│ ├── routes/ # API routes +│ │ └── deviceRoutes.js +│ ├── socket/ # Socket.IO handlers +│ ├── mock-data/ # Mock data generator +│ │ └── mockDataGenerator.js +│ ├── index.js # Server entry point +│ └── package.json +│ +└── README.md # This file +``` + +## Technology Stack + +### Frontend + +- React 19 +- TypeScript +- Vite +- Tailwind CSS 3 +- Syncfusion React Components +- Socket.IO Client + +### Backend + +- Node.js +- Express.js +- Socket.IO + +## Syncfusion Components + +- Grid +- Charts +- Toast +- DateRangePicker +- Dialog +- Sidebar +- Toolbar +- Buttons +- DropDownList + +## Grid Features + +- Remote Custom Binding +- Paging +- Sorting +- Filtering +- Searching +- Editing +- Column Chooser +- Excel Export +- PDF Export + +## API Overview + +### Devices + +- GET /api/devices/grid +- PUT /api/devices/:deviceId +- GET /api/devices/summary + +### Alerts + +- GET /api/alerts/grid + +### Reports + +- GET /api/reports + +## Sample Data + +- 1000 Industrial IoT Devices +- Temperature Sensors +- Pressure Sensors +- Humidity Sensors +- Voltage Sensors +- Flow Sensors +- Vibration Sensors + +Each device contains: + +- Device ID +- Device Name +- Location +- Sensor Type +- Reading Value +- Threshold +- Signal Strength +- Battery Level +- Status +- Last Updated + +## Performance Features + +- Remote Data Binding +- Server-side Processing +- Incremental Socket Updates +- Responsive Layout + +## Learning Objectives + +This sample demonstrates: + +- Remote Custom Binding +- Server-side data operations +- Socket.IO integration +- Real-time synchronization +- Live IoT monitoring + +## License + +© 2026 Syncfusion. All rights reserved. diff --git a/use-cases/Iot-monitoring-sample/client/index.html b/use-cases/Iot-monitoring-sample/client/index.html new file mode 100644 index 0000000..4b96213 --- /dev/null +++ b/use-cases/Iot-monitoring-sample/client/index.html @@ -0,0 +1,14 @@ + + + + + + + Industrial IoT Monitoring Dashboard + + + +
+ + + diff --git a/use-cases/Iot-monitoring-sample/client/package.json b/use-cases/Iot-monitoring-sample/client/package.json new file mode 100644 index 0000000..1e5290a --- /dev/null +++ b/use-cases/Iot-monitoring-sample/client/package.json @@ -0,0 +1,36 @@ +{ + "name": "iot-monitoring-client", + "version": "1.0.0", + "description": "Industrial IoT Monitoring Dashboard - React Frontend", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview" + }, + "dependencies": { + "@syncfusion/ej2-base": "*", + "@syncfusion/ej2-react-buttons": "*", + "@syncfusion/ej2-react-calendars": "*", + "@syncfusion/ej2-react-charts": "*", + "@syncfusion/ej2-react-grids": "*", + "@syncfusion/ej2-react-inputs": "*", + "@syncfusion/ej2-react-popups": "*", + "@syncfusion/ej2-tailwind3-theme": "^34.1.30", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^6.20.0", + "socket.io-client": "^4.7.2", + "typescript": "^5.3.3" + }, + "devDependencies": { + "@types/react": "^18.2.43", + "@types/react-dom": "^18.2.17", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.16", + "postcss": "^8.4.32", + "tailwindcss": "^3.4.0", + "vite": "^5.0.8" + } +} diff --git a/use-cases/Iot-monitoring-sample/client/postcss.config.js b/use-cases/Iot-monitoring-sample/client/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/use-cases/Iot-monitoring-sample/client/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/use-cases/Iot-monitoring-sample/client/src/App.tsx b/use-cases/Iot-monitoring-sample/client/src/App.tsx new file mode 100644 index 0000000..f3de76a --- /dev/null +++ b/use-cases/Iot-monitoring-sample/client/src/App.tsx @@ -0,0 +1,130 @@ +/** + * Main App Component + * Root application with routing and layout + */ + +import { useState, useEffect } from 'react' +import { BrowserRouter, Routes, Route } from 'react-router-dom' +import { Header } from './components/Header' +import { Sidebar } from './components/Sidebar' +import { ToastNotificationContainer, Toast } from './components/ToastNotification' +import { ErrorBoundary } from './components/ErrorBoundary' +import { AppProvider, useAppContext } from './context/AppContext' +import { useSocketConnection, useSocket } from './hooks/useSocket' +import Dashboard from './pages/Dashboard' +import Alerts from './pages/Alerts' +import Reports from './pages/Reports' +import { apiService } from './services/apiService' + +const AppLayout = () => { + const [isSidebarOpen, setIsSidebarOpen] = useState(false) + const [toasts, setToasts] = useState([]) + const { setSummary } = useAppContext() + + // Initialize socket connection + // Connection state is now managed by AppContext via socketService.onConnectionStateChange() + useSocketConnection() + + // Listen for summary updates + useSocket('summary_update', (summary) => { + setSummary(summary) + }) + + // Listen for anomalies and show toast + // Server is single source of truth — trust that server only sends valid, non-duplicate alerts + // Toast display is simply: receive event → show toast with auto-timeout + // No client-side deduplication needed + useSocket( + 'anomaly_detected', + (alert: { + message: string + severity: string + alertType?: string + deviceId?: string + deviceName?: string + }) => { + // Determine toast type based on severity or alert type + const isBatteryAlert = alert.alertType?.includes('BATTERY') + const toastType = isBatteryAlert ? 'warning' : 'error' + + console.log(`[Toast] Showing: ${alert.message} (type: ${toastType})`) + addToast(alert.message, toastType) + } + ) + + // Fetch initial summary — silent on failure; the Dashboard / Alerts + // pages render their own empty / retry state. The summary card on + // the dashboard simply shows zeros until a real value arrives. + useEffect(() => { + let cancelled = false + const fetchSummary = async () => { + try { + const summary = await apiService.getSummary() + if (!cancelled) setSummary(summary) + } catch { + // Intentionally swallowed — server may be unavailable + } + } + fetchSummary() + return () => { + cancelled = true + } + }, [setSummary]) + + const addToast = (message: string, type: Toast['type'] = 'info') => { + const id = Date.now().toString() + setToasts((prev) => [...prev, { id, message, type }]) + } + + const removeToast = (id: string) => { + setToasts((prev) => prev.filter((t) => t.id !== id)) + } + + const { alertCount } = useAppContext() + + return ( +
+ {/* Sidebar — fixed w-60 on md+ */} + setIsSidebarOpen(false)} + /> + + {/* Main wrapper — offset by sidebar on md+ */} +
+ {/* Header — h-14 */} +
setIsSidebarOpen(!isSidebarOpen)} + isSidebarOpen={isSidebarOpen} + alertCount={alertCount} + /> + + {/* Page content — scrollable, padding matches header */} +
+
+ + + } /> + } /> + } /> + + +
+
+
+ + {/* Toast notifications */} + +
+ ) +} + +export default function App() { + return ( + + + + + + ) +} diff --git a/use-cases/Iot-monitoring-sample/client/src/components/ErrorBoundary.tsx b/use-cases/Iot-monitoring-sample/client/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..56656d1 --- /dev/null +++ b/use-cases/Iot-monitoring-sample/client/src/components/ErrorBoundary.tsx @@ -0,0 +1,60 @@ +/** + * Error Boundary Component + * Catches and displays errors + */ + +import React, { ReactNode } from 'react' + +interface Props { + children: ReactNode + fallback?: (error: Error, retry: () => void) => ReactNode +} + +interface State { + hasError: boolean + error: Error | null +} + +export class ErrorBoundary extends React.Component { + constructor(props: Props) { + super(props) + this.state = { hasError: false, error: null } + } + + static getDerivedStateFromError(error: Error) { + return { hasError: true, error } + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + console.error('Error caught:', error, errorInfo) + } + + retry = () => { + this.setState({ hasError: false, error: null }) + } + + render() { + if (this.state.hasError) { + return ( + this.props.fallback?.(this.state.error!, this.retry) || ( +
+

+ Something went wrong +

+

+ {this.state.error?.message} +

+ +
+ ) + ) + } + + return this.props.children + } +} diff --git a/use-cases/Iot-monitoring-sample/client/src/components/Header.tsx b/use-cases/Iot-monitoring-sample/client/src/components/Header.tsx new file mode 100644 index 0000000..936da98 --- /dev/null +++ b/use-cases/Iot-monitoring-sample/client/src/components/Header.tsx @@ -0,0 +1,93 @@ +/** + * Header Component + * Top navigation bar — clean enterprise style + */ + +import React, { useState, useEffect } from 'react' +import { useAppContext } from '../context/AppContext' + +interface HeaderProps { + onMenuToggle: () => void + isSidebarOpen: boolean + alertCount?: number +} + +export const Header: React.FC = ({ onMenuToggle, alertCount = 0 }) => { + const { socketConnected } = useAppContext() + const [currentTime, setCurrentTime] = useState(new Date()) + + useEffect(() => { + const timer = setInterval(() => setCurrentTime(new Date()), 1000) + return () => clearInterval(timer) + }, []) + + const timeStr = currentTime.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) + const dateStr = currentTime.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }) + + return ( +
+ {/* Mobile menu toggle */} + + + {/* App title (desktop) */} +
+
+ + + +
+ Industrial IoT Monitoring +
+ + {/* Spacer */} +
+ + {/* Live time display */} +
+ {timeStr} + {dateStr} +
+ + {/* Divider */} +
+ + {/* Connection status */} +
+ + +
+ + {/* Alert count badge */} + {alertCount > 0 && ( +
+ + + + + {alertCount > 9 ? '9+' : alertCount} + +
+ )} + + {/* User avatar */} +
+ IO +
+
+ ) +} diff --git a/use-cases/Iot-monitoring-sample/client/src/components/LoadingSkeleton.tsx b/use-cases/Iot-monitoring-sample/client/src/components/LoadingSkeleton.tsx new file mode 100644 index 0000000..4f588c5 --- /dev/null +++ b/use-cases/Iot-monitoring-sample/client/src/components/LoadingSkeleton.tsx @@ -0,0 +1,42 @@ +/** + * Loading Skeleton Component + * Placeholder for loading states + */ + +import React from 'react' + +interface LoadingSkeletonProps { + count?: number + height?: string + type?: 'card' | 'table' | 'chart' +} + +export const LoadingSkeleton: React.FC = ({ + count = 1, + height = 'h-12', + type = 'card' +}) => { + const skeletonItems = Array.from({ length: count }) + + if (type === 'card') { + return ( +
+ {skeletonItems.map((_, i) => ( +
+
+
+
+
+ ))} +
+ ) + } + + return ( +
+ {skeletonItems.map((_, i) => ( +
+ ))} +
+ ) +} diff --git a/use-cases/Iot-monitoring-sample/client/src/components/Sidebar.tsx b/use-cases/Iot-monitoring-sample/client/src/components/Sidebar.tsx new file mode 100644 index 0000000..6474855 --- /dev/null +++ b/use-cases/Iot-monitoring-sample/client/src/components/Sidebar.tsx @@ -0,0 +1,142 @@ +/** + * Sidebar Component + * Dark enterprise navigation sidebar with active indicators and footer + */ + +import React from 'react' +import { Link, useLocation } from 'react-router-dom' + +interface SidebarProps { + isOpen: boolean + onClose: () => void +} + +interface NavItem { + id: string + label: string + path: string + icon: React.ReactNode + badge?: number +} + +const navItems: NavItem[] = [ + { + id: 'dashboard', + label: 'Dashboard', + path: '/', + icon: ( + + + + ) + }, + { + id: 'alerts', + label: 'Alerts', + path: '/alerts', + icon: ( + + + + ) + }, + { + id: 'reports', + label: 'Reports', + path: '/reports', + icon: ( + + + + ) + } +] + +export const Sidebar: React.FC = ({ isOpen, onClose }) => { + const location = useLocation() + + return ( + <> + {/* Mobile backdrop */} + {isOpen && ( +
+ )} + + {/* Sidebar panel */} + + + ) +} diff --git a/use-cases/Iot-monitoring-sample/client/src/components/StatusBadge.tsx b/use-cases/Iot-monitoring-sample/client/src/components/StatusBadge.tsx new file mode 100644 index 0000000..b47d5f6 --- /dev/null +++ b/use-cases/Iot-monitoring-sample/client/src/components/StatusBadge.tsx @@ -0,0 +1,100 @@ +/** + * Status Badge Component + * Compact badge optimized for Grid row heights + */ + +import React from 'react' + +interface StatusBadgeProps { + status: 'Normal' | 'Warning' | 'Critical' | 'Offline' | 'High' | 'Medium' | 'Low' + className?: string + showDot?: boolean +} + +const statusConfig: Record = { + Normal: { + bg: 'bg-emerald-50', + text: 'text-emerald-700', + dot: 'bg-emerald-500' + }, + Warning: { + bg: 'bg-amber-50', + text: 'text-amber-700', + dot: 'bg-amber-400' + }, + Critical: { + bg: 'bg-red-50', + text: 'text-red-700', + dot: 'bg-red-500' + }, + Offline: { + bg: 'bg-slate-100', + text: 'text-slate-600', + dot: 'bg-slate-400' + }, + High: { + bg: 'bg-red-50', + text: 'text-red-700', + dot: 'bg-red-500' + }, + Medium: { + bg: 'bg-orange-50', + text: 'text-orange-700', + dot: 'bg-orange-500' + }, + Low: { + bg: 'bg-blue-50', + text: 'text-blue-700', + dot: 'bg-blue-500' + } +} + +export const StatusBadge: React.FC = ({ + status, + className = '', + showDot = true +}) => { + const cfg = statusConfig[status] || statusConfig.Normal + + return ( +
+ + {showDot && ( + + )} + + {status} + +
+ ) +} \ No newline at end of file diff --git a/use-cases/Iot-monitoring-sample/client/src/components/SummaryCard.tsx b/use-cases/Iot-monitoring-sample/client/src/components/SummaryCard.tsx new file mode 100644 index 0000000..ba7c5b4 --- /dev/null +++ b/use-cases/Iot-monitoring-sample/client/src/components/SummaryCard.tsx @@ -0,0 +1,95 @@ +import React from 'react' + +interface SummaryCardProps { + title: string + value: string | number + icon: React.ReactNode + color: 'blue' | 'green' | 'orange' | 'red' | 'violet' + subtext?: string + trend?: { value: string; positive?: boolean } +} + +const colorMap = { + blue: { + bg: 'bg-blue-50', + ring: 'ring-blue-100', + icon: 'text-blue-600' + }, + green: { + bg: 'bg-emerald-50', + ring: 'ring-emerald-100', + icon: 'text-emerald-600' + }, + orange: { + bg: 'bg-amber-50', + ring: 'ring-amber-100', + icon: 'text-amber-600' + }, + red: { + bg: 'bg-red-50', + ring: 'ring-red-100', + icon: 'text-red-600' + }, + violet: { + bg: 'bg-violet-50', + ring: 'ring-violet-100', + icon: 'text-violet-600' + } +} + +export const SummaryCard: React.FC = ({ + title, + value, + icon, + color, + subtext, + trend +}) => { + const c = colorMap[color] + + return ( +
+
+ +
+ +

+ {title} +

+ +
+ + {value} + + + {trend && ( + + {trend.positive !== false ? '↑' : '↓'} {trend.value} + + )} +
+ + {subtext && ( +

+ {subtext} +

+ )} + +
+ +
+ {icon} +
+ +
+
+ ) +} \ No newline at end of file diff --git a/use-cases/Iot-monitoring-sample/client/src/components/ToastNotification.tsx b/use-cases/Iot-monitoring-sample/client/src/components/ToastNotification.tsx new file mode 100644 index 0000000..79e672f --- /dev/null +++ b/use-cases/Iot-monitoring-sample/client/src/components/ToastNotification.tsx @@ -0,0 +1,186 @@ +/** + * Toast Notification Component + * Only one toast is visible at a time; additional notifications are queued. + * Duplicate messages are grouped and displayed once while queued. + */ + +import React, { useEffect, useState, useMemo } from 'react' + +export interface Toast { + id: string + message: string + type: 'success' | 'warning' | 'error' | 'info' + duration?: number +} + +interface ToastNotificationProps { + toasts: Toast[] + onRemove: (id: string) => void +} + +const MAX_VISIBLE = 1 +const DEFAULT_TOAST_DURATION = 4000 // 4 seconds + +// Icons per type +const icons: Record = { + success: ( + + + + ), + error: ( + + + + ), + warning: ( + + + + ), + info: ( + + + + ), +} + +const typeStyles: Record = { + success: 'bg-white border-l-4 border-emerald-500 text-slate-700', + error: 'bg-white border-l-4 border-red-500 text-slate-700', + warning: 'bg-white border-l-4 border-amber-400 text-slate-700', + info: 'bg-white border-l-4 border-blue-500 text-slate-700', +} + +const iconStyles: Record = { + success: 'text-emerald-500', + error: 'text-red-500', + warning: 'text-amber-500', + info: 'text-blue-500', +} + +interface GroupedToast { + representativeId: string // id to remove when dismissed + allIds: string[] // all ids in this group + message: string + type: Toast['type'] + count: number + duration?: number +} + +const ToastItem: React.FC<{ group: GroupedToast; onRemove: () => void; queueCount: number }> = ({ + group, + onRemove, + queueCount, +}) => { + const [visible, setVisible] = useState(false) + const [exiting, setExiting] = useState(false) + + // Animate in + useEffect(() => { + const id = requestAnimationFrame(() => setVisible(true)) + return () => cancelAnimationFrame(id) + }, []) + + // Auto-dismiss after duration + useEffect(() => { + const timer = window.setTimeout(() => { + setExiting(true) + window.setTimeout(onRemove, 300) + }, group.duration || DEFAULT_TOAST_DURATION) + return () => window.clearTimeout(timer) + }, [group.duration, onRemove]) + + const handleClose = () => { + setExiting(true) + window.setTimeout(onRemove, 300) + } + + return ( +
+ {icons[group.type]} + +
+

+ {group.message} + {group.count > 1 && ( + + ×{group.count} + + )} +

+ {queueCount > 0 && ( +

+{queueCount} more in queue

+ )} +
+ + +
+ ) +} + +/** + * Groups toasts with identical messages into single items with a count badge. + * Shows at most MAX_VISIBLE at once; extras are queued. + */ +export const ToastNotificationContainer: React.FC = ({ toasts, onRemove }) => { + const grouped = useMemo(() => { + const map = new Map() + for (const t of toasts) { + const key = `${t.type}::${t.message}` + const existing = map.get(key) + if (existing) { + existing.count++ + existing.allIds.push(t.id) + } else { + map.set(key, { + representativeId: t.id, + allIds: [t.id], + message: t.message, + type: t.type, + count: 1, + duration: t.duration, + }) + } + } + return Array.from(map.values()) + }, [toasts]) + + const visible = grouped.slice(0, MAX_VISIBLE) + const queueCount = grouped.length - visible.length + + if (visible.length === 0) return null + + return ( +
+ {visible.map((group, idx) => ( + group.allIds.forEach((id) => onRemove(id))} + /> + ))} +
+ ) +} diff --git a/use-cases/Iot-monitoring-sample/client/src/context/AppContext.tsx b/use-cases/Iot-monitoring-sample/client/src/context/AppContext.tsx new file mode 100644 index 0000000..7957559 --- /dev/null +++ b/use-cases/Iot-monitoring-sample/client/src/context/AppContext.tsx @@ -0,0 +1,88 @@ +/** + * App Context + * Global state management for app-wide data + */ + +import React, { createContext, useState, useCallback, useEffect, ReactNode } from 'react' +import { socketService } from '../services/socketService' + +export interface Summary { + totalDevices: number + onlineDevices: number + criticalDevices: number + avgBattery: number +} + +export interface Alert { + alertId: string + deviceId: string + deviceName: string + location: string + sensorType: string + alertType: string + severity: 'Low' | 'Medium' | 'High' | 'Critical' + status?: 'Active' | 'Acknowledged' | 'Resolved' + message: string + timestamp: string | Date +} + +export interface AppContextType { + socketConnected: boolean + setSocketConnected: (connected: boolean) => void + summary: Summary | null + setSummary: (summary: Summary) => void + alertCount: number + setAlertCount: (count: number) => void + alerts: Alert[] + setAlerts: React.Dispatch> + showNotification: (message: string, type: 'success' | 'warning' | 'error' | 'info') => void +} + +export const AppContext = createContext(undefined) + +interface AppProviderProps { + children: ReactNode +} + +export const AppProvider: React.FC = ({ children }) => { + const [socketConnected, setSocketConnected] = useState(false) + const [summary, setSummary] = useState(null) + const [alertCount, setAlertCount] = useState(0) + const [alerts, setAlerts] = useState([]) + + // Listen to actual Socket.IO connection state changes + useEffect(() => { + const unsubscribe = socketService.onConnectionStateChange((connected) => { + setSocketConnected(connected) + console.log(`[AppContext] Socket connection state: ${connected ? 'connected' : 'disconnected'}`) + }) + return unsubscribe + }, []) + + const showNotification = useCallback((message: string, type: 'success' | 'warning' | 'error' | 'info') => { + // This will be connected to a Toast component + console.log(`[${type.toUpperCase()}] ${message}`) + }, []) + + const value: AppContextType = { + socketConnected, + setSocketConnected, + summary, + setSummary, + alertCount, + setAlertCount, + alerts, + setAlerts, + showNotification + } + + return {children} +} + +export const useAppContext = () => { + const context = React.useContext(AppContext) + if (!context) { + throw new Error('useAppContext must be used within AppProvider') + } + return context +} diff --git a/use-cases/Iot-monitoring-sample/client/src/hooks/useGridState.ts b/use-cases/Iot-monitoring-sample/client/src/hooks/useGridState.ts new file mode 100644 index 0000000..d88f7df --- /dev/null +++ b/use-cases/Iot-monitoring-sample/client/src/hooks/useGridState.ts @@ -0,0 +1,232 @@ +/** + * useGridState Hook + * Centralized grid state management using plain useState. + * + * Handles all grid state transitions: pagination, sorting, filtering, searching + * Ensures page index resets when filters or search changes. + * + * NOTE: Implemented with useState (NOT useReducer) to keep grid state local + * to the consuming component and avoid extra render passes. + */ + +import { useState, useCallback, useRef, useEffect } from 'react' + +// ------------------------------------------------------------------ +// Types +// ------------------------------------------------------------------ + +export interface FilterPredicate { + field: string + operator: string + value: string | number | boolean + predicate?: 'and' | 'or' + matchCase?: boolean +} + +export interface GridRequestState { + skip: number + take: number + sortBy: string + sortDirection: 'ascending' | 'descending' + searchValue: string + filters: FilterPredicate[] +} + +export interface GridResponse { + result: T[] + count: number +} + +export interface GridStateData { + request: GridRequestState + response: GridResponse + isLoading: boolean + error: Error | null +} + +// ------------------------------------------------------------------ +// Hook +// ------------------------------------------------------------------ + +export interface UseGridStateOptions { + pageSize?: number + initialFilters?: FilterPredicate[] + initialSearch?: string +} + +export interface UseGridStateReturn { + // State + state: GridStateData + isLoading: boolean + error: Error | null + data: T[] + totalCount: number + request: GridRequestState + + // Pagination + pageIndex: number + pageCount: number + pageSize: number + + // Filters + hasActiveFilters: boolean + + // Actions + setPage: (pageIndex: number) => void + setSort: (field: string, direction: 'ascending' | 'descending') => void + setSearch: (query: string) => void + setFilters: (filters: FilterPredicate[]) => void + resetFilters: () => void + setLoading: (loading: boolean) => void + setData: (response: GridResponse) => void + setError: (error: Error | null) => void +} + +export function useGridState(options: UseGridStateOptions = {}): UseGridStateReturn { + const { pageSize = 10, initialFilters = [], initialSearch = '' } = options + + const [request, setRequest] = useState(() => ({ + skip: 0, + take: pageSize, + sortBy: '', + sortDirection: 'ascending', + searchValue: initialSearch, + filters: initialFilters, + })) + + const [data, setDataInternal] = useState([]) + const [count, setCount] = useState(0) + const [isLoading, setIsLoading] = useState(true) + const [error, setErrorInternal] = useState(null) + + // ------------------------------------------------------------------ + // Stable refs for callback identity (prevents re-renders) + // ------------------------------------------------------------------ + const pageSizeRef = useRef(pageSize) + useEffect(() => { + pageSizeRef.current = pageSize + }, [pageSize]) + + // ------------------------------------------------------------------ + // Actions — all use functional updates to avoid stale closures + // ------------------------------------------------------------------ + + const setPage = useCallback((pageIndex: number) => { + const skip = pageIndex * pageSizeRef.current + setRequest((prev) => (prev.skip === skip ? prev : { ...prev, skip })) + }, []) + + const setSort = useCallback( + (field: string, direction: 'ascending' | 'descending' = 'ascending') => { + setRequest((prev) => { + if (prev.sortBy === field && prev.sortDirection === direction) return prev + return { ...prev, sortBy: field, sortDirection: direction, skip: 0 } + }) + }, + [] + ) + + const setSearch = useCallback((query: string) => { + setRequest((prev) => + prev.searchValue === query ? prev : { ...prev, searchValue: query, skip: 0 } + ) + }, []) + + const setFilters = useCallback((filters: FilterPredicate[]) => { + setRequest((prev) => { + if (prev.filters.length === filters.length) { + let same = true + for (let i = 0; i < filters.length; i++) { + const a = prev.filters[i] + const b = filters[i] + if (a.field !== b.field || a.operator !== b.operator || a.value !== b.value) { + same = false + break + } + } + if (same) return prev + } + return { ...prev, filters, skip: 0 } + }) + }, []) + + const resetFilters = useCallback(() => { + setRequest((prev) => { + if ( + prev.filters.length === 0 && + prev.searchValue === '' && + prev.sortBy === '' && + prev.sortDirection === 'ascending' && + prev.skip === 0 + ) { + return prev + } + return { + ...prev, + filters: [], + searchValue: '', + sortBy: '', + sortDirection: 'ascending', + skip: 0, + } + }) + }, []) + + const setLoading = useCallback((loading: boolean) => { + setIsLoading((prev) => (prev === loading ? prev : loading)) + }, []) + + const setData = useCallback((response: GridResponse) => { + setDataInternal(response.result) + setCount(response.count) + setIsLoading(false) + setErrorInternal(null) + }, []) + + const setError = useCallback((err: Error | null) => { + setErrorInternal(err) + setIsLoading(false) + }, []) + + // ------------------------------------------------------------------ + // Derived values + // ------------------------------------------------------------------ + const pageIndex = Math.floor(request.skip / pageSizeRef.current) + const pageCount = Math.ceil(count / pageSizeRef.current) || 1 + const hasActiveFilters = request.filters.length > 0 || request.searchValue.length > 0 + + const state: GridStateData = { + request, + response: { result: data, count }, + isLoading, + error, + } + + return { + // State + state, + isLoading, + error, + data, + totalCount: count, + request, + + // Pagination + pageIndex, + pageCount, + pageSize: pageSizeRef.current, + + // Filters + hasActiveFilters, + + // Actions + setPage, + setSort, + setSearch, + setFilters, + resetFilters, + setLoading, + setData, + setError, + } +} diff --git a/use-cases/Iot-monitoring-sample/client/src/hooks/useSocket.ts b/use-cases/Iot-monitoring-sample/client/src/hooks/useSocket.ts new file mode 100644 index 0000000..e3f3114 --- /dev/null +++ b/use-cases/Iot-monitoring-sample/client/src/hooks/useSocket.ts @@ -0,0 +1,82 @@ +/** + * useSocket Hook + * React Hook for Socket.IO integration. + * + * Uses a ref for the latest callback so socket subscription is only + * set up once per (event, connected) change. Prevents re-subscribing + * on every render and avoids the listener leak that would otherwise + * cause duplicate socket deliveries. + */ + +import { useEffect, useRef, useCallback } from 'react' +import { socketService } from '../services/socketService' + +export const useSocket = ( + event: string, + callback: (data: any) => void, + connected: boolean = true +) => { + const callbackRef = useRef(callback) + + // Always keep the latest callback reference + useEffect(() => { + callbackRef.current = callback + }, [callback]) + + useEffect(() => { + if (!connected) return + + const wrappedCallback = (data: any) => { + callbackRef.current(data) + } + + socketService.subscribe(event, wrappedCallback) + + return () => { + socketService.unsubscribe(event, wrappedCallback) + } + }, [event, connected]) + + const emit = useCallback( + (data?: any) => { + socketService.emit(event, data) + }, + [event] + ) + + return { emit } +} + +/** + * useSocketConnection Hook + * Manages Socket.IO connection lifecycle + */ +export const useSocketConnection = () => { + const connectionAttempted = useRef(false) + + useEffect(() => { + if (connectionAttempted.current) return + + connectionAttempted.current = true + + const connect = async () => { + try { + await socketService.connect() + } catch (error) { + console.error('Failed to connect socket:', error) + } + } + + connect() + + return () => { + // Optional: disconnect on unmount + // socketService.disconnect() + } + }, []) + + return { + isConnected: socketService.getIsConnected(), + socketId: socketService.getSocketId() + } +} diff --git a/use-cases/Iot-monitoring-sample/client/src/index.css b/use-cases/Iot-monitoring-sample/client/src/index.css new file mode 100644 index 0000000..61f60ac --- /dev/null +++ b/use-cases/Iot-monitoring-sample/client/src/index.css @@ -0,0 +1,108 @@ + +@import '../node_modules/@syncfusion/ej2-base/styles/tailwind3.css'; +@import '../node_modules/@syncfusion/ej2-buttons/styles/tailwind3.css'; +@import '../node_modules/@syncfusion/ej2-calendars/styles/tailwind3.css'; +@import '../node_modules/@syncfusion/ej2-dropdowns/styles/tailwind3.css'; +@import '../node_modules/@syncfusion/ej2-inputs/styles/tailwind3.css'; +@import '../node_modules/@syncfusion/ej2-navigations/styles/tailwind3.css'; +@import '../node_modules/@syncfusion/ej2-popups/styles/tailwind3.css'; +@import '../node_modules/@syncfusion/ej2-splitbuttons/styles/tailwind3.css'; +@import '../node_modules/@syncfusion/ej2-notifications/styles/tailwind3.css'; +@import "../node_modules/@syncfusion/ej2-react-grids/styles/tailwind3.css"; +@tailwind base; +@tailwind components; +@tailwind utilities; + + +/* ────────────────────────────────────────────────────────── + BASE + ────────────────────────────────────────────────────────── */ +*, *::before, *::after { + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background-color: #f8fafc; + color: #1e293b; + -webkit-font-smoothing: antialiased; +} + + +/* ────────────────────────────────────────────────────────── + SYNCFUSION CHARTS — Light background + ────────────────────────────────────────────────────────── */ +.e-chart-container, +.e-accumulationchart-container { + background: transparent !important; +} + +/* ────────────────────────────────────────────────────────── + LOADING SKELETON ANIMATION + ────────────────────────────────────────────────────────── */ +@keyframes shimmer { + 0% { background-position: -800px 0; } + 100% { background-position: 800px 0; } +} + +.skeleton { + background: linear-gradient( + 90deg, + #f1f5f9 0%, + #e2e8f0 50%, + #f1f5f9 100% + ); + background-size: 800px 100%; + animation: shimmer 1.5s ease-in-out infinite; + border-radius: 6px; +} + +/* ────────────────────────────────────────────────────────── + SCROLLBAR — subtle modern + ────────────────────────────────────────────────────────── */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: #cbd5e1; + border-radius: 99px; +} +::-webkit-scrollbar-thumb:hover { + background: #94a3b8; +} + +/* ────────────────────────────────────────────────────────── + UTILITY CLASSES + ────────────────────────────────────────────────────────── */ +.card { + @apply bg-white rounded-xl border border-slate-200 shadow-sm; +} + +.card-header { + @apply px-6 py-4 border-b border-slate-100; +} + +.card-body { + @apply p-6; +} + + +/* DateRangePicker wrapper */ +.analytics-date-range { + border-radius: 6px !important; + overflow: hidden; + border: 1px solid #cbd5e1 !important; +} + +.analytics-date-range .e-input { + border-radius: 6px 0 0 6px !important; +} + +.analytics-date-range .e-input-group-icon { + border-radius: 0 6px 6px 0 !important; +} + diff --git a/use-cases/Iot-monitoring-sample/client/src/main.tsx b/use-cases/Iot-monitoring-sample/client/src/main.tsx new file mode 100644 index 0000000..c7abdbd --- /dev/null +++ b/use-cases/Iot-monitoring-sample/client/src/main.tsx @@ -0,0 +1,14 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App.tsx' +import './index.css' + +// Register Syncfusion license key here if you have one: +// import { registerLicense } from '@syncfusion/ej2-base' +// registerLicense('YOUR_LICENSE_KEY') + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/use-cases/Iot-monitoring-sample/client/src/pages/Alerts.tsx b/use-cases/Iot-monitoring-sample/client/src/pages/Alerts.tsx new file mode 100644 index 0000000..df90d91 --- /dev/null +++ b/use-cases/Iot-monitoring-sample/client/src/pages/Alerts.tsx @@ -0,0 +1,354 @@ +/** + * Alerts Page + * Historical anomaly records with LOCAL DATA BINDING. + */ +import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; +import { + GridComponent, + ColumnsDirective, + ColumnDirective, + Inject, + Page, + Resize, + Sort, + Filter, + Search, + Selection, + Edit, + VirtualScroll, + Toolbar, +} from '@syncfusion/ej2-react-grids'; +import { DateRangePickerComponent } from '@syncfusion/ej2-react-calendars'; +import { apiService } from '../services/apiService'; +import { useAppContext } from '../context/AppContext'; +import '../index.css' + +// ------------------------------------------------------------------ +// Types +// ------------------------------------------------------------------ +interface Alert { + alertId: string; + deviceId: string; + deviceName: string; + location: string; + sensorType: string; + alertType: string; + severity: 'Low' | 'Medium' | 'High' | 'Critical'; + status?: 'Active' | 'Acknowledged' | 'Resolved'; + message: string; + timestamp: string | Date; +} + +// ------------------------------------------------------------------ +// Component +// ------------------------------------------------------------------ +export default function Alerts() { + const gridRef = useRef(null); + const { setSummary, alerts, setAlerts } = useAppContext(); + + const [isLoading, setIsLoading] = useState(true); + const [serverAvailable, setServerAvailable] = useState(null); + const [dateRange, setDateRange] = useState<[Date, Date] | null>(null); + const dateRangePickerRef = useRef(null); + + const requestTokenRef = useRef(0); + + // ------------------------------------------------------------------ + // Load Data + // ------------------------------------------------------------------ + const loadAlerts = useCallback(async () => { + const token = ++requestTokenRef.current; + setIsLoading(true); + + try { + const response = await apiService.getAlerts({ skip: 0, take: 999999 }); + if (token !== requestTokenRef.current) return; + + const alertsData = response.result || response || []; + const parsedAlerts = alertsData.map((a: any) => ({ + ...a, + timestamp: a.timestamp ? new Date(a.timestamp) : a.timestamp, + })) as Alert[]; + + setAlerts(parsedAlerts); + setServerAvailable(true); + } catch (err) { + if (token !== requestTokenRef.current) return; + console.error('[Alerts] load failed', err); + setServerAvailable(false); + } finally { + if (token === requestTokenRef.current) setIsLoading(false); + } + }, [setAlerts]); + + // ------------------------------------------------------------------ + // Action Handler (Status Change + Persisted Delete) + // ------------------------------------------------------------------ + const handleActionComplete = useCallback(async (args: any) => { + // Status Change + if (args.requestType === 'save' && args.data) { + const updatedAlert = args.data as Alert; + if (!updatedAlert.alertId) return; + + if (args.endEdit) args.endEdit(); + + setAlerts((prev) => + prev.map((a) => + a.alertId === updatedAlert.alertId ? { ...a, status: updatedAlert.status } : a + ) + ); + + apiService.getSummary?.().then(setSummary).catch(console.error); + } + + // Persist delete on server so remount/refetch cannot restore rows + else if (args.requestType === 'delete' && args.data) { + const deletedAlerts: Alert[] = Array.isArray(args.data) ? args.data : [args.data]; + const deletedIds: string[] = deletedAlerts + .map((a) => a.alertId) + .filter((id): id is string => Boolean(id)); + + if (deletedIds.length === 0) return; + + + setAlerts((prev) => prev.filter((a) => !deletedIds.includes(a.alertId))); + + try { + await Promise.all(deletedIds.map((id: string) => apiService.deleteAlert(id))); + const summary = await apiService.getSummary(); + setSummary(summary); + console.log(`✅ Deleted ${deletedIds.length} alert(s) on server`); + } catch (err) { + console.error('[Alerts] delete failed — reloading from server', err); + // Restore authoritative state if the server rejected any delete + await loadAlerts(); + } + } + }, [setAlerts, setSummary, loadAlerts]); + + // ------------------------------------------------------------------ + // Improved Date Range Filter + // ------------------------------------------------------------------ + const filteredAlerts = useMemo(() => { + if (!dateRange || !dateRange[0] || !dateRange[1]) { + return alerts; + } + + const startDate = new Date(dateRange[0]); + const endDate = new Date(dateRange[1]); + + // Normalize to full days + startDate.setHours(0, 0, 0, 0); + endDate.setHours(23, 59, 59, 999); + + return alerts.filter((alert) => { + if (!alert.timestamp) return false; + const alertDate = new Date(alert.timestamp); + return alertDate >= startDate && alertDate <= endDate; + }); + }, [alerts, dateRange]); + + // ------------------------------------------------------------------ + // Handlers + // ------------------------------------------------------------------ + const handleDateRangeChange = useCallback((e: any) => { + if (e?.value && Array.isArray(e.value) && e.value.length === 2 && e.value[0] && e.value[1]) { + setDateRange([e.value[0], e.value[1]]); + console.log('Date range updated:', e.value[0], 'to', e.value[1]); // for debugging + } else { + setDateRange(null); + } + }, []); + +const handleClearFilters = useCallback(() => { + setDateRange(null); + + // Safely reset DateRangePicker (without optional chaining assignment) + if (dateRangePickerRef.current) { + dateRangePickerRef.current.value = undefined as any; + } + + // Reset Grid filters + if (gridRef.current) { + gridRef.current.clearFiltering(); + } +}, []); + + // ------------------------------------------------------------------ + // Load on Mount + // ------------------------------------------------------------------ + useEffect(() => { + loadAlerts(); + }, []); + + // ------------------------------------------------------------------ + // Render + // ------------------------------------------------------------------ + if (serverAvailable === false && alerts.length === 0) { + return ( +
+
+
+ + + +
+

Server Unavailable

+

We couldn't load the alert history.

+ +
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

Alerts

+

Historical anomaly records and device alerts

+
+ +
+ + {/* Filters */} +
+
+ + {/* Left Section */} +
+ + {/* Date Range */} +
+
+ + + +
+ + +
+
+ + {/* Right Section */} +
+

+ Total Alerts +

+ +

+ {filteredAlerts.length.toLocaleString()} +

+
+ +
+
+ + {/* Grid */} +
+ {isLoading ? ( +
+
+
+

Loading alerts...

+
+
+ ) : filteredAlerts.length === 0 ? ( +
+
+ + + +
+

No Data Available

+

+ {dateRange ? 'No alerts match the selected date range.' : 'No alerts recorded yet.'} +

+
+ ) : ( + 100} + height="400px" + clipMode="EllipsisWithTooltip" + actionComplete={handleActionComplete} + actionFailure={(err) => console.error('[Alerts] Grid action failed:', err)} + > + + + + + + + ( +
+ {props.message} +
+ )} + /> + + +
+ +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/use-cases/Iot-monitoring-sample/client/src/pages/Dashboard.tsx b/use-cases/Iot-monitoring-sample/client/src/pages/Dashboard.tsx new file mode 100644 index 0000000..072b205 --- /dev/null +++ b/use-cases/Iot-monitoring-sample/client/src/pages/Dashboard.tsx @@ -0,0 +1,800 @@ +/** + * Dashboard Page + * Main landing page with summary cards and device monitoring grid. + */ + +import { useEffect, useRef, useCallback, useMemo, useState } from 'react' +import { + GridComponent, + ColumnsDirective, + ColumnDirective, + Inject, + Page, + Sort, + Filter, + Search, + Resize, + Toolbar, + DataStateChangeEventArgs, + FilterSettingsModel, + ToolbarItems, +} from '@syncfusion/ej2-react-grids' +import { useAppContext } from '../context/AppContext' +import { useSocket } from '../hooks/useSocket' +import { SummaryCard } from '../components/SummaryCard' +import { StatusBadge } from '../components/StatusBadge' +import { apiService } from '../services/apiService' +import { fetchDeviceGridData, parseGridState, type GridRequestState } from '../services/GridDataAdaptor' + +// ------------------------------------------------------------------ +// Dashboard-side device name mapping +// Keep this function pure and deterministic: it must be safe to +// run on every custom-binding fetch without breaking realtime +// incremental updates. +// ------------------------------------------------------------------ + +function mapDeviceNameShort(name: string) { + // Prefer realistic concise industrial names over aggressive trimming. + // Only apply targeted replacements to avoid changing unrelated names. + return name + .replace(/Pump Vibration Detection Sensor Unit/gi, 'Pump Vib Sensor') + .replace(/Oven Surface Temperature Sensor/gi, 'Oven Temp Sensor') + .replace(/Process Vessel Pressure Monitoring Sensor/gi, 'Vessel Pressure') + .replace(/Air Compressor Monitoring Device/gi, 'Air Compressor') + .replace(/Warehouse Environmental Sensor/gi, 'Env Sensor') + + // Generic fallbacks (avoid unnecessary truncation) + .replace(/Monitoring Device/gi, 'Device') + .replace(/\bSensor\b/gi, 'Sensor') + .trim() +} + +// ------------------------------------------------------------------ +// Types +// ------------------------------------------------------------------ + +interface Device { + deviceId: string + deviceName: string + location: string + sensorType: string + status: 'Normal' | 'Warning' | 'Critical' | 'Offline' + readingValue: number | null + threshold: number + signalStrength: number + batteryLevel: number + lastUpdated: string | Date +} + +interface DeviceUpdate { + deviceId: string + status?: 'Normal' | 'Warning' | 'Critical' | 'Offline' + readingValue?: number | null + signalStrength?: number + batteryLevel?: number + lastUpdated?: string + rowIndex?: number +} + +const PAGE_SIZE = 10 +const SOCKET_FLUSH_INTERVAL_MS = 1000 +const STATUS_REFRESH_INTERVAL_MS = 20000 + +function getDeviceStatus( + readingValue: number | null | undefined, + threshold: number, + sensorType: string, + timeMs = Date.now() +): Device['status'] { + const zeroAllowedSensors = ['Flow', 'Vibration'] + const isUnavailable = + readingValue === null || + readingValue === undefined || + Number.isNaN(readingValue) || + (readingValue === 0 && !zeroAllowedSensors.includes(sensorType)) + + if (isUnavailable) { + return 'Offline' + } + + const safeThreshold = Math.max(threshold || 1, 1) + const normalizedReading = readingValue / safeThreshold + const oscillation = Math.sin((timeMs / 1000) * 1.35 + normalizedReading) * 0.24 + const adjustedReading = normalizedReading + oscillation + + if (adjustedReading >= 1.06) return 'Critical' + if (adjustedReading >= 0.82) return 'Warning' + return 'Normal' +} + +// ------------------------------------------------------------------ +// Dashboard component +// ------------------------------------------------------------------ + +export default function Dashboard() { + const { summary, socketConnected } = useAppContext() + const gridRef = useRef(null) + + // ------------------------------------------------------------------ + // Grid request state management (direct useState — no hook abstraction) + // Includes 'where' field for complex predicate tree support (checkbox filters) + // ------------------------------------------------------------------ + const [request, setRequest] = useState({ + skip: 0, + take: PAGE_SIZE, + sortBy: '', + sortDirection: 'ascending', + searchValue: '', + filters: [], + where: [], + }) + + // Grid response data + loading/error state + const [data, setData] = useState([]) + const [totalCount, setTotalCount] = useState(0) + const [isLoading, setIsLoading] = useState(true) + + // ------------------------------------------------------------------ + // Filter choice dialog datasource (unfiltered distinct values) + // ------------------------------------------------------------------ + + const unfilteredDistinctValuesRef = useRef<{ + sensorType: string[] + status: Array<'Normal' | 'Warning' | 'Critical' | 'Offline'> + batteryLevel: number[] + }>({ + sensorType: [], + status: ['Normal', 'Warning', 'Critical', 'Offline'], + batteryLevel: [], + }) + + // ------------------------------------------------------------------ + // Server connectivity state — set to false when initial fetch fails + // ------------------------------------------------------------------ + const [serverAvailable, setServerAvailable] = useState(null) + + // ------------------------------------------------------------------ + // Refs for callbacks so we never recreate loadData, avoiding the + // "callback identity changes → effect re-runs → fetch loop" pattern + // that caused the visible flicker. + // ------------------------------------------------------------------ + const requestRef = useRef(request) + requestRef.current = request + + const setLoadingRef = useRef(setIsLoading) + setLoadingRef.current = setIsLoading + const setDataStateRef = useRef((result: Device[], count: number) => { + setData(result) + setTotalCount(count) + }) + + // Token used to discard stale responses when a newer request fires + const requestTokenRef = useRef(0) + + // ------------------------------------------------------------------ + // Core: load data from server. Stable identity (no deps). + // ------------------------------------------------------------------ + const loadData = useCallback(async () => { + const token = ++requestTokenRef.current + setLoadingRef.current(true) + try { + const data = await fetchDeviceGridData(requestRef.current) + // Discard stale responses + if (token !== requestTokenRef.current) return + // Map long device names to short industrial names and normalise dates + const devicesWithDates = (data.result as any).map((device: any) => ({ + ...device, + deviceName: mapDeviceNameShort(String(device.deviceName)), + lastUpdated: device.lastUpdated ? new Date(device.lastUpdated) : device.lastUpdated, + readingValue: device.readingValue === null || device.readingValue === undefined ? null : Number(device.readingValue), + status: getDeviceStatus(device.readingValue, Number(device.threshold), String(device.sensorType), Date.now()), + })) + setDataStateRef.current(devicesWithDates as Device[], data.count) + + // Populate unfiltered choice lists once. Crucially, we must not + // re-scope the choice datasource from gridState.data on every open. + const distinctSensorTypes = new Set() + const distinctBatteryLevels = new Set() + ;(devicesWithDates as Device[]).forEach((d) => { + if (d.sensorType) distinctSensorTypes.add(String(d.sensorType)) + if (Number.isFinite(d.batteryLevel)) distinctBatteryLevels.add(Number(d.batteryLevel)) + }) + if (unfilteredDistinctValuesRef.current.sensorType.length === 0) { + unfilteredDistinctValuesRef.current.sensorType = Array.from(distinctSensorTypes).sort() + } + if (unfilteredDistinctValuesRef.current.batteryLevel.length === 0) { + unfilteredDistinctValuesRef.current.batteryLevel = Array.from(distinctBatteryLevels).sort((a, b) => a - b) + } + + if (serverAvailable !== true) setServerAvailable(true) + } catch (err) { + if (token !== requestTokenRef.current) return + console.warn('[Dashboard] grid fetch failed', err) + // Mark the server as unavailable so we can stop the connection + // request loop and show an empty / retry state. + setServerAvailable(false) + } + }, [serverAvailable]) + + // ------------------------------------------------------------------ + // Custom Binding: dataStateChange — fires for page / sort / filter / search + // ------------------------------------------------------------------ + const dataStateChange = useCallback((args: DataStateChangeEventArgs) => { + // Handle Syncfusion filter choice requests (Excel/Checkbox dialog) + const action = (args as any).action + if ( + action && + (action.requestType === 'filterchoicerequest' || + action.requestType === 'filtersearchbegin' || + action.requestType === 'stringfilterrequest') + ) { + // Create a request for all records WITHOUT any current filters + // This ensures we get the complete dataset for distinct value extraction + const unfilteredRequest: GridRequestState = { + skip: 0, + take: 1000, + sortBy: requestRef.current.sortBy, + sortDirection: requestRef.current.sortDirection, + searchValue: '', // Clear search for filter choice + filters: [], // CLEAR FILTERS - this is the key fix! + where: [], // CLEAR WHERE - this is the key fix! + } + + fetchDeviceGridData(unfilteredRequest) + .then((result) => { + try { + // Pass complete unfiltered dataset for distinct value extraction + ;(args as any).dataSource(result.result as any) + } catch (err) { + console.warn('[Dashboard] filter choice dataSource call failed', err) + } + }) + .catch((err) => { + console.warn('[Dashboard] filter choice request failed', err) + try { + // Fallback to current page if fetch fails + ;(args as any).dataSource(data) + } catch { + /* ignore */ + } + }) + return + } + + const state = parseGridState(args as Parameters[0]) + + setRequest((prevRequest) => { + let updated = prevRequest + + // Update skip/page + if (state.skip !== prevRequest.skip) { + updated = { ...updated, skip: state.skip } + } + + // Update sort + if (state.sortBy !== prevRequest.sortBy || state.sortDirection !== prevRequest.sortDirection) { + updated = { ...updated, sortBy: state.sortBy, sortDirection: state.sortDirection } + } + + // Update search + if (state.searchValue !== prevRequest.searchValue) { + updated = { ...updated, searchValue: state.searchValue } + } + + // Handle where tree (complex predicates from Grid filter UI) + // This preserves nested predicate structures for checkbox filters with multiple selections + const prevWhereKey = JSON.stringify(prevRequest.where || []) + const nextWhereKey = JSON.stringify(state.where || []) + if (nextWhereKey !== prevWhereKey) { + // When filters change, reset to first page and apply new where tree + updated = { ...updated, where: state.where || [], skip: 0 } + } + + return updated + }) + }, []) + + // ------------------------------------------------------------------ + // Load data when the request changes. Stable callback + primitive + // dependencies → effect fires only when a request value actually + // changes, never on a parent re-render. + // ------------------------------------------------------------------ + const { skip, take, sortBy, sortDirection, searchValue, filters, where } = request + // Serialise the filter list and where tree so the effect re-fires when the values + // (not just the length) change. + const filtersKey = filters + .map((f) => `${f.field}:${f.operator}:${String(f.value)}`) + .join('|') + const whereKey = JSON.stringify(where || []) + useEffect(() => { + loadData() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [skip, take, sortBy, sortDirection, searchValue, filtersKey, whereKey]) + + // Listen for alert updates in shared context and refresh dashboard + // + // When an alert is resolved/acknowledged/closed/updated in the Alert Grid, + // the shared context is updated. We need to refresh the summary and device + // grid to reflect any changes (e.g., critical device count, summary stats). + // + // Strategy: + // 1. Monitor the alerts array from shared context + // 2. Debounce to avoid excessive API calls (use ref to track last alert state) + // 3. When alerts change meaningfully, refresh the summary via getSummary() + // 4. This keeps dashboard KPIs (critical alerts count, avg battery, etc.) in sync + const { alerts: contextAlerts, setSummary } = useAppContext() + const contextAlertsKeyRef = useRef('') + + useEffect(() => { + const currentKey = JSON.stringify( + contextAlerts.map(a => ({ id: a.alertId, status: a.status })) + ) + + if (currentKey !== contextAlertsKeyRef.current && contextAlerts.length > 0) { + contextAlertsKeyRef.current = currentKey + + // Debounce the summary refresh to avoid excessive calls + const timer = setTimeout(() => { + apiService + .getSummary() + .then((summary) => { + setSummary(summary) + console.log('[Dashboard] Summary auto-refreshed after alert change') + }) + .catch(() => {}) // Silently ignore fetch errors + }, 300) // 300ms debounce + + return () => clearTimeout(timer) + } + }, [contextAlerts, setSummary]) + + + const updateBufferRef = useRef>>(new Map()) + const flushTimeoutRef = useRef | null>(null) + const dataRef = useRef([]) + dataRef.current = data + + const flushUpdates = useCallback(() => { + const grid = gridRef.current + const buffer = updateBufferRef.current + if (!grid || buffer.size === 0) return + + const data = dataRef.current + const updates: Array<{ rowIdx: number; updates: Partial }> = [] + + buffer.forEach((update, deviceId) => { + const rowIdx = data.findIndex((d) => d.deviceId === deviceId) + if (rowIdx === -1) return + + const row = data[rowIdx] + const merged: Partial = {} + + const effectiveReading = update.readingValue !== undefined ? update.readingValue : row.readingValue + merged.readingValue = update.readingValue !== undefined ? update.readingValue : row.readingValue + merged.status = getDeviceStatus(effectiveReading, row.threshold, row.sensorType, Date.now()) + + if (update.signalStrength !== undefined) merged.signalStrength = update.signalStrength + if (update.batteryLevel !== undefined) merged.batteryLevel = update.batteryLevel + if (update.lastUpdated !== undefined) merged.lastUpdated = update.lastUpdated + Object.assign(row, merged) + updates.push({ rowIdx, updates: merged }) + }) + buffer.clear() + + }, []) + + const handleDeviceUpdate = useCallback((update: DeviceUpdate) => { + if (!gridRef.current || !update?.deviceId) return + + if (typeof (update as any).deviceName === 'string') { + ;(update as any).deviceName = mapDeviceNameShort((update as any).deviceName) + } + + const existing = updateBufferRef.current.get(update.deviceId) + updateBufferRef.current.set(update.deviceId, { ...existing, ...update }) + + if (flushTimeoutRef.current === null) { + flushTimeoutRef.current = setTimeout(() => { + flushUpdates() + flushTimeoutRef.current = null + }, SOCKET_FLUSH_INTERVAL_MS) + } + }, [flushUpdates]) + + const refreshStatuses = useCallback(() => { + setData((prevData) => + prevData.map((device) => { + const nextStatus = getDeviceStatus(device.readingValue, device.threshold, device.sensorType, Date.now()) + return nextStatus === device.status ? device : { ...device, status: nextStatus } + }) + ) + }, []) + + useEffect(() => { + if (data.length === 0) return + + const intervalId = window.setInterval(() => { + refreshStatuses() + }, STATUS_REFRESH_INTERVAL_MS) + + return () => { + window.clearInterval(intervalId) + if (flushTimeoutRef.current !== null) { + clearTimeout(flushTimeoutRef.current) + } + } + }, [data.length, refreshStatuses]) + + useSocket('device_update', handleDeviceUpdate, socketConnected && serverAvailable !== false) + + // ------------------------------------------------------------------ + // Derived values + // ------------------------------------------------------------------ + const healthPct = summary + ? Math.round((summary.onlineDevices / Math.max(summary.totalDevices, 1)) * 100) + : 0 + + // ------------------------------------------------------------------ + // Stable dataSource reference. The previous implementation passed + // `{ result: data, count }` inline, which created a new object on + // every render and forced the Grid to do a full rebind. + // ------------------------------------------------------------------ + const dataSource = useMemo( + () => ({ result: data, count: totalCount }), + [data, totalCount] + ) + +// ------------------------------------------------------------------ +// Stable Toolbar with Enter-only Search +// ------------------------------------------------------------------ +const toolbar: (ToolbarItems | object)[] = useMemo(() => [ + { + id: 'customSearch', + align: 'Left', + template: () => { + const handleSearch = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + const grid = gridRef.current; + const value = (e.target as HTMLInputElement).value.trim(); + if (grid) { + grid.search(value); + } + } + }; + + return ( +
+ +
+ + + +
+
+ ↵ Enter +
+
+ ); + } + }, +], []); + // ------------------------------------------------------------------ + // Render + // ------------------------------------------------------------------ + + // Server unavailable — show empty state and a retry button. Do NOT + // render the Grid because the dataSource is empty anyway and we want + // to stop the request loop. + if (serverAvailable === false && data.length === 0) { + return ( +
+
+
+

Dashboard

+

Real-time monitoring of all connected IoT devices

+
+
+
+
+ + + +
+

Server Unavailable

+

+ We couldn't reach the device service. The dashboard will keep trying + to reconnect — you can also retry manually. +

+ +
+
+ ) + } + + if (isLoading && data.length === 0) { + return ( +
+
+ {[1, 2, 3, 4].map((i) => ( +
+
+
+
+
+ ))} +
+
+
+ {[1, 2, 3, 4, 5, 6].map((i) => ( +
+ ))} +
+
+ ) + } + + const filterSettings : FilterSettingsModel = {type: 'CheckBox'} + + return ( +
+ {/* Page header */} +
+
+

+ Dashboard +

+ +

+ Real-time monitoring of all connected IoT devices +

+
+
+ {!socketConnected && ( + + Live updates paused + + )} +
+
+ +
+
+ + + + } + color="blue" + subtext="Across all locations" + /> +
+ +
+ + + + } + color="green" + subtext={`${healthPct}% fleet health`} + trend={{ value: `${healthPct}%`, positive: healthPct >= 80 }} + /> +
+ +
+ + + + } + color="red" + subtext="Require attention" + trend={ + (summary?.criticalDevices || 0) > 0 + ? { value: 'Action Needed', positive: false } + : undefined + } + /> +
+ +
+ + + + + + } + color="orange" + subtext="Fleet battery average" + /> +
+
+ + {/* ── Device monitoring grid ── */} + {/* Main content: keep the grid as the primary focus */} +
+ {/* Empty state when the server returned no rows */} + {data.length === 0 ? ( +
+
+ + + +
+

No Data Available

+
+ ) : ( +
+ + + + + + + } + /> + { + const color = + props.status === 'Critical' + ? 'text-red-600' + : props.status === 'Warning' + ? 'text-amber-600' + : 'text-slate-800' + + return ( + + {Number(props.readingValue).toFixed(2)} + + ) + }} + /> + + { + const s = props.signalStrength + const barColor = s >= 75 ? 'bg-emerald-500' : s >= 50 ? 'bg-amber-400' : 'bg-red-400' + return ( +
+
+ {[1, 2, 3, 4].map((bar) => ( +
= bar * 25 ? barColor : 'bg-slate-200'}`} + style={{ height: `${bar * 3 + 2}px` }} + /> + ))} +
+ {s}% +
+ ) + }} + /> + { + const pct = Math.round(props.batteryLevel) + const barColor = pct > 50 ? 'bg-emerald-500' : pct > 20 ? 'bg-amber-400' : 'bg-red-500' + const textColor = pct > 50 ? 'text-emerald-600' : pct > 20 ? 'text-amber-600' : 'text-red-600' + return ( +
+
+
+
+ {pct}% +
+ ) + }} + /> + + + + +
+ )} +
+
+ ) +} diff --git a/use-cases/Iot-monitoring-sample/client/src/pages/Reports.tsx b/use-cases/Iot-monitoring-sample/client/src/pages/Reports.tsx new file mode 100644 index 0000000..3385b21 --- /dev/null +++ b/use-cases/Iot-monitoring-sample/client/src/pages/Reports.tsx @@ -0,0 +1,312 @@ +/** + * Reports Page + * Summary reports with export functionality + */ + +import { useState, useEffect, useRef } from 'react' +import { + GridComponent, + ColumnsDirective, + ColumnDirective, + Inject, + Page, + Sort, + ExcelExport, + PdfExport +} from '@syncfusion/ej2-react-grids' +import { apiService } from '../services/apiService' + +interface ReportData { + sensorType: string + totalDevices: number + averageReading: number + maxReading: number + minReading: number + avgBattery: number +} + +export default function Reports() { + const [reportData, setReportData] = useState([]) + const [isLoading, setIsLoading] = useState(true) + + const gridRef = useRef(null) + + const fetchReports = async () => { + setIsLoading(true) + + try { + const data = await apiService.getReports() + setReportData(data) + } catch (error) { + console.error('Failed to fetch reports:', error) + } finally { + setIsLoading(false) + } + } + + useEffect(() => { + fetchReports() + }, []) + + const handleExcelExport = () => { + gridRef.current?.excelExport() + } + + const handlePdfExport = () => { + gridRef.current?.pdfExport() + } + + if (isLoading) { + return ( +
+
+
+
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+
+ +
+ {[1, 2, 3, 4, 5].map((i) => ( +
+ ))} +
+
+ ) + } + + const totalDevices = reportData.reduce((sum, r) => sum + r.totalDevices, 0) + + const avgReading = reportData.length + ? reportData.reduce((sum, r) => sum + r.averageReading, 0) / + reportData.length + : 0 + + const avgBattAll = reportData.length + ? reportData.reduce((sum, r) => sum + r.avgBattery, 0) / + reportData.length + : 0 + + return ( +
+ {/* Page Header */} +
+

Reports

+

+ Summarized device and sensor analytics +

+
+ + {/* Summary Cards */} +
+ {[ + { + label: 'Total Monitored Devices', + value: totalDevices.toLocaleString(), + color: 'text-blue-600', + icon: '📡' + }, + { + label: 'Avg Reading (All Sensors)', + value: avgReading.toFixed(2), + color: 'text-violet-600', + icon: '📊' + }, + { + label: 'Fleet Avg Battery', + value: `${avgBattAll.toFixed(1)}%`, + color: 'text-emerald-600', + icon: '🔋' + } + ].map((card) => ( +
+
+ {card.icon} + +

+ {card.label} +

+
+ +

+ {card.value} +

+
+ ))} +
+ + {/* Reports Grid */} +
+ {/* Grid Header */} +
+
+

+ Sensor Type Summary +

+ +

+ {reportData.length} Sensor Types +

+
+ +
+ + + +
+
+ + + + + + + + + + + + + + + + + + + +
+ {reportData.length} sensor types •{' '} + {totalDevices.toLocaleString()} monitored devices +
+
+ + {/* Breakdown */} +
+
+

+ Sensor Type Breakdown +

+
+ +
+ {reportData.map((report) => { + const badge = + report.avgBattery > 60 + ? 'bg-emerald-50 text-emerald-600' + : report.avgBattery > 30 + ? 'bg-amber-50 text-amber-600' + : 'bg-red-50 text-red-600' + + return ( +
+
+
+ {report.sensorType} +
+ +
+ {report.totalDevices} devices • Avg{' '} + {report.averageReading.toFixed(2)} • Range{' '} + {report.minReading.toFixed(1)} -{' '} + {report.maxReading.toFixed(1)} +
+
+ + + {report.avgBattery.toFixed(1)}% + +
+ ) + })} +
+
+
+ ) +} \ No newline at end of file diff --git a/use-cases/Iot-monitoring-sample/client/src/services/GridDataAdaptor.ts b/use-cases/Iot-monitoring-sample/client/src/services/GridDataAdaptor.ts new file mode 100644 index 0000000..3b1d8ab --- /dev/null +++ b/use-cases/Iot-monitoring-sample/client/src/services/GridDataAdaptor.ts @@ -0,0 +1,222 @@ +/** + * GridDataAdaptor + * + * Client-side helper for Syncfusion Grid Custom Binding. + * + * Responsibilities: + * - Parse dataStateChange arguments. + * - Preserve the Syncfusion where tree. + * - Send paging, sorting, searching and filtering parameters to the server. + * - Return { result, count } back to the Grid. + * + * All data operations (filtering, searching, sorting and paging) + * are performed on the server. + */ + + +// Use relative proxy path in dev; absolute URL in production +const API_BASE_URL = 'http://localhost:3001/api' + +// ------------------------------------------------------------------ +// Types +// ------------------------------------------------------------------ + +/** A single filter predicate as sent by Syncfusion Grid */ +export interface FilterPredicate { + field: string + /** e.g. 'equal' | 'notequal' | 'contains' | 'startswith' | 'endswith' | 'greaterthan' | 'lessthan' */ + operator: string + value: unknown + matchCase?: boolean + predicate?: 'and' | 'or' +} + +/** Syncfusion predicate node in the where tree */ +export interface PredicateNode { + field?: string + operator?: string + value?: unknown + matchCase?: boolean + predicates?: PredicateNode[] + condition?: 'and' | 'or' + isComplex?: boolean +} + +export interface GridRequestState { + skip: number + take: number + sortBy: string + sortDirection: 'ascending' | 'descending' + searchValue: string + /** Structured filter predicates — one entry per filtered column */ + filters: FilterPredicate[] + /** Preserved Syncfusion where tree for advanced filtering (multi-predicate support) */ + where?: PredicateNode[] + /** Virtual scroll request detected */ + isVirtualScroll?: boolean + /** Request type from dataStateChange */ + requestType?: string +} + +export interface GridResult> { + result: T[] + count: number +} + +// ------------------------------------------------------------------ +// Fetch devices — called from Dashboard dataStateChange handler +// ------------------------------------------------------------------ + +export async function fetchDeviceGridData(state: GridRequestState): Promise { + const params = new URLSearchParams({ + skip: String(state.skip), + take: String(state.take), + sortBy: state.sortBy, + sortDirection: state.sortDirection, + searchValue: state.searchValue, + }) + + // Send preserved where tree to server for correct multi-predicate evaluation + // The where tree includes complex filter groups from multi-select checkbox filters + if (state.where && state.where.length > 0) { + params.set('where', JSON.stringify(state.where)) + } else if (state.filters && state.filters.length > 0) { + // Fallback to flat filters if where tree is not available + params.set('filters', JSON.stringify(state.filters)) + } + + + const response = await fetch(`${API_BASE_URL}/devices/grid?${params}`) + if (!response.ok) { + throw new Error(`Grid fetch failed: ${response.status} ${response.statusText}`) + } + return response.json() as Promise +} + +// ------------------------------------------------------------------ +// Fetch alerts — called from Alerts page dataStateChange handler +// ------------------------------------------------------------------ + +export async function fetchAlertGridData( + state: GridRequestState, + startDate?: Date, + endDate?: Date +): Promise { + const params: Record = { + skip: String(state.skip), + take: String(state.take), + sortBy: state.sortBy, + sortDirection: state.sortDirection, + searchValue: state.searchValue, + } + + // Send where tree for complex multi-select filtering (e.g., multiple severity levels) + if (state.where && state.where.length > 0) { + params['where'] = JSON.stringify(state.where) + } else if (state.filters && state.filters.length > 0) { + params['filters'] = JSON.stringify(state.filters) + } + + if (startDate && endDate) { + params['startDate'] = startDate.toISOString() + params['endDate'] = endDate.toISOString() + } + + // Indicate if this is a virtual scroll request (for row virtualization > 100 records) + if (state.isVirtualScroll) { + params['virtualScroll'] = 'true' + } + + const response = await fetch(`${API_BASE_URL}/alerts/grid?${new URLSearchParams(params)}`) + if (!response.ok) { + throw new Error(`Alerts fetch failed: ${response.status} ${response.statusText}`) + } + return response.json() as Promise +} + +// ------------------------------------------------------------------ +// Parse dataStateChange event args -> GridRequestState +// ------------------------------------------------------------------ +export function parseGridState(args: { + skip?: number + take?: number + sorted?: Array<{ name: string; direction: string }> + where?: unknown[] + action?: { requestType: string; searchString?: string } + searchString?: string +}): GridRequestState { + const skip = args.skip ?? 0 + const take = args.take ?? 12 + + let sortBy = '' + let sortDirection: 'ascending' | 'descending' = 'ascending' + + if (args.sorted && args.sorted.length > 0) { + sortBy = args.sorted[0].name ?? '' + sortDirection = + String(args.sorted[0].direction ?? 'ascending').toLowerCase() === 'descending' + ? 'descending' + : 'ascending' + } + + let searchValue = + args.searchString ?? + (args.action?.requestType === 'searching' ? (args.action.searchString ?? '') : '') + + searchValue = searchValue.trim().toLowerCase() + const requestType = args.action?.requestType ?? '' + const isVirtualScroll = requestType === 'virtualscroll' + + // Preserve the original Syncfusion where tree for correct multi-predicate evaluation. + // The where array may contain: + // - Simple predicates: { field, operator, value, matchCase } + // - Nested groups: { predicates: [...], condition: 'and'|'or' } + // Example multi-select: multiple severity values are OR'd within a group, + // then AND'd with other column filters. + const where = (Array.isArray(args.where) ? args.where : []) as PredicateNode[] + const filters: FilterPredicate[] = [] + + return { + skip, + take, + sortBy, + sortDirection, + searchValue, + filters, + where, + isVirtualScroll, + requestType, + } +} + +// ------------------------------------------------------------------ +// Filter choice data sources (for filter dropdowns) +// ------------------------------------------------------------------ + +/** + * Get ALL filtered device records for filter dropdown distinct values. + * + */ +export async function fetchDeviceGridDataForFilterChoices( + state: GridRequestState +): Promise { + const params = new URLSearchParams({ + skip: '0', + take: String(999999), // Unlimited — get all matching records + sortBy: state.sortBy, + sortDirection: state.sortDirection, + searchValue: state.searchValue, + }) + + if (state.where && state.where.length > 0) { + params.set('where', JSON.stringify(state.where)) + } else if (state.filters && state.filters.length > 0) { + params.set('filters', JSON.stringify(state.filters)) + } + + const response = await fetch(`${API_BASE_URL}/devices/grid?${params}`) + if (!response.ok) { + throw new Error(`Filter choices fetch failed: ${response.status} ${response.statusText}`) + } + return response.json() as Promise +} diff --git a/use-cases/Iot-monitoring-sample/client/src/services/apiService.ts b/use-cases/Iot-monitoring-sample/client/src/services/apiService.ts new file mode 100644 index 0000000..8fd2de5 --- /dev/null +++ b/use-cases/Iot-monitoring-sample/client/src/services/apiService.ts @@ -0,0 +1,147 @@ +/** + * API Service + * + * Thin fetch wrapper with: + * - per-request timeout (default 8s) + * - server-availability tracking: when a request fails we expose a + * callback so the UI can render an "offline" state and stop the + * request loop + */ + +const API_BASE_URL = 'http://localhost:3001/api' +const DEFAULT_TIMEOUT_MS = 8000 + +export interface GridQueryParams { + skip?: number + take?: number + sortBy?: string + sortDirection?: 'ascending' | 'descending' + searchValue?: string +} + +export interface AlertsQueryParams extends GridQueryParams { + startDate?: string + endDate?: string +} + +type ServerStatusListener = (online: boolean) => void + +class ApiServiceClass { + private serverOnline = true + private listeners: Set = new Set() + // In-flight requests are aborted on disconnect to avoid leaks. + private activeControllers: Set = new Set() + + /** Subscribe to server-online state changes. Returns an unsubscribe fn. */ + onServerStatusChange(listener: ServerStatusListener): () => void { + this.listeners.add(listener) + listener(this.serverOnline) + return () => { + this.listeners.delete(listener) + } + } + + private setServerOnline(online: boolean) { + if (this.serverOnline === online) return + this.serverOnline = online + if (!online) this.cancelAll() + this.listeners.forEach((l) => { + try { + l(online) + } catch { + /* ignore */ + } + }) + } + + private cancelAll() { + for (const c of this.activeControllers) { + try { + c.abort() + } catch { + /* ignore */ + } + } + this.activeControllers.clear() + } + + /** True when the last request succeeded. Useful for retry buttons. */ + isServerOnline(): boolean { + return this.serverOnline + } + + private async fetchJson(url: string, init?: RequestInit): Promise { + const controller = new AbortController() + this.activeControllers.add(controller) + const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS) + try { + const response = await fetch(url, { ...init, signal: controller.signal }) + clearTimeout(timer) + if (!response.ok) { + throw new Error(`HTTP ${response.status} ${response.statusText}`) + } + this.setServerOnline(true) + return response.json() + } catch (err) { + clearTimeout(timer) + this.setServerOnline(false) + throw err + } finally { + this.activeControllers.delete(controller) + } + } + + private toQueryString(params: object): string { + const qs = new URLSearchParams() + Object.entries(params as Record).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== '') { + qs.append(key, String(value)) + } + }) + return qs.toString() + } + + async getDevices(params: GridQueryParams) { + return this.fetchJson(`${API_BASE_URL}/devices/grid?${this.toQueryString(params)}`) + } + + async updateDevice(deviceId: string, data: Record) { + return this.fetchJson(`${API_BASE_URL}/devices/${deviceId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) + } + + async getSummary() { + return this.fetchJson(`${API_BASE_URL}/devices/summary`) + } + + async getAlerts(params: AlertsQueryParams) { + return this.fetchJson(`${API_BASE_URL}/alerts/grid?${this.toQueryString(params)}`) + } + + async updateAlertStatus(alertId: string, status: string) { + return this.fetchJson(`${API_BASE_URL}/alerts/${encodeURIComponent(alertId)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status }), + }) + } + + async deleteAlert(alertId: string) { + return this.fetchJson(`${API_BASE_URL}/alerts/${encodeURIComponent(alertId)}`, { + method: 'DELETE', + }) + } + + async getAnalytics() { + return this.fetchJson(`${API_BASE_URL}/analytics`) + } + + async getReports() { + return this.fetchJson(`${API_BASE_URL}/reports`) + } +} + +export const apiService = new ApiServiceClass() diff --git a/use-cases/Iot-monitoring-sample/client/src/services/socketService.ts b/use-cases/Iot-monitoring-sample/client/src/services/socketService.ts new file mode 100644 index 0000000..e527f90 --- /dev/null +++ b/use-cases/Iot-monitoring-sample/client/src/services/socketService.ts @@ -0,0 +1,239 @@ +/** + * Socket Service + * Reusable React Socket.IO client with automatic reconnect. + * + * - Subscriptions are stored in a Map keyed by event name + * - Server-triggered events only fire when the socket is actually connected + * - Connection state listeners are notified on every change so the UI + * can render a clean "offline / reconnecting" state. + */ + +import { io, Socket } from 'socket.io-client' + +class SocketServiceClass { + private socket: Socket | null = null + private subscriptions: Map = new Map() + private isConnected = false + private reconnectAttempts = 0 + private maxReconnectAttempts = 10 + private connectionStateListeners: Array<(connected: boolean) => void> = [] + private url: string = 'http://localhost:3001' + // private url = (import.meta.env.VITE_API_URL || '').replace('/api', '') + + /** + * Subscribe to connection state changes + * Returns unsubscribe function + */ + onConnectionStateChange(callback: (connected: boolean) => void): () => void { + this.connectionStateListeners.push(callback) + // Immediately notify of current state + callback(this.isConnected) + return () => { + const idx = this.connectionStateListeners.indexOf(callback) + if (idx > -1) this.connectionStateListeners.splice(idx, 1) + } + } + + /** + * Notify all listeners of connection state change + */ + private notifyConnectionStateChange(): void { + this.connectionStateListeners.forEach((cb) => { + try { + cb(this.isConnected) + } catch (error) { + console.error('Error in connection state listener:', error) + } + }) + } + + /** + * Connect to Socket.IO server + */ + connect(url: string = 'http://localhost:3001'): Promise { + //connect(url = this.url): Promise { + return new Promise((resolve, reject) => { + // If a socket already exists and is connected, do nothing + if (this.socket && this.isConnected) { + resolve() + return + } + + // If a previous socket exists, tear it down first + if (this.socket) { + try { + this.socket.removeAllListeners() + this.socket.disconnect() + } catch { + /* ignore */ + } + this.socket = null + } + + this.url = url + + try { + this.socket = io(url, { + reconnection: true, + reconnectionDelay: 1500, + reconnectionDelayMax: 8000, + reconnectionAttempts: this.maxReconnectAttempts, + transports: ['websocket', 'polling'], + timeout: 8000, + }) + + this.socket.on('connect', () => { + console.log('[Socket] connected:', this.socket?.id) + this.isConnected = true + this.reconnectAttempts = 0 + this.notifyConnectionStateChange() + resolve() + }) + + this.socket.on('disconnect', (reason) => { + console.log('[Socket] disconnected:', reason) + this.isConnected = false + this.notifyConnectionStateChange() + }) + + this.socket.on('reconnect', () => { + console.log('[Socket] reconnected') + this.isConnected = true + this.reconnectAttempts = 0 + this.notifyConnectionStateChange() + }) + + this.socket.on('reconnect_attempt', () => { + this.reconnectAttempts++ + console.log(`[Socket] reconnecting... attempt ${this.reconnectAttempts}`) + }) + + this.socket.on('connect_error', (error) => { + // Only log the first few errors to avoid flooding the console + if (this.reconnectAttempts < 3) { + console.warn('[Socket] connect error:', error.message) + } + this.isConnected = false + this.notifyConnectionStateChange() + if (this.reconnectAttempts === 1) { + // Reject the initial connect promise so callers can show + // a graceful "server unavailable" state. + reject(error) + } + }) + + // Forward server-pushed events to subscribers — but ONLY when + // the socket is actually connected. This prevents the + // `socketService.triggerSubscribers` map from dispatching stale + // events after the server has gone down. + this.socket.on('device_update', (data) => { + if (!this.isConnected) return + this.triggerSubscribers('device_update', data) + }) + this.socket.on('anomaly_detected', (data) => { + if (!this.isConnected) return + this.triggerSubscribers('anomaly_detected', data) + }) + this.socket.on('summary_update', (data) => { + if (!this.isConnected) return + this.triggerSubscribers('summary_update', data) + }) + } catch (error) { + console.error('[Socket] connection failed:', error) + reject(error) + } + }) + } + + /** + * Disconnect from Socket.IO server + */ + disconnect(): void { + if (this.socket) { + try { + this.socket.removeAllListeners() + this.socket.disconnect() + } catch { + /* ignore */ + } + this.socket = null + } + this.isConnected = false + this.subscriptions.clear() + this.notifyConnectionStateChange() + } + + /** + * Subscribe to event + */ + subscribe(event: string, callback: Function): void { + if (!this.subscriptions.has(event)) { + this.subscriptions.set(event, []) + } + // Prevent duplicate subscriptions for the same callback + const list = this.subscriptions.get(event)! + if (!list.includes(callback)) { + list.push(callback) + } + } + + /** + * Unsubscribe from event + */ + unsubscribe(event: string, callback: Function): void { + const subscribers = this.subscriptions.get(event) + if (subscribers) { + const index = subscribers.indexOf(callback) + if (index > -1) { + subscribers.splice(index, 1) + } + } + } + + /** + * Emit event to server + */ + emit(event: string, data?: any): void { + if (this.socket && this.isConnected) { + this.socket.emit(event, data) + } + } + + /** + * Trigger all subscribers for an event + */ + private triggerSubscribers(event: string, data: any): void { + const subscribers = this.subscriptions.get(event) || [] + for (const callback of subscribers) { + try { + callback(data) + } catch (error) { + console.error(`[Socket] error in subscriber for ${event}:`, error) + } + } + } + + /** + * Get connection status + */ + getIsConnected(): boolean { + return this.isConnected + } + + /** + * Get socket ID + */ + getSocketId(): string | undefined { + return this.socket?.id + } + + /** + * Get the configured URL (useful for debugging) + */ + getUrl(): string { + return this.url + } +} + +// Export singleton instance +export const socketService = new SocketServiceClass() diff --git a/use-cases/Iot-monitoring-sample/client/src/utils/constants.ts b/use-cases/Iot-monitoring-sample/client/src/utils/constants.ts new file mode 100644 index 0000000..21998ef --- /dev/null +++ b/use-cases/Iot-monitoring-sample/client/src/utils/constants.ts @@ -0,0 +1,26 @@ +/** + * Application Constants + */ + +export const SENSOR_TYPES = ['Temperature', 'Pressure', 'Humidity', 'Voltage', 'Flow', 'Vibration'] + +export const LOCATIONS = ['Factory A', 'Factory B', 'Warehouse', 'Plant 1', 'Plant 2'] + +export const DEVICE_STATUSES = { + Normal: { color: '#10b981', bg: '#ecfdf5', label: 'Normal' }, + Warning: { color: '#f59e0b', bg: '#fffbeb', label: 'Warning' }, + Critical: { color: '#ef4444', bg: '#fef2f2', label: 'Critical' } +} + +export const SEVERITY_LEVELS = { + High: { color: '#ef4444', bg: '#fef2f2' }, + Medium: { color: '#f59e0b', bg: '#fffbeb' }, + Low: { color: '#3b82f6', bg: '#eff6ff' }, + Critical: { color: '#7c3aed', bg: '#faf5ff' } +} + +export const GRID_PAGE_SIZES = [12, 24, 48, 96] + +export const CHARTS_REFRESH_INTERVAL = 30000 // 30 seconds + +export const SOCKET_UPDATE_INTERVAL = 2500 // 2.5 seconds diff --git a/use-cases/Iot-monitoring-sample/client/src/utils/dateUtils.ts b/use-cases/Iot-monitoring-sample/client/src/utils/dateUtils.ts new file mode 100644 index 0000000..59c5224 --- /dev/null +++ b/use-cases/Iot-monitoring-sample/client/src/utils/dateUtils.ts @@ -0,0 +1,47 @@ +/** + * Date Utilities + * Helper functions for date manipulation and formatting + */ + +export const formatDate = (date: Date | string): string => { + const d = new Date(date) + return d.toLocaleString('en-US', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }) +} + +export const getDateRange = (rangeType: 'today' | 'yesterday' | 'last7days' | 'custom') => { + const now = new Date() + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + + switch (rangeType) { + case 'today': + return { + startDate: today, + endDate: new Date(today.getTime() + 24 * 60 * 60 * 1000 - 1) + } + case 'yesterday': + const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000) + return { + startDate: yesterday, + endDate: new Date(yesterday.getTime() + 24 * 60 * 60 * 1000 - 1) + } + case 'last7days': + const sevenDaysAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000) + return { + startDate: sevenDaysAgo, + endDate: now + } + default: + return { startDate: today, endDate: now } + } +} + +export const toISOString = (date: Date | string): string => { + return new Date(date).toISOString() +} diff --git a/use-cases/Iot-monitoring-sample/client/src/vite-env.d.ts b/use-cases/Iot-monitoring-sample/client/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/use-cases/Iot-monitoring-sample/client/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/use-cases/Iot-monitoring-sample/client/tailwind.config.js b/use-cases/Iot-monitoring-sample/client/tailwind.config.js new file mode 100644 index 0000000..b4757a2 --- /dev/null +++ b/use-cases/Iot-monitoring-sample/client/tailwind.config.js @@ -0,0 +1,64 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: { + colors: { + primary: { + 50: '#eff6ff', + 100: '#dbeafe', + 200: '#bfdbfe', + 500: '#3b82f6', + 600: '#2563eb', + 700: '#1d4ed8', + DEFAULT: '#3b82f6', + }, + secondary: '#8b5cf6', + success: '#10b981', + warning: '#f59e0b', + danger: '#ef4444', + info: '#3b82f6', + dark: '#1f2937', + light: '#f8fafc', + }, + fontFamily: { + sans: ['-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'Roboto', '"Helvetica Neue"', 'Arial', 'sans-serif'], + }, + animation: { + 'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite', + 'shimmer': 'shimmer 1.5s ease-in-out infinite', + 'fade-in': 'fadeIn 0.2s ease-out', + 'slide-up': 'slideUp 0.25s ease-out', + }, + keyframes: { + shimmer: { + '0%': { backgroundPosition: '-800px 0' }, + '100%': { backgroundPosition: '800px 0' }, + }, + fadeIn: { + from: { opacity: '0' }, + to: { opacity: '1' }, + }, + slideUp: { + from: { opacity: '0', transform: 'translateY(8px)' }, + to: { opacity: '1', transform: 'translateY(0)' }, + }, + }, + boxShadow: { + 'soft': '0 1px 3px 0 rgb(0 0 0 / 0.06), 0 1px 2px -1px rgb(0 0 0 / 0.06)', + 'md-soft': '0 4px 12px -2px rgb(0 0 0 / 0.08), 0 2px 4px -2px rgb(0 0 0 / 0.05)', + 'card': '0 1px 3px 0 rgb(0 0 0 / 0.05), 0 0 0 1px rgb(226 232 240)', + 'card-hover': '0 4px 16px -4px rgb(0 0 0 / 0.12), 0 0 0 1px rgb(203 213 225)', + }, + borderRadius: { + 'xl': '12px', + '2xl': '16px', + }, + }, + }, + darkMode: 'class', + plugins: [], +} diff --git a/use-cases/Iot-monitoring-sample/client/tsconfig.json b/use-cases/Iot-monitoring-sample/client/tsconfig.json new file mode 100644 index 0000000..3fb09ff --- /dev/null +++ b/use-cases/Iot-monitoring-sample/client/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/use-cases/Iot-monitoring-sample/client/tsconfig.node.json b/use-cases/Iot-monitoring-sample/client/tsconfig.node.json new file mode 100644 index 0000000..97ede7e --- /dev/null +++ b/use-cases/Iot-monitoring-sample/client/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/use-cases/Iot-monitoring-sample/client/tsconfig.node.tsbuildinfo b/use-cases/Iot-monitoring-sample/client/tsconfig.node.tsbuildinfo new file mode 100644 index 0000000..d8e7ddb --- /dev/null +++ b/use-cases/Iot-monitoring-sample/client/tsconfig.node.tsbuildinfo @@ -0,0 +1 @@ +{"fileNames":["./node_modules/typescript/lib/lib.d.ts","./node_modules/typescript/lib/lib.es5.d.ts","./node_modules/typescript/lib/lib.dom.d.ts","./node_modules/typescript/lib/lib.webworker.importscripts.d.ts","./node_modules/typescript/lib/lib.scripthost.d.ts","./node_modules/typescript/lib/lib.decorators.d.ts","./node_modules/typescript/lib/lib.decorators.legacy.d.ts","./node_modules/@types/estree/index.d.ts","./node_modules/rollup/dist/rollup.d.ts","./node_modules/rollup/dist/parseast.d.ts","./node_modules/vite/types/hmrpayload.d.ts","./node_modules/vite/types/customevent.d.ts","./node_modules/vite/types/hot.d.ts","./node_modules/vite/dist/node/types.d-agj9qkwt.d.ts","./node_modules/esbuild/lib/main.d.ts","./node_modules/source-map-js/source-map.d.ts","./node_modules/postcss/lib/previous-map.d.ts","./node_modules/postcss/lib/input.d.ts","./node_modules/postcss/lib/css-syntax-error.d.ts","./node_modules/postcss/lib/declaration.d.ts","./node_modules/postcss/lib/root.d.ts","./node_modules/postcss/lib/warning.d.ts","./node_modules/postcss/lib/lazy-result.d.ts","./node_modules/postcss/lib/no-work-result.d.ts","./node_modules/postcss/lib/processor.d.ts","./node_modules/postcss/lib/result.d.ts","./node_modules/postcss/lib/document.d.ts","./node_modules/postcss/lib/rule.d.ts","./node_modules/postcss/lib/node.d.ts","./node_modules/postcss/lib/comment.d.ts","./node_modules/postcss/lib/container.d.ts","./node_modules/postcss/lib/at-rule.d.ts","./node_modules/postcss/lib/list.d.ts","./node_modules/postcss/lib/postcss.d.ts","./node_modules/postcss/lib/postcss.d.mts","./node_modules/vite/dist/node/runtime.d.ts","./node_modules/vite/types/importglob.d.ts","./node_modules/vite/types/metadata.d.ts","./node_modules/vite/dist/node/index.d.ts","./node_modules/@babel/types/lib/index.d.ts","./node_modules/@types/babel__generator/index.d.ts","./node_modules/@babel/parser/typings/babel-parser.d.ts","./node_modules/@types/babel__template/index.d.ts","./node_modules/@types/babel__traverse/index.d.ts","./node_modules/@types/babel__core/index.d.ts","./node_modules/@vitejs/plugin-react/dist/index.d.ts","./vite.config.ts","./node_modules/@types/prop-types/index.d.ts","./node_modules/@types/react/global.d.ts","./node_modules/csstype/index.d.ts","./node_modules/@types/react/index.d.ts","./node_modules/@types/react-dom/index.d.ts"],"fileIdsList":[[40],[40,41,42,43,44],[40,42],[51],[48,49,50],[39,45],[31],[29,31],[20,28,29,30,32,34],[18],[21,26,31,34],[17,34],[21,22,25,26,27,34],[21,22,23,25,26,34],[18,19,20,21,22,26,27,28,30,31,32,34],[34],[16,18,19,20,21,22,23,25,26,27,28,29,30,31,32,33],[16,34],[21,23,24,26,27,34],[25,34],[26,27,31,34],[19,29],[9,38],[8,9],[9,10,11,12,13,14,15,35,36,37,38],[11,12,13,14],[11,12,13],[11],[12],[9],[39,46]],"fileInfos":[{"version":"a7297ff837fcdf174a9524925966429eb8e5feecc2cc55cc06574e6b092c1eaa","impliedFormat":1},{"version":"c430d44666289dae81f30fa7b2edebf186ecc91a2d4c71266ea6ae76388792e1","affectsGlobalScope":true,"impliedFormat":1},{"version":"080941d9f9ff9307f7e27a83bcd888b7c8270716c39af943532438932ec1d0b9","affectsGlobalScope":true,"impliedFormat":1},{"version":"80e18897e5884b6723488d4f5652167e7bb5024f946743134ecc4aa4ee731f89","affectsGlobalScope":true,"impliedFormat":1},{"version":"cd034f499c6cdca722b60c04b5b1b78e058487a7085a8e0d6fb50809947ee573","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e7f8264d0fb4c5339605a15daadb037bf238c10b654bb3eee14208f860a32ea","affectsGlobalScope":true,"impliedFormat":1},{"version":"782dec38049b92d4e85c1585fbea5474a219c6984a35b004963b00beb1aab538","affectsGlobalScope":true,"impliedFormat":1},{"version":"751764bb94219b4ce8f5475dc35d3de2e432fea01a0c9610cd7f69ad05e398c6","impliedFormat":1},{"version":"ee70b8037ecdf0de6c04f35277f253663a536d7e38f1539d270e4e916d225a3f","affectsGlobalScope":true,"impliedFormat":1},{"version":"a660aa95476042d3fdcc1343cf6bb8fdf24772d31712b1db321c5a4dcc325434","impliedFormat":1},{"version":"282f98006ed7fa9bb2cd9bdbe2524595cfc4bcd58a0bb3232e4519f2138df811","impliedFormat":1},{"version":"6222e987b58abfe92597e1273ad7233626285bc2d78409d4a7b113d81a83496b","impliedFormat":1},{"version":"cbe726263ae9a7bf32352380f7e8ab66ee25b3457137e316929269c19e18a2be","impliedFormat":1},{"version":"8b96046bf5fb0a815cba6b0880d9f97b7f3a93cf187e8dcfe8e2792e97f38f87","impliedFormat":99},{"version":"bacf2c84cf448b2cd02c717ad46c3d7fd530e0c91282888c923ad64810a4d511","affectsGlobalScope":true,"impliedFormat":1},{"version":"402e5c534fb2b85fa771170595db3ac0dd532112c8fa44fc23f233bc6967488b","impliedFormat":1},{"version":"52dcc257df5119fb66d864625112ce5033ac51a4c2afe376a0b299d2f7f76e4a","impliedFormat":1},{"version":"e5bab5f871ef708d52d47b3e5d0aa72a08ee7a152f33931d9a60809711a2a9a3","impliedFormat":1},{"version":"e16dc2a81595736024a206c7d5c8a39bfe2e6039208ef29981d0d95434ba8fcf","impliedFormat":1},{"version":"cc4a4903fb698ca1d961d4c10dce658aa3a479faf40509d526f122b044eaf6a4","impliedFormat":1},{"version":"19ee8416e6473ed6c7adb868fa796b5653cf0fa2a337658e677eaa0d134388c3","impliedFormat":1},{"version":"1328ab4e442614b28cdb3d4b414cf68325c0da0dca07287a338d0654b7a00261","impliedFormat":1},{"version":"a039dc21f045919f3cbee2ec13812cc6cc3eebc99dae4be00973230f468d19a6","impliedFormat":1},{"version":"3fbe57af01460e49dcd29df55d6931e1672bc6f1be0fb073d11410bc16f9037d","impliedFormat":1},{"version":"f760be449e8562ec5c09bb5187e8e1eabf3c113c0c58cddda53ef8c69f3e2131","impliedFormat":1},{"version":"44325ed13294fce6ab825b82947bbeed2611db7dad9d9135260192f375e5a189","impliedFormat":1},{"version":"e392e8fb5b514eafc585601c1d781485aa6dd6a320e75daf1064a4c6918a1b45","impliedFormat":1},{"version":"46e4a36e8ddbdfb4e7330e11c81c970dc8b218611df9183d39c41c5f8c653b55","impliedFormat":1},{"version":"370bde134aa8c2abc926d0e99d3a4d5d5dba65c6ee65459137e4f02670cbf841","impliedFormat":1},{"version":"6332f565867cf4a740a70e30f31cefba37ef7cebcf74f22eab8d744fde6d193e","impliedFormat":1},{"version":"2977b7884aedc895a1d0c9c210c7cf3272c29d6959a08a6fa3ff71e0aff08175","impliedFormat":1},{"version":"17f2922d41ddd032830a91371c948cd9ce903b35c95adca72271a54584f19b0b","impliedFormat":1},{"version":"3eed76ede2a1a14d7c9bb0a642041282dcc264811139d3dd275c9fe14efc9840","impliedFormat":1},{"version":"354a7f8e1287d9d6b7561bc97fdd8cbc2f7c1dd79e4cb37b942e8a5cfaff1085","impliedFormat":1},{"version":"8d369483f0c2b9ee388129cfdb6a43bc8112b377e86a41884bd06e19ce04f4c1","impliedFormat":99},{"version":"82e687ebd99518bc63ea04b0c3810fb6e50aa6942decd0ca6f7a56d9b9a212a6","impliedFormat":99},{"version":"7f698624bbbb060ece7c0e51b7236520ebada74b747d7523c7df376453ed6fea","impliedFormat":1},{"version":"8f07f2b6514744ac96e51d7cb8518c0f4de319471237ea10cf688b8d0e9d0225","impliedFormat":1},{"version":"257b83faa134d971c738a6b9e4c47e59bb7b23274719d92197580dd662bfafc3","impliedFormat":99},{"version":"556ccd493ec36c7d7cb130d51be66e147b91cc1415be383d71da0f1e49f742a9","impliedFormat":1},{"version":"b6d03c9cfe2cf0ba4c673c209fcd7c46c815b2619fd2aad59fc4229aaef2ed43","impliedFormat":1},{"version":"95aba78013d782537cc5e23868e736bec5d377b918990e28ed56110e3ae8b958","impliedFormat":1},{"version":"670a76db379b27c8ff42f1ba927828a22862e2ab0b0908e38b671f0e912cc5ed","impliedFormat":1},{"version":"13b77ab19ef7aadd86a1e54f2f08ea23a6d74e102909e3c00d31f231ed040f62","impliedFormat":1},{"version":"069bebfee29864e3955378107e243508b163e77ab10de6a5ee03ae06939f0bb9","impliedFormat":1},{"version":"26e0ffceb2198feb1ef460d5d14111c69ad07d44c5a67fd4bfeb74c969aa9afb","impliedFormat":99},{"version":"0393232c02a304a94ab06c7368046e1b629943b98797a524ed1db9227fcf32b1","signature":"4b96dd19fd2949d28ce80e913412b0026dc421e5bf6c31d87c7b5eb11b5753b4"},{"version":"87d9d29dbc745f182683f63187bf3d53fd8673e5fca38ad5eaab69798ed29fbc","impliedFormat":1},{"version":"eb5b19b86227ace1d29ea4cf81387279d04bb34051e944bc53df69f58914b788","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac51dd7d31333793807a6abaa5ae168512b6131bd41d9c5b98477fc3b7800f9f","impliedFormat":1},{"version":"09ddcfcfbe77a8232d155ca1030005106b1328f6210df43629d0be750da07c16","affectsGlobalScope":true,"impliedFormat":1},{"version":"17ed71200119e86ccef2d96b73b02ce8854b76ad6bd21b5021d4269bec527b5f","impliedFormat":1}],"root":[47],"options":{"allowSyntheticDefaultImports":true,"composite":true,"module":99,"skipLibCheck":true,"strict":true},"referencedMap":[[42,1],[45,2],[41,1],[43,3],[44,1],[52,4],[51,5],[46,6],[32,7],[30,8],[31,9],[19,10],[20,8],[27,11],[18,12],[23,13],[24,14],[29,15],[35,16],[34,17],[17,18],[25,19],[26,20],[21,21],[28,7],[22,22],[10,23],[9,24],[39,25],[36,26],[14,27],[12,28],[13,29],[38,30],[47,31]],"latestChangedDtsFile":"./vite.config.d.ts","version":"5.9.3"} \ No newline at end of file diff --git a/use-cases/Iot-monitoring-sample/client/tsconfig.tsbuildinfo b/use-cases/Iot-monitoring-sample/client/tsconfig.tsbuildinfo new file mode 100644 index 0000000..6d9ecbd --- /dev/null +++ b/use-cases/Iot-monitoring-sample/client/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/advancedfilterpanel.tsx","./src/components/errorboundary.tsx","./src/components/header.tsx","./src/components/loadingskeleton.tsx","./src/components/sidebar.tsx","./src/components/statusbadge.tsx","./src/components/summarycard.tsx","./src/components/toastnotification.tsx","./src/context/appcontext.tsx","./src/hooks/usegridstate.ts","./src/hooks/usesocket.ts","./src/pages/alerts.tsx","./src/pages/analytics.tsx","./src/pages/dashboard.tsx","./src/pages/reports.tsx","./src/services/griddataadaptor.ts","./src/services/apiservice.ts","./src/services/socketservice.ts","./src/utils/constants.ts","./src/utils/dateutils.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/use-cases/Iot-monitoring-sample/client/vite.config.d.ts b/use-cases/Iot-monitoring-sample/client/vite.config.d.ts new file mode 100644 index 0000000..340562a --- /dev/null +++ b/use-cases/Iot-monitoring-sample/client/vite.config.d.ts @@ -0,0 +1,2 @@ +declare const _default: import("vite").UserConfig; +export default _default; diff --git a/use-cases/Iot-monitoring-sample/client/vite.config.js b/use-cases/Iot-monitoring-sample/client/vite.config.js new file mode 100644 index 0000000..0229e00 --- /dev/null +++ b/use-cases/Iot-monitoring-sample/client/vite.config.js @@ -0,0 +1,17 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + host: true, + allowedHosts: true, + proxy: { + '/api': { + target: 'http://localhost:3001', + changeOrigin: true + } + } + } +}); diff --git a/use-cases/Iot-monitoring-sample/client/vite.config.ts b/use-cases/Iot-monitoring-sample/client/vite.config.ts new file mode 100644 index 0000000..6ec4ad1 --- /dev/null +++ b/use-cases/Iot-monitoring-sample/client/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:3001', + changeOrigin: true + } + } + } +}) diff --git a/use-cases/Iot-monitoring-sample/server/.env b/use-cases/Iot-monitoring-sample/server/.env new file mode 100644 index 0000000..0d0d843 --- /dev/null +++ b/use-cases/Iot-monitoring-sample/server/.env @@ -0,0 +1 @@ +CLIENT_URL=http://localhost:5173 \ No newline at end of file diff --git a/use-cases/Iot-monitoring-sample/server/controllers/DeviceController.js b/use-cases/Iot-monitoring-sample/server/controllers/DeviceController.js new file mode 100644 index 0000000..66834e2 --- /dev/null +++ b/use-cases/Iot-monitoring-sample/server/controllers/DeviceController.js @@ -0,0 +1,264 @@ +/** + * Device Controller + * Handles HTTP request routing for devices. + * + * Architecture: Syncfusion Custom Binding + * - All Grid operations (paging, sorting, filtering, searching) are + * performed server-side using DeviceService utility functions. + * - Every response is { result: [...], count: N } so the Grid pager + * stays synchronised with the server-side total record count. + * + */ + +export class DeviceController { + constructor(deviceService, socketService = null) { + this.deviceService = deviceService; + this.socketService = socketService + } + + /** + * GET /api/devices/grid + * Custom Binding endpoint — paging, sorting, filtering, searching with virtual scroll support. + * Query params: + * skip, take, sortBy, sortDirection, searchValue, + * filters (JSON array), where (JSON array - predicate tree), + * virtualScroll (boolean) + */ + getDevicesForGrid(req, res) { + try { + const { + skip = '0', + take = '10', + sortBy = '', + sortDirection = 'ascending', + searchValue = '', + filters: filtersJson = '[]', + where: whereJson = '[]', + virtualScroll = 'false', + } = req.query; + + // Parse structured filter predicates sent by the Grid + let filters = []; + try { + filters = JSON.parse(filtersJson); + } catch { + filters = []; + } + + // Parse preserved where tree (for multi-predicate correctness) + // Supports complex filter groups from multi-select checkbox filters + let whereTree = []; + try { + whereTree = JSON.parse(whereJson); + } catch { + whereTree = []; + } + + const isVirtualScroll = virtualScroll === 'true'; + + const data = this.deviceService.getDevicesForGrid( + parseInt(skip, 10), + parseInt(take, 10), + sortBy, + sortDirection, + searchValue, + filters, + whereTree, + isVirtualScroll + ); + + res.json(data); + } catch (error) { + console.error('Error fetching devices:', error); + res.status(500).json({ error: error.message }); + } + } + + /** + * PUT /api/devices/:deviceId + * Update device (for cell editing) + */ + updateDevice(req, res) { + const { deviceId } = req.params; + const updatedFields = req.body; + + try { + const device = this.deviceService.updateDevice(deviceId, updatedFields); + + if (!device) { + return res.status(404).json({ error: 'Device not found' }); + } + + res.json(device); + } catch (error) { + console.error('Error updating device:', error); + res.status(500).json({ error: error.message }); + } + } + + /** + * GET /api/devices/summary + * Get dashboard summary statistics + */ + getSummary(req, res) { + try { + const summary = this.deviceService.getSummary(); + res.json(summary); + } catch (error) { + console.error('Error fetching summary:', error); + res.status(500).json({ error: error.message }); + } + } + + /** + * GET /api/alerts/grid + * Custom Binding endpoint — paging, sorting, filtering, searching, date range with virtual scroll support. + * Query params: + * skip, take, sortBy, sortDirection, searchValue, + * filters (JSON array), where (JSON array - predicate tree), + * startDate (ISO), endDate (ISO), virtualScroll (boolean) + * + * Automatically enables row virtualization when total records > 100. + */ + getAlertsForGrid(req, res) { + try { + const { + skip = '0', + take = '12', + sortBy = '', + sortDirection = 'ascending', + searchValue = '', + filters: filtersJson = '[]', + where: whereJson = '[]', + startDate = null, + endDate = null, + virtualScroll = 'false', + } = req.query; + + // Parse structured filter predicates sent by the Grid + let filters = []; + try { + filters = JSON.parse(filtersJson); + } catch { + filters = []; + } + + // Parse preserved where tree (for multi-predicate correctness) + // Supports multi-select checkbox filters with OR conditions within groups + let whereTree = []; + try { + whereTree = JSON.parse(whereJson); + } catch { + whereTree = []; + } + + const isVirtualScroll = virtualScroll === 'true'; + + const data = this.deviceService.getAlertsForGrid( + parseInt(skip, 10), + parseInt(take, 10), + sortBy, + sortDirection, + searchValue, + filters, + whereTree, + startDate, + endDate, + isVirtualScroll + ); + + res.json(data); + } catch (error) { + console.error('Error fetching alerts:', error); + res.status(500).json({ error: error.message }); + } + } + + /** + * PUT /api/alerts/:alertId + * Update an alert's status (Active | Acknowledged | Resolved) + */ + updateAlertStatus(req, res) { + try { + const { alertId } = req.params + const { status } = req.body + if (!alertId || !status) return res.status(400).json({ error: 'alertId and status required' }) + + const result = this.deviceService.updateAlertStatus(alertId, status) + if (!result) return res.status(404).json({ error: 'Alert not found' }) + + // Notify connected clients of summary change if socketService available + try { + if (this.socketService && typeof this.socketService.io !== 'undefined') { + this.socketService.io.emit('summary_update', this.deviceService.getSummary()) + } + } catch (e) { + /* ignore socket emit failures */ + } + + res.json({ success: true }) + } catch (error) { + console.error('Error updating alert status:', error) + res.status(500).json({ error: error.message }) + } + } + + /** + * DELETE /api/alerts/:alertId + * Permanently remove an alert and push recalculated device status (if any). + */ + deleteAlert(req, res) { + try { + const { alertId } = req.params + if (!alertId) return res.status(400).json({ error: 'alertId required' }) + + const result = this.deviceService.deleteAlert(alertId) + if (!result) return res.status(404).json({ error: 'Alert not found' }) + + try { + if (this.socketService && typeof this.socketService.io !== 'undefined') { + if (result.updatedDevice) { + // Reuse the existing dashboard device_update path — never deletes rows + this.socketService.io.emit('device_update', result.updatedDevice) + } + this.socketService.io.emit('summary_update', this.deviceService.getSummary()) + } + } catch (e) { + /* ignore socket emit failures */ + } + + res.json({ success: true, removed: result.removed, updatedDevice: result.updatedDevice }) + } catch (error) { + console.error('Error deleting alert:', error) + res.status(500).json({ error: error.message }) + } + } + + /** + * GET /api/analytics + * Get analytics data + */ + getAnalytics(req, res) { + try { + const analytics = this.deviceService.getAnalytics(); + res.json(analytics); + } catch (error) { + console.error('Error fetching analytics:', error); + res.status(500).json({ error: error.message }); + } + } + + /** + * GET /api/reports + * Get reports data + */ + getReports(req, res) { + try { + const reports = this.deviceService.getReports(); + res.json(reports); + } catch (error) { + console.error('Error fetching reports:', error); + res.status(500).json({ error: error.message }); + } + } +} diff --git a/use-cases/Iot-monitoring-sample/server/index.js b/use-cases/Iot-monitoring-sample/server/index.js new file mode 100644 index 0000000..3cb0a43 --- /dev/null +++ b/use-cases/Iot-monitoring-sample/server/index.js @@ -0,0 +1,90 @@ +/** + * IoT Monitoring Dashboard - Backend Server + * Node.js Express + Socket.IO + */ + +import express from 'express'; +import { createServer } from 'http'; +import { Server } from 'socket.io'; +import cors from 'cors'; +import { MockDataGenerator } from './mock-data/mockDataGenerator.js'; +import { DeviceService } from './services/DeviceService.js'; +import { SocketService } from './services/SocketService.js'; +import { DeviceController } from './controllers/DeviceController.js'; +import { AlertManager } from './services/AlertManager.js'; +import { createDeviceRoutes } from './routes/deviceRoutes.js'; + +import dotenv from 'dotenv'; +dotenv.config(); + +const PORT = process.env.PORT || 3001; + +// Initialize Express app +const app = express(); +const httpServer = createServer(app); +const io = new Server(httpServer, { + cors: { + origin: process.env.CLIENT_URL, + methods: ['GET', 'POST', 'PUT', 'DELETE'], + credentials: true + } +}); + +app.use(cors({ + origin: process.env.CLIENT_URL, + credentials: true, + methods: ['GET', 'POST', 'PUT', 'DELETE'] +})); +app.use(express.json()); + +// Initialize services +const deviceService = new DeviceService(); +const alertManager = new AlertManager(); +const socketService = new SocketService(io, deviceService, alertManager); +const deviceController = new DeviceController(deviceService, socketService); + +// Initialize with mock data +console.log('Generating mock data for 1000 devices...'); +const mockDevices = MockDataGenerator.generateDevices(1000); +deviceService.setDevices(mockDevices); +console.log('✓ Mock data generated successfully'); + +// Routes +app.use('/api', createDeviceRoutes(deviceController)); + +// Health check endpoint +app.get('/health', (req, res) => { + res.json({ + status: 'healthy', + timestamp: new Date().toISOString(), + devices: deviceService.getDevices().length, + alerts: deviceService.alerts.length + }); +}); + +// Socket.IO initialization +socketService.initialize(); + +// Start the server +httpServer.listen(PORT, () => { + console.log('\n╔════════════════════════════════════════════════════════╗'); + console.log('║ IoT Monitoring Dashboard - Server Started ║'); + console.log('╚════════════════════════════════════════════════════════╝\n'); + console.log(`🚀 Server running at http://localhost:${PORT}`); + console.log(`🔗 Socket.IO listening for real-time connections`); + console.log(`📊 Health check: http://localhost:${PORT}/health\n`); + + // Start real-time device updates via Socket.IO + socketService.startDeviceUpdates(); + console.log('📡 Real-time device updates: ACTIVE\n'); +}); + +// Graceful shutdown +process.on('SIGTERM', () => { + console.log('\n\n📛 SIGTERM signal received: closing HTTP server'); + socketService.stopDeviceUpdates(); + httpServer.close(() => { + console.log('✓ HTTP server closed'); + process.exit(0); + }); +}); diff --git a/use-cases/Iot-monitoring-sample/server/mock-data/mockDataGenerator.js b/use-cases/Iot-monitoring-sample/server/mock-data/mockDataGenerator.js new file mode 100644 index 0000000..1d874e7 --- /dev/null +++ b/use-cases/Iot-monitoring-sample/server/mock-data/mockDataGenerator.js @@ -0,0 +1,181 @@ +/** + * Mock Data Generator + * Generates 1000 IoT devices with realistic Industrial IoT sensor data + */ + +import { Device } from '../models/Device.js'; + +const SENSOR_TYPES = ['Temperature', 'Pressure', 'Humidity', 'Voltage', 'Flow', 'Vibration']; + +// Realistic industrial IoT locations +const LOCATIONS = [ + 'Assembly Line 1', + 'Assembly Line 2', + 'Assembly Line 3', + 'Production Floor A', + 'Production Floor B', + 'Warehouse Zone 1', + 'Warehouse Zone 2', + 'Cooling System', + 'Compressor Station A', + 'Compressor Station B', + 'Pump House', + 'Control Room', + 'Maintenance Depot', + 'Quality Lab', + 'Shipping Dock', +]; + +// Realistic industrial IoT device names by sensor type +const DEVICE_NAMES = { + Temperature: [ + 'Boiler Temp', + 'Reactor Temp', + 'Oven Temp', + 'Tank Temp', + 'Coolant Temp', + 'Furnace Temp', + 'Bearing Temp' + ], + + Pressure: [ + 'Hydraulic Press', + 'Air Pressure', + 'Steam Pressure', + 'Pump Pressure', + 'Line Pressure', + 'Tank Pressure', + 'Compressor Pressure' + ], + + Humidity: [ + 'Warehouse Humidity', + 'Room Humidity', + 'Dry Room', + 'Storage Humidity', + 'HVAC Humidity', + 'Lab Humidity', + 'Air Humidity' + ], + + Voltage: [ + 'UPS Monitor', + 'Transformer', + 'Power Panel', + 'Main Bus', + 'Generator', + 'Breaker', + 'DC Supply' + ], + + Flow: [ + 'Water Flow', + 'Coolant Flow', + 'Steam Flow', + 'Fuel Flow', + 'Air Flow', + 'Oil Flow', + 'Chemical Flow' + ], + + Vibration: [ + 'Motor Vibration', + 'Pump Vibration', + 'Fan Vibration', + 'Bearing Monitor', + 'Conveyor Drive', + 'Gearbox', + 'Compressor' + ] +}; + +const STATUSES = ['Normal', 'Warning', 'Critical']; + +export class MockDataGenerator { + static generateDevices(count = 1000) { + const devices = []; + + for (let i = 1; i <= count; i++) { + const deviceId = `DEV-${String(i).padStart(4, '0')}`; + const sensorType = SENSOR_TYPES[Math.floor(Math.random() * SENSOR_TYPES.length)]; + const location = LOCATIONS[Math.floor(Math.random() * LOCATIONS.length)]; + const deviceNameOptions = DEVICE_NAMES[sensorType] || DEVICE_NAMES['Temperature']; + const baseDeviceName = deviceNameOptions[Math.floor(Math.random() * deviceNameOptions.length)]; + + // Append sequential number to make each device name unique: "Furnace Temp Sensor-001" + const deviceName = `${baseDeviceName}-${String(i).padStart(3, '0')}`; + + const threshold = this.getThresholdForSensor(sensorType); + const readingValue = Math.random() * threshold * 1.2; + + // Realistic initial battery: 60-100% for most devices, very few below 30% + // This avoids showing 0% on startup + let initialBattery; + const batteryRoll = Math.random(); + if (batteryRoll < 0.7) { + // 70% of devices: healthy battery 60-100% + initialBattery = 60 + Math.random() * 40; + } else if (batteryRoll < 0.95) { + // 25% of devices: medium battery 30-60% + initialBattery = 30 + Math.random() * 30; + } else { + // 5% of devices: low battery 20-30% + initialBattery = 20 + Math.random() * 10; + } + + const device = new Device( + deviceId, + deviceName, + location, + sensorType, + this.getRandomStatus(), + Math.round(readingValue * 100) / 100, + threshold, + Math.floor(Math.random() * 100) + 30, // Signal strength 30-130 + Math.round(initialBattery * 100) / 100, // Realistic battery 20-100% + new Date().toISOString() + ); + + devices.push(device); + } + + return devices; + } + + static getThresholdForSensor(sensorType) { + const thresholds = { + 'Temperature': 80, + 'Pressure': 150, + 'Humidity': 100, + 'Voltage': 240, + 'Flow': 500, + 'Vibration': 10 + }; + return thresholds[sensorType] || 100; + } + + static getRandomStatus() { + const random = Math.random(); + if (random < 0.7) return 'Normal'; + if (random < 0.9) return 'Warning'; + return 'Critical'; + } + + static updateDeviceReading(device) { + const threshold = this.getThresholdForSensor(device.sensorType); + + // Gradual battery drain: 0.1% to 0.5% per update + // This simulates realistic battery usage over time + const drainRate = 0.1 + Math.random() * 0.4; // 0.1% to 0.5% + const newBattery = Math.max(0, device.batteryLevel - drainRate); + + return { + ...device, + status: this.getRandomStatus(), + readingValue: Math.round((Math.random() * threshold * 1.2) * 100) / 100, + signalStrength: Math.floor(Math.random() * 100) + 30, + batteryLevel: Math.round(newBattery * 100) / 100, + lastUpdated: new Date().toISOString() + }; + } +} diff --git a/use-cases/Iot-monitoring-sample/server/models/Device.js b/use-cases/Iot-monitoring-sample/server/models/Device.js new file mode 100644 index 0000000..e735b05 --- /dev/null +++ b/use-cases/Iot-monitoring-sample/server/models/Device.js @@ -0,0 +1,59 @@ +/** + * Device Model + * Represents an IoT device with sensor data + */ +export class Device { + constructor( + deviceId, + deviceName, + location, + sensorType, + status, + readingValue, + threshold, + signalStrength, + batteryLevel, + lastUpdated + ) { + this.deviceId = deviceId; + this.deviceName = deviceName; + this.location = location; + this.sensorType = sensorType; + this.status = status; + this.readingValue = readingValue; + this.threshold = threshold; + this.signalStrength = signalStrength; + this.batteryLevel = batteryLevel; + this.lastUpdated = lastUpdated; + } +} + +/** + * Alert Model + * Represents an anomaly alert + */ +export class Alert { + constructor( + alertId, + deviceId, + deviceName, + location, + sensorType, + alertType, + severity, + message, + timestamp + ) { + this.alertId = alertId; + this.deviceId = deviceId; + this.deviceName = deviceName; + this.location = location; + this.sensorType = sensorType; + this.alertType = alertType; + this.severity = severity; + this.message = message; + this.timestamp = timestamp; + // Alert status: Active | Acknowledged | Resolved + this.status = 'Active' + } +} diff --git a/use-cases/Iot-monitoring-sample/server/package.json b/use-cases/Iot-monitoring-sample/server/package.json new file mode 100644 index 0000000..ed63b02 --- /dev/null +++ b/use-cases/Iot-monitoring-sample/server/package.json @@ -0,0 +1,20 @@ +{ + "name": "iot-monitoring-server", + "version": "1.0.0", + "description": "Industrial IoT Monitoring Dashboard - Backend Server", + "main": "index.js", + "type": "module", + "scripts": { + "start": "node index.js", + "dev": "node --watch index.js" + }, + "keywords": ["iot", "monitoring", "socket.io", "express"], + "author": "Syncfusion", + "license": "MIT", + "dependencies": { + "express": "^4.18.2", + "socket.io": "^4.7.2", + "cors": "^2.8.5", + "dotenv": "^16.3.1" + } +} diff --git a/use-cases/Iot-monitoring-sample/server/routes/deviceRoutes.js b/use-cases/Iot-monitoring-sample/server/routes/deviceRoutes.js new file mode 100644 index 0000000..1cba606 --- /dev/null +++ b/use-cases/Iot-monitoring-sample/server/routes/deviceRoutes.js @@ -0,0 +1,25 @@ +/** + * Device Routes + * Define API endpoints for device management + */ + +import express from 'express'; + +export function createDeviceRoutes(deviceController) { + const router = express.Router(); + + // Device endpoints — Custom Binding (GET with query params) + router.get('/devices/grid', (req, res) => deviceController.getDevicesForGrid(req, res)); + router.put('/devices/:deviceId', (req, res) => deviceController.updateDevice(req, res)); + router.get('/devices/summary', (req, res) => deviceController.getSummary(req, res)); + + // Alert endpoints — Custom Binding (GET with query params) + router.get('/alerts/grid', (req, res) => deviceController.getAlertsForGrid(req, res)); + router.put('/alerts/:alertId', (req, res) => deviceController.updateAlertStatus(req, res)); + router.delete('/alerts/:alertId', (req, res) => deviceController.deleteAlert(req, res)); + + // Reports endpoints + router.get('/reports', (req, res) => deviceController.getReports(req, res)); + + return router; +} diff --git a/use-cases/Iot-monitoring-sample/server/services/AlertManager.js b/use-cases/Iot-monitoring-sample/server/services/AlertManager.js new file mode 100644 index 0000000..2f038f1 --- /dev/null +++ b/use-cases/Iot-monitoring-sample/server/services/AlertManager.js @@ -0,0 +1,185 @@ +/** + * Alert Manager v3 + * + * Threshold-crossing alert logic with hysteresis and noise reduction. + * + * For each device + alert type the manager tracks a small finite state: + * + * - 'NORMAL' – last reading was below the threshold (or in the + * hysteresis band while recovering) + * - 'EXCEEDED' – we have already raised an alert for the current + * excursion; suppress further alerts until the value + * drops back into the hysteresis band + * + * A new alert is raised ONLY on a NORMAL → EXCEEDED transition. + * Sub-sequent readings that remain in the EXCEEDED state are ignored. + * When the reading drops back below the threshold, the state returns + * to NORMAL, ready to fire again on the next crossing. + * + * The hysteresis band (`HYSTERESIS`) keeps a small buffer around the + * threshold so that values that flap right around the threshold do + * not create a stream of alerts. + * + * enter EXCEEDED : reading > threshold + HYSTERESIS + * leave EXCEEDED : reading <= threshold - HYSTERESIS + * + * Example (threshold = 80, HYSTERESIS = 2): + * 70 72 75 78 → state stays NORMAL, no alert + * 82 → NORMAL → EXCEEDED, alert created + * 84 85 83 86 → state stays EXCEEDED, no new alert + * 76 → EXCEEDED → NORMAL, alert state cleared + * 81 → NORMAL → EXCEEDED, NEW alert created + */ + +const HYSTERESIS = 2 // +/- this many units around the threshold + +// How long to suppress duplicate alerts for the same (device, alertType) +// once a transition has been recorded. Acts as a final safety net in +// case of clock skew / out-of-order updates. +const DEDUP_WINDOW_MS = 5000 + +// Periodic cleanup of stale dedup keys. +const CLEANUP_INTERVAL_MS = 60000 + +export class AlertManager { + constructor() { + /** + * Map> + * state = 'NORMAL' | 'EXCEEDED' + */ + this.deviceStateMap = new Map() + + /** + * Map — last time we emitted an alert + * for this pair. Used as a final dedup safety net. + */ + this.lastEmittedAt = new Map() + + this.cleanupTimer = setInterval(() => this.cleanupStaleEntries(), CLEANUP_INTERVAL_MS) + } + + /** + * Initialise a device's state when it joins the system. + * Without this the first reading would be misinterpreted as a crossing. + */ + seedDevice(deviceId, alertType) { + if (!this.deviceStateMap.has(deviceId)) { + this.deviceStateMap.set(deviceId, new Map()) + } + const inner = this.deviceStateMap.get(deviceId) + if (!inner.has(alertType)) { + inner.set(alertType, { state: 'NORMAL' }) + } + } + + /** + * Check whether a new alert should be emitted for a (device, alertType) + * pair given the latest reading. + * + * Returns { shouldEmit, isClearTransition }. + * - shouldEmit=true and isClearTransition=false → create a new alert + * - shouldEmit=false and isClearTransition=true → silent state reset + * - shouldEmit=false and isClearTransition=false → no-op + */ + evaluate(device, alertType, threshold) { + const value = this._extractValue(device, alertType) + if (value == null || !Number.isFinite(value)) { + return { shouldEmit: false, isClearTransition: false } + } + + const deviceId = device.deviceId + this.seedDevice(deviceId, alertType) + const slot = this.deviceStateMap.get(deviceId).get(alertType) + + const upper = threshold + HYSTERESIS + const lower = threshold - HYSTERESIS + + const currentlyExceeded = value > upper + const isRecovery = value <= lower + + if (slot.state === 'NORMAL') { + if (currentlyExceeded) { + // Crossing event. Final dedup safety net: if we've emitted + // an alert for this pair within DEDUP_WINDOW_MS, skip. + if (this._recentlyEmitted(deviceId, alertType)) { + slot.state = 'EXCEEDED' + return { shouldEmit: false, isClearTransition: false } + } + slot.state = 'EXCEEDED' + this.lastEmittedAt.set(this._dedupKey(deviceId, alertType), Date.now()) + return { shouldEmit: true, isClearTransition: false } + } + return { shouldEmit: false, isClearTransition: false } + } + + // slot.state === 'EXCEEDED' + if (isRecovery) { + slot.state = 'NORMAL' + return { shouldEmit: false, isClearTransition: true } + } + + // Still exceeded — suppress noise. + return { shouldEmit: false, isClearTransition: false } + } + + /** + * Get the relevant numeric value for an alert type. + * Currently supports TEMPERATURE_THRESHOLD and BATTERY_LOW. + */ + _extractValue(device, alertType) { + switch (alertType) { + case 'TEMPERATURE_THRESHOLD': + return Number(device.readingValue) + case 'BATTERY_LOW': + return Number(device.batteryLevel) + default: + return null + } + } + + _dedupKey(deviceId, alertType) { + return `${deviceId}::${alertType}` + } + + _recentlyEmitted(deviceId, alertType) { + const key = this._dedupKey(deviceId, alertType) + const ts = this.lastEmittedAt.get(key) + if (!ts) return false + return Date.now() - ts < DEDUP_WINDOW_MS + } + + /** + * Remove dedup keys that are older than the dedup window. + */ + cleanupStaleEntries() { + const cutoff = Date.now() - DEDUP_WINDOW_MS + for (const [key, ts] of this.lastEmittedAt) { + if (ts < cutoff) this.lastEmittedAt.delete(key) + } + } + + /** + * Drop all state for a device (e.g. when it leaves the fleet). + */ + clearDevice(deviceId) { + this.deviceStateMap.delete(deviceId) + } + + /** + * Drop everything (e.g. on a fleet reset). + */ + clearAll() { + this.deviceStateMap.clear() + this.lastEmittedAt.clear() + } + + /** + * Stop the cleanup timer. + */ + dispose() { + if (this.cleanupTimer) { + clearInterval(this.cleanupTimer) + this.cleanupTimer = null + } + } +} diff --git a/use-cases/Iot-monitoring-sample/server/services/DeviceService.js b/use-cases/Iot-monitoring-sample/server/services/DeviceService.js new file mode 100644 index 0000000..29043d4 --- /dev/null +++ b/use-cases/Iot-monitoring-sample/server/services/DeviceService.js @@ -0,0 +1,552 @@ +/** + * Device Service + * Enhanced Custom Binding server-side data layer. + * + * Features: + * - Paging, sorting, filtering and searching happen here. The Grid + * never receives a full dataset. + * - Predicate tree parsing for multi-select checkbox filters + * (handles nested OR/AND conditions correctly) + * - Status is derived from readingValue vs threshold (plus hysteresis band) + * - Anomaly detection delegates to AlertManager + * + * Filtering order: Filter → Search → Sort → Skip/Take (Virtualization) + * This ensures correct total counts for virtual scroll engine + */ + +import { Alert } from '../models/Device.js' + +// ------------------------------------------------------------------ +// Predicate evaluation — handles single predicates +// ------------------------------------------------------------------ + +/** + * Evaluate a single predicate against an item. + * Supports operators: equal, notequal, contains, startswith, endswith, + * greaterthan, greaterthanorequal, lessthan, lessthanorequal + */ +function evaluatePredicate(item, predicate) { + const { field, operator, value, matchCase } = predicate + + let itemValue = item[field] + let filterValue = value + + if (typeof itemValue === 'string') { + itemValue = matchCase ? itemValue : itemValue.toLowerCase() + filterValue = matchCase ? String(filterValue) : String(filterValue ?? '').toLowerCase() + } else if (typeof itemValue === 'number') { + filterValue = Number(filterValue) + } + + switch (operator) { + case 'equal': + return itemValue == filterValue + case 'notequal': + return itemValue != filterValue + case 'contains': + return typeof itemValue === 'string' && itemValue.includes(filterValue) + case 'startswith': + return typeof itemValue === 'string' && itemValue.startsWith(filterValue) + case 'endswith': + return typeof itemValue === 'string' && itemValue.endsWith(filterValue) + case 'greaterthan': + return itemValue > filterValue + case 'greaterthanorequal': + return itemValue >= filterValue + case 'lessthan': + return itemValue < filterValue + case 'lessthanorequal': + return itemValue <= filterValue + default: + return true + } +} + +// ------------------------------------------------------------------ +// Predicate tree evaluation — handles complex nested structures +// ------------------------------------------------------------------ + +/** + * Recursively evaluate a Syncfusion where tree (preserved structure with nested groups/conditions). + * Handles: + * - Leaf nodes: { field, operator, value, matchCase } + * - Nested groups: { predicates: [...], condition: 'and'|'or' } + * - Multi-select filters: multiple values OR'd within a group, then AND'd with other groups + * + * Example multi-select filter on 'severity' with values ['High', 'Critical']: + * { + * predicates: [ + * { field: 'severity', operator: 'equal', value: 'High' }, + * { field: 'severity', operator: 'equal', value: 'Critical' } + * ], + * condition: 'or' + * } + * + * When combined with another column filter on 'deviceName': + * [ + * { predicates: [...severity filter...], condition: 'or' }, + * { field: 'deviceName', operator: 'contains', value: 'Sensor' } + * ] + * These are AND'd at the top level. + */ +function evaluateWhereTree(item, node) { + if (!node || typeof node !== 'object') return true + + // Nested group: evaluate all child predicates, combine via condition + if (Array.isArray(node.predicates) && node.predicates.length > 0) { + const condition = node.condition || 'and' + const results = node.predicates.map((child) => evaluateWhereTree(item, child)) + + if (condition === 'or') { + // OR: at least one child must be true + return results.some((r) => r === true) + } else { + // AND (default): all children must be true + return results.every((r) => r === true) + } + } + + // Leaf node: single predicate + if (node.field !== undefined) { + return evaluatePredicate(item, node) + } + + return true +} + +/** + * Apply column filters to data using where tree (complex nested predicates). + * Order of operations: + * 1. Apply column filters (from where tree or flat filters) + * 2. Apply global search + * 3. Apply sorting + * 4. Apply skip/take (for paging or virtualization) + * + * This method returns the filtered & sorted data. + * The caller must handle skip/take for virtualization. + */ +function applyFilters(data, filters, whereTree) { + if (!filters || filters.length === 0) { + // If whereTree is provided, use it for evaluation (preserves grouping/nesting) + if (whereTree && whereTree.length > 0) { + return data.filter((item) => { + // whereTree is an array of predicates/groups at the top level + // These are implicitly AND-combined + const results = whereTree.map((node) => evaluateWhereTree(item, node)) + return results.every((r) => r === true) + }) + } + return data + } + + if (whereTree && whereTree.length > 0) { + // Prefer whereTree over flat filters (preserves grouping semantics) + return data.filter((item) => { + const results = whereTree.map((node) => evaluateWhereTree(item, node)) + return results.every((r) => r === true) + }) + } + + // Fallback to flat filters (backward compatibility) + // Filters are evaluated in order, with `predicate` controlling combination: + // - 'or' => accumulated OR passed + // - default or 'and' => accumulated AND passed + return data.filter((item) => { + let result = false + for (let i = 0; i < filters.length; i++) { + const pred = filters[i] + const passed = evaluatePredicate(item, pred) + if (i === 0) { + result = passed + } else if (pred.predicate === 'or') { + result = result || passed + } else { + result = result && passed + } + } + return result + }) +} + +// ------------------------------------------------------------------ +// Status derivation +// ------------------------------------------------------------------ + +/** + * Derive a device's status from its current readingValue vs threshold. + * Uses a small hysteresis band to keep the status from flapping right + * around the threshold. + */ +function deriveStatus(readingValue, threshold) { + if (readingValue == null || threshold == null) return 'Normal' + if (readingValue > threshold + 2) return 'Critical' + if (readingValue > threshold) return 'Warning' + return 'Normal' +} + +// ------------------------------------------------------------------ +// DeviceService +// ------------------------------------------------------------------ + +export class DeviceService { + constructor() { + this.devices = [] + this.alerts = [] + this.alertIdCounter = 1 + } + + setDevices(devices) { + this.devices = devices + } + + getDevices() { + return this.devices + } + + // ------------------------------------------------------------------ + // Custom Binding — devices grid with virtual scroll support + // ------------------------------------------------------------------ + /** + * Get paginated, filtered, sorted, searched devices. + * Supports virtual scroll requests for large datasets. + * + * Filter order: + * 1. Apply column filters (where tree with multi-predicate support) + * 2. Apply global search + * 3. Apply sorting + * 4. Apply skip/take (virtualization or regular paging) + * + * Returns { result: [...], count: totalAfterFilterButBeforePaging } + * The count is used by the virtual scroll engine to know the total filtered size. + */ + getDevicesForGrid( + skip = 0, + take = 12, + sortBy = '', + sortDirection = 'ascending', + searchValue = '', + filters = [], + whereTree = null, + virtualScroll = false + ) { + let data = [...this.devices] + + // 1. FILTER: Apply column filters (where tree for multi-select support) + if ((filters && filters.length > 0) || (whereTree && whereTree.length > 0)) { + data = applyFilters(data, filters, whereTree) + } + + // 2. SEARCH: Apply global search + if (searchValue && searchValue.trim()) { + const q = searchValue.toLowerCase() + data = data.filter( + (d) => + d.deviceId.toLowerCase().includes(q) || + d.deviceName.toLowerCase().includes(q) || + d.location.toLowerCase().includes(q) || + d.sensorType.toLowerCase().includes(q) || + d.status.toLowerCase().includes(q) + ) + } + + // 3. SORT: Apply sorting + if (sortBy) { + data.sort((a, b) => { + let av = a[sortBy] + let bv = b[sortBy] + if (typeof av === 'string') { + av = av.toLowerCase() + bv = bv.toLowerCase() + } + if (av < bv) return sortDirection === 'ascending' ? -1 : 1 + if (av > bv) return sortDirection === 'ascending' ? 1 : -1 + return 0 + }) + } + + // 4. VIRTUALIZATION/PAGING: Apply skip/take + // totalCount is the size after filtering but before paging + // This is required by the virtual scroll engine + const totalCount = data.length + const result = data.slice(skip, skip + take) + + return { result, count: totalCount } + } + + getDeviceById(deviceId) { + return this.devices.find((d) => d.deviceId === deviceId) + } + + updateDevice(deviceId, updatedFields) { + const idx = this.devices.findIndex((d) => d.deviceId === deviceId) + if (idx === -1) return null + this.devices[idx] = { ...this.devices[idx], ...updatedFields } + // Re-derive status if readingValue or threshold changed + const d = this.devices[idx] + d.status = deriveStatus(d.readingValue, d.threshold) + return d + } + + getSummary() { + const totalDevices = this.devices.length + const onlineDevices = this.devices.filter((d) => d.status !== 'Critical').length + const criticalDevices = this.devices.filter((d) => d.status === 'Critical').length + const avgBattery = this.devices.length + ? Math.round( + (this.devices.reduce((s, d) => s + (d.batteryLevel || 0), 0) / this.devices.length) * 100 + ) / 100 + : 0 + return { totalDevices, onlineDevices, criticalDevices, avgBattery } + } + + // ------------------------------------------------------------------ + // Anomaly detection + // ------------------------------------------------------------------ + + /** + * Returns an array of candidate alerts. The caller is expected to: + * 1. call `alertManager.evaluate(device, alertType, threshold)` for + * each candidate; + * 2. when `shouldEmit` is true, persist + broadcast; + * 3. when `isClearTransition` is true, no further action is needed + * — the state machine has been updated. + */ + checkForAnomalies(device) { + const candidates = [] + if (device.sensorType === 'Temperature' && Number.isFinite(device.readingValue) && Number.isFinite(device.threshold)) { + candidates.push({ + alertType: 'TEMPERATURE_THRESHOLD', + threshold: device.threshold, + }) + } + if (Number.isFinite(device.batteryLevel) && device.batteryLevel < 20) { + candidates.push({ + alertType: 'BATTERY_LOW', + threshold: 20, + }) + } + return candidates + } + + /** + * Build a friendly message for a candidate alert. + */ + buildAlertMessage(device, alertType) { + if (alertType === 'TEMPERATURE_THRESHOLD') { + return `Temperature exceeded threshold: ${device.readingValue.toFixed(1)}°C > ${device.threshold}°C` + } + if (alertType === 'BATTERY_LOW') { + return `Battery level critical: ${Math.round(device.batteryLevel)}%` + } + return `${alertType} triggered` + } + + persistAlert(candidate) { + return this.createAlert( + candidate.deviceId, + candidate.deviceName, + candidate.location, + candidate.sensorType, + candidate.alertType, + candidate.severity, + candidate.message + ) + } + + createAlert(deviceId, deviceName, location, sensorType, alertType, severity, message) { + const alert = new Alert( + `ALT-${String(this.alertIdCounter++).padStart(6, '0')}`, + deviceId, + deviceName, + location, + sensorType, + alertType, + severity, + message, + new Date().toISOString() + ) + // Ensure new alerts are Active by default + alert.status = 'Active' + this.alerts.push(alert) + return alert + } + + /** + * Update an alert's status. If set to 'Resolved' the alert is removed + * from the active alerts collection so it no longer appears in the + * Active Alerts grid. + */ + updateAlertStatus(alertId, status) { + const idx = this.alerts.findIndex((a) => a.alertId === alertId) + if (idx === -1) return null + if (status === 'Resolved') { + // Remove from active alerts + const removed = this.alerts.splice(idx, 1)[0] + return { removed } + } + this.alerts[idx].status = status + return { updated: this.alerts[idx] } + } + + /** + * Permanently delete an alert and re-derive the linked device status + * from its current reading/threshold. Device rows are never deleted. + * Returns { removed, updatedDevice } or null if alertId is unknown. + */ + deleteAlert(alertId) { + const idx = this.alerts.findIndex((a) => a.alertId === alertId) + if (idx === -1) return null + + const [removed] = this.alerts.splice(idx, 1) + let updatedDevice = null + + if (removed?.deviceId) { + const device = this.getDeviceById(removed.deviceId) + if (device) { + // Status always follows live reading vs threshold after alert removal + device.status = deriveStatus(device.readingValue, device.threshold) + device.lastUpdated = new Date().toISOString() + updatedDevice = { + deviceId: device.deviceId, + status: device.status, + readingValue: device.readingValue, + signalStrength: device.signalStrength, + batteryLevel: device.batteryLevel, + lastUpdated: device.lastUpdated, + } + } + } + + return { removed, updatedDevice } + } + + // ------------------------------------------------------------------ + // Custom Binding — alerts grid with virtual scroll support + // ------------------------------------------------------------------ + /** + * Get paginated, filtered, sorted, searched alerts. + * Automatically enables virtualization when total records > 100. + * + * Filter order: + * 1. Apply date range filter + * 2. Apply column filters (where tree with multi-predicate support) + * 3. Apply global search + * 4. Apply sorting + * 5. Apply skip/take (virtualization or regular paging) + * + * Returns { result: [...], count: totalAfterFilterButBeforePaging } + * The count is used by the virtual scroll engine to know the total filtered size. + * + * Virtualization is automatically enabled when total filtered count > 100 + * (detected at the client by examining the total count). + */ + getAlertsForGrid( + skip = 0, + take = 12, + sortBy = '', + sortDirection = 'ascending', + searchValue = '', + filters = [], + whereTree = null, + startDate = null, + endDate = null, + virtualScroll = false + ) { + let data = [...this.alerts] + + // 1. DATE RANGE: Apply temporal filter first (if provided) + if (startDate && endDate) { + const start = new Date(startDate).getTime() + const end = new Date(endDate) + end.setHours(23, 59, 59, 999) + const endMs = end.getTime() + data = data.filter((a) => { + const t = new Date(a.timestamp).getTime() + return t >= start && t <= endMs + }) + } + + // 2. FILTER: Apply column filters (where tree for multi-select support) + if ((filters && filters.length > 0) || (whereTree && whereTree.length > 0)) { + data = applyFilters(data, filters, whereTree) + } + + // 3. SEARCH: Apply global search + if (searchValue && searchValue.trim()) { + const q = searchValue.toLowerCase() + data = data.filter( + (a) => + a.alertId.toLowerCase().includes(q) || + a.deviceId.toLowerCase().includes(q) || + a.deviceName.toLowerCase().includes(q) || + a.location.toLowerCase().includes(q) || + a.sensorType.toLowerCase().includes(q) || + a.severity.toLowerCase().includes(q) || + a.message.toLowerCase().includes(q) + ) + } + + // 4. SORT: Apply sorting + if (sortBy) { + data.sort((a, b) => { + let av = a[sortBy] + let bv = b[sortBy] + if (typeof av === 'string') { + av = av.toLowerCase() + bv = bv.toLowerCase() + } + if (av < bv) return sortDirection === 'ascending' ? -1 : 1 + if (av > bv) return sortDirection === 'ascending' ? 1 : -1 + return 0 + }) + } + + // 5. VIRTUALIZATION/PAGING: Apply skip/take + // totalCount is the size after all filtering but before paging + // This is required by the virtual scroll engine + const totalCount = data.length + const result = data.slice(skip, skip + take) + + return { result, count: totalCount } + } + + // ------------------------------------------------------------------ + // reports + // ------------------------------------------------------------------ + + + getReports() { + const reportData = this.devices.reduce((acc, device) => { + const existing = acc.find((r) => r.sensorType === device.sensorType) + if (existing) { + existing.totalDevices++ + existing.readings.push(device.readingValue) + existing.averageReading = + Math.round( + (existing.readings.reduce((a, b) => a + b, 0) / existing.readings.length) * 100 + ) / 100 + existing.maxReading = Math.max(...existing.readings) + existing.minReading = Math.min(...existing.readings) + existing.avgBattery += device.batteryLevel + } else { + acc.push({ + sensorType: device.sensorType, + totalDevices: 1, + averageReading: device.readingValue, + maxReading: device.readingValue, + minReading: device.readingValue, + avgBattery: device.batteryLevel, + readings: [device.readingValue], + }) + } + return acc + }, []) + reportData.forEach((report) => { + report.avgBattery = Math.round((report.avgBattery / report.totalDevices) * 100) / 100 + delete report.readings + }) + return reportData + } +} + +export { deriveStatus } diff --git a/use-cases/Iot-monitoring-sample/server/services/SocketService.js b/use-cases/Iot-monitoring-sample/server/services/SocketService.js new file mode 100644 index 0000000..8364977 --- /dev/null +++ b/use-cases/Iot-monitoring-sample/server/services/SocketService.js @@ -0,0 +1,192 @@ +/** + * Socket Service + * + * Pushes incremental device updates to connected clients. + * + * Key changes from the previous version: + * - Status is DERIVED from readingValue vs threshold. It is no longer + * randomised, so the status does not flap on every poll. + * - Alerts are emitted only on threshold-CROSSING transitions, as + * decided by the AlertManager. Repeated readings that remain in + * the exceeded state are silently ignored. + * - Updates are throttled to once every ~3 seconds. The interval is + * stable (no random jitter per tick) to keep server load predictable. + * - The summary update is debounced: at most one summary_update is + * emitted per polling cycle. + */ + +// Keep cadence snappy enough for visible "live" cells without flooding +// the client (Dashboard flushes socket buffer every ~200ms). +const TICK_INTERVAL_MS = 1500 +const UPDATE_FRACTION = 0.15 // update ~15% of devices per tick + +// Helper used to derive a status from a reading — must match the +// logic used by DeviceService.du service. +function deriveStatus(readingValue, threshold) { + if (readingValue == null || threshold == null) return 'Normal' + if (readingValue > threshold + 2) return 'Critical' + if (readingValue > threshold) return 'Warning' + return 'Normal' +} + +export class SocketService { + constructor(io, deviceService, alertManager) { + this.io = io + this.deviceService = deviceService + this.alertManager = alertManager + this.updateInterval = null + } + + initialize() { + this.io.on('connection', (socket) => { + console.log(`[Socket] client connected: ${socket.id}`) + + socket.on('disconnect', () => { + console.log(`[Socket] client disconnected: ${socket.id}`) + }) + + socket.on('subscribe_device_updates', () => { + // Reserved for future use — currently every connected client + // receives updates. + }) + + socket.on('unsubscribe_device_updates', () => { + // Reserved for future use. + }) + }) + + // Seed the AlertManager state for every existing device so the + // very first reading is not misinterpreted as a crossing. + for (const device of this.deviceService.getDevices()) { + const candidates = this.deviceService.checkForAnomalies(device) + for (const c of candidates) { + this.alertManager.seedDevice(device.deviceId, c.alertType) + } + } + } + + startDeviceUpdates() { + if (this.updateInterval) return // already running + + this.updateInterval = setInterval(() => { + this._tick().catch((err) => { + console.error('[Socket] tick error:', err) + }) + }, TICK_INTERVAL_MS) + } + + stopDeviceUpdates() { + if (this.updateInterval) { + clearInterval(this.updateInterval) + this.updateInterval = null + } + } + + async _tick() { + const devices = this.deviceService.getDevices() + if (devices.length === 0) return + + const updateCount = Math.max(1, Math.floor(devices.length * UPDATE_FRACTION)) + let didMutateSummary = false + let batteryShifted = false + + for (let i = 0; i < updateCount; i++) { + const randomIndex = Math.floor(Math.random() * devices.length) + const device = devices[randomIndex] + if (!device) continue + + const oldBattery = device.batteryLevel + const oldStatus = device.status + + // Simulate a realistic sensor reading — small drift around the + // current value, with occasional excursions. Critical: the + // status is *derived*, not random. + // const drift = (Math.random() - 0.5) * 1.5 + // const excursionChance = 0.05 // 5% chance of a spike + // const spike = Math.random() < excursionChance + // ? device.threshold * (0.15 + Math.random() * 0.15) * (Math.random() < 0.5 ? 1 : -1) + // : 0 + + // device.readingValue = Math.max( + // 0, + // Math.round((device.readingValue + drift + spike) * 100) / 100 + // ) + + const threshold = device.threshold; + + const r = Math.random(); + + if (r < 0.70) { + // Normal (70%) + device.readingValue = + threshold * (0.55 + Math.random() * 0.30); + } + else if (r < 0.90) { + // Warning (20%) + device.readingValue = + threshold * (0.92 + Math.random() * 0.07); + } + else { + // Critical (10%) + device.readingValue = + threshold * (1.05 + Math.random() * 0.20); + } + + device.readingValue = + Math.round(device.readingValue * 100) / 100; + + device.status = deriveStatus(device.readingValue, threshold); + device.status = deriveStatus(device.readingValue, device.threshold) + + // Signal strength: small random walk + device.signalStrength = Math.max( + 0, + Math.min(100, device.signalStrength + Math.round((Math.random() - 0.5) * 4)) + ) + + // Realistic gradual battery drain + const drainRate = 0.1 + Math.random() * 0.4 + device.batteryLevel = Math.max(0, Math.round((device.batteryLevel - drainRate) * 100) / 100) + device.lastUpdated = new Date().toISOString() + + // Emit per-device delta + this.io.emit('device_update', { + deviceId: device.deviceId, + status: device.status, + readingValue: device.readingValue, + signalStrength: device.signalStrength, + batteryLevel: device.batteryLevel, + lastUpdated: device.lastUpdated, + }) + + // Anomaly detection via AlertManager (threshold-crossing state machine) + const candidates = this.deviceService.checkForAnomalies(device) + for (const candidate of candidates) { + const decision = this.alertManager.evaluate(device, candidate.alertType, candidate.threshold) + if (decision.shouldEmit) { + const alertRecord = this.deviceService.persistAlert({ + deviceId: device.deviceId, + deviceName: device.deviceName, + location: device.location, + sensorType: device.sensorType, + alertType: candidate.alertType, + severity: candidate.alertType === 'TEMPERATURE_THRESHOLD' ? 'High' : 'High', + message: this.deviceService.buildAlertMessage(device, candidate.alertType), + }) + this.io.emit('anomaly_detected', alertRecord) + } + // Clear transitions are intentionally silent — we don't fire a + // toast for "back to normal" to avoid alert noise. + } + + if (Math.abs(oldBattery - device.batteryLevel) > 0.5) batteryShifted = true + if (oldStatus !== device.status) didMutateSummary = true + } + + // Debounced summary update: at most one per tick, only when the + // summary has actually changed. + if (didMutateSummary || batteryShifted) { + this.io.emit('summary_update', this.deviceService.getSummary()) + } + } +}