LynxJS Alerts: หน้า Alert Rules บนมือถือ

LynxJS Alerts: หน้า Alert Rules บนมือถือ

ShowkhunWorkshop

LynxJS Alerts: หน้า Alert Rules บนมือถือ

Branch: step-14-mobile-alerts Phase: Mobile (5/5) — Alerts Repo: kangana1024/showkhun-workshop


มาถึงบทสุดท้ายของ phase mobile แล้วครับ! เราจะทำหน้า Alerts ให้ user เห็นว่าระบบกำลัง “เฝ้า” อะไรอยู่บ้าง — temperature เกินกี่องศาถึงเตือน, device เงียบนานแค่ไหนถึงผิดปกติ แต่บทนี้พี่อยากเล่า อย่างตรงไปตรงมา เรื่องนึง: เราจะสร้าง UI บน “สิ่งที่ backend มีจริง” ไม่ใช่บนสิ่งที่เราอยากให้มี (◡‿◡)

ข้อเท็จจริงสำคัญที่ต้องพูดก่อน: backend ของเรา (จาก workshop alerting engine) expose แค่ alert RULES — คือ “กฎการเฝ้า” — แต่ ไม่มี endpoint ประวัติ alert ที่ trigger ไปแล้ว และ ไม่ broadcast alert ผ่าน WebSocket (การส่ง alert จริงไปทาง operator-configured webhook ของ engine) ดังนั้นหน้า Alerts นี้คือ “รายการกฎ” ไม่ใช่ “feed การแจ้งเตือนแบบ realtime” ส่วน read/unread กับ notification preferences เป็น affordance ฝั่ง client ล้วนๆ README ในโปรเจกต์ก็บันทึกข้อนี้ไว้ตรงๆ บทความนี้อ้างอิงโค้ดจริงใน branch step-14-mobile-alerts


ก่อนเริ่ม: น้องๆ จะได้อะไรจากบทนี้?

  • เข้าใจความต่างระหว่าง alert RULES (มีจริงใน backend) กับ triggered-alert history (ไม่มี endpoint)
  • โหลด rule ผ่าน GET /api/v1/alert-rules แล้วกรอง severity แบบ client-side
  • แปลง AlertCondition (3 type: threshold/offline/anomaly) เป็นข้อความที่คนอ่านง่าย
  • ทำ read/unread แบบ in-memory — และรู้ว่าทำไมมันถึงเป็น client-side
  • ทำ notification preferences ในแอป (ผ่าน context) — และรู้ว่าทำไมมันคุม “พฤติกรรมในแอป” เท่านั้น
  • ปิดท้าย: สร้าง UI ที่ “ซื่อสัตย์กับ backend contract”

ทำไมต้องซื่อสัตย์กับ Contract? (WHY ก่อน HOW)

ถ้าเราทำหน้า “Alert History” ปลอมๆ ที่ดูเหมือนมี feed แจ้งเตือน แต่จริงๆ backend ไม่มีข้อมูลส่งมา — user จะหลงคิดว่าระบบทำงาน ทั้งที่มันไม่มีอะไรอยู่หลังบ้าน นั่นอันตรายกว่าการบอกความจริงเยอะ

อุปมา: เหมือนติดมาตรวัดน้ำมันปลอมในรถ เข็มชี้เต็มถังตลอด ดูสบายใจดี… จนรถดับกลางทาง เราเลือกที่จะแสดง “สิ่งที่วัดได้จริง” แทน

มาดูภาพรวมว่าอะไรอยู่ตรงไหน:

graph TD
    A[📱 AlertsScreen] -->|GET /alert-rules| B[Alert RULES มีจริง]
    A -.->|ไม่มี endpoint| C[Triggered history ✗]
    A -.->|ไม่ broadcast| D[Realtime alert feed ✗]
    E[🔔 การส่ง alert จริง] -->|engine ยิงเอง| F[operator webhook เช่น Slack]
    A -->|client-side| G[read/unread + notification prefs]

Step 1: useAlertRules — โหลดกฎจาก Backend

hook useAlertRules (screens/useAlertRules.ts) โหลด rule ทั้งหมด comment ในโค้ดจริงเขียนเหตุผลไว้ชัดเจนว่าทำไม severity filter ถึงทำฝั่ง client:

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

  const abortRef = useRef<AbortController | null>(null)

  const load = useCallback(async (isRefresh: boolean) => {
    abortRef.current?.abort()
    const controller = new AbortController()
    abortRef.current = controller
    if (isRefresh) setRefreshing(true)
    else setLoading(true)
    setError(null)
    try {
      const { rules } = await api.listAlertRules({ limit: 100 }, controller.signal)
      setAllRules(rules)
    } catch (err) {
      if (controller.signal.aborted) return
      setError(err instanceof ApiError ? err.message : 'failed to load alert rules')
    } finally {
      if (!controller.signal.aborted) {
        setLoading(false)
        setRefreshing(false)
      }
    }
  }, [])

  // กรอง severity ฝั่ง client
  const rules = severity === 'all'
    ? allRules
    : allRules.filter((r) => r.severity === severity)

  return { loading, refreshing, error, rules, severity, setSeverity, refresh }
}

WHY กรอง severity ฝั่ง client? เพราะ list endpoint ของ backend กรองได้แค่ enabled/type/device_id ไม่มี filter severity และ rule มักมีไม่เยอะ (หลักสิบ) การกรองในเครื่องจึงเร็วและง่ายกว่าการขอ backend เพิ่ม


Step 2: อธิบาย Condition ให้คนอ่านรู้เรื่อง

rule มี 3 type จาก backend (threshold, offline, anomaly) แต่ละ type ใช้ field ใน AlertCondition ต่างกัน เราแปลงเป็นประโยคที่คนอ่านเข้าใจใน screens/alertText.ts:

const OPERATOR_TEXT: Record<AlertOperator, string> = {
  gt: '>', gte: '≥', lt: '<', lte: '≤', eq: '=', neq: '≠',
}

/**
 * describeCondition แปลง condition ของ rule เป็นข้อความตาม type
 * ค่าทั้งหมดถูก format โดยแอป (ตัวเลข, operator ที่รู้จัก) ไม่ใช่ markup ดิบ
 */
export function describeCondition(rule: AlertRule): string {
  const c = rule.condition
  switch (rule.type) {
    case 'threshold': {
      if (!c.metric || !c.operator) return 'Threshold rule'
      const op = OPERATOR_TEXT[c.operator] ?? c.operator
      return `${humanize(c.metric)} ${op} ${c.value ?? 0}`
    }
    case 'offline': {
      const seconds = c.offline_after_seconds ?? 0
      const minutes = Math.round(seconds / 60)
      return `No data for ${minutes >= 1 ? `${minutes}m` : `${seconds}s`}`
    }
    case 'anomaly': {
      const z = c.zscore_threshold ?? 0
      return `${c.metric ? humanize(c.metric) : 'Metric'} z-score > ${z}`
    }
    default:
      return 'Alert rule'
  }
}

/** scopeText บอกว่า rule ใช้กับ device ไหน */
export function scopeText(rule: AlertRule): string {
  return rule.device_id ? `Device ${rule.device_id}` : 'All devices'
}

แบบนี้ rule ที่หน้าตาเป็น JSON ดิบๆ จะกลายเป็นประโยคอย่าง Temperature > 35 หรือ No data for 5m ที่ user เข้าใจทันที


Step 3: read/unread — Client-side ล้วนๆ

นี่คืออีกจุดที่เราต้องตรงไปตรงมา backend ไม่มี concept read/unread บน alert rule เลย เราจึงทำเป็น state ในแอป (in-memory ต่อ session) — comment ในโค้ดจริงเขียนเตือนไว้:

/**
 * useReadState ตามว่า user mark rule ไหนว่าอ่านแล้ว backend ไม่มี concept
 * read/unread บน alert rule นี่จึงเป็น affordance ฝั่ง client แบบ in-memory
 * สำหรับ session ปัจจุบันเท่านั้น
 */
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 toggleRead = useCallback((id: string) => {
    setRead((prev) => ({ ...prev, [id]: !prev[id] }))
  }, [])

  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 }
}

ทำไมยังทำ ถ้ามันหายตอนปิดแอป? เพราะมันยังช่วย UX ระหว่าง session — กวาดตาดูว่า rule ไหน “ยังไม่ได้เปิดดู” ได้ ส่วนการ persist จริงต้องรอ backend มี endpoint รองรับก่อน เราไม่แกล้งทำเป็นว่ามันถูกบันทึก


Step 4: AlertsScreen — Segment “Rules” / “Preferences”

หน้าหลักแบ่งเป็น 2 segment: รายการ rule กับ preferences พอแตะ rule ก็เข้า detail ด้วย local state (เหมือน pattern เดิมทุกบท):

type AlertsTab = 'rules' | 'preferences'

const SEVERITY_OPTIONS: ChipOption<SeverityFilter>[] = [
  { value: 'all', label: 'All' },
  { value: 'critical', label: 'Critical' },
  { value: 'warning', label: 'Warning' },
  { value: 'info', label: 'Info' },
]

export function AlertsScreen() {
  const state = useAlertRules()
  const readState = useReadState()
  const [selected, setSelected] = useState<AlertRule | null>(null)
  const [tab, setTab] = useState<AlertsTab>('rules')

  if (selected) {
    return (
      <AlertDetail
        rule={selected}
        read={readState.isRead(selected.id)}
        onBack={() => setSelected(null)}
        onMarkRead={() => readState.markRead(selected.id)}
        onToggleRead={() => readState.toggleRead(selected.id)}
      />
    )
  }

  return (
    <view className='Screen'>
      <ScreenHeader title='Alerts' subtitle='Monitoring rules and notifications'
        action={
          tab === 'rules'
            ? { label: 'Mark all read',
                onTap: () => readState.markAllRead(state.rules.map((r) => r.id)) }
            : undefined
        } />
      {/* segment toggle: Rules / Preferences ... */}
      {tab === 'preferences'
        ? <scroll-view className='Alerts__scroll' scroll-orientation='vertical'>
            <NotificationPreferences />
          </scroll-view>
        : <RulesList state={state} readState={readState} onSelect={setSelected} />}
    </view>
  )
}

แต่ละแถวคือ AlertRow ที่มี dot สีแสดงว่ายังไม่อ่าน, badge severity, และประโยค condition:

export function AlertRow(props: AlertRowProps) {
  const { rule } = props
  return (
    <Card bindtap={props.onTap}>
      <view className='AlertRow__head'>
        <view className='AlertRow__titleBox'>
          {!props.read ? <view className='AlertRow__unreadDot' /> : null}
          <text className={props.read ? 'AlertRow__name AlertRow__name--read' : 'AlertRow__name'}>
            {rule.name}
          </text>
        </view>
        <Badge label={rule.severity} tone={severityTone(rule.severity)} />
      </view>
      <text className='AlertRow__condition'>{describeCondition(rule)}</text>
      <text className='AlertRow__scope'>
        {scopeText(rule)} · {rule.enabled ? 'Enabled' : 'Disabled'}
      </text>
    </Card>
  )
}

AlertDetail โชว์ config เต็มของ rule (severity/condition/scope/status/cooldown) แล้วพอเปิดดูก็ mark read ให้อัตโนมัติ:

export function AlertDetail(props: AlertDetailProps) {
  const { rule } = props

  // เปิด detail = mark ว่าอ่านแล้ว
  useEffect(() => {
    props.onMarkRead()
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [])
  // ... render severity / condition / scope / status / cooldown ...
}

Step 5: Notification Preferences — คุม “พฤติกรรมในแอป”

อันสุดท้าย preferences เก็บผ่าน context (screens/notificationPrefs.ts) อีกครั้งที่ comment ในโค้ดจริงเล่าตรงๆ ว่ามันคุมแค่ในแอป:

/**
 * Notification preferences เป็น local app settings backend ส่ง alert ผ่าน
 * webhook ที่ operator ตั้งไว้ ไม่มี API routing notification ต่อ user
 * preferences เหล่านี้จึงคุม "พฤติกรรมในแอป" เท่านั้น
 */
export interface NotificationPrefs {
  enabled: boolean
  bySeverity: Record<AlertSeverity, boolean>
}

export const DEFAULT_PREFS: NotificationPrefs = {
  enabled: true,
  bySeverity: { info: false, warning: true, critical: true },
}

provider ถูก wire ที่ App.tsx ครอบทั้งแอป (เพื่อให้ทุก screen เข้าถึง prefs ได้):

export function App() {
  const [tab, setTab] = useState<TabKey>('dashboard')
  const prefs = useNotificationPrefsState()

  return (
    <ThemeProvider initial='light'>
      <NotificationPrefsContext.Provider value={prefs}>
        <view className='AppShell'>
          <view className='AppShell__content'>
            <ActiveScreen tab={tab} />
          </view>
          <TabBar active={tab} onChange={setTab} />
        </view>
      </NotificationPrefsContext.Provider>
    </ThemeProvider>
  )
}

ตัว panel ใช้ Toggle (switch ง่ายๆ ที่เราเพิ่งเพิ่มใน ui/) เปิด/ปิดต่อ severity ได้:

export function NotificationPreferences() {
  const { prefs, setEnabled, setSeverity } = useNotificationPrefs()

  return (
    <view className='Screen__body'>
      <Card>
        <view className='Prefs__row'>
          <view className='Prefs__rowText'>
            <text className='Prefs__rowLabel'>Notifications</text>
            <text className='Prefs__rowHint'>Show alert notifications in the app</text>
          </view>
          <Toggle value={prefs.enabled} onChange={setEnabled} />
        </view>
      </Card>

      <Card>
        {SEVERITIES.map((s) => (
          <view key={s.value} className='Prefs__row'>
            <view className='Prefs__rowText'>
              <text className='Prefs__rowLabel'>{s.label}</text>
              <text className='Prefs__rowHint'>{s.hint}</text>
            </view>
            <Toggle value={prefs.bySeverity[s.value]} disabled={!prefs.enabled}
              onChange={(v) => setSeverity(s.value, v)} />
          </view>
        ))}
      </Card>
    </view>
  )
}
เมื่อเลือกแสดง UI เฉพาะสิ่งที่ backend มีจริง:

   ╔══════════════════════════════════╗
   ║                                  ║
   ║        ┌( •_•)┘  Honest UI       ║
   ║                                  ║
   ║   "ไม่แกล้งมี feature ที่ไม่มี"   ║
   ║                                  ║
   ╚══════════════════════════════════╝

สรุป: บทนี้เราทำอะไรไปบ้าง

ส่วนประกอบ มาจากไหน
Alert rules backend จริงGET /api/v1/alert-rules
Condition text แปลง 3 type (threshold/offline/anomaly) เป็นประโยคอ่านง่าย
Severity filter client-side (list endpoint ไม่กรอง severity)
read/unread client-side, in-memory (backend ไม่มี concept นี้)
Notification prefs client-side ในแอป (backend ส่งจริงผ่าน operator webhook)
Triggered history / realtime alert feed ไม่มีใน backend — เราจึงไม่ทำ UI ปลอม

จุดที่พี่ภูมิใจที่สุดของบทนี้ไม่ใช่ code สวย แต่คือ ความซื่อสัตย์กับ contract — เราสร้างบนสิ่งที่มีจริง และบอกตรงๆ ว่าอะไรเป็นแค่ความช่วยเหลือฝั่ง client ถ้าวันหน้า backend เพิ่ม endpoint ประวัติ alert หรือ per-user routing เราค่อยมาต่อยอดได้สบาย


Mobile App ครบทั้ง 5 บทแล้ว!

น้องๆ มาไกลมากนะครับ จาก project ว่างๆ จนได้ mobile client ที่ใช้งานได้จริง:

Branch Feature
step-10-lynx-setup ReactLynx + Rspeedy setup, tab nav, typed API client, theme
step-11-mobile-dashboard real-time dashboard ผ่าน WebSocket hub + auto-reconnect
step-12-mobile-control device list ค้นหา/กรอง + ส่ง command + confirm dialog
step-13-mobile-charts line chart จาก time-series (SVG builder เอง)
step-14-mobile-alerts หน้า alert rules + read/unread + notification prefs

ทั้งหมดวางบน type ที่ตรงกับ Go backend, inject config ตอน build, ไม่ฝัง secret และซื่อสัตย์กับ backend contract


Next Step

Phase mobile จบแล้วครับ! ขั้นต่อไปเราจะไปสร้าง Admin Panel ด้วย Vite — หน้าจอฝั่ง web สำหรับ operator จัดการ device/rule แบบเต็มรูปแบบ มาลุยกันต่อ! (^_^)v


Navigation: