สร้าง Admin Monitoring Dashboard แบบ Real-time
สร้าง Admin Monitoring Dashboard แบบ Real-time
Branch:
step-17-admin-monitoringPhase: 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):

เห็นกราฟเส้นด้านล่างมั้ย? นั่นคือข้อมูล 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 ข้อ:
- WebSocket > polling — สถานะ live ใช้ WebSocket hub จริง ไม่ใช่
refetchIntervalเพราะเราอยากเห็นค่าทันทีที่ sensor ส่งมา และ proxy ต้องตั้งws: true(เราทำไว้ตั้งแต่ step-15) - อย่าอ่าน
Date.now()ใน render — ใช้ ticking clock (useNow) ส่งnowเป็น prop เพื่อให้ “live” decay แบบ reactive และ pure - 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 หน้าด้วยนะน้องๆ! (•̀ᴗ•́)و
- ก่อนหน้า: Workshop #19: Admin CRUD Operations
- ถัดไป: Workshop #21: Authentication & RBAC