สร้าง Admin Monitoring Dashboard แบบ Real-time

สร้าง Admin Monitoring Dashboard แบบ Real-time

ShowkhunWorkshop

สร้าง Admin Monitoring Dashboard แบบ Real-time

Branch: step-17-admin-monitoring Phase: Development (17/19) — Admin Console Repo: kangana1024/showkhun-workshop


สวัสดีน้องๆ ทุกคน! เรากลับมาแล้ว (ง่ะ•̀ω•́)ง

วันนี้เราจะมาทำของที่ “Admin คนเดียวรู้เรื่อง แต่ทุกคนได้ประโยชน์” — นั่นก็คือ Monitoring Dashboard หน้าที่ Admin จ้องตลอดเวลา

ลองนึกภาพว่าคุณเป็นยามรักษาความปลอดภัยโรงงานขนาดใหญ่ คุณต้องดูจอหลายตัวพร้อมกัน ดูว่าระบบไหน OK ระบบไหนพัง Dashboard ของเราทำหน้าที่แบบนั้นแหละ แต่สำหรับ IoT devices ทั้งหมดในระบบ — และที่เด็ดคือ ข้อมูลมัน live จริงๆ ไหลเข้ามาทาง WebSocket

มาลุยกัน!


สิ่งที่น้องๆ จะได้เรียนรู้วันนี้

  • ทำไมต้องมี Monitoring Dashboard (WHY ก่อนนะ!)
  • Overview cards ที่ share component กับหน้า Overview
  • Device Status Grid ที่ card “ติดไฟ” เมื่อมี live data — ทำด้วย WebSocket จริง (ไม่ใช่ polling)
  • เขียน RealtimeClient ที่ auto-reconnect แบบ exponential backoff + re-subscribe rooms เอง
  • กราฟ readings จาก InfluxDB ด้วย Recharts (lazy-loaded) + pivot ข้อมูลเป็นรูปที่ chart กิน
  • Time-range control (1h / 6h / 24h / 7d) ที่ map เป็น downsampling params ฝั่ง backend
  • Alert Rules overview สรุป rule แยกตาม severity/type

ทำไมต้องมี Dashboard? (WHY)

  ╔══════════════════════════════════╗
  ║  ถ้าไม่มี Dashboard...           ║
  ║                                  ║
  ║  Admin: "sensor-7 พังเมื่อไหร่?" ║
  ║  Dev:   "เดี๋ยวไปดู log..."       ║
  ║  Admin: "ค่ามันพุ่งตอนไหน?"      ║
  ║  Dev:   "เดี๋ยว query InfluxDB..."║
  ║  Admin: (╯°□°)╯︵ ┻━┻           ║
  ╚══════════════════════════════════╝

ถ้าเราไม่มีหน้า Monitor รวม ทุกคนต้องวิ่งไปเปิด log, query database แยกกันตลอดเวลา นั่นคือ “ข้อมูลมีอยู่ แต่หาไม่เจอ”

Monitoring Dashboard แก้ปัญหานี้ด้วยการรวมทุกอย่างไว้ที่เดียว ทำให้ Admin เห็นภาพรวมระบบทั้งหมดได้ใน 3 วินาทีแรกที่เปิดหน้า

เกร็ดสำคัญ: โพสต์นี้ ไม่ได้ embed Chronograf iframe นะครับ — เราดึงข้อมูล readings จริงจาก backend (/api/v1/devices/:id/readings ซึ่ง query InfluxDB) แล้ววาดกราฟเองด้วย Recharts 3 และรับ live sensor data ผ่าน WebSocket hub ของเราเอง ทุกอย่างอยู่ในแอป ไม่ต้องพึ่งเครื่องมือนอก


ภาพรวมสถาปัตยกรรม

ก่อนลงมือ มาดู flow ของ Dashboard ทั้งหมดกันก่อน — มี 2 เส้นทางข้อมูล: historical (REST → InfluxDB → กราฟ) กับ live (WebSocket → grid ติดไฟ):

graph TD
    subgraph Browser
      A[📊 MonitoringPage]
      B[🗂️ DeviceStatusGrid]
      C[📈 ReadingsChart Recharts]
      D[🔌 RealtimeClient]
    end
    A -->|useReadingsQuery| E[GET /api/v1/devices/:id/readings]
    E --> F[(📈 InfluxDB)]
    F --> C
    D <-.->|wss /api/v1/ws<br/>sensor_data| G[🐹 WebSocket Hub]
    G --> B

เหมือนห้อง Control Room เลย — กราฟด้านล่างดูประวัติย้อนหลัง ส่วน grid ด้านบน “ติดไฟ” ทันทีที่ sensor ส่งค่าใหม่เข้ามา real-time


Step 1: เพิ่ม Recharts

โพสต์นี้เพิ่ม dependency เดียว (เวอร์ชันตรงโปรเจกต์จริง):

npm install recharts@3

Recharts เป็น chart library ที่ค่อนข้างหนัก เราเลย lazy-load มันแยกเป็น chunk ต่างหาก โหลดเฉพาะตอนหน้า monitoring ต้องการ (เดี๋ยวเห็นใน Step 7)


Step 2: RealtimeClient — WebSocket ที่ดูแลตัวเองได้

ทำไม WebSocket ไม่ใช่ polling ทุก 30 วิ?

เพราะ sensor data มันคือ “real-time” จริงๆ — ถ้า poll ทุก 30 วิ เราจะพลาดค่าที่พุ่งขึ้นมาแว้บเดียว และยิ่ง device เยอะ การ poll ก็ยิ่งเปลือง backend มี WebSocket hub อยู่แล้ว (จากโพสต์ก่อนๆ ในซีรีส์) เราแค่ต้องเขียน client ฝั่งเบราว์เซอร์มาเชื่อม

RealtimeClient (src/api/realtime.ts) จัดการ connection เดียว มี auto-reconnect แบบ exponential backoff และ re-subscribe rooms ทุกครั้งที่ (re)connect:

export type ConnectionState = 'connecting' | 'open' | 'closed' | 'reconnecting'

/** ชื่อ room ของ live data ต่อ device (ตรงกับ backend ws.DeviceRoom) */
export function deviceRoom(deviceId: string): string {
  return `device:${deviceId}`
}

const INITIAL_BACKOFF_MS = 1_000
const MAX_BACKOFF_MS = 30_000

export class RealtimeClient {
  private socket: WebSocket | null = null
  private desiredRooms = new Set<string>()
  private backoff = INITIAL_BACKOFF_MS

  /** แทนที่ชุด room ที่ subscribe ทั้งหมดด้วย rooms ใหม่ (diff ให้) */
  setRooms(rooms: string[]): void {
    const next = new Set(rooms)
    for (const room of Array.from(this.desiredRooms)) {
      if (!next.has(room)) this.unsubscribe(room)
    }
    for (const room of rooms) {
      if (!this.desiredRooms.has(room)) this.subscribe(room)
    }
  }

  private handleMessage(data: unknown): void {
    if (typeof data !== 'string') return
    const msg = JSON.parse(data) as WSMessage
    if (msg.type === 'sensor_data') {
      const payload = msg.payload as LiveReading
      if (payload && typeof payload.device_id === 'string') {
        this.handlers.onSensorData?.(payload, msg.room ?? '')
      }
    }
  }

  // onclose → ถ้าไม่ได้ปิดเองก็ scheduleReconnect() ที่ backoff x2 (cap 30s)
}

protocol ตรงกับ backend internal/ws: client ส่ง {action:"subscribe"|"unsubscribe", room} และรับ {type, room, payload} กลับมา

Analogy: RealtimeClient เหมือน เครื่องรับวิทยุที่จูนคลื่นเองอัตโนมัติ — สัญญาณหลุดก็จูนกลับมาเอง (reconnect) แล้วเปิดคลื่นที่เราฟังอยู่ค้างไว้ (re-subscribe rooms) เราแค่บอกว่าอยากฟังคลื่นไหนบ้าง

ห่อด้วย React hook

useRealtime ดูแล lifecycle ของ client ตลอดอายุ component — สร้างตอน mount, ปิดตอน unmount, และ reconcile rooms เมื่อเปลี่ยน:

export function useRealtime({ rooms, onReading, enabled = true }: UseRealtimeOptions) {
  const [state, setState] = useState<ConnectionState>('closed')
  const clientRef = useRef<RealtimeClient | null>(null)
  const onReadingRef = useRef(onReading)

  // เก็บ callback ล่าสุดใน ref ไม่ต้องสร้าง client ใหม่ทุก render
  useEffect(() => { onReadingRef.current = onReading }, [onReading])

  useEffect(() => {
    if (!enabled) return
    const client = new RealtimeClient({
      onSensorData: (reading, room) => onReadingRef.current?.(reading, room),
      onStateChange: setState,
    })
    clientRef.current = client
    client.connect()
    return () => { client.close(); clientRef.current = null }
  }, [enabled])

  // reconcile rooms เมื่อ list เปลี่ยน (serialize เพื่อกัน churn)
  const roomsKey = rooms.join('|')
  useEffect(() => {
    clientRef.current?.setRooms(roomsKey ? roomsKey.split('|') : [])
  }, [roomsKey])

  return state
}

Step 3: Device Status Grid ที่ “ติดไฟ”

แต่ละ card ของ device จะมี indicator “live” เมื่อมี reading เข้ามาในช่วง 10 วินาทีล่าสุด — แล้วค่อยๆ จางหายเองถ้าไม่มีข้อมูลใหม่

จุดเด่นคือ — เราไม่อ่าน Date.now() ตอน render (impure!) แต่รับ now เป็น prop จาก ticking clock ของ parent:

// src/features/monitoring/DeviceStatusGrid.tsx
const LIVE_WINDOW_MS = 10_000

export function DeviceStatusGrid({ devices, live, now, selectedId, onSelect }: Props) {
  return (
    <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
      {devices.map((d) => {
        const snapshot = live[d.device_id]
        const isLive = snapshot && now - snapshot.receivedAt < LIVE_WINDOW_MS
        // ... card: ชื่อ + device_id + status badge + ถ้า isLive โชว์จุดเขียวกระพริบ
        //     ถ้ามี snapshot โชว์สรุป fields ล่าสุด เช่น "temperature 27.4 · humidity 61"
      })}
    </div>
  )
}

ส่วน now มาจาก hook useNow ที่ tick ทุกวินาที ทำให้สถานะ “live → ไม่ live” decay ได้เองแบบ reactive:

// src/lib/useNow.ts
export function useNow(intervalMs = 1_000): number {
  const [now, setNow] = useState(() => Date.now())
  useEffect(() => {
    const id = setInterval(() => setNow(Date.now()), intervalMs)
    return () => clearInterval(id)
  }, [intervalMs])
  return now
}

WHY ส่ง now เป็น prop? เพราะการอ่าน Date.now() ตรงๆ ใน render ทำให้ component ไม่ pure — React จะไม่รู้ว่าเมื่อไหร่ควร re-render ให้ badge “live” หาย การมี ticking clock ตัวเดียวที่ parent แล้วส่งลงมา ทำให้ทุก card decay พร้อมกันและ test ได้ง่าย


Step 4: ดึง Readings จาก InfluxDB + Time Range Control

backend มี endpoint /api/v1/devices/:id/readings ที่ query InfluxDB ให้ — รับ params range, window, aggregate สำหรับ downsampling

เรานิยาม preset ของช่วงเวลาไว้ โดยช่วงกว้างจะ downsample ฝั่ง server เพื่อให้กราฟเบา:

// src/features/monitoring/rangeOptions.ts
export const RANGE_OPTIONS: RangeOption[] = [
  { range: '1h', label: '1h' },                                  // ดิบ ไม่ downsample
  { range: '6h', window: '5m', aggregate: 'mean', label: '6h' },
  { range: '24h', window: '15m', aggregate: 'mean', label: '24h' },
  { range: '168h', window: '1h', aggregate: 'mean', label: '7d' },
]

RangeControl เป็น segmented button ให้กดเลือกช่วง:

export function RangeControl({ value, onChange }: Props) {
  return (
    <div role="group" aria-label="Time range">
      {RANGE_OPTIONS.map((opt) => (
        <button key={opt.range} onClick={() => onChange(opt)}
          aria-pressed={opt.range === value.range}>
          {opt.label}
        </button>
      ))}
    </div>
  )
}

แล้ว query ก็แปลง preset เป็น params ส่งเข้า fetch client:

const readingsQ = useReadingsQuery(selectedId, useMemo(() => ({
  range: range.range,
  window: range.window,
  aggregate: range.aggregate,
  limit: 1000,
  source: range.window ? ('downsampled' as const) : undefined,
}), [range]))

WHY downsample ช่วงกว้าง? เพราะข้อมูล 7 วันแบบดิบอาจมีหลายหมื่นจุด วาดกราฟแล้วเครื่องค้าง การให้ backend aggregate เป็นค่าเฉลี่ยทุก 1 ชม. ก่อน ทำให้กราฟลื่นและสื่อความหมายเท่าเดิม


Step 5: Pivot ข้อมูล + วาดกราฟด้วย Recharts

backend ส่ง readings มาเป็น flat rows {time, field, value} แต่ Recharts อยาก ได้ “หนึ่งแถวต่อ timestamp มีคอลัมน์ต่อ field” เราเลยต้อง pivot ก่อน:

// src/features/monitoring/chart.ts
export function pivotReadings(rows: ReadingRow[]): { points: ChartPoint[]; fields: string[] } {
  const byTime = new Map<string, ChartPoint>()
  const fields = new Set<string>()

  for (const row of rows) {
    if (typeof row.value !== 'number' || Number.isNaN(row.value)) continue // กราฟวาดได้แต่ตัวเลข
    fields.add(row.field)
    let point = byTime.get(row.time)
    if (!point) {
      const d = new Date(row.time)
      point = { t: d.getTime(), label: d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }
      byTime.set(row.time, point)
    }
    point[row.field] = row.value
  }

  const points = Array.from(byTime.values()).sort((a, b) => a.t - b.t)
  return { points, fields: Array.from(fields).sort() }
}

แล้ว ReadingsChart ก็วาด line ต่อหนึ่ง field โดยใช้สีจาก design token (เพื่อให้กราฟ match ธีม):

// src/features/monitoring/ReadingsChart.tsx
export function ReadingsChart({ rows }: { rows: ReadingRow[] }) {
  const { points, fields } = useMemo(() => pivotReadings(rows), [rows])
  if (points.length === 0) {
    return <StateBlock title="No readings in range" description="ลองขยายช่วงเวลา หรือส่ง telemetry เข้ามา" />
  }
  return (
    <div className="h-72 w-full">
      <ResponsiveContainer width="100%" height="100%">
        <LineChart data={points}>
          <CartesianGrid stroke="var(--color-border)" strokeDasharray="3 3" />
          <XAxis dataKey="label" stroke="var(--color-muted)" fontSize={11} />
          <YAxis stroke="var(--color-muted)" fontSize={11} width={44} />
          <Tooltip /* contentStyle ใช้ var(--color-surface-raised) */ />
          <Legend />
          {fields.map((field, i) => (
            <Line key={field} type="monotone" dataKey={field}
              stroke={SERIES_COLORS[i % SERIES_COLORS.length]}
              strokeWidth={2} dot={false} connectNulls isAnimationActive={false} />
          ))}
        </LineChart>
      </ResponsiveContainer>
    </div>
  )
}

เกร็ด: ใช้ var(--color-...) ใน stroke ของกราฟด้วย — กราฟเลยเปลี่ยนสีตามธีมอัตโนมัติ ไม่ต้อง hardcode hex สองที่


Step 6: Overview Cards + Alert Rules Overview

Overview cards ใช้ component เดียวกัน กับหน้า Overview (OverviewStats) — แต่ละการ์ดเป็น query นับจำนวนแบบ limit: 1 (เอา total จาก pagination พอ ไม่ต้องดึงทั้งหน้า):

const devicesQ = useDevicesQuery({ limit: 1 })
const onlineQ = useDevicesQuery({ status: 'online', limit: 1 })
const rulesQ = useAlertRulesQuery({ limit: 1 })
const enabledRulesQ = useAlertRulesQuery({ enabled: true, limit: 1 })
// → Total / Online / Not online / Alert rules (enabled / total)

ส่วน AlertRulesOverview ดึง rule มาสรุปแยกตาม severity และ type ให้เห็นภาพรวมการตั้งค่า monitoring:

// src/features/monitoring/AlertRulesOverview.tsx
const { data } = useAlertRulesQuery({ limit: 100 })
// countBy(rules, r => r.severity, ALERT_SEVERITIES) → badge "critical: 2  warning: 5  info: 1"
// countBy(rules, r => r.type, ALERT_RULE_TYPES)     → badge "Threshold: 4  Offline: 2  Anomaly: 2"

Step 7: MonitoringPage รวมทุกอย่าง

ถึงเวลาประกอบทุกชิ้นเข้าด้วยกัน! จุดน่าสนใจ:

1. lazy-load กราฟ — Recharts หนัก เลยแยก chunk โหลดเฉพาะตอน render:

const ReadingsChart = lazy(() =>
  import('../features/monitoring/ReadingsChart').then((m) => ({ default: m.ReadingsChart })),
)

2. subscribe ทุก device room — grid ทุก card เลยติดไฟได้พร้อมกัน:

const rooms = useMemo(() => devices.map((d) => deviceRoom(d.device_id)), [devices])

const connection = useRealtime({
  rooms,
  enabled: devices.length > 0,
  onReading: (reading) => {
    setLive((prev) => ({
      ...prev,
      [reading.device_id]: { reading, receivedAt: Date.now() },
    }))
  },
})

3. connection badge — โชว์สถานะ WebSocket (Live / Connecting / Reconnecting / Offline) บน header:

<PageHeader title="Monitoring"
  description="Live device status and sensor readings."
  actions={<ConnectionBadge state={connection} />} />

layout เป็น: overview cards ด้านบน → device grid (2 ส่วน) + alert rules overview (1 ส่วน) → กราฟ readings เต็มความกว้างด้านล่าง พร้อม RangeControl ที่มุมขวาของการ์ดกราฟ

<Card>
  <CardHeader title={`Readings — ${selectedDevice?.name}`}
    subtitle={selectedDevice?.device_id}
    action={<RangeControl value={range} onChange={setRange} />} />
  <Suspense fallback={<LoadingState />}>
    <ReadingsChart rows={readingsQ.data?.data ?? []} />
  </Suspense>
</Card>

กดที่ card device ไหนใน grid → กราฟด้านล่างก็เปลี่ยนไปแสดง readings ของ device นั้นทันที


หน้า Monitoring จริงเป็นแบบนี้

มาดูของจริงกัน! รูปนี้คือหน้า Monitoring ที่รันด้วย E2E test (Playwright) ยิงไปที่ backend จริง — มีการ์ดสรุปด้านบน (total / online / not online / alert rules), grid สถานะ device ทุกตัวที่จะ “ติดไฟ” เมื่อมี live data เข้ามาทาง WebSocket, แผงสรุป alert rules แยกตาม severity/type ทางขวา และที่เด็ดสุดคือ กราฟ readings ด้านล่างที่ดึงข้อมูลจริงจาก InfluxDB มาวาดเป็นเส้น sensor ตามช่วงเวลาที่เลือก (1h / 6h / 24h / 7d):

หน้า Monitoring ของ Admin — การ์ดสรุป, grid สถานะ device, แผง alert rules และกราฟ readings จาก InfluxDB

เห็นกราฟเส้นด้านล่างมั้ย? นั่นคือข้อมูล sensor จริงที่เรา ingest เข้า InfluxDB แล้ว query กลับมา pivot + วาดด้วย Recharts — ไม่ใช่ภาพ mock นะ ของจริงล้วนๆ และ grid ด้านบนก็รับ sensor_data ผ่าน WebSocket จริง


สรุปสิ่งที่สร้างวันนี้

น้องๆ ทำได้แล้ว! (ノ◕ヮ◕)ノ*:・゚✧ มาดูกันว่าเราสร้างอะไรไปบ้าง:

Component ไฟล์จริง กลไกหลัก
RealtimeClient api/realtime.ts WS auto-reconnect (backoff) + re-subscribe rooms
useRealtime features/monitoring/useRealtime.ts จัดการ 1 connection ตลอด lifetime
DeviceStatusGrid features/monitoring/DeviceStatusGrid.tsx card ติดไฟตาม live window 10s
ReadingsChart features/monitoring/ReadingsChart.tsx Recharts (lazy) + pivot rows
RangeControl features/monitoring/rangeOptions.ts preset → downsample params
OverviewStats features/monitoring/OverviewStats.tsx share กับหน้า Overview
AlertRulesOverview features/monitoring/AlertRulesOverview.tsx สรุป rule by severity/type

ข้อควรระวัง 3 ข้อ:

  1. WebSocket > polling — สถานะ live ใช้ WebSocket hub จริง ไม่ใช่ refetchInterval เพราะเราอยากเห็นค่าทันทีที่ sensor ส่งมา และ proxy ต้องตั้ง ws: true (เราทำไว้ตั้งแต่ step-15)
  2. อย่าอ่าน Date.now() ใน render — ใช้ ticking clock (useNow) ส่ง now เป็น prop เพื่อให้ “live” decay แบบ reactive และ pure
  3. Downsample ช่วงกว้าง + lazy-load Recharts — ให้ backend aggregate ก่อน และโหลด chart เป็น chunk แยก ไม่ถ่วง first load

Next Step

Workshop หน้าเราจะไปทำของจริงจังที่สุด — Authentication + RBAC ทั้งฝั่ง Go backend (JWT access+refresh, argon2id, refresh rotation + reuse detection) และฝั่ง React (login จริง, silent refresh, user management) เพื่อให้ Admin Console มียามเฝ้าอย่างเป็นทางการ

มาลุยกัน workshop หน้าด้วยนะน้องๆ! (•̀ᴗ•́)و