สร้าง Real-time Dashboard ด้วย LynxJS

สร้าง Real-time Dashboard ด้วย LynxJS

Showkhun · Workshop ·

Branch: workshop/dev-11-lynxjs-dashboard Phase: 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

Mermaid Diagram

เห็นไหมว่าทุกอย่างเชื่อมต่อกันแบบ 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:

บทหน้าเราจะไปสร้าง Device Control Interface — หน้าที่ให้ผู้ใช้สั่ง on/off อุปกรณ์ผ่านมือถือได้! มาลุยกันต่อเลย (ง •̀_•́)ง