+
+ {[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 ? (
+
+ ) : (
+
+
+
+
+
+
+
+ }
+ />
+ {
+ 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 (
+
+ )
+ }}
+ />
+
+
+
+
+
+ )}
+
+
+ )
+}
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())
+ }
+ }
+}