Implement a comprehensive online status monitoring system: - OnlineStatusService: Centralized service for checking device/sensor online status with caching, configurable thresholds, and batch operations - useOnlineStatus hook: React hook for easy component integration with auto-polling, loading states, and app state handling - Support for status levels: online (<5 min), warning (5-60 min), offline (>60 min) - Utility functions for status colors, labels, and formatting Includes 49 passing tests covering service and hook functionality. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
370 lines
9.5 KiB
TypeScript
370 lines
9.5 KiB
TypeScript
/**
|
|
* Online Status Service
|
|
*
|
|
* Provides functionality to check and monitor device/sensor online status.
|
|
* Supports individual status checks and batch monitoring with configurable thresholds.
|
|
*/
|
|
|
|
import { api } from './api';
|
|
import type { WPSensor } from '@/types';
|
|
|
|
/**
|
|
* Online status levels
|
|
* - online: Device is actively communicating (< 5 minutes since last seen)
|
|
* - warning: Device may have issues (5-60 minutes since last seen)
|
|
* - offline: Device is not communicating (> 60 minutes since last seen)
|
|
*/
|
|
export type OnlineStatus = 'online' | 'warning' | 'offline';
|
|
|
|
/**
|
|
* Thresholds for determining online status (in minutes)
|
|
*/
|
|
export interface StatusThresholds {
|
|
/** Maximum minutes for 'online' status (default: 5) */
|
|
onlineMaxMinutes: number;
|
|
/** Maximum minutes for 'warning' status (default: 60) */
|
|
warningMaxMinutes: number;
|
|
}
|
|
|
|
export const DEFAULT_THRESHOLDS: StatusThresholds = {
|
|
onlineMaxMinutes: 5,
|
|
warningMaxMinutes: 60,
|
|
};
|
|
|
|
/**
|
|
* Device status information
|
|
*/
|
|
export interface DeviceStatusInfo {
|
|
deviceId: string;
|
|
deviceName: string;
|
|
status: OnlineStatus;
|
|
lastSeen: Date;
|
|
lastSeenMinutesAgo: number;
|
|
lastSeenFormatted: string;
|
|
wellId?: number;
|
|
mac?: string;
|
|
}
|
|
|
|
/**
|
|
* Beneficiary online status summary
|
|
*/
|
|
export interface BeneficiaryOnlineStatus {
|
|
beneficiaryId: string;
|
|
totalDevices: number;
|
|
onlineCount: number;
|
|
warningCount: number;
|
|
offlineCount: number;
|
|
overallStatus: OnlineStatus;
|
|
devices: DeviceStatusInfo[];
|
|
lastChecked: Date;
|
|
}
|
|
|
|
/**
|
|
* Status check result
|
|
*/
|
|
export interface StatusCheckResult {
|
|
ok: boolean;
|
|
data?: BeneficiaryOnlineStatus;
|
|
error?: string;
|
|
}
|
|
|
|
/**
|
|
* Calculate minutes since a given date
|
|
*/
|
|
function getMinutesAgo(date: Date): number {
|
|
const now = new Date();
|
|
return Math.floor((now.getTime() - date.getTime()) / (1000 * 60));
|
|
}
|
|
|
|
/**
|
|
* Determine online status based on lastSeen timestamp
|
|
*/
|
|
export function calculateOnlineStatus(
|
|
lastSeen: Date,
|
|
thresholds: StatusThresholds = DEFAULT_THRESHOLDS
|
|
): OnlineStatus {
|
|
const minutesAgo = getMinutesAgo(lastSeen);
|
|
|
|
if (minutesAgo < thresholds.onlineMaxMinutes) {
|
|
return 'online';
|
|
} else if (minutesAgo < thresholds.warningMaxMinutes) {
|
|
return 'warning';
|
|
}
|
|
return 'offline';
|
|
}
|
|
|
|
/**
|
|
* Format last seen time for display
|
|
*/
|
|
export function formatLastSeen(lastSeen: Date): string {
|
|
const minutesAgo = getMinutesAgo(lastSeen);
|
|
|
|
if (minutesAgo < 1) {
|
|
return 'Just now';
|
|
} else if (minutesAgo < 60) {
|
|
return `${minutesAgo} min ago`;
|
|
} else if (minutesAgo < 24 * 60) {
|
|
const hours = Math.floor(minutesAgo / 60);
|
|
return `${hours} hour${hours > 1 ? 's' : ''} ago`;
|
|
} else {
|
|
const days = Math.floor(minutesAgo / (24 * 60));
|
|
return `${days} day${days > 1 ? 's' : ''} ago`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get status color for UI display
|
|
*/
|
|
export function getStatusColor(status: OnlineStatus): string {
|
|
switch (status) {
|
|
case 'online':
|
|
return '#22c55e'; // green
|
|
case 'warning':
|
|
return '#f59e0b'; // amber
|
|
case 'offline':
|
|
return '#ef4444'; // red
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get status label for display
|
|
*/
|
|
export function getStatusLabel(status: OnlineStatus): string {
|
|
switch (status) {
|
|
case 'online':
|
|
return 'Online';
|
|
case 'warning':
|
|
return 'Warning';
|
|
case 'offline':
|
|
return 'Offline';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Calculate overall status for a group of devices
|
|
* Returns the "worst" status among all devices
|
|
*/
|
|
export function calculateOverallStatus(devices: DeviceStatusInfo[]): OnlineStatus {
|
|
if (devices.length === 0) {
|
|
return 'offline';
|
|
}
|
|
|
|
// If any device is offline, overall is offline
|
|
if (devices.some(d => d.status === 'offline')) {
|
|
return 'offline';
|
|
}
|
|
|
|
// If any device has warning, overall is warning
|
|
if (devices.some(d => d.status === 'warning')) {
|
|
return 'warning';
|
|
}
|
|
|
|
// All devices are online
|
|
return 'online';
|
|
}
|
|
|
|
/**
|
|
* Transform WPSensor to DeviceStatusInfo
|
|
*/
|
|
function sensorToStatusInfo(sensor: WPSensor): DeviceStatusInfo {
|
|
const lastSeen = sensor.lastSeen instanceof Date ? sensor.lastSeen : new Date(sensor.lastSeen);
|
|
const minutesAgo = getMinutesAgo(lastSeen);
|
|
|
|
return {
|
|
deviceId: sensor.deviceId,
|
|
deviceName: sensor.name,
|
|
status: sensor.status === 'warning' ? 'warning' : sensor.status as OnlineStatus,
|
|
lastSeen: lastSeen,
|
|
lastSeenMinutesAgo: minutesAgo,
|
|
lastSeenFormatted: formatLastSeen(lastSeen),
|
|
wellId: sensor.wellId,
|
|
mac: sensor.mac,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Online Status Service
|
|
* Singleton service for checking device online status
|
|
*/
|
|
class OnlineStatusService {
|
|
private thresholds: StatusThresholds = DEFAULT_THRESHOLDS;
|
|
private cache = new Map<string, { data: BeneficiaryOnlineStatus; timestamp: number }>();
|
|
private cacheMaxAgeMs = 30000; // 30 seconds cache
|
|
|
|
/**
|
|
* Set custom status thresholds
|
|
*/
|
|
setThresholds(thresholds: Partial<StatusThresholds>): void {
|
|
this.thresholds = { ...this.thresholds, ...thresholds };
|
|
}
|
|
|
|
/**
|
|
* Get current thresholds
|
|
*/
|
|
getThresholds(): StatusThresholds {
|
|
return { ...this.thresholds };
|
|
}
|
|
|
|
/**
|
|
* Set cache max age
|
|
*/
|
|
setCacheMaxAge(maxAgeMs: number): void {
|
|
this.cacheMaxAgeMs = maxAgeMs;
|
|
}
|
|
|
|
/**
|
|
* Clear all cached data
|
|
*/
|
|
clearCache(): void {
|
|
this.cache.clear();
|
|
}
|
|
|
|
/**
|
|
* Check if cached data is still valid
|
|
*/
|
|
private isCacheValid(cacheEntry: { timestamp: number }): boolean {
|
|
return Date.now() - cacheEntry.timestamp < this.cacheMaxAgeMs;
|
|
}
|
|
|
|
/**
|
|
* Check online status for a single beneficiary's devices
|
|
* @param beneficiaryId - The beneficiary ID to check
|
|
* @param forceRefresh - Skip cache and fetch fresh data
|
|
* @returns Status check result
|
|
*/
|
|
async checkBeneficiaryStatus(
|
|
beneficiaryId: string,
|
|
forceRefresh = false
|
|
): Promise<StatusCheckResult> {
|
|
// Check cache first (unless force refresh)
|
|
if (!forceRefresh) {
|
|
const cached = this.cache.get(beneficiaryId);
|
|
if (cached && this.isCacheValid(cached)) {
|
|
return { ok: true, data: cached.data };
|
|
}
|
|
}
|
|
|
|
try {
|
|
// Fetch devices from API
|
|
const response = await api.getDevicesForBeneficiary(beneficiaryId);
|
|
|
|
if (!response.ok) {
|
|
return {
|
|
ok: false,
|
|
error: typeof response.error === 'string' ? response.error : response.error?.message || 'Failed to fetch devices',
|
|
};
|
|
}
|
|
|
|
const sensors = response.data || [];
|
|
|
|
// Transform sensors to status info
|
|
const devices: DeviceStatusInfo[] = sensors.map(sensorToStatusInfo);
|
|
|
|
// Count statuses
|
|
const onlineCount = devices.filter(d => d.status === 'online').length;
|
|
const warningCount = devices.filter(d => d.status === 'warning').length;
|
|
const offlineCount = devices.filter(d => d.status === 'offline').length;
|
|
|
|
const result: BeneficiaryOnlineStatus = {
|
|
beneficiaryId,
|
|
totalDevices: devices.length,
|
|
onlineCount,
|
|
warningCount,
|
|
offlineCount,
|
|
overallStatus: calculateOverallStatus(devices),
|
|
devices,
|
|
lastChecked: new Date(),
|
|
};
|
|
|
|
// Update cache
|
|
this.cache.set(beneficiaryId, { data: result, timestamp: Date.now() });
|
|
|
|
return { ok: true, data: result };
|
|
} catch (error) {
|
|
return {
|
|
ok: false,
|
|
error: error instanceof Error ? error.message : 'Unknown error occurred',
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check online status for multiple beneficiaries
|
|
* @param beneficiaryIds - Array of beneficiary IDs to check
|
|
* @param forceRefresh - Skip cache and fetch fresh data
|
|
* @returns Map of beneficiary ID to status result
|
|
*/
|
|
async checkMultipleBeneficiaries(
|
|
beneficiaryIds: string[],
|
|
forceRefresh = false
|
|
): Promise<Map<string, StatusCheckResult>> {
|
|
const results = new Map<string, StatusCheckResult>();
|
|
|
|
// Process in parallel but limit concurrency
|
|
const batchSize = 3;
|
|
for (let i = 0; i < beneficiaryIds.length; i += batchSize) {
|
|
const batch = beneficiaryIds.slice(i, i + batchSize);
|
|
const batchResults = await Promise.all(
|
|
batch.map(id => this.checkBeneficiaryStatus(id, forceRefresh))
|
|
);
|
|
|
|
batch.forEach((id, index) => {
|
|
results.set(id, batchResults[index]);
|
|
});
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Get cached status for a beneficiary (no API call)
|
|
* Returns undefined if not cached or cache expired
|
|
*/
|
|
getCachedStatus(beneficiaryId: string): BeneficiaryOnlineStatus | undefined {
|
|
const cached = this.cache.get(beneficiaryId);
|
|
if (cached && this.isCacheValid(cached)) {
|
|
return cached.data;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Check if a specific device is online
|
|
* Convenience method for quick device checks
|
|
*/
|
|
async isDeviceOnline(beneficiaryId: string, deviceId: string): Promise<boolean> {
|
|
const result = await this.checkBeneficiaryStatus(beneficiaryId);
|
|
if (!result.ok || !result.data) {
|
|
return false;
|
|
}
|
|
|
|
const device = result.data.devices.find(d => d.deviceId === deviceId);
|
|
return device?.status === 'online';
|
|
}
|
|
|
|
/**
|
|
* Get summary statistics for a beneficiary's devices
|
|
*/
|
|
async getStatusSummary(
|
|
beneficiaryId: string
|
|
): Promise<{ online: number; warning: number; offline: number; total: number } | null> {
|
|
const result = await this.checkBeneficiaryStatus(beneficiaryId);
|
|
if (!result.ok || !result.data) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
online: result.data.onlineCount,
|
|
warning: result.data.warningCount,
|
|
offline: result.data.offlineCount,
|
|
total: result.data.totalDevices,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Export singleton instance
|
|
export const onlineStatusService = new OnlineStatusService();
|
|
|
|
// Export class for testing
|
|
export { OnlineStatusService };
|