项目文件夹

文件
wehub-resource-sync 4ce4204b6c
CI / Lint (push) Failing after 2s
CI / Build (push) Has been skipped
SDK CI / PHP SDK (push) Failing after 1s
Split PHP SDK / PHP SDK tests (push) Failing after 1s
CI / Test (push) Failing after 1s
CI / Dashboard (push) Failing after 0s
SDK CI / JavaScript SDK (push) Failing after 0s
SDK CI / Python SDK (push) Failing after 2s
SDK CI / Java SDK (push) Failing after 1s
Split PHP SDK / Mirror sdk/php -> rmyndharis/openwa-php (push) Has been skipped
CI / Test (PostgreSQL migrations) (push) Failing after 7m47s
CI / Docker Build (push) Has been skipped
chore: import upstream snapshot with attribution
2026-07-13 12:24:08 +08:00

702 行
44 KiB
Markdown

# 17 - Dashboard Design
## 17.1 Overview
The dashboard is a web-based management interface for OpenWA that lets users manage sessions, webhooks, and monitor activity without using the API directly.
### Tech Stack
```mermaid
flowchart LR
subgraph Frontend
REACT[React 19]
VITE[Vite 8]
TS[TypeScript]
CSS[Hand-written per-page CSS]
LUCIDE[lucide-react Icons]
end
subgraph State
TANSTACK[TanStack Query]
CTX[React Context]
end
subgraph Backend
API[OpenWA API]
WS[socket.io WebSocket]
end
Frontend --> State
State --> Backend
```
The styling foundation is **plain CSS** — there is no Tailwind, no shadcn/ui, and no CSS-in-JS.
Each page and shared component ships its own stylesheet colocated beside the source
(`Sessions.tsx` + `Sessions.css`, `Layout.tsx` + `Layout.css`, ...), imported directly by the
component. Icons come from `lucide-react`; charts from `recharts`; i18n from `react-i18next`.
Client state is **TanStack Query** for server data (see `src/hooks/queries.ts`) plus a couple of
small React Context providers (`useRole`, `useTheme`) — there is no Zustand store.
### Design Principles
1. **Minimalist** - Clean, uncluttered interface
2. **Responsive** - Works on desktop and mobile
3. **Real-time** - Live updates via WebSocket
4. **Accessible** - WCAG 2.1 AA compliant
5. **Dark mode** - Support for light/dark themes
## 17.2 Information Architecture
The dashboard is a **flat, single-level route table** (see `src/App.tsx`). Each sidebar entry maps
to one top-level page — there are no nested detail/`:id` routes; a session or chat is opened in-place
(modals / split panes) rather than via its own URL.
```mermaid
flowchart TB
subgraph "Always visible"
HOME[Dashboard /]
SESSIONS[Sessions /sessions]
CHATS[Chats /chats]
WEBHOOKS[Webhooks /webhooks]
TEMPLATES[Templates /templates]
TESTER[Message Tester /message-tester]
LOGS[Logs /logs]
end
subgraph "Admin-only"
APIKEYS[API Keys /api-keys]
INFRA[Infrastructure /infrastructure]
PLUGINS[Plugins /plugins]
end
HOME --> SESSIONS
HOME --> CHATS
SESSIONS --> CHATS
```
### Navigation Structure
The route table lives in `src/App.tsx`; the sidebar items in `src/components/Layout.tsx`. Routes
guarded by `role === 'admin'` are only mounted (and only shown in the sidebar) for an admin key —
a non-admin hitting the path falls through to the `*` redirect.
```
/ → Dashboard (overview + charts)
/sessions → Sessions (create / start / stop / QR / delete)
/chats → Chats (chat list + message thread, live via WebSocket)
/webhooks → Webhooks (per-session webhook endpoints)
/templates → Message Templates
/message-tester → Message Tester (ad-hoc send-* + check-number)
/logs → Activity / Audit Logs
/api-keys → API Keys Management [admin only]
/infrastructure → Infrastructure status & config [admin only]
/plugins → Plugins (install / enable / configure) [admin only]
* → redirect to /
```
> There is **no Settings page** and no `/sessions/:id`, `/sessions/:id/chat`, or `/webhooks/:id`
> route. Theme (light/dark/system) and color-palette selection live in a popover menu in the sidebar
> footer (`Layout.tsx`), persisted client-side by the `useTheme` hook.
## 17.3 Wireframes
### Dashboard Home
```
┌─────────────────────────────────────────────────────────────────────┐
│ 🔵 OpenWA 🔍 Search 👤 Admin ☀️ │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┬─────────────┬─────────────┬─────────────┐ │
│ │ 📱 5 │ ✅ 4 │ 📨 1,234 │ 🔗 3 │ │
│ │ Sessions │ Connected │ Messages │ Webhooks │ │
│ │ │ │ (Today) │ Active │ │
│ └─────────────┴─────────────┴─────────────┴─────────────┘ │
│ │
│ ┌──────────────────────────────────┐ ┌────────────────────────┐ │
│ │ Sessions Overview │ │ Recent Activity │ │
│ │ ┌──────┬──────┬──────┬──────┐ │ │ │ │
│ │ │ 🟢 │ 🟢 │ 🟢 │ 🟡 │ │ │ 10:30 Message sent │ │
│ │ │ CS-1 │ CS-2 │ Sales│ Supp │ │ │ 10:28 Webhook called │ │
│ │ │ 123 │ 456 │ 789 │ - │ │ │ 10:25 Session online │ │
│ │ └──────┴──────┴──────┴──────┘ │ │ 10:20 Message recv │ │
│ │ │ │ 10:15 QR scanned │ │
│ │ [+ New Session] │ │ │ │
│ └──────────────────────────────────┘ │ [View All →] │ │
│ └────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ Message Volume (Last 7 Days) │ │
│ │ ┌────────────────────────────────────────────────────────┐ │ │
│ │ │ ▓▓ │ │ │
│ │ │ ▓▓ ▓▓ ▓▓ │ │ │
│ │ │ ▓▓ ▓▓ ▓▓ ▓▓ ▓▓ │ │ │
│ │ │ ▓▓▓▓ ▓▓ ▓▓ ▓▓ ▓▓ ▓▓ ▓▓ ▓▓ ▓▓ │ │ │
│ │ │ Mon Tue Wed Thu Fri Sat Sun │ │ │
│ │ └────────────────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
### Session List
```
┌─────────────────────────────────────────────────────────────────────┐
│ ← Sessions [+ New Session] │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 🔍 Search sessions... Filter: [All ▾] [Status ▾] │
│ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ ● Customer Support 1 🟢 Connected │ │
│ │ 📱 +62 812-3456-789 │ │
│ │ 📨 1,234 messages | Last active: 2 min ago │ │
│ │ ─────────────────────────────────────────────────────────── │ │
│ │ [📷 QR] [💬 Test Chat] [⚙️ Settings] [🗑️ Delete] │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ ● Sales Bot 🟢 Connected │ │
│ │ 📱 +62 821-9876-543 │ │
│ │ 📨 567 messages | Last active: 5 min ago │ │
│ │ ─────────────────────────────────────────────────────────── │ │
│ │ [📷 QR] [💬 Test Chat] [⚙️ Settings] [🗑️ Delete] │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ ○ Support Backup 🟡 Disconnected │ │
│ │ 📱 Not connected │ │
│ │ 📨 0 messages | Never active │ │
│ │ ─────────────────────────────────────────────────────────── │ │
│ │ [📷 Scan QR] [⚙️ Settings] [🗑️ Delete] │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ ──────────────────────────────────────────────────────────────── │
│ Showing 3 of 3 sessions [◀] 1 [▶] │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
### Session Detail
```
┌─────────────────────────────────────────────────────────────────────┐
│ ← Sessions / Customer Support 1 🟢 Connected │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ ┌─────────┐ │ │
│ │ │ 👤 │ Customer Support 1 │ │
│ │ │ Avatar │ +62 812-3456-789 │ │
│ │ │ │ Status: 🟢 Connected │ │
│ │ └─────────┘ Platform: Android │ │
│ │ │ │
│ │ [Restart Session] [Logout] [Delete] │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────┬─────────────────────┐ │
│ │ 📊 Statistics │ ⚙️ Configuration │ │
│ ├─────────────────────┼─────────────────────┤ │
│ │ │ │ │
│ │ Messages Sent │ Auto Reconnect │ │
│ │ ████████░░ 1,234 │ [✓] Enabled │ │
│ │ │ │ │
│ │ Messages Received │ Webhook URL │ │
│ │ ██████████ 2,567 │ https://... │ │
│ │ │ │ │
│ │ Webhook Calls │ Proxy │ │
│ │ ███████░░░ 890 │ None │ │
│ │ │ │ │
│ │ Uptime │ Created │ │
│ │ 99.9% (30 days) │ 2026-01-15 │ │
│ │ │ │ │
│ └─────────────────────┴─────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Recent Messages [View All →] │ │
│ │ ───────────────────────────────────────────────────────── │ │
│ │ → +62 821... | Hello, how can I help? | 10:30 ✓✓ │ │
│ │ ← +62 821... | I need product info | 10:28 │ │
│ │ → +62 821... | Sure! Here's our catalog | 10:25 ✓✓ │ │
│ │ ← +62 813... | Thanks for your help! | 10:20 │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ [💬 Open Test Chat] │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
### QR Code Scanner
```
┌─────────────────────────────────────────────────────────────────────┐
│ Scan QR Code │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────┐ │
│ │ │ │
│ │ ████████████████████ │ │
│ │ ██ ████ │ │
│ │ ██ ██████████ ████ │ │
│ │ ██ ██ ██ ████ │ │
│ │ ██ ██ ██ ████ │ │
│ │ ██ ██ ██ ████ │ │
│ │ ██ ██████████ ████ │ │
│ │ ██ ████ │ │
│ │ ████████████████████ │ │
│ │ │ │
│ └─────────────────────────┘ │
│ │
│ Expires in: 0:45 │
│ │
│ ────────────────────────────────────────────────────── │
│ │
│ 1. Open WhatsApp on your phone │
│ 2. Tap Menu ⋮ or Settings ⚙ │
│ 3. Tap Linked Devices │
│ 4. Tap Link a Device │
│ 5. Point your phone at this screen │
│ │
│ ────────────────────────────────────────────────────── │
│ │
│ [Refresh QR] [Cancel] │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
### Test Chat Interface
```
┌─────────────────────────────────────────────────────────────────────┐
│ ← Test Chat Session: Customer Support │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────┐ ┌────────────────────────────────────┐ │
│ │ Contacts │ │ +62 821-9876-543 │ │
│ │ ────────────────── │ │ John Doe │ │
│ │ │ ├────────────────────────────────────┤ │
│ │ 🔍 Search... │ │ │ │
│ │ │ │ ┌──────────────────────────────┐ │ │
│ │ ┌────────────────┐ │ │ │ Hello! How can I help you? │ │ │
│ │ │ 👤 John Doe │ │ │ │ 10:30 ✓✓ │ │ │
│ │ │ Last: Hi! │ │ │ └──────────────────────────────┘ │ │
│ │ └────────────────┘ │ │ │ │
│ │ │ │ ┌─────────────────────────┐ │ │
│ │ ┌────────────────┐ │ │ │ I need help with my │ │ │
│ │ │ 👤 Jane Smith │ │ │ │ order #12345 │ │ │
│ │ │ Last: OK │ │ │ │ 10:31 │ │ │
│ │ └────────────────┘ │ │ └─────────────────────────┘ │ │
│ │ │ │ │ │
│ │ ┌────────────────┐ │ │ ┌──────────────────────────────┐ │ │
│ │ │ 👤 Bob Wilson │ │ │ │ Sure! Let me check that │ │ │
│ │ │ Last: Thx │ │ │ │ for you. One moment... │ │ │
│ │ └────────────────┘ │ │ │ 10:32 ✓✓ │ │ │
│ │ │ │ └──────────────────────────────┘ │ │
│ │ │ │ │ │
│ │ ────────────────── │ ├────────────────────────────────────┤ │
│ │ [+ New Chat] │ │ 📎 [ ] 📤 │ │
│ └──────────────────────┘ │ Type a message... │ │
│ └────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
### Webhook Management
```
┌─────────────────────────────────────────────────────────────────────┐
│ ← Webhooks [+ New Webhook] │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ 🔗 Main Webhook ✅ Active │ │
│ │ https://api.example.com/webhook/openwa │ │
│ │ Events: message.received, message.ack, session.status │ │
│ │ Sessions: All │ │
│ │ ──────────────────────────────────────────────────────────── │ │
│ │ Success Rate: 99.8% | Avg Latency: 125ms | Last: 2 min ago │ │
│ │ [Test] [View Logs] [Edit] [Disable] │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ 🔗 Analytics Webhook ✅ Active │ │
│ │ https://analytics.example.com/track │ │
│ │ Events: message.received │ │
│ │ Sessions: cs-1, sales │ │
│ │ ──────────────────────────────────────────────────────────── │ │
│ │ Success Rate: 100% | Avg Latency: 89ms | Last: 5 min ago │ │
│ │ [Test] [View Logs] [Edit] [Disable] │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ 🔗 Backup Webhook ⏸️ Disabled │ │
│ │ https://backup.example.com/wa │ │
│ │ Events: message.received, message.ack │ │
│ │ Sessions: All │ │
│ │ ──────────────────────────────────────────────────────────── │ │
│ │ [Enable] [Edit] [Delete] │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
## 17.4 Component Library
> **No component framework is installed.** shadcn/ui is *not* adopted — there is no `npx shadcn`
> init, no `components/ui/` directory, no `cn()` utility, and no `@/components` import alias. The
> wireframes above are design intent; the implementation is hand-written.
### Bespoke components
The UI is built from a small set of project-specific components under `dashboard/src/components/`,
each with a colocated CSS file. There is no design-system package to pull from.
| Component | File | Responsibility |
| --- | --- | --- |
| `Layout` | `components/Layout.tsx` | App shell: collapsible sidebar nav, mobile drawer, language menu, theme/palette popover, logout, live version badge |
| `ToastProvider` / `useToast` | `components/Toast.tsx` | Context-based toast notifications (success/error/warning/info) with de-dup keys |
| `PageHeader` | `components/PageHeader.tsx` | Shared page title / subtitle / badge / actions header |
| `DashboardCharts` | `components/DashboardCharts.tsx` | `recharts`-based message-volume / activity charts on the Dashboard |
| `FilterBuilder` | `components/FilterBuilder.tsx` | Visual condition builder for webhook event filters |
| `ErrorBoundary` | `components/ErrorBoundary.tsx` | Top-level React error boundary wrapping the whole app |
| `GithubIcon` | `components/GithubIcon.tsx` | Inline brand SVG |
Pages live under `dashboard/src/pages/`, each as a `*.tsx` + `*.css` pair (e.g. `Sessions.tsx` +
`Sessions.css`). Pages are lazy-loaded in `App.tsx` via `React.lazy` + `Suspense`.
A representative bespoke component — the shared page header — shows the actual conventions
(plain props, a colocated stylesheet, BEM-ish class names, no utility classes):
```typescript
// components/PageHeader.tsx
import type { ReactNode } from 'react';
import './PageHeader.css';
interface PageHeaderProps {
title: string;
subtitle?: string;
badge?: ReactNode;
actions?: ReactNode;
}
export function PageHeader({ title, subtitle, badge, actions }: PageHeaderProps) {
return (
<header className="page-header">
<div className="page-header__title-group">
<h1>{title}</h1>
{badge && <span className="page-header__badge">{badge}</span>}
</div>
{actions && <div className="page-header__actions">{actions}</div>}
{subtitle && <p className="page-header__subtitle">{subtitle}</p>}
</header>
);
}
```
Icons are imported individually from `lucide-react`; there is no `Avatar`/`Card`/`Badge` primitive
library — those visuals are composed directly with `div`s and the page's own CSS.
## 17.5 State Management
There is **no Zustand store** (and no global client-state library). Server data is owned by
**TanStack Query** (`@tanstack/react-query`); the only other shared state is two small React Context
providers — `useRole` (the authenticated key's role) and `useTheme` (mode + palette, persisted to
`localStorage`).
### API client — raw payloads, no `{ data }` envelope
The client lives in `src/services/api.ts`. A single `request<T>()` helper attaches the `X-API-Key`
header from `sessionStorage`, then returns **the parsed JSON body as-is** — the backend sends the
raw handler payload, so `request<Session[]>('/sessions')` resolves to a bare `Session[]`, not
`{ data: Session[] }`. (A `204 No Content` resolves to `undefined`; a `401` clears the stored key
and redirects to login.) Endpoints are grouped into typed namespaces — `sessionApi`, `webhookApi`,
`templateApi`, `apiKeyApi`, `auditApi`, `messageApi`, `infraApi`, `pluginsApi`, `statsApi`, ...
```typescript
// src/services/api.ts (abridged)
export const API_BASE_URL = `${(import.meta.env.VITE_API_URL ?? '').replace(/\/+$/, '')}/api`;
async function request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const apiKey = sessionStorage.getItem('openwa_api_key');
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(apiKey ? { 'X-API-Key': apiKey } : {}),
...options.headers,
},
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.message || `HTTP ${response.status}`);
}
if (response.status === 204) return undefined as T;
return response.json(); // ← the raw payload, no unwrapping
}
export const sessionApi = {
list: () => request<Session[]>('/sessions'), // bare array
get: (id: string) => request<Session>(`/sessions/${id}`),
create: (name: string) =>
request<Session>('/sessions', { method: 'POST', body: JSON.stringify({ name }) }),
delete: (id: string) => request<void>(`/sessions/${id}`, { method: 'DELETE' }),
// QR returns a raw { qrCode, status } object — not { qr, expiresAt }, and there is no expiry timer.
getQR: (id: string) => request<{ qrCode: string; status: string }>(`/sessions/${id}/qr`),
};
```
### TanStack Query hooks
Hooks wrap those namespaces in `src/hooks/queries.ts` — each returns the raw payload directly (no
`.data.data`). Cache invalidation, not a store, keeps the UI in sync after a mutation.
```typescript
// src/hooks/queries.ts (abridged)
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { sessionApi, webhookApi } from '../services/api';
export const queryKeys = {
sessions: ['sessions'] as const,
webhooks: ['webhooks'] as const,
// ...
};
export function useSessionsQuery() {
return useQuery({
queryKey: queryKeys.sessions,
queryFn: sessionApi.list, // resolves to Session[] directly
staleTime: 30_000,
});
}
export function useStopSessionMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => sessionApi.stop(id),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: queryKeys.sessions }),
});
}
```
> **There is no `useSessionQr` polling hook.** QR codes are not fetched on a 30 s
> `refetchInterval`. The Sessions page fetches the current QR imperatively via `sessionApi.getQR()`
> and then receives fresh codes pushed over the WebSocket (`session.qr` events, see §17.6).
## 17.6 WebSocket Integration
Real-time updates use **socket.io** (`socket.io-client`), not a raw browser `WebSocket`. The hook is
`src/hooks/useWebSocket.ts`. Key facts:
- **Namespace `/events`** (not `/ws`). The client connects to
`${VITE_WS_URL || window.location.origin}/events` — same-origin by default; `VITE_WS_URL` only
overrides it for split-origin deployments.
- **API key via the socket.io `auth` payload (and an `X-API-Key` header for proxies), deliberately
*not* in the query string** — a key in the handshake URL would leak into access logs / `Referer`.
- **Reconnection is socket.io's built-in mechanism** — `reconnectionAttempts: 5`,
`reconnectionDelay: 1000` — not a hand-rolled `setTimeout(connect, 3000)`. When all attempts are
exhausted the manager fires `reconnect_failed`; the hook surfaces that as `connectionFailed` so the
UI can offer a manual `reconnect()`.
- **Server → client envelope.** Every push arrives as a single `message` event whose payload is
`{ type: 'event', timestamp, payload: { event, sessionId, data } }`. The hook registers one
handler, switches on `payload.event`, and fans out to typed callbacks.
- **Subscriptions** are sent with `socket.emit('message', { type: 'subscribe' | 'unsubscribe', ... })`.
```typescript
// src/hooks/useWebSocket.ts (abridged)
import { useEffect, useRef, useCallback, useState } from 'react';
import { io, Socket } from 'socket.io-client';
const SOCKET_URL = import.meta.env.VITE_WS_URL || window.location.origin;
interface ServerEventEnvelope {
type: string; // 'event'
timestamp: string;
payload?: { event: string; sessionId: string; data: Record<string, unknown> };
}
export function useWebSocket(events: WebSocketEvents = {}) {
const socketRef = useRef<Socket | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [connectionFailed, setConnectionFailed] = useState(false);
const connect = useCallback(() => {
if (socketRef.current?.connected) return;
const apiKey = sessionStorage.getItem('openwa_api_key');
if (!apiKey) return;
socketRef.current = io(`${SOCKET_URL}/events`, {
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 1000,
auth: { apiKey }, // key in the handshake auth, NOT the URL
extraHeaders: { 'X-API-Key': apiKey }, // header copy for proxies
});
socketRef.current.on('connect', () => { setIsConnected(true); setConnectionFailed(false); });
socketRef.current.on('disconnect', () => setIsConnected(false));
socketRef.current.io.on('reconnect_failed', () => setConnectionFailed(true));
}, []);
useEffect(() => {
connect();
const socket = socketRef.current;
const handle = (msg: ServerEventEnvelope) => {
if (!msg || msg.type !== 'event' || !msg.payload) return;
const { event, sessionId, data } = msg.payload;
switch (event) {
case 'session.status':
events.onSessionStatus?.({ sessionId, status: String(data.status), timestamp: msg.timestamp });
break;
case 'session.qr':
events.onQRCode?.({ sessionId, qrCode: String(data.qrCode), timestamp: msg.timestamp });
break;
case 'message.received':
case 'message.sent':
events.onMessage?.({ sessionId, message: data, timestamp: msg.timestamp });
break;
// ...message.ack, message.reaction, message.revoked
}
};
socket?.on('message', handle);
return () => { socket?.off('message', handle); };
}, [connect, events]);
return { isConnected, connectionFailed, reconnect, subscribe, unsubscribe };
}
```
## 17.7 Theme Configuration
Theming is **not** shadcn HSL design tokens. It is a plain `useTheme` hook (`src/hooks/useTheme.ts`)
that toggles two attributes on `<html>` and lets the CSS do the rest. The values are persisted to
`localStorage` under `openwa_theme` and `openwa_palette`:
- **Mode** — `light | dark | system`. `system` removes `data-theme` so a `prefers-color-scheme`
media query in the global CSS takes over; otherwise `data-theme="light|dark"` is set explicitly.
- **Palette** — one of seven accent palettes (`openwa`, `blue`, `graphite`, `indigo`, `amber`,
`rose`, `teal`), applied as `data-palette="…"`. Each palette is a set of CSS custom properties
scoped to that attribute selector.
The mode/palette pickers render in the sidebar footer popover (`Layout.tsx`); there is no
`ThemeProvider` context wrapper — it's a hook consumed directly where needed.
```typescript
// src/hooks/useTheme.ts (abridged)
export type Theme = 'light' | 'dark' | 'system';
export type ThemePalette = 'openwa' | 'blue' | 'graphite' | 'indigo' | 'amber' | 'rose' | 'teal';
export function useTheme() {
const [theme, setTheme] = useState<Theme>(/* localStorage 'openwa_theme' ?? 'system' */);
const [palette, setPalette] = useState<ThemePalette>(/* localStorage 'openwa_palette' ?? 'openwa' */);
useEffect(() => {
const root = document.documentElement;
if (theme === 'system') root.removeAttribute('data-theme');
else root.setAttribute('data-theme', theme);
localStorage.setItem('openwa_theme', theme);
}, [theme]);
useEffect(() => {
document.documentElement.setAttribute('data-palette', palette);
localStorage.setItem('openwa_palette', palette);
}, [palette]);
return { theme, setTheme, palette, setPalette, /* resolvedTheme, paletteOptions, ... */ };
}
```
The actual colors live in the global CSS as variables keyed off `[data-theme]` / `[data-palette]`
e.g. `:root { --color-accent: #25d366; } [data-palette='blue'] { --color-accent: #2563eb; }` — so
switching mode or palette is a single attribute write with no re-render of the tree.
## 17.8 Build & Deployment
### Vite Configuration
The dev server listens on **2886** and proxies `/api` to the API on `2785`. The WebSocket proxy is
on **`/socket.io`** (socket.io's transport path) with `ws: true`*not* `/ws`. There is no `@`
import alias and no custom `manualChunks`/Radix vendor split; code-splitting is handled by the
per-page `React.lazy` imports in `App.tsx`. The build-time version (`__APP_VERSION__`) is injected
from `package.json` via `define`.
```typescript
// vite.config.ts
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
const { version: pkgVersion } = JSON.parse(
readFileSync(resolve(process.cwd(), 'package.json'), 'utf-8'),
) as { version: string };
export default defineConfig({
plugins: [react()],
appType: 'spa', // SPA fallback for client-side routing
define: {
__APP_VERSION__: JSON.stringify(process.env.APP_VERSION || pkgVersion),
__BUILD_TIME__: JSON.stringify(new Date().toISOString()),
},
server: {
port: 2886,
proxy: {
'/api': {
target: 'http://localhost:2785',
changeOrigin: true,
secure: false,
},
// socket.io transport — NOT '/ws'
'/socket.io': {
target: 'http://localhost:2785',
ws: true,
changeOrigin: true,
},
},
},
});
```
### Production Build & Serving
The dashboard has **no container of its own**. In production the NestJS API serves the bundled
SPA from the same process and port (default `2785`) via `@nestjs/serve-static`, so there is no
nginx image and no separate dashboard service to deploy.
How it fits together:
- `npm run build:all` builds the API (`dist/`) **and** the dashboard (`dashboard/dist/`). The root
`Dockerfile` does this in its builder stage and copies `dashboard/dist` into the runtime image.
- `ServeStaticModule` is registered conditionally in `src/app.module.ts`: it only activates when
`dashboard/dist/index.html` exists, serves it with SPA fallback, and `exclude`s `/api` and
`/socket.io` so those keep returning real API/WebSocket responses. Opt out with
`SERVE_DASHBOARD=false`.
- Run it directly with `npm run prod` (build + serve) or `node dist/main` against a prebuilt image.
In development the build is absent, so serve-static stays inert and the Vite dev server (port
`2886`, see above) handles the UI with HMR while proxying `/api` + `/socket.io` to the API.
**Split-origin hosting is still supported.** Same-origin serving is the default, not a lock-in: to
host the dashboard separately (a CDN, an object store, or its own container), build it with the API
origin baked in and deploy `dashboard/dist` wherever you like:
```bash
VITE_API_URL=https://api.example.com npm run build # in dashboard/
```
`dashboard/src/services/api.ts` reads `VITE_API_URL` and calls that origin instead of same-origin
`/api`. Set `SERVE_DASHBOARD=false` on the API so it stops serving its own copy. Remember to add the
dashboard's origin to `CORS_ORIGINS` on the API.
For TLS or public exposure of the default single-port setup, terminate at your own reverse proxy
(nginx, Caddy, a cloud load balancer, or a k8s Ingress) in front of the API; see
`docs/12-troubleshooting-faq.md` for an nginx example.
---
<div align="center">
[← 16 - Risk Management](./16-risk-management.md) · [Documentation Index](./README.md) · [Next: 18 - SDK Design →](./18-sdk-design.md)
</div>