ตั้ง Vite Admin Panel ให้ IoT Platform กัน!

ตั้ง Vite Admin Panel ให้ IoT Platform กัน!

ShowkhunWorkshop

Branch: step-15-vite-setup Phase: Development (15/19) — Admin Console Repo: kangana1024/showkhun-workshop


เฮ้! น้องๆ มาแล้ว (ノ◕ヮ◕)ノ*:・゚✧

ถึงตอนนี้ backend ของเราพร้อมหมดแล้วครับ — device registry, sensor ingestion, MQTT, WebSocket hub, alerting engine ครบ แต่… ยังไม่มีหน้าให้คนใช้งานเลย จะให้ admin นั่งยิง curl ทุกวันก็คงไม่ไหว 555

นั่นแหละคือเหตุผลที่เราต้องสร้าง Admin Console!

คิดง่ายๆ ว่า Admin Console มันเหมือน ห้องควบคุมในโรงงาน — มีจอแสดงสถานะเครื่องทุกตัว, มีปุ่มให้กด, มีแจ้งเตือนเมื่อมีอะไรผิดปกติ แทนที่วิศวกรจะต้องวิ่งไปดูเครื่องทีละตัว

โพสต์นี้เราจะวาง foundation ของ console ทั้งหมด แล้วตอนถัดๆ ไปค่อยต่อยอดเป็น CRUD, monitoring, auth มาลุยกัน!


สิ่งที่น้องๆ จะได้กลับบ้าน

หลังจากอ่านจบ น้องๆ จะรู้จัก:

  • ว่าทำไมถึงเลือก Vite + SPA แทน Next.js สำหรับ Admin Console (WHY ก่อนเลย!)
  • ตั้งค่า Vite 8 + React 19 + TypeScript แบบ project references
  • ใช้ Tailwind CSS v4 ผ่าน plugin (ไม่มี tailwind.config.js แล้วนะ!) พร้อม design tokens ใน CSS
  • เขียน fetch client เองที่มี timeout, error envelope และ “seam” สำหรับแปะ token
  • จัดการ auth state ด้วย Zustand แบบเก็บ token ใน memory เท่านั้น
  • ตั้ง React Router v7 พร้อม ProtectedRoute + role-based access
  • ดึงข้อมูลจริงจาก /api/v1/devices และ /api/v1/alert-rules ด้วย TanStack Query

ก่อนจะเริ่ม: ทำไมถึงเลือก Stack นี้?

เรามักถูกถามว่า “พี่ใช้ Vite เพราะอะไร ไม่ใช้ Next.js?”

คำตอบง่ายมาก — Admin Console ไม่ต้องการ SSR

Admin Console  →  ต้องการ SPA (Single Page Application)
                  - Login แล้วค้างหน้าไว้นาน
                  - Data update แบบ real-time (WebSocket)
                  - ไม่ต้องการ SEO (มันอยู่หลัง login)
                  - Users มีจำนวนน้อย แต่ใช้บ่อยมาก

Public Website →  ต้องการ SSR (Server Side Rendering)
                  - มี traffic สูง
                  - SEO สำคัญ
                  - First load ต้องเร็ว

Vite ใช้ native ES modules ทำให้ dev server เริ่มต้นเร็วมาก แถมพอ build จริงก็ tree-shake + code-split ให้อัตโนมัติ เหมือนเปิดไฟเทียบกับรอต้มน้ำ 555

เกร็ดสำคัญ: โค้ดจริงใน branch step-15-vite-setup คือ Vite 8, React 19, react-router 7, Tailwind v4, TanStack Query v5 และ Zustand 5 — โพสต์นี้อ้างอิงตามเวอร์ชันจริงในโปรเจกต์ทั้งหมด ไม่ได้มโนเอา


ภาพรวม Architecture ก่อน Code

ก่อนลงมือ เรามาดูภาพรวมว่า pieces ต่างๆ มันต่อกันยังไง:

graph LR
    A[🖥️ Browser SPA] -->|/api/v1/*| B[⚡ Vite Dev Server]
    B -->|proxy + ws:true| C[🐹 Go Backend :3000]
    C --> D[(🍃 MongoDB)]
    C --> E[(📈 InfluxDB)]
    A -.->|wss /api/v1/ws| C

เห็นมั้ยครับว่ามันต่อกันยังไง? Vite dev server ทำหน้าที่ proxy request ที่ขึ้นต้นด้วย /api ไปให้ backend ที่ port 3000 — ทั้ง REST และ WebSocket (เพราะตั้ง ws: true) เราก็ไม่ต้องกังวลเรื่อง CORS เลยในช่วง development แถม host ของ backend ก็ไม่หลุดเข้าไปใน bundle ด้วย


Step 1: สร้างโปรเจกต์ + ติดตั้ง dependencies

# อยู่ที่ root ของ monorepo
npm create vite@latest frontend-admin -- --template react-ts

cd frontend-admin

# dependencies หลัก (เวอร์ชันตรงกับโปรเจกต์จริง)
npm install react-router@7 zustand @tanstack/react-query lucide-react

# Tailwind v4 มาเป็น Vite plugin แล้ว — ไม่ต้อง postcss/autoprefixer
npm install -D tailwindcss @tailwindcss/vite @types/node

สังเกตว่า ไม่มี axios, react-hook-form, zod, sonner ในขั้นนี้นะครับ — โพสต์นี้เรายังไม่ต้องใช้ เราจะเขียน fetch client เองให้เข้าใจกลไก แล้วค่อยเพิ่ม form library + validation ตอน CRUD ในโพสต์ถัดไป


Step 2: Vite Config — ตั้ง proxy ให้ backend

หัวใจของ dev experience อยู่ที่ vite.config.ts ตรงนี้แหละ มาดูทีละบรรทัด:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'

// Vite dev server จะ proxy ทุก request ที่ขึ้นต้น /api ไปหา Go backend
// ทำให้ browser คุยกับ same-origin (เลี่ยง CORS) และไม่เอา host backend
// ไปฝังใน bundle ตอน dev
export default defineConfig({
  plugins: [react(), tailwindcss()],
  server: {
    port: 5174,
    proxy: {
      // REST + WebSocket ใช้ prefix /api/v1 ร่วมกัน
      // ws:true ทำให้ proxy upgrade connection สำหรับ /api/v1/ws ได้ด้วย
      '/api': {
        target: process.env.VITE_PROXY_TARGET ?? 'http://localhost:3000',
        changeOrigin: true,
        ws: true,
      },
    },
  },
})

เห็น tailwindcss() ใน plugins มั้ยครับ? นั่นคือวิธีของ Tailwind v4 — ไม่ต้องมี tailwind.config.js กับ postcss.config.js ให้รก เหมือนสมัยก่อนแล้ว ทุกอย่างมาอยู่ใน CSS แทน (เดี๋ยวเห็นใน Step ถัดไป)

WHY ws: true? เพราะ monitoring ในโพสต์หน้าๆ จะ subscribe live sensor data ผ่าน WebSocket ถ้าไม่ upgrade proxy ตั้งแต่ตอน dev เดี๋ยวจะงงว่าทำไม real-time ไม่ทำงานในเครื่องตัวเอง


Step 3: TypeScript แบบ Project References

โปรเจกต์จริงไม่ได้ใช้ tsconfig.json ก้อนเดียว แต่แยกเป็น project references — ไฟล์ root ชี้ไปหา 2 config ย่อย (app กับ node) เพราะ code ที่รันในเบราว์เซอร์กับ code ที่รันใน Node (เช่น vite.config.ts) มันคนละ environment กัน

tsconfig.json (ตัว root เป็นแค่ตัวชี้):

{
  "files": [],
  "references": [
    { "path": "./tsconfig.app.json" },
    { "path": "./tsconfig.node.json" }
  ]
}

tsconfig.app.json (config ของ code ฝั่ง browser) — เปิด strict เต็มสูบ:

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2023", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "moduleResolution": "bundler",
    "verbatimModuleSyntax": true,
    "noEmit": true,
    "jsx": "react-jsx",

    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "noUncheckedSideEffectImports": true
  },
  "include": ["src"]
}

ทำไมเปิด strict ขนาดนี้? เพราะ Admin Console มันคุยกับ backend ที่เป็น Go ซึ่ง type-safe สุดๆ ถ้าฝั่ง frontend หละหลวม เดี๋ยวจะมี bug ประเภท “field หาย แต่ไม่มีใครรู้จน production” เราเลยให้ TypeScript เป็น “ยามด่านแรก” ตั้งแต่ตอนเขียน

verbatimModuleSyntax: true คือตัวที่บังคับให้ import type ต้องเขียน import type { ... } ชัดเจน — อ่านโค้ดแล้วรู้เลยว่าอันไหนคือ type อันไหนคือ value


Step 4: Tailwind v4 + Design Tokens ใน CSS

นี่คือจุดที่เปลี่ยนไปเยอะที่สุดจาก Tailwind v3 — theme ทั้งหมดอยู่ใน CSS ผ่าน @theme ไม่ใช่ใน JS config อีกต่อไป

ทำไมต้องนิยาม token เอง?

แทนที่จะใช้ bg-blue-600 กระจายทั่ว app เรานิยามชื่อเชิงความหมาย เช่น surface, brand, critical ไว้ที่เดียว ถ้าวันนึงผู้บริหารบอกว่า “เปลี่ยนสีหลัก” เราแก้ใน @theme ที่เดียวจบ ทั้ง console ก็เปลี่ยนสีตาม

src/index.css:

@import 'tailwindcss';

/* Dark mode คุมด้วย class `dark` บน <html> (ตั้งใน index.html)
   custom variant ทำให้ utility dark: ตอบสนองต่อ class นั้น */
@custom-variant dark (&:where(.dark, .dark *));

/* Design tokens — Tailwind v4 อ่านตัวแปร @theme พวกนี้ไปสร้าง utility class
   ให้อัตโนมัติ (เช่น bg-surface, text-muted) แก้พาเลตที่เดียว เปลี่ยนทั้ง console */
@theme {
  --color-canvas: #0b0f17;
  --color-surface: #131a26;
  --color-surface-raised: #1b2433;
  --color-border: #26303f;
  --color-muted: #8b97a8;
  --color-foreground: #e6ebf2;

  --color-brand: #2dd4bf;
  --color-brand-strong: #14b8a6;

  --color-info: #38bdf8;
  --color-success: #34d399;
  --color-warning: #fbbf24;
  --color-critical: #f87171;
}

body {
  margin: 0;
  background-color: var(--color-canvas);
  color: var(--color-foreground);
}

console เป็นธีมมืดเป็นค่า default (เพราะ <html class="dark"> ใน index.html) ดูสบายตาเวลานั่งจ้องนานๆ ในห้อง control room


Step 5: โครงสร้าง Directory — บ้านของ Code

ก่อนเขียน code จริง เรามาออกแบบ “บ้าน” กันก่อน โครงสร้างจริงในโปรเจกต์เป็นแบบ feature-based ผสมกับ shared building blocks:

frontend-admin/
├── index.html
├── vite.config.ts
├── tsconfig.json
└── src/
    ├── main.tsx
    ├── App.tsx
    ├── index.css
    ├── api/                  ← ชั้นคุยกับ backend
    │   ├── config.ts         ← base URL, prefix, websocket URL
    │   ├── client.ts         ← fetch wrapper + ApiError + token seam
    │   ├── endpoints.ts      ← typed client (listDevices, ...)
    │   └── types.ts          ← domain types ตรงกับ Go structs
    ├── auth/
    │   └── session.ts        ← bootstrap session (เปลี่ยนใหญ่ใน step-18)
    ├── stores/
    │   └── auth.ts           ← Zustand auth store
    ├── providers/
    │   └── QueryProvider.tsx ← TanStack Query client
    ├── routes/
    │   ├── router.tsx        ← createBrowserRouter
    │   ├── ProtectedRoute.tsx
    │   └── nav.ts            ← เมนู sidebar
    ├── components/
    │   ├── layout/           ← AppLayout, PageHeader
    │   └── ui/               ← Badge, Button, Card, Spinner, StateBlock
    ├── features/
    │   ├── devices/queries.ts
    │   └── alertRules/queries.ts
    ├── lib/                  ← cn(), format helpers
    └── pages/                ← Login, Overview, Devices, AlertRules, ...

ของที่ “ใช้ซ้ำทุกหน้า” อยู่ใน components/ui ส่วนของที่ “ผูกกับ feature” (เช่น query ของ devices) อยู่ใน features/<feature>/ แยกชัดเจน หาของเจอเร็ว


Step 6: Domain Types — สัญญากับ Backend

ก่อนเขียน logic เราต้องมี “พิมพ์เขียว” ของข้อมูลก่อน จุดสำคัญคือ — field name ต้องตรงกับ json:"..." tag ของ Go struct เป๊ะ ไม่งั้น response จะ deserialize ผิด

Analogy: Types คือ “สัญญา” ระหว่าง frontend กับ backend ถ้าเขียนตรงกัน ทั้งสองฝั่งทำงานคู่ขนานได้โดยไม่ต้องรอกัน

src/api/types.ts (ตัวอย่างส่วน Device — สังเกต snake_case ตาม Go):

export type DeviceStatus = 'online' | 'offline' | 'error' | 'maintenance'

export type DeviceType =
  | 'temperature_humidity'
  | 'motion'
  | 'relay'
  | 'gateway'

export interface Device {
  id: string
  device_id: string
  name: string
  description?: string
  type: DeviceType
  status: DeviceStatus
  group_id?: string
  tags?: string[]
  enabled: boolean
  last_seen_at?: string
  created_at: string
  updated_at: string
}

และ envelope ที่ backend ห่อ response มาให้ (จาก backend/internal/httpx/response.go) — แยกระหว่าง object เดี่ยวกับ list:

// object เดี่ยว: { data: T }
export interface DataEnvelope<T> {
  data: T
}

// list มี pagination มาด้วย: { data: T[], pagination: {...} }
export interface ListEnvelope<T> {
  data: T[]
  pagination: Pagination
}

export interface Pagination {
  total: number
  limit: number
  offset: number
}

สังเกต pagination เป็นแบบ offset/limit (ไม่ใช่ page/totalPages) — เพราะ backend ใช้แบบนี้ ฝั่ง frontend เลยต้องตรงกัน


Step 7: Fetch Client — ยามที่คอย Guard ทุก Request

ทำไมเขียน fetch client เอง ไม่ใช้ axios?

เพราะของที่เราต้องการมันเฉพาะเจาะจง: timeout แบบ abortable, แปลง error envelope ของ backend เป็น ApiError ที่ branch ได้ (notFound / unauthorized), และ seam สำหรับแปะ token + จัดการ 401 โดยที่ตัว client เองไม่ต้อง import auth store ตรงๆ (เลี่ยง circular dependency)

มันเหมือน เจ้าหน้าที่รักษาความปลอดภัยที่ประตู — ทุก request ผ่านมือเขาก่อน เขาแปะ badge (token) ให้ และถ้า badge หมดอายุ (401) เขาก็มีโอกาสไป “ต่ออายุ” ให้ครั้งนึงก่อนจะปล่อยให้ fail

ก่อนอื่นนิยาม ApiError ที่พก HTTP status มาด้วย:

// ApiError พก status + message จาก error envelope ของ backend
// ให้ caller branch ได้ว่า 404 หรือ 401 และโชว์ validation รายฟิลด์ได้
export class ApiError extends Error {
  readonly status: number
  readonly fields?: Record<string, string>

  constructor(status: number, message: string, fields?: Record<string, string>) {
    super(message)
    this.name = 'ApiError'
    this.status = status
    this.fields = fields
  }

  get notFound(): boolean { return this.status === 404 }
  get unauthorized(): boolean { return this.status === 401 }
  get forbidden(): boolean { return this.status === 403 }
}

ต่อมาคือ “seam” — client ไม่รู้จัก auth store แต่ให้ store มา register callback ไว้แทน:

type TokenProvider = () => string | null
type Unauthorized = (() => Promise<string | null>) | null

let tokenProvider: TokenProvider = () => null
let onUnauthorized: Unauthorized = null

/** auth store จะเรียกอันนี้เพื่อบอก client ว่า "เอา token ปัจจุบันได้จากนี่นะ" */
export function setTokenProvider(provider: TokenProvider): void {
  tokenProvider = provider
}

/** handler ที่ client เรียกครั้งเดียวตอนเจอ 401 — อาจ refresh token เงียบๆ
 * แล้ว return token ใหม่เพื่อ retry หรือ return null เพื่อยอมแพ้ */
export function setUnauthorizedHandler(handler: Unauthorized): void {
  onUnauthorized = handler
}

แล้วฟังก์ชัน request ก็ห่อ fetch พร้อม timeout + retry-on-401 ครั้งเดียว:

export async function request<T>(
  path: string,
  opts: RequestOptions = {},
): Promise<T> {
  try {
    return await rawRequest<T>(path, opts, tokenProvider())
  } catch (err) {
    // เจอ 401 + ยังไม่ใช่ request anonymous + มี handler → ลอง refresh 1 ครั้ง
    if (
      err instanceof ApiError &&
      err.unauthorized &&
      !opts.anonymous &&
      onUnauthorized
    ) {
      const refreshed = await onUnauthorized()
      if (refreshed) {
        return rawRequest<T>(path, opts, refreshed) // retry ด้วย token ใหม่
      }
    }
    throw err
  }
}

ส่วน rawRequest ข้างในใช้ AbortController ตั้ง timeout, แปะ Authorization: Bearer <token> ถ้ามี, และอ่าน error envelope ออกมาเป็น ApiError — ในโพสต์นี้ onUnauthorized ยังแค่ clear session (ยังไม่มี refresh จริง) เดี๋ยว step-18 ค่อยเสียบ silent refresh เข้าไป โดยที่ทุก call site ไม่ต้องแก้อะไรเลย นี่แหละพลังของ seam


Step 8: Zustand Auth Store — เก็บ token ใน memory เท่านั้น

ทำไม Zustand แทน Redux?

Redux ดีครับ แต่ verbose มากสำหรับงานขนาดนี้ Zustand เหมือน ลิ้นชักเก็บของ — เปิดหยิบปิดเสร็จ ไม่ต้องมี action creator / reducer / middleware stack วุ่นวาย

แต่จุดที่สำคัญกว่าเรื่อง verbose คือ เรื่องความปลอดภัย — เราเก็บ access token ไว้ใน memory เท่านั้น ไม่ลง localStorage

src/stores/auth.ts:

import { create } from 'zustand'
import { setTokenProvider } from '../api/client'

export type Role = 'admin' | 'operator' | 'viewer'

export interface AuthUser {
  id: string
  email: string
  username: string
  role: Role
  display_name?: string
}

interface AuthState {
  // access token อยู่ใน memory เท่านั้น (ไม่ลง localStorage) เพื่อให้ payload XSS
  // ดูด credential อายุยาวจาก storage ไม่ได้
  accessToken: string | null
  user: AuthUser | null
  // true จนกว่า session bootstrap ครั้งแรกจะเสร็จ — ให้ router หยุดตัดสินใจไว้ก่อน
  // จะได้ไม่กระพริบหน้า login ให้คนที่ login อยู่แล้ว
  initializing: boolean

  setSession: (token: string, user: AuthUser) => void
  clearSession: () => void
  finishInit: () => void
}

export const useAuthStore = create<AuthState>((set) => ({
  accessToken: null,
  user: null,
  initializing: true,
  setSession: (token, user) => set({ accessToken: token, user }),
  clearSession: () => set({ accessToken: null, user: null }),
  finishInit: () => set({ initializing: false }),
}))

WHY ไม่เก็บ token ลง localStorage? เพราะถ้าโดน XSS แฮกเกอร์อ่าน localStorage ได้หมด ก็ขโมย token อายุยาวไปได้เลย การเก็บ access token (อายุสั้น) ใน memory แล้ว refresh ทีหลัง ปลอดภัยกว่ามาก — refresh แล้วหาย ก็แค่ login ใหม่ ไม่ได้ทำให้ใช้งานยากขึ้นเท่าไหร่

ที่ฉลาดคือ store เป็นคนไป “เสียบสาย” token เข้า client เอง — และนิยาม RBAC helper ไว้ที่นี่ด้วย:

// ลำดับ role สำหรับเช็คแบบ "อย่างน้อย" (operator ครอบ viewer, admin ครอบ operator)
const ROLE_RANK: Record<Role, number> = { viewer: 1, operator: 2, admin: 3 }

export function roleAtLeast(role: Role | null, required: Role): boolean {
  if (!role) return false
  return ROLE_RANK[role] >= ROLE_RANK[required]
}

// เสียบ token ใน memory เข้า API client — client เรียก getToken() ทุก request
// เลยเห็นค่าล่าสุดเสมอ โดยไม่ต้อง register ใหม่
setTokenProvider(() => useAuthStore.getState().accessToken)

หมายเหตุ: ในโพสต์นี้ backend ยังไม่มี auth จริง (มาในโพสต์ step-18) auth/session.ts เลยทำเป็น “demo session” ให้เลือก role ไว้ explore console ก่อน แต่ทั้ง app พึ่งพาแค่ auth store ไม่ได้พึ่ง session module ตรงๆ ดังนั้นพอ step-18 มาเปลี่ยน session.ts เป็น login จริง ส่วนที่เหลือแทบไม่ต้องแตะเลย


Step 9: React Router v7 + Protected Routes

ทำไมต้องมี Protected Routes?

ลองนึกภาพถ้าไม่มี — ใครก็พิมพ์ /users เข้ามาได้เลย เหมือนสร้างตึกออฟฟิศแต่ไม่มีประตู ใครก็เดินเข้าได้หมด 555

ProtectedRoute ทำหน้าที่เช็ค 2 อย่าง: (1) login แล้วหรือยัง ถ้ายัง → ส่งไป /login, (2) ถ้ากำหนด minRole ไว้ → role ถึงเกณฑ์ไหม ไม่ถึงก็ส่งไป /forbidden

src/routes/ProtectedRoute.tsx:

import type { ReactNode } from 'react'
import { Navigate, useLocation } from 'react-router'
import {
  useAuthStore,
  selectIsAuthenticated,
  roleAtLeast,
  type Role,
} from '../stores/auth'
import { LoadingState } from '../components/ui/StateBlock'

export function ProtectedRoute({
  children,
  minRole,
}: {
  children: ReactNode
  minRole?: Role
}) {
  const initializing = useAuthStore((s) => s.initializing)
  const authenticated = useAuthStore(selectIsAuthenticated)
  const role = useAuthStore((s) => s.user?.role ?? null)
  const location = useLocation()

  // ยัง bootstrap ไม่เสร็จ → โชว์ spinner ไม่ให้หน้า login กระพริบ
  if (initializing) {
    return <LoadingState label="Restoring session…" />
  }
  // ยังไม่ login → ไป login พร้อมจำว่าจะไปไหน เพื่อเด้งกลับมาทีหลัง
  if (!authenticated) {
    return <Navigate to="/login" replace state={{ from: location.pathname }} />
  }
  // role ไม่ถึง → ห้ามเข้า
  if (minRole && !roleAtLeast(role, minRole)) {
    return <Navigate to="/forbidden" replace />
  }
  return <>{children}</>
}

สังเกต import จาก react-router (ไม่ใช่ react-router-dom) — ตั้งแต่ v7 เป็นต้นมา package รวมเป็นตัวเดียว ใช้ react-router ตรงๆ ได้เลย

router หลักใช้ createBrowserRouter วาง layout protected ครอบหน้าทั้งหมด แล้วหน้า /users ห่อ ProtectedRoute อีกชั้นด้วย minRole="admin":

import { createBrowserRouter, Navigate } from 'react-router'

export const router = createBrowserRouter([
  { path: '/login', element: <LoginPage /> },
  { path: '/forbidden', element: <ForbiddenPage /> },
  {
    path: '/',
    element: (
      <ProtectedRoute>
        <AppLayout />
      </ProtectedRoute>
    ),
    children: [
      { index: true, element: <OverviewPage /> },
      { path: 'devices', element: <DevicesPage /> },
      { path: 'alert-rules', element: <AlertRulesPage /> },
      { path: 'monitoring', element: <MonitoringPage /> },
      {
        path: 'users',
        element: (
          <ProtectedRoute minRole="admin">
            <UsersPage />
          </ProtectedRoute>
        ),
      },
      { path: '*', element: <NotFoundPage /> },
    ],
  },
  { path: '*', element: <Navigate to="/" replace /> },
])

กฎเหล็ก: route guard ฝั่ง frontend คือ “ความสะดวก” ไม่ใช่ “ความปลอดภัย” จริง — backend ยังต้องเช็ค RBAC ของมันเองทุก request (เดี๋ยวเห็นในโพสต์ step-18) ฝั่ง client guard ไว้แค่ไม่ให้คนเห็นหน้าที่ตัวเองเข้าไม่ได้


Step 10: ดึงข้อมูลจริงด้วย TanStack Query

แทนที่จะ manage loading / error state เองในทุกหน้า เราใช้ TanStack Query จัดการ server state ให้ — cache, refetch, retry อัตโนมัติ

ตั้งค่า client ที่ QueryProvider ด้วย default ที่เหมาะกับ dashboard: stale time สั้น, ไม่ refetch รัวตอน focus, และ ไม่ retry error 4xx (auth/validation):

function makeClient(): QueryClient {
  return new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 15_000,
        refetchOnWindowFocus: false,
        retry: (failureCount, error) => {
          if (error instanceof ApiError) {
            // อย่า retry client error (auth, not-found, validation)
            if (error.status >= 400 && error.status < 500) return false
          }
          return failureCount < 2
        },
      },
      mutations: { retry: false },
    },
  })
}

แต่ละ feature มี query hook ของตัวเอง พร้อม query keys ที่ structure ชัดเจน (สำคัญมากตอน invalidate cache ทีหลัง):

// src/features/devices/queries.ts
export const deviceKeys = {
  all: ['devices'] as const,
  list: (params: ListDevicesParams) => ['devices', 'list', params] as const,
  detail: (id: string) => ['devices', 'detail', id] as const,
}

export function useDevicesQuery(params: ListDevicesParams = {}) {
  return useQuery({
    queryKey: deviceKeys.list(params),
    queryFn: ({ signal }) => api.listDevices(params, signal),
  })
}

เกร็ดเด็ด: สังเกต queryFn: ({ signal }) => ... — TanStack Query ส่ง AbortSignal มาให้ เราส่งต่อเข้า fetch client เลย พอ component unmount หรือ query ถูกแทนที่ request เก่าก็ถูก abort อัตโนมัติ ไม่มี race condition

หน้า Overview ก็แค่ยิง query แล้วเอา pagination.total มาโชว์เป็นการ์ดสรุป — เคล็ดลับคือ query ที่นับจำนวนใช้ limit: 1 พอ เพราะ total มาจาก pagination metadata อยู่แล้ว ไม่ต้องดึงทั้งหน้า:

const devicesQ = useDevicesQuery({ limit: 100 })
const onlineQ = useDevicesQuery({ status: 'online', limit: 1 })
const rulesQ = useAlertRulesQuery({ limit: 1 })
const enabledRulesQ = useAlertRulesQuery({ enabled: true, limit: 1 })
// แล้วเอา devicesQ.data?.pagination.total ฯลฯ มาวางในการ์ด

Step 11: ประกอบทุกอย่างใน App Entry

src/App.tsx ร้อยทุกอย่างเข้าด้วยกัน — จุดน่าสนใจคือเรา bootstrapSession() ก่อน render ครั้งแรก (synchronous + idempotent) เพื่อให้ router เห็น auth state จริงตั้งแต่แรก ไม่กระพริบหน้า login:

import { RouterProvider } from 'react-router'
import { QueryProvider } from './providers/QueryProvider'
import { router } from './routes/router'
import { bootstrapSession } from './auth/session'

// restore session ที่เก็บไว้ก่อน render แรก
bootstrapSession()

export function App() {
  return (
    <QueryProvider>
      <RouterProvider router={router} />
    </QueryProvider>
  )
}

Step 12: Environment Variables

.env.example จริงในโปรเจกต์ — สั้นกว่าที่คิด เพราะ dev ใช้ proxy เป็นหลัก:

# Base URL ของ backend (REST + WebSocket ใช้ host เดียวกัน)
# ปล่อยว่างไว้เพื่อใช้ Vite dev proxy (แนะนำตอน dev local):
# request จะวิ่งไป same-origin ใต้ /api/v1 แล้ว Vite forward ไป backend
# ตั้งค่าเฉพาะตอน build ที่จะคุยกับ remote backend ตรงๆ ห้าม commit URL จริง
VITE_API_BASE_URL=

# target ที่ dev-server proxy จะ forward ไป (ใช้แค่ตอนรัน vite ใน dev)
VITE_PROXY_TARGET=http://localhost:3000

Note: ใน Vite ตัวแปรต้องขึ้นต้น VITE_ เท่านั้นถึงจะ expose ให้ client-side ได้ — ตัวที่ไม่ขึ้น VITE_ จะไม่ถูก bundle เข้าไป เป็น security feature ที่ดีมาก เราเลยไม่ฝัง host จริงไว้ใน bundle เด็ดขาด


สรุป — เราทำอะไรไปบ้างวันนี้?

(✿◡‿◡) สิ่งที่น้องๆ ได้กลับบ้านวันนี้
Component Technology (เวอร์ชันจริง) หน้าที่
Build Tool Vite 8 Fast dev server, HMR, proxy /api → backend
UI Framework React 19 + TypeScript 5.9 Component-based UI, strict types
Styling Tailwind CSS v4 (@theme ใน CSS) Design tokens, dark mode
Routing React Router v7 createBrowserRouter + ProtectedRoute
Server state TanStack Query v5 cache + AbortSignal + no-retry-4xx
Client state Zustand 5 auth token ใน memory + roleAtLeast()
HTTP fetch client เอง timeout, ApiError, token seam

Key Concepts ที่สำคัญ

  • Token seam — client ไม่ import auth store ตรงๆ แต่ให้ store register callback แทน เลี่ยง circular dependency และเตรียมที่ไว้เสียบ silent refresh ใน step-18
  • Token ใน memory — access token ไม่ลง localStorage เพื่อกัน XSS ดูด credential
  • Field name ตรง Go structsnake_case ตาม json:"..." tag, pagination เป็น offset/limit
  • Protected Routes = ความสะดวก ส่วนความปลอดภัยจริงอยู่ที่ backend RBAC

หน้าตาจริงเป็นยังไง?

พอวาง foundation เสร็จ รัน npm run dev (port 5174) แล้วเปิดเบราว์เซอร์มาดู หน้าแรกที่เจอคือหน้า Login นี่แหละ — เรียบ สะอาด ธีมมืด ในโพสต์นี้ยังเป็น “demo sign-in” ให้เลือก role ก่อน (backend ได้ auth จริงในโพสต์ step-18) รูปนี้พี่ถ่ายจาก E2E test ที่รันจริงด้วย Playwright นะ ไม่ได้ตัดต่อ:

หน้า Login ของ ShowKhun Admin — ฟอร์ม email/password บนธีมมืด พร้อม validation

พอ login ผ่านปุ๊บ ก็เด้งเข้าหน้า Overview ที่มี Sidebar (AppLayout) ทางซ้ายแสดงเมนูครบ Overview / Devices / Alert Rules / Monitoring / Users พร้อมการ์ดสรุปที่ดึง pagination.total จาก /api/v1/devices และ /api/v1/alert-rules จริงๆ — นี่คือโครงที่เราจะต่อยอดไปเรื่อยๆ ใน workshop ถัดๆ ไป:

หน้า Overview ของ Admin Panel — Sidebar ทางซ้าย พร้อมการ์ดสรุปจำนวน device และ alert rules


Next Step

Workshop นี้เราวาง foundation ทุกอย่างพร้อมแล้ว! ขั้นต่อไปคือมาสร้าง CRUD operations จริงๆ สำหรับจัดการ devices และ alert rules — มี DataTable แบบ reusable (sort/filter/select/paginate), modal form ที่ validate ด้วย React Hook Form + Zod, toast และ confirm dialog

มาลุยกันต่อใน Workshop ถัดไปได้เลย!