สร้าง Real-time Dashboard ด้วย LynxJS
Branch:
workshop/dev-11-lynxjs-dashboardPhase: Mobile Frontend (2/5) Repo: kangana1024/iot-workshop
Hook: ทำไมต้อง Real-time?
สวัสดีน้องๆ ทุกคน! พี่โชว์มาแล้ว ٩(◕‿◕。)۶
เคยนึกภาพไหมว่าถ้าเราต้องดูสถานะเซ็นเซอร์ในโรงงาน แต่ข้อมูลอัปเดตทุก 30 วินาทีเพราะต้อง refresh หน้าเอง? มันก็เหมือนกับขับรถแล้วมองกระจกหลังทุก 30 วิ แทนที่จะมองตลอดเวลา — อันตรายมาก!
Real-time Dashboard คือหัวใจของระบบ IoT เพราะมันทำให้เราเห็นข้อมูลทันทีที่เซ็นเซอร์รายงาน ไม่ต้องรอ, ไม่ต้องกด refresh
บทนี้เราจะสร้างหน้า Dashboard แบบ live ด้วย LynxJS มาลุยกันเลย!
สิ่งที่น้องๆ จะได้เรียนรู้
- WebSocket Manager พร้อมระบบ Auto-reconnection แบบ Exponential Backoff
- Dashboard Store ด้วย Zustand + Immer
- SensorCard Component พร้อม Pulse Animation
- ConnectionStatus Bar แบบ Slide-in เมื่อ offline
- Dashboard Screen พร้อม Pull-to-refresh และ Last Updated Timestamp
ภาพรวมระบบ: ข้อมูลไหลยังไง?
ก่อนลงโค้ด เรามาทำความเข้าใจ “ทำไม” ก่อนนะ
ลองนึกภาพ Dashboard เหมือน กระดานสกอร์ในสนามกีฬา — มันต้องอัปเดตทันทีที่มีคะแนนใหม่ ไม่ใช่รอให้เกมจบแล้วค่อยแสดง ระบบของเราก็เหมือนกัน เซ็นเซอร์ส่งข้อมูล → WebSocket รับ → Store เก็บ → UI แสดงผล แบบ real-time
เห็นไหมว่าทุกอย่างเชื่อมต่อกันแบบ event-driven ข้อมูลไหลทางเดียว (one-way data flow) ทำให้ debug ง่ายมาก
Step 1: WebSocket Service
ทำไมต้อง WebSocket Manager แยกต่างหาก?
เหตุผลง่ายมาก — ถ้าเราเปิด WebSocket connection ตรงใน component มันจะตายทุกครั้งที่ component unmount แล้วเปิดใหม่ทุกครั้งที่ mount ลองนึกภาพว่าคุณโทรหาเพื่อนทุกครั้งที่ก้าวเข้าห้อง แล้ววางสายทุกครั้งที่ออกไปข้างนอก — มันเปลืองและวุ่นวายมาก
ดังนั้นเราจึงทำ Singleton WebSocket Manager แยกออกมา เปิดครั้งเดียว ใช้ได้ทุก component
┌─────────────────────────────────────────┐
│ WebSocketManager │
│ ┌───────────┐ ┌──────────────────┐ │
│ │ ws: WS │ │ handlers: Map │ │
│ │ attempts │ │ 'sensor_update' │ │
│ │ timer │ │ 'connected' │ │
│ └───────────┘ │ 'disconnected' │ │
│ └──────────────────┘ │
│ connect() → _connect() → scheduleReconnect()
└─────────────────────────────────────────┘
1.1 WebSocket Manager
// src/services/websocket.ts
import { WS_URL } from '../utils/env';
type MessageHandler = (data: unknown) => void;
type EventType = 'sensor_update' | 'device_status' | 'alert' | 'connected' | 'disconnected';
class WebSocketManager {
private ws: WebSocket | null = null;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private reconnectAttempts = 0;
private maxReconnectAttempts = 10;
private reconnectDelay = 2000;
private handlers = new Map<EventType, Set<MessageHandler>>();
private isManualClose = false;
connect(token: string) {
this.isManualClose = false;
this._connect(token);
}
private _connect(token: string) {
if (this.ws?.readyState === WebSocket.OPEN) return;
const url = `${WS_URL}?token=${token}`;
this.ws = new WebSocket(url);
this.ws.onopen = () => {
console.log('[WS] Connected');
this.reconnectAttempts = 0;
this.emit('connected', { connected: true });
};
this.ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data as string);
this.emit(msg.type as EventType, msg.payload);
} catch (err) {
console.error('[WS] Parse error:', err);
}
};
this.ws.onclose = () => {
console.log('[WS] Disconnected');
this.emit('disconnected', { connected: false });
if (!this.isManualClose) {
this.scheduleReconnect(token);
}
};
this.ws.onerror = (error) => {
console.error('[WS] Error:', error);
};
}
private scheduleReconnect(token: string) {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('[WS] Max reconnect attempts reached');
return;
}
const delay = this.reconnectDelay * Math.pow(1.5, this.reconnectAttempts);
this.reconnectAttempts++;
console.log(`[WS] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
this.reconnectTimer = setTimeout(() => this._connect(token), delay);
}
disconnect() {
this.isManualClose = true;
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
this.ws?.close();
this.ws = null;
}
on(event: EventType, handler: MessageHandler) {
if (!this.handlers.has(event)) {
this.handlers.set(event, new Set());
}
this.handlers.get(event)!.add(handler);
}
off(event: EventType, handler: MessageHandler) {
this.handlers.get(event)?.delete(handler);
}
private emit(event: EventType, data: unknown) {
this.handlers.get(event)?.forEach((handler) => handler(data));
}
get isConnected() {
return this.ws?.readyState === WebSocket.OPEN;
}
}
export const wsManager = new WebSocketManager();
Exponential Backoff คืออะไร? ลองนึกภาพว่าคุณโทรหาเพื่อนไม่ติด ครั้งแรกรอ 2 วิ ครั้งที่สองรอ 3 วิ ครั้งที่สามรอ 4.5 วิ… มันฉลาดกว่าการโทรซ้ำทุก 2 วิตลอด เพราะถ้า server มีปัญหา การ spam reconnect จะยิ่งทำให้มันหนักขึ้น
Step 2: Dashboard Store
ทำไมต้อง Zustand + Immer?
State ของ Dashboard มีความซับซ้อน — มีหลาย card, แต่ละ card มีข้อมูลหลายชั้น ถ้าเราอัปเดต state แบบ immutable ล้วนๆ โค้ดจะยาวและน่าปวดหัวมาก
Immer แก้ปัญหานี้ด้วยการให้เราเขียนโค้ดเหมือน mutate โดยตรง แต่จริงๆ มันสร้าง immutable copy ให้ เหมือนมีผู้ช่วยที่คอยถ่ายเอกสารให้ทุกครั้งที่เราแก้ไข
// src/stores/useDashboardStore.ts
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
import type { SensorReading, Device } from '../types';
interface SensorCardData {
device: Device;
reading: SensorReading | null;
isLoading: boolean;
error: string | null;
}
interface DashboardState {
cards: Record<string, SensorCardData>;
isConnected: boolean;
lastUpdated: Date | null;
isRefreshing: boolean;
setConnected: (connected: boolean) => void;
setCard: (deviceId: string, data: Partial<SensorCardData>) => void;
updateReading: (reading: SensorReading) => void;
setRefreshing: (v: boolean) => void;
setLastUpdated: () => void;
}
export const useDashboardStore = create<DashboardState>()(
immer((set) => ({
cards: {},
isConnected: false,
lastUpdated: null,
isRefreshing: false,
setConnected: (connected) =>
set((state) => {
state.isConnected = connected;
}),
setCard: (deviceId, data) =>
set((state) => {
if (!state.cards[deviceId]) {
state.cards[deviceId] = {
device: null as unknown as Device,
reading: null,
isLoading: false,
error: null,
};
}
Object.assign(state.cards[deviceId], data);
}),
updateReading: (reading) =>
set((state) => {
if (state.cards[reading.deviceId]) {
state.cards[reading.deviceId].reading = reading;
state.cards[reading.deviceId].isLoading = false;
}
state.lastUpdated = new Date();
}),
setRefreshing: (v) =>
set((state) => {
state.isRefreshing = v;
}),
setLastUpdated: () =>
set((state) => {
state.lastUpdated = new Date();
}),
}))
);
cardsเป็นRecord<string, SensorCardData>— ก็คือ object ที่ key คือ deviceId ทำให้เราอัปเดต card เฉพาะตัวได้ทันที โดยไม่ต้อง loop หา
Step 3: Sensor Card Component
ทำไม Pulse Animation ถึงสำคัญ?
UX research บอกว่าผู้ใช้จะ “มองไม่เห็น” การเปลี่ยนแปลงของตัวเลขถ้าไม่มี visual feedback บางคนอาจไม่รู้เลยว่าข้อมูลอัปเดตไปแล้ว Pulse animation ช่วย “จิ้ม” สายตาให้ดูที่ card ที่เพิ่งได้รับข้อมูลใหม่
เหมือนกับไฟกระพริบในห้องควบคุม — พอมีอะไรเปลี่ยนแปลง มันต้องดึงความสนใจ!
// src/components/dashboard/SensorCard.tsx
import { View, Text, StyleSheet, Animated } from '@lynx-js/react';
import { useRef, useEffect } from '@lynx-js/react';
import { Card } from '../ui/Card';
import { Badge } from '../ui/Badge';
import { useTheme } from '../../theme';
import type { Device, SensorReading } from '../../types';
interface SensorCardProps {
device: Device;
reading: SensorReading | null;
isLoading?: boolean;
}
export function SensorCard({ device, reading, isLoading }: SensorCardProps) {
const { colors } = useTheme();
const pulseAnim = useRef(new Animated.Value(1)).current;
// Pulse animation เมื่อได้รับข้อมูลใหม่
useEffect(() => {
if (reading) {
Animated.sequence([
Animated.timing(pulseAnim, { toValue: 1.03, duration: 150, useNativeDriver: true }),
Animated.timing(pulseAnim, { toValue: 1, duration: 150, useNativeDriver: true }),
]).start();
}
}, [reading?.timestamp]);
const statusVariant = {
online: 'success' as const,
offline: 'default' as const,
error: 'danger' as const,
}[device.status];
return (
<Animated.View style={{ transform: [{ scale: pulseAnim }] }}>
<Card variant="elevated">
{/* Header */}
<View style={styles.header}>
<View style={styles.headerLeft}>
<Text style={[styles.deviceName, { color: colors.text }]}>
{device.name}
</Text>
<Text style={[styles.location, { color: colors.textSecondary }]}>
{device.location}
</Text>
</View>
<Badge label={device.status} variant={statusVariant} />
</View>
{/* Readings */}
{isLoading ? (
<ReadingsSkeleton />
) : reading ? (
<View style={styles.readings}>
{reading.temperature !== undefined && (
<ReadingItem
label="Temperature"
value={`${reading.temperature.toFixed(1)}°C`}
icon="thermometer-outline"
color={colors.danger}
/>
)}
{reading.humidity !== undefined && (
<ReadingItem
label="Humidity"
value={`${reading.humidity.toFixed(0)}%`}
icon="water-outline"
color={colors.info}
/>
)}
{reading.battery !== undefined && (
<ReadingItem
label="Battery"
value={`${reading.battery}%`}
icon="battery-half-outline"
color={
reading.battery > 50
? colors.success
: reading.battery > 20
? colors.warning
: colors.danger
}
/>
)}
</View>
) : (
<Text style={[styles.noData, { color: colors.textSecondary }]}>
No data available
</Text>
)}
</Card>
</Animated.View>
);
}
function ReadingItem({
label,
value,
color,
}: {
label: string;
value: string;
icon: string;
color: string;
}) {
const { colors } = useTheme();
return (
<View style={styles.readingItem}>
<Text style={[styles.readingLabel, { color: colors.textSecondary }]}>
{label}
</Text>
<Text style={[styles.readingValue, { color }]}>{value}</Text>
</View>
);
}
function ReadingsSkeleton() {
const { colors } = useTheme();
return (
<View style={styles.skeleton}>
{[1, 2, 3].map((i) => (
<View
key={i}
style={[styles.skeletonItem, { backgroundColor: colors.border }]}
/>
))}
</View>
);
}
const styles = StyleSheet.create({
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: 12,
},
headerLeft: { flex: 1, marginRight: 8 },
deviceName: { fontSize: 15, fontWeight: '600', marginBottom: 2 },
location: { fontSize: 12 },
readings: { gap: 8 },
readingItem: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
readingLabel: { fontSize: 13 },
readingValue: { fontSize: 15, fontWeight: '700' },
noData: { fontSize: 13, textAlign: 'center', paddingVertical: 8 },
skeleton: { gap: 8 },
skeletonItem: { height: 20, borderRadius: 4 },
});
ReadingsSkeletonคือ placeholder ขณะโหลดข้อมูล — แทนที่จะโชว์หน้าว่างเปล่า เราโชว์กล่องสีเทาๆ ให้รู้ว่า “กำลังโหลดอยู่นะ” เหมือน placeholder ของรูปในนิตยสารก่อนพิมพ์จริง
Step 4: Connection Status Bar
ทำไมต้องมี Status Bar?
เวลา WebSocket หลุด แอปยังแสดงข้อมูลเก่าอยู่ ถ้าไม่มีการแจ้งเตือน ผู้ใช้จะนึกว่าข้อมูลยังสด แต่จริงๆ มันค้างอยู่ — อันตรายมากในระบบ IoT!
// src/components/dashboard/ConnectionStatus.tsx
import { View, Text, StyleSheet, Animated } from '@lynx-js/react';
import { useRef, useEffect } from '@lynx-js/react';
import { useTheme } from '../../theme';
interface ConnectionStatusProps {
isConnected: boolean;
}
export function ConnectionStatus({ isConnected }: ConnectionStatusProps) {
const { colors } = useTheme();
const slideAnim = useRef(new Animated.Value(isConnected ? -40 : 0)).current;
useEffect(() => {
Animated.timing(slideAnim, {
toValue: isConnected ? -40 : 0,
duration: 300,
useNativeDriver: true,
}).start();
}, [isConnected]);
return (
<Animated.View
style={[
styles.bar,
{ backgroundColor: colors.warning, transform: [{ translateY: slideAnim }] },
]}
>
<View style={styles.dot} />
<Text style={styles.text}>Reconnecting to server...</Text>
</Animated.View>
);
}
const styles = StyleSheet.create({
bar: {
height: 36,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
paddingHorizontal: 16,
},
dot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: '#fff',
},
text: { color: '#fff', fontSize: 13, fontWeight: '500' },
});
Slide animation ทำงานง่ายมาก —
translateY: -40คือซ่อนอยู่เหนือหน้าจอ พอ offline ก็ slide ลงมาtranslateY: 0เหมือน notification bar บนมือถือ!
Step 5: Dashboard Screen
รวมทุกอย่างเข้าด้วยกัน!
นี่คือ step ที่สนุกที่สุด เราจะเอาทุกอย่างที่สร้างมา ประกอบเข้าด้วยกันใน DashboardScreen
// src/screens/DashboardScreen.tsx
import {
View,
Text,
ScrollView,
RefreshControl,
StyleSheet,
} from '@lynx-js/react';
import { useEffect, useCallback } from '@lynx-js/react';
import { useDashboardStore } from '../stores/useDashboardStore';
import { wsManager } from '../services/websocket';
import { api } from '../services/api';
import { SensorCard } from '../components/dashboard/SensorCard';
import { ConnectionStatus } from '../components/dashboard/ConnectionStatus';
import { useTheme } from '../theme';
import { format } from 'date-fns';
import AsyncStorage from '@lynx-js/async-storage';
import type { SensorReading } from '../types';
export default function DashboardScreen() {
const { colors } = useTheme();
const {
cards,
isConnected,
lastUpdated,
isRefreshing,
setConnected,
setCard,
updateReading,
setRefreshing,
} = useDashboardStore();
// ========== Load Initial Data ==========
const loadDevices = useCallback(async () => {
try {
const response = await api.getDevices();
for (const device of response.data) {
setCard(device.id, { device, isLoading: true });
// โหลด latest reading ของแต่ละ device
try {
const reading = await api.getLatestReadings(device.id);
setCard(device.id, { reading, isLoading: false });
} catch {
setCard(device.id, { isLoading: false });
}
}
} catch (err) {
console.error('Failed to load devices:', err);
}
}, []);
// ========== WebSocket Setup ==========
useEffect(() => {
const setupWs = async () => {
const token = await AsyncStorage.getItem('auth_token');
if (!token) return;
wsManager.on('connected', () => setConnected(true));
wsManager.on('disconnected', () => setConnected(false));
wsManager.on('sensor_update', (data) => {
updateReading(data as SensorReading);
});
wsManager.connect(token);
};
setupWs();
loadDevices();
return () => {
wsManager.off('connected', () => setConnected(true));
wsManager.off('disconnected', () => setConnected(false));
wsManager.disconnect();
};
}, []);
// ========== Pull-to-refresh ==========
const handleRefresh = useCallback(async () => {
setRefreshing(true);
await loadDevices();
setRefreshing(false);
}, [loadDevices]);
const cardList = Object.values(cards);
return (
<View style={[styles.container, { backgroundColor: colors.background }]}>
<ConnectionStatus isConnected={isConnected} />
<ScrollView
contentContainerStyle={styles.scrollContent}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={handleRefresh}
tintColor={colors.primary}
/>
}
>
{/* Header */}
<View style={styles.header}>
<Text style={[styles.title, { color: colors.text }]}>
Live Dashboard
</Text>
{lastUpdated && (
<Text style={[styles.lastUpdated, { color: colors.textSecondary }]}>
Updated {format(lastUpdated, 'HH:mm:ss')}
</Text>
)}
</View>
{/* Stats Row */}
<View style={styles.statsRow}>
<StatBadge
label="Total"
value={cardList.length}
color={colors.primary}
/>
<StatBadge
label="Online"
value={cardList.filter((c) => c.device?.status === 'online').length}
color={colors.success}
/>
<StatBadge
label="Offline"
value={cardList.filter((c) => c.device?.status === 'offline').length}
color={colors.textSecondary}
/>
<StatBadge
label="Error"
value={cardList.filter((c) => c.device?.status === 'error').length}
color={colors.danger}
/>
</View>
{/* Sensor Cards */}
{cardList.length === 0 ? (
<EmptyState />
) : (
cardList.map((card) =>
card.device ? (
<SensorCard
key={card.device.id}
device={card.device}
reading={card.reading}
isLoading={card.isLoading}
/>
) : null
)
)}
</ScrollView>
</View>
);
}
function StatBadge({
label,
value,
color,
}: {
label: string;
value: number;
color: string;
}) {
const { colors } = useTheme();
return (
<View style={[styles.statBadge, { backgroundColor: colors.surface }]}>
<Text style={[styles.statValue, { color }]}>{value}</Text>
<Text style={[styles.statLabel, { color: colors.textSecondary }]}>
{label}
</Text>
</View>
);
}
function EmptyState() {
const { colors } = useTheme();
return (
<View style={styles.emptyState}>
<Text style={[styles.emptyText, { color: colors.textSecondary }]}>
No devices found. Pull down to refresh.
</Text>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
scrollContent: { padding: 16 },
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
},
title: { fontSize: 22, fontWeight: '700' },
lastUpdated: { fontSize: 12 },
statsRow: {
flexDirection: 'row',
gap: 8,
marginBottom: 16,
},
statBadge: {
flex: 1,
alignItems: 'center',
paddingVertical: 10,
borderRadius: 10,
},
statValue: { fontSize: 20, fontWeight: '700' },
statLabel: { fontSize: 11, marginTop: 2 },
emptyState: { alignItems: 'center', paddingVertical: 40 },
emptyText: { fontSize: 14 },
});
ข้อสังเกตสำคัญ:
loadDevicesเรียก API แบบ parallel per device ไม่ใช่รอทีละตัว — เหมือนสั่งอาหารทั้งโต๊ะพร้อมกัน ไม่ใช่รอคนแรกได้อาหารก่อนแล้วค่อยสั่งคนต่อไป!
สรุปสิ่งที่เราทำวันนี้
(ノ◕ヮ◕)ノ*:・゚✧ Dashboard สำเร็จแล้ว!
วันนี้เราสร้างระบบ Real-time Dashboard ครบเซต:
- WebSocket Manager — Singleton พร้อม Auto-reconnection แบบ Exponential Backoff ไม่หลุดไม่มีทางรู้ตัว
- Dashboard Store — Zustand + Immer จัดการ state หลาย card พร้อมกันได้ smooth
- SensorCard — Pulse animation เมื่อรับข้อมูลใหม่ + Skeleton loading
- ConnectionStatus Bar — Slide-in เตือนเมื่อ offline ไม่ให้ผู้ใช้เชื่อข้อมูลเก่า
- Dashboard Screen — Pull-to-refresh, Stats Row, Last Updated timestamp ครบ
สิ่งที่น้องๆ ควรจำไว้คือ WHY ก่อน HOW เสมอ — ทุก feature ที่เราสร้างมีเหตุผล ไม่ใช่สร้างเพราะ “มันเท่ดี”
Next Step
Navigation:
- Prev: #13 LynxJS Setup
- Next: #15 Device Control Interface
บทหน้าเราจะไปสร้าง Device Control Interface — หน้าที่ให้ผู้ใช้สั่ง on/off อุปกรณ์ผ่านมือถือได้! มาลุยกันต่อเลย (ง •̀_•́)ง