Lynx.js Workshop ตอนที่ 3: Advanced Integration และ Best Practices

Lynx.js Workshop ตอนที่ 3: Advanced Integration และ Best Practices

ShowkhunIT

Lynx.js Workshop ตอนที่ 3: Advanced Integration และ Best Practices

อ้างอิงโค้ดจริงจาก: โปรเจกต์ frontend-mobile ใน kangana1024/showkhun-workshop (branch step-14-mobile-alerts)


มาถึงตอนสุดท้ายของซีรีส์แล้วครับ! 🎉

สองตอนที่แล้วเราอ่านข้อมูลอย่างเดียว — ดู dashboard, ดูค่าสด ตอนนี้ถึงเวลา “สั่งงานกลับ” และทำของที่ production app จริงต้องมี: สั่งเปิด/ปิด device พร้อม dialog ยืนยันกันกดพลาด, วาดกราฟจากข้อมูลย้อนหลัง, จัดการ alert rules, และทำ dark mode แบบมืออาชีพ

ที่สำคัญ — ทุกอย่างในตอนนี้มีเรื่อง security กับ edge case แฝงอยู่ ซึ่งเป็นสิ่งที่ tutorial ทั่วไปมักข้าม แต่พี่โชว์จะชี้ให้เห็นว่าโค้ดจริงเขาคิดเผื่ออะไรไว้บ้าง มาลุยกัน!


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

  • สั่งงาน device ด้วย command preset ตาม device type + dialog ยืนยัน สำหรับ action อันตราย
  • วาด line chart จาก time-series ด้วย SVG ที่ build แบบ กัน injection by construction
  • จัดการ alert rules จาก backend + read/unread state ฝั่ง client (เพราะ backend ไม่มี)
  • notification preferences ผ่าน React Context
  • theme dark/light ด้วย Context + CSS variables
  • Best practices: abort, in-app affordance, การไม่ฝัง secret, การรู้ขอบเขตของ backend contract

Part A: สั่งงาน Device (Command + Confirmation)

WHY: ทำไม command ต้องมี dialog ยืนยัน?

ลองนึกภาพปุ่ม “Reboot device” บนหน้าจอ ถ้ากดปุ๊บทำงานปั๊บ แล้วนิ้วเราพลาดไปโดน — device รีบูตกลางดึกซะงั้น 😱

action บางอย่าง “ย้อนกลับไม่ได้” หรือ “มีผลกระทบ” เราเลยต้องมีด่านยืนยัน เหมือนตอนจะลบไฟล์สำคัญ ระบบถามซ้ำ “แน่ใจนะ?” — กันพลาดไว้ก่อน

ในโค้ดเรา command ถูกนิยามเป็น preset แยกตาม device type ในไฟล์ commands.ts พร้อม flag destructive บอกว่าอันไหนอันตราย:

export interface CommandPreset {
  label: string
  /** action ต้องผ่าน validator ฝั่ง backend: ตัวอักษร/ตัวเลข + ขีดกลาง */
  command: CommandPayload
  destructive?: boolean
}

const BY_TYPE: Record<DeviceType, CommandPreset[]> = {
  relay: [
    { label: 'Turn on', command: { action: 'turn-on' } },
    { label: 'Turn off', command: { action: 'turn-off' }, destructive: true },
  ],
  motion: [
    { label: 'Arm', command: { action: 'arm' } },
    { label: 'Disarm', command: { action: 'disarm' }, destructive: true },
  ],
  temperature_humidity: [
    { label: 'Calibrate', command: { action: 'calibrate' } },
  ],
  gateway: [
    { label: 'Sync devices', command: { action: 'sync' } },
  ],
}

/** คืน command ที่ใช้ได้ของ device type นั้น + command ร่วม (reboot ฯลฯ) */
export function commandsForType(type: DeviceType): CommandPreset[] {
  return [...(BY_TYPE[type] ?? []), ...COMMON]
}

สังเกต comment บรรทัด action — มันต้องผ่าน validator ฝั่ง backend (ตัวอักษร/ตัวเลข + ขีดกลาง) นี่คือการ “คิดเผื่อ” ว่า client กับ backend ต้องตกลง contract กันให้ตรง

Flow ของการสั่งงาน

graph LR
    A[แตะปุ่ม command] --> B{destructive?}
    B -->|ใช่| C[🛑 เปิด Dialog ยืนยัน]
    B -->|ไม่| C
    C -->|กด Confirm| D[POST /api/v1/devices/:id/commands]
    D --> E[แสดง feedback]
    C -->|กด Cancel| F[ปิด ไม่ทำอะไร]

ใน DeviceDetail.tsx เราเก็บ command ที่กำลังจะยืนยันไว้ใน state pending แล้วเปิด Dialog:

const [pending, setPending] = useState<CommandPreset | null>(null)

// ปุ่มแต่ละอันแค่ set pending ยังไม่ยิงคำสั่งทันที
{commandsForType(device.type).map((preset) => (
  <Button
    key={preset.command.action}
    label={preset.label}
    variant={preset.destructive ? 'danger' : 'secondary'}
    disabled={sending}
    onTap={() => setPending(preset)}
  />
))}

// Dialog จะโผล่เมื่อมี pending — กด Confirm ถึงจะยิงจริง
<Dialog
  visible={pending !== null}
  title={pending ? `${pending.label}?` : ''}
  message={pending && device ? `Send "${pending.command.action}" to ${device.name}?` : undefined}
  destructive={pending?.destructive}
  busy={sending}
  onConfirm={confirmCommand}
  onCancel={() => setPending(null)}
/>

ตัว Dialog เองมีลูกเล่นเล็ก ๆ ที่ดี — ถ้าไม่ visible มัน return null ไม่กิน layout เลย และตอน busy ปุ่มจะ disable กัน double-submit:

export function Dialog(props: DialogProps) {
  if (!props.visible) return null   // ซ่อนแล้วหายไปจาก layout เลย
  return (
    <view className='Dialog__backdrop'>
      <view className='Dialog__card'>
        <text className='Dialog__title'>{props.title}</text>
        <view className='Dialog__actions'>
          <Button label='Cancel' variant='secondary' onTap={props.onCancel} disabled={props.busy} />
          <Button
            label={props.busy ? 'Working…' : props.confirmLabel ?? 'Confirm'}
            variant={props.destructive ? 'danger' : 'primary'}
            onTap={props.onConfirm}
            disabled={props.busy}
          />
        </view>
      </view>
    </view>
  )
}

Part B: วาด Chart จาก Time-series

WHY: ทำไมต้อง build SVG เองด้วยมือ?

แทนที่จะลากไลบรารี chart หนัก ๆ เข้ามา โปรเจกต์เราเลือก สร้าง SVG string เอง แล้ว render ผ่าน element <svg> ของ Lynx เหตุผลคือเบา ควบคุมได้เต็มที่ และ — ที่สำคัญ — ปลอดภัยจาก injection เพราะข้อมูลค่ามาจาก backend

แต่เดี๋ยวก่อน! ข้อมูลจาก server เอามายัดใส่ SVG string ตรง ๆ มันเสี่ยง markup injection นะ โค้ดเลยคิดเผื่อสองชั้น

ชั้นแรก — ดึงข้อมูลผ่าน hook useReadings ที่แปลง row ดิบเป็นจุด {t, v} โดย กรองเฉพาะตัวเลขที่ valid เท่านั้น:

/** ดึงเฉพาะค่าที่เป็นตัวเลขออกมา เรียงตามเวลา */
function toPoints(rows: ReadingRow[]): TimePoint[] {
  const points: TimePoint[] = []
  for (const row of rows) {
    const value = typeof row.value === 'number' ? row.value : Number(row.value)
    const time = Date.parse(row.time)
    if (Number.isFinite(value) && !Number.isNaN(time)) {   // ค่าเพี้ยน/NaN ถูกทิ้ง
      points.push({ t: time, v: value })
    }
  }
  points.sort((a, b) => a.t - b.t)
  return points
}

ชั้นสอง — ตอน build SVG ใน chart.ts มันinterpolate แต่ตัวเลขลงไปใน path เท่านั้น (ค่า server เป็น number หมดแล้ว) ส่วนสีก็ผ่าน safeColor() ที่ validate รูปแบบก่อน:

/**
 * validate CSS color token — ยอมแค่รูปแบบที่ปลอดภัย
 * เพื่อให้ค่าสี "หลุดออกนอก" attribute ของ SVG ไม่ได้
 */
function safeColor(color: string): string {
  return /^(#[0-9a-fA-F]{3,8}|rgba?\([\d.,\s]+\)|[a-zA-Z]+)$/.test(color)
    ? color
    : '#888888'
}

/**
 * buildLineChartSvg วาดจุดตัวเลขเป็น SVG line chart
 * เฉพาะ "ตัวเลข" เท่านั้นที่ถูกใส่ลง path/coordinate
 * ค่าจาก server จึง inject markup ไม่ได้
 */
export function buildLineChartSvg(points, dim, colors): string {
  // ... คำนวณ scale แล้วต่อ path "M x y L x y ..."
  const path = `<path d="${d.trim()}" fill="none" stroke="${safeColor(colors.line)}" stroke-width="2" />`
  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}">${area}${path}${baseline}</svg>`
}

คอมเมนต์ใน source บอกชัดเลยว่า “secure by construction” — สีมาจาก theme ของแอปเอง ไม่ใช่จาก server อยู่แล้ว แต่ก็ยัง validate ไว้ให้ปลอดภัยโดยโครงสร้าง นี่แหละ mindset ของ production code

ฝั่ง component LineChart ก็แค่ส่ง SVG string ที่ build แล้วเข้า prop content ของ <svg> พร้อมเลือกสีตาม theme:

export function LineChart(props: LineChartProps) {
  const { theme } = useTheme()
  const colors = theme === 'dark'
    ? { line: '#5b8dff', fill: 'rgba(91,141,255,0.18)', axis: '#2a3548' }
    : { line: '#2f6df6', fill: 'rgba(47,109,246,0.12)', axis: '#dfe3ec' }

  const svg = buildLineChartSvg(props.points, { width: 320, height: 160 }, colors)
  return <svg className='LineChart' content={svg} />
}

ส่วนหน้า DeviceCharts ให้ผู้ใช้เลือก metric, ช่วงเวลา (1H/6H/24H/7D) และ window สำหรับ downsample — แล้วยิงผ่าน getReadings:

const RANGES = [
  { value: '1h',   label: '1H', window: '1m' },
  { value: '6h',   label: '6H', window: '5m' },
  { value: '24h',  label: '24H', window: '15m' },
  { value: '168h', label: '7D', window: '1h' },
]

const params: ReadingsParams = {
  range: range.value,
  field,
  window: range.window,
  aggregate: 'mean',
  limit: 500,
}

เกร็ด: backend เป็นคน clamp range/window/limit ให้อยู่ในขอบเขตที่ปลอดภัยอยู่แล้ว (กัน query หนัก ๆ) ฝั่ง client เลยแค่ส่ง “เจตนา” ไปก็พอ — นี่คือการแบ่งความรับผิดชอบที่ดี


Part C: Alert Rules + รู้ขอบเขตของ Backend

WHY: บางอย่าง backend ไม่มีให้ ก็ต้องทำฝั่ง client

นี่เป็นบทเรียนสำคัญของ production จริง — ไม่ใช่ทุกอย่างที่ UI อยากได้ จะมี API รองรับ

หน้า Alerts ของเราโหลด alert rules (ตัว config การเฝ้าระวัง) มาจาก GET /api/v1/alert-rules แต่ backend ไม่มี endpoint ประวัติ alert ที่เคยยิง และ ไม่ได้ broadcast alert ทาง WebSocket (การส่ง alert จริงเป็น webhook ที่ operator ตั้งค่า) คอมเมนต์ใน useAlertRules.ts พูดเรื่องนี้ตรง ๆ:

/**
 * useAlertRules โหลด alert rule ที่ตั้งไว้ (ตัว config การ monitor)
 * backend เปิดให้ดู alert rule แต่ไม่มี endpoint ประวัติ alert ที่ยิงแล้ว
 * อันนี้จึงเป็น source of truth เดียวของหน้า alert
 * ส่วน filter ตาม severity ทำฝั่ง client เพราะ endpoint กรองตาม
 * type/enabled/device ได้ แต่ไม่กรองตาม severity
 */
export function useAlertRules(): AlertRulesState {
  const [allRules, setAllRules] = useState<AlertRule[]>([])
  const [severity, setSeverity] = useState<SeverityFilter>('all')

  // โหลด rule ทั้งหมด แล้วกรอง severity ในเครื่อง
  const rules = severity === 'all'
    ? allRules
    : allRules.filter((r) => r.severity === severity)

  return { /* loading, error, */ rules, severity, setSeverity, /* refresh */ }
}

เพราะ backend ไม่มีคอนเซ็ปต์ “อ่านแล้ว/ยังไม่อ่าน” บน alert rule เราเลยทำ read state ฝั่ง client แบบ in-memory ให้เป็น affordance ของ session นี้:

/**
 * useReadState เก็บว่า rule ไหนถูก mark ว่าอ่านแล้ว
 * backend ไม่มี read/unread บน alert rule นี่จึงเป็น affordance ฝั่ง client
 */
export function useReadState() {
  const [read, setRead] = useState<Record<string, boolean>>({})

  const isRead = useCallback((id: string) => Boolean(read[id]), [read])
  const markRead = useCallback((id: string) => {
    setRead((prev) => (prev[id] ? prev : { ...prev, [id]: true }))
  }, [])
  const markAllRead = useCallback((ids: string[]) => {
    setRead((prev) => {
      const next = { ...prev }
      for (const id of ids) next[id] = true
      return next
    })
  }, [])

  return { isRead, markRead, /* toggleRead, */ markAllRead }
}

บทเรียน: การเขียน frontend ที่ดีต้อง เข้าใจ contract ของ backend ตามจริง แล้วออกแบบ UX ให้ซื่อสัตย์กับมัน อย่าแสดง UI หลอก ๆ ว่าทำได้ ทั้งที่ไม่มี API รองรับ — comment ในโค้ดของเราระบุข้อจำกัดพวกนี้ไว้ชัดเจน เป็นมรดกให้คนมาทำต่อ


Part D: Notification Preferences ผ่าน Context

notification preferences ก็เป็น local setting (เพราะ backend ส่ง alert ผ่าน webhook ที่ operator ตั้ง ไม่มี per-user routing) เราเก็บผ่าน React Context เพื่อแชร์ทั้งแอป:

export interface NotificationPrefs {
  enabled: boolean
  bySeverity: Record<AlertSeverity, boolean>
}

// ค่าเริ่มต้น — เปิดเฉพาะ warning/critical, ปิด info
export const DEFAULT_PREFS: NotificationPrefs = {
  enabled: true,
  bySeverity: { info: false, warning: true, critical: true },
}

export const NotificationPrefsContext = createContext<NotificationPrefsContextValue>({
  prefs: DEFAULT_PREFS,
  setEnabled: () => {},
  setSeverity: () => {},
})

แล้วใน App.tsx ก็ห่อ provider ครอบทั้งแอป ทำให้ทุกหน้าเข้าถึง prefs เดียวกันได้ — pattern เดียวกับ React บนเว็บเป๊ะ


Part E: Theme Dark/Light ด้วย Context + CSS Variables

ปิดท้ายด้วยของที่ผู้ใช้รักที่สุด — dark mode 🌙 วิธีของ Lynx เนียนมาก: เก็บ theme ปัจจุบันใน Context แล้วสะท้อนลง attribute data-theme บน <view> ที่ครอบ ทำให้ palette CSS variable ใน theme.css มีผลทั้ง subtree:

export function ThemeProvider(props: ThemeProviderProps) {
  const [theme, setTheme] = useState<ThemeName>(props.initial ?? 'light')

  const toggle = useCallback(() => {
    setTheme((prev) => (prev === 'light' ? 'dark' : 'light'))
  }, [])

  const value = useMemo(() => ({ theme, toggle, setTheme }), [theme, toggle])

  return (
    <ThemeContext.Provider value={value}>
      {/* data-theme คุม CSS variable ของทั้ง subtree */}
      <view data-theme={theme} className='ThemeRoot'>
        {props.children}
      </view>
    </ThemeContext.Provider>
  )
}

หน้าไหนอยากรู้ธีมหรือสลับธีม ก็แค่เรียก hook useTheme():

export function SettingsScreen() {
  const { theme, toggle } = useTheme()
  return (
    // ...
    <Button
      label={theme === 'dark' ? 'Switch to light' : 'Switch to dark'}
      variant='secondary'
      onTap={toggle}
    />
  )
}

อุปมา: data-theme เหมือนสวิตช์ไฟหลักของบ้าน — กดทีเดียว ไฟทุกห้อง (component ทุกตัว) เปลี่ยนโทนตามทันที เพราะทุกห้องดึงสีจาก CSS variable ชุดเดียวกัน


สรุป Best Practices จากโค้ดจริง

ตลอดทั้งซีรีส์ เราเจอ pattern ของ production app ที่ดีหลายอย่าง มารวบให้ตรงนี้:

Best Practice เห็นได้จากตรงไหน
ไม่ฝัง host/secret ลง bundle lynx.config.ts inject SHOWKHUN_API_BASE_URL ตอน build
Typed contract กับ backend api/types.ts field ตรงกับ Go struct เป๊ะ
Timeout + abort ทุก request request() ใน client.ts
Auto-reconnect + re-subscribe RealtimeClient exponential backoff
ยืนยันก่อน action อันตราย destructive preset + Dialog
SVG ปลอดภัยจาก injection safeColor() + interpolate แต่ตัวเลข
ซื่อสัตย์กับ backend contract read state / prefs เป็น affordance ฝั่ง client พร้อม comment อธิบาย
Theme ผ่าน Context + CSS var ThemeProvider + data-theme

สรุปทั้งซีรีส์

ใน 3 ตอนนี้ น้อง ๆ ได้สร้าง mobile client ของระบบ IoT จริงด้วย Lynx.js:

  1. ตอนที่ 1 — รู้จัก ReactLynx, setup โปรเจกต์, รันบนมือถือด้วย QR, และ element พื้นฐาน
  2. ตอนที่ 2 — typed REST client + WebSocket auto-reconnect, ประกอบเป็น Dashboard real-time
  3. ตอนที่ 3 — สั่งงาน device, วาด chart, จัดการ alert, theme และ best practices

ที่สำคัญที่สุด — ทุกบรรทัดในซีรีส์นี้ดึงมาจากโค้ดที่ run ได้จริงใน frontend-mobile ไม่ใช่ตัวอย่างลอย ๆ ดังนั้นน้องเอาไปต่อยอดเป็นโปรเจกต์ของตัวเองได้เลย 🚀

   ╔══════════════════════════════╗
   ║                              ║
   ║   \(^o^)/                   ║
   ║                              ║
   ║   "จบซีรีส์! ไปสร้างของจริงกัน" ║
   ║                              ║
   ╚══════════════════════════════╝

Happy IoT Building! 🌟