瀏覽代碼

ARCHITECTURE: rewrite diagrams for current state

The old diagrams (from commit 9f2269a) predated the dashboard,
SQLite, JWT, SSE, metrics, alerts, and consumer work in 6af966d.
The doc was significantly out of date.

Old doc had 12 mermaid diagrams but with serious issues:
- Two duplicate 'System Overview' flowcharts
- Two near-duplicate 'Component Architecture' flowcharts
- 'S1 publishes events, S2 publishes commands' (wrong - both
  servers publish both topics)
- No mention of dashboard, JWT, SQLite, SSE, alerts, metrics
- No 'command queue for offline routers' shown
- Only 5 commands listed (13 implemented)
- Missing 'wifi_connected'/'wifi_disconnected' events
- Missing 'command_result' event
- No offline-watcher / reconnect-flush sequences
- No login flow
- No component breakdown (server = 6 files, dashboard = 6 pages)
- No docker-compose service list
- No ER diagram of SQLite tables

New doc (one canonical section per topic):
1. System Overview - one big flowchart with all 4 planes
2. Data Flows - 4 sequence diagrams (event, command, login, offline)
3. Component Architecture - 3 sub-diagrams (Go server, Dashboard, Router)
4. Auth & Persistence - ER diagram + auth table + file map
5. Error Handling - 3 flowcharts (publish, command queue, offline watcher)
6. Topics & Ports - consolidated
7. Commands - all 13 with UI surfacing flag
8. Events - all 9 with source
9. Build & Deploy
10. Source Layout

Static syntax check: 12 diagrams, all balanced, no malformed arrows.
Verified via Python regex scan before commit.
Gogs 2 月之前
父節點
當前提交
af0fd298d4
共有 1 個文件被更改,包括 508 次插入268 次删除
  1. 508 268
      ARCHITECTURE.md

+ 508 - 268
ARCHITECTURE.md

@@ -1,6 +1,21 @@
 # client2server Architecture
 
-> Complete system architecture for bidirectional event forwarding between OpenWrt routers and central server.
+> System architecture for bidirectional event forwarding between OpenWrt routers
+> and a central Go server, with persistent storage, real-time web dashboard, and
+> command queue.
+
+## Table of Contents
+1. [System Overview](#system-overview)
+2. [Data Flows](#data-flows)
+3. [Component Architecture](#component-architecture)
+4. [Auth & Persistence](#auth--persistence)
+5. [Error Handling & Resilience](#error-handling--resilience)
+6. [Topics & Ports](#topics--ports)
+7. [Commands](#commands)
+8. [Events](#events)
+9. [Build & Deploy](#build--deploy)
+
+---
 
 ## System Overview
 
@@ -9,346 +24,571 @@ flowchart TB
     subgraph Router["OpenWrt Router"]
         direction TB
         Lua[("client2server-unified.lua")]
-        DHCP[("DHCP Monitor")]
-        WAN[("WAN Monitor")]
+        Hotplug[("/etc/hotplug.d/* scripts")]
+        Wifi[("wifi_connected / disconnected")]
+        DHCP[("dhcp_lease_new / expire")]
+        WAN[("wan_link / dhcp events")]
         CMD[("Command Executor")]
-        Link[("Link Monitor")]
+        Buf[("Event buffer
+/tmp/event_buffer")]
     end
-    
-    subgraph Cloud["Cloud"]
-        subgraph CaddyLB["Caddy Load Balancer :3843"]
-            WS[("WebSocket")]
-            API[("HTTP/WS :3843")]
-        end
-        
-        subgraph Server["Go Servers (x2)"]
-            S1[("server1")]
-            S2[("server2")]
-        end
-        
-        subgraph Redpanda["Redpanda"]
-            Events[("router-events")]
-            Commands[("router-commands")]
-        end
-        
-        subgraph Backend["LuIS Backend"]
-            API2[("API Server")]
-            DB[(Database)]
-        end
+
+    subgraph Edge["Reverse Proxy / LB"]
+        Caddy[("Caddy :80/:443 dashboard
+Caddy :3843 API/WS")]
+    end
+
+    subgraph App["Go Servers (x2, behind Caddy)"]
+        S1[("server1 :3843")]
+        S2[("server2 :3843")]
+        SQLite1[("SQLite")]
+        SQLite2[("SQLite")]
+    end
+
+    subgraph Bus["Redpanda :9092"]
+        REvents[("router-events")]
+        RCommands[("router-commands")]
+    end
+
+    subgraph UI["Dashboard (React SPA, :80)"]
+        Dash[("Login / Overview / Routers
+Events / Commands / Alerts")]
+    end
+
+    subgraph Consumer["Downstream Consumers"]
+        AnyClient[("Any Kafka client
+(LuIS, analytics, etc.)")]
     end
-    
-    Router -->|WS| CaddyLB
-    CaddyLB --> WS
-    WS <-->|lb| S1
-    WS <-->|lb| S2
-    S1 -->|publish| Events
-    S2 -->|publish| Commands
-    Events --> Redpanda
-    Commands --> Redpanda
-    Redpanda --> API2
-    API2 --> DB
+
+    Router -->|WebSocket :3843| Caddy
+    Caddy -->|lb| S1
+    Caddy -->|lb| S2
+    Lua -->|offline| Buf
+    Buf -->|reconnect| Lua
+    Hotplug -->|HTTP POST /api/events| S1
+    S1 -->|publish| REvents
+    S1 -->|publish| RCommands
+    S2 -->|publish| REvents
+    S2 -->|publish| RCommands
+    REvents --> AnyClient
+    RCommands --> AnyClient
+    S1 --> SQLite1
+    S2 --> SQLite2
+    Caddy -->|static + /api proxy| Dash
+    Dash -->|SSE /api/events/stream| S1
+    Dash -->|REST /api/command| S1
+    S1 -->|queued if offline| Caddy
+    Caddy -->|WebSocket send| Router
+    Router --> CMD
 ```
 
+**Read it as three planes:**
+
+| Plane | What flows | Direction |
+|---|---|---|
+| **Telemetry** | DHCP / WiFi / WAN events | Router → Caddy → Go server → Redpanda → SQLite + SSE → Dashboard |
+| **Control** | `reboot`, `uci_set`, `status`, etc. | Dashboard → REST → Go server (queues if router offline) → Caddy → Router via WS |
+| **Persistence** | events, commands, alerts, users | Go server ↔ SQLite (local file per server instance) |
+
+---
+
 ## Data Flows
 
-### Event Flow (Router → Server)
+### 1. Event Flow (Router → Dashboard, real-time)
 
 ```mermaid
 sequenceDiagram
     participant R as Router
-    participant C as Caddy
+    participant H as Hotplug
     participant S as Go Server
-    participant K as Redpanda
-    participant L as LuIS
-
-    Note over R,L: DHCP Event Example
-    
-    R->>C: WebSocket connect → :3843
-    activate C
-    C->>S: Proxy to server1
-    activate S
-    S->>K: Produce → router-events
-    activate K
-    K-->>S: ACK
-    deactivate K
-    S-->>C: ACK
-    deactivate S
-    C-->>R: Connected!
-    deactivate C
-    
-    Note over R,L: Later - New DHCP Lease
-    R->>C: Event: dhcp_lease_new
-    C->>S: Forward event
-    S->>K: Publish event
-    S->>R: ACK
-    
-    Note over R,L: LuIS Dashboard consumes
-    L->>K: Consume router-events
-    K-->>L: Event data
+    participant DB as SQLite
+    participant RP as Redpanda
+    participant D as Dashboard (SSE)
+    participant C as Consumer
+
+    Note over R,C: Hybrid delivery: instant hotplug + ≤1s luv state-diff
+
+    par Hotplug path (instant)
+        H->>S: POST /api/events (curl)
+    and Lua state-diff (≤1s)
+        R->>S: WS frame: {type: event, ...}
+    end
+
+    S->>S: authenticate (legacy token or JWT)
+    S->>DB: INSERT INTO events
+    S->>RP: publish (fire-and-forget)
+    S->>D: SSE push to all subscribers
+    RP-->>C: consume (any Kafka client)
+    S-->>R: ACK
+    S-->>H: 200 OK
 ```
 
-### Command Flow (Server → Router)
+### 2. Command Flow (Dashboard → Router)
 
 ```mermaid
 sequenceDiagram
-    participant L as LuIS
+    participant D as Dashboard
     participant S as Go Server
-    participant K as Redpanda
+    participant DB as SQLite
     participant R as Router
-    
-    Note over L,R: Server sends command to router
-    L->>S: POST /api/command
-    S->>K: Publish → router-commands
-    K-->>S: ACK
-    S-->>L: command_id
-    
-    Note over R,R: Router receives via WebSocket
-    R->>R: Listen for commands
-    R->>R: Execute: uci set network.lan.ipaddr='192.168.1.1'
-    R->>R: Commit: uci commit network
-    R->>S: Return result
-    S->>K: Publish result (optional)
+    participant RP as Redpanda (optional)
+
+    D->>S: POST /api/command {router_id, command, args}
+    S->>S: authenticate (JWT, role check)
+    S->>DB: INSERT INTO commands (id, status=pending)
+    alt Router is online
+        S->>R: WebSocket send {type: command, ...}
+        R->>R: execute (uci_set, shell, reboot, etc.)
+        R->>S: WS result {type: command_result, command_id}
+        S->>DB: UPDATE status=completed
+        S->>D: SSE push command_result
+    else Router is offline
+        S->>DB: command stays in queue
+        Note over S,R: ...later, router reconnects...
+        S->>R: WebSocket send all queued commands
+    end
+    opt Long-lived audit
+        S->>RP: publish (optional)
+    end
 ```
 
-## Component Architecture
+### 3. Login Flow (Dashboard only)
 
 ```mermaid
-flowchart LR
-    subgraph Router["Router (OpenWrt)"]
-        WSCli[("WebSocket Client")]
-        Buf[("Buffer File
-/tmp/event_buffer")]
-        Events[("Event Sources")] 
-        CMD[("Command Executor")]
-        
-        Events -->|new| WSCli
-        WSCli -->|offline| Buf
-        Buf -->|flush| WSCli
-    end
-    
-    subgraph Server["Go Server"]
-        WSHan[("WS Handler")]
-        RedProd[("Producer")]
-        RedCons[("Consumer")]
-        Routers[("Router State")]
-        CMDServ[("Command Service")]
+sequenceDiagram
+    participant D as Dashboard
+    participant S as Go Server
+    participant DB as SQLite
+
+    D->>S: POST /api/auth/login {username, password}
+    S->>DB: SELECT * FROM users WHERE username=?
+    S->>S: scrypt.CompareHashAndPassword
+    alt valid
+        S-->>D: 200 {token: JWT, role, expires}
+        D->>D: store c2s_token in localStorage
+        D->>S: GET /api/auth/me (Bearer JWT)
+        S-->>D: 200 {username, role}
+    else invalid
+        S-->>D: 401
     end
-    
-    WSCli -->|connect| WSHan
-    WSHan --> RedProd
-    RedProd -->|topics| Redpanda["Redpanda"]
-    Redpanda --> RedCons
-    RedCons --> CMDServ
-    CMDServ --> Routers
 ```
 
-## Port Mappings
-
-| Service | Port | Protocol | Purpose |
-|---------|------|----------|--------||
-| Caddy WS | 3843 | WebSocket | Router connections |
-| Caddy HTTP | 3843 | HTTP | Same port as WS; serves `/api/*` + `/health` |
-| Redpanda | 9092 | Kafka | Event storage |
-| Redpanda REST | 8082 | HTTP | Schema registry |
+### 4. Offline Watcher (alerts)
 
-## Configuration
+```mermaid
+sequenceDiagram
+    participant T as Ticker (30s)
+    participant S as Go Server
+    participant DB as SQLite
+    participant D as Dashboard (SSE)
+
+    loop every 30s
+        T->>S: tick
+        S->>DB: SELECT routers WHERE last_seen < now - 60s
+        loop each offline router
+            S->>DB: INSERT alerts (kind=router_offline)
+            S->>D: SSE push {kind: alert, ...}
+        end
+    end
 
-```yaml
-# docker-compose.yml
-environment:
-  REDPANDA_BROKERS: redpanda:9092
-  TOKEN: ***
-  PORT: 3843
+    Note over S,D: On reconnect: alert auto-cleared
 ```
 
-## Build Targets
+---
+
+## Component Architecture
+
+### Go server (single process, 6 files)
 
 ```mermaid
 flowchart LR
-    A[("Source")] --> B[("Build")]
-    B --> C[("Package")]
-    B --> D[("Docker")]
-    B --> E[("IPK")]
-    
-    C -->|Go| D[("server binary")]
-    C -->|Lua| E[("OpenWrt ipk")]
-    
-    subgraph Build["Builds"]
-        direction TB
-        F[("docker-compose up")]
-        G[("./scripts/feeds")]
+    subgraph HTTP["HTTP routes (port 3843)"]
+        Login["/api/auth/login"]
+        Me["/api/auth/me"]
+        EvIn["POST /api/events"]
+        EvList["GET /api/events/list"]
+        EvStream["GET /api/events/stream"]
+        Routers["GET /api/routers"]
+        Cmd["POST /api/command"]
+        CmdList["GET /api/commands"]
+        Metrics["GET /api/metrics"]
+        Alerts["GET /api/alerts"]
+        Ack["POST /api/alerts/:id/ack"]
+        Health["GET /health"]
     end
-```
 
-## Topics (Redpanda)
+    subgraph WS["WebSocket /ws"]
+        RouterWS["router connections"]
+    end
 
+    subgraph Core["Server internals"]
+        Store[("store.go
+SQLite layer")]
+        Auth[("auth.go
+JWT + scrypt")]
+        SSE[("sse.go
+broadcaster")]
+        Metrics2[("metrics.go
+1-min buckets")]
+        Consumer[("consumer.go
+Redpanda → SQLite")]
+        Publisher[("Kafka producer
+(franz-go)")]
+        RouterMgr[("router state
+in-memory map")]
+        CmdQueue[("command queue
+per router")]
+        IdemJanitor[("idempotency
+TTL janitor")]
+    end
 
-| Topic | Partitions | Retention | Purpose |
-|-------|-----------|-----------|---------|
-| router-events | 3 | 7 days | All router events |
-| router-commands | 3 | 1 hour | Commands to routers |
+    Login --> Auth
+    Me --> Auth
+    Cmd --> Store
+    CmdList --> Store
+    Metrics --> Metrics2
+    Alerts --> Store
+    Ack --> Store
+    EvList --> Store
+    EvIn --> Store
+    EvIn --> Publisher
+    EvIn --> SSE
+    RouterWS --> RouterMgr
+    RouterWS --> CmdQueue
+    RouterWS --> Publisher
+    Consumer --> Store
+    Publisher --> RP["Redpanda :9092"]
+    RP --> Consumer
+    SSE --> EvStream
+    IdemJanitor --> Store
+```
 
-## Error Handling
+### Dashboard (React SPA)
 
 ```mermaid
-flowchart TD
-    A[("Connection Lost")] --> B{Router Online?}
-    B -->|Yes| C[("Buffer Events")]
-    C --> D[("Retry Every 5s")]
-    D --> E{Connected?}
-    E -->|Yes| F[("Flush Buffer")]
-    E -->|No| D
-    B -->|No| G[("Wait")]
-    G --> H[("Timer Reset")]
-    H --> A
+flowchart TB
+    subgraph SPA["dashboard/ (Vite build → Caddy)"]
+        Routes[("Wouter router")]
+        AuthGate[("Auth gate
+(localStorage c2s_token)")]
+        Login[("Login page")]
+        Overview[("Overview: stats + charts")]
+        Routers[("Routers: status grid")]
+        Events[("Events: filterable table")]
+        Commands[("Commands: console + history")]
+        Alerts[("Alerts: inbox")]
+        TanQ[("TanStack Query
+data fetching")]
+        SSEClient[("EventSource
+/api/events/stream")]
+        API[("lib/api.ts
+fetch + JWT header")]
+    end
+
+    Routes --> AuthGate
+    AuthGate -->|no token| Login
+    AuthGate -->|has token| Overview
+    Overview --> TanQ
+    Routers --> TanQ
+    Events --> TanQ
+    Commands --> TanQ
+    Alerts --> TanQ
+    TanQ --> API
+    TanQ --> SSEClient
+    API -->|REST| GoServer[("Go server :3843")]
+    SSEClient -->|EventSource| GoServer
 ```
 
-## System Overview
+### Router (OpenWrt)
 
 ```mermaid
-flowchart TB
-    subgraph Router["OpenWrt Router"]
-        direction TB
+flowchart LR
+    subgraph Router["OpenWrt"]
+        Init[("/etc/init.d/client2server")]
         Lua[("client2server-unified.lua")]
-        DHCP[("DHCP Monitor")]
-        WAN[("WAN Monitor")]
-        CMD[("Command Executor")]
-    end
-    
-    subgraph CaddyLB["Caddy Load Balancer"]
-        WS["WebSocket :3843"]
-        API["HTTP API :3843"]
-    end
-    
-    subgraph Server["Go Servers"]
-        S1["server1"]
-        S2["server2"]
+        HP1[("hotplug.d/wireless/01-wifi")]
+        HP2[("hotplug.d/dhcp/02-dhcp")]
+        UCI[("/etc/config/client2server")]
+        UB[("UCI store")]
+        Buf[("/tmp/event_buffer")]
+        WScli[("WebSocket client
+(mosquitto/lua-websockets)")]
+        CMDex[("Command executor")]
     end
-    
-    subgraph Redpanda["Redpanda Cluster"]
-        Events[("router-events")]
-        Commands[("router-commands")]
-    end
-    
-    subgraph Backend["LuIS Backend"]
-        API2[("API Server")]
-        DB[(Database)]
-    end
-    
-    Router -->|WebSocket| CaddyLB
-    CaddyLB --> WS
-    WS --> S1
-    WS --> S2
-    S1 --> Events
-    S2 --> Commands
-    Events --> Redpanda
-    Commands --> Redpanda
-    Redpanda --> API2
-    API2 --> DB
+
+    Init --> Lua
+    UCI --> Lua
+    HP1 -->|curl POST /api/events| Server[("Go server :3843")]
+    HP2 -->|curl POST /api/events| Server
+    Lua -->|WS| Server
+    Lua --> Buf
+    Buf --> Lua
+    Server -->|WS frame| WScli
+    WScli --> CMDex
+    CMDex --> UB
+    Lua -->|read state| Sys[("/sys/class/net/*
+/usr/bin/ubus")]
+    Lua -->|poll dnsmasq leases| DNS[("/tmp/dhcp.leases")]
 ```
 
-## Data Flow
+---
+
+## Auth & Persistence
+
+### Auth
+
+| Caller | Method | Where it lives |
+|---|---|---|
+| Routers (Lua) | legacy `TOKEN` shared secret | `Authorization: Bearer <token>` on HTTP, query on WS |
+| Hotplug scripts | legacy `TOKEN` shared secret | `-H "Authorization: Bearer $TOKEN"` |
+| Dashboard | JWT (HS256, 24h) | `Authorization: Bearer <jwt>` for REST, `?token=<jwt>` for SSE |
+
+- Passwords: scrypt-hashed, stored in `users` table
+- Default admin/admin created on first boot if `users` table empty (warning logged)
+- Roles: `system_admin` (everything), `project_admin` (commands + read), `user` (read-only)
+
+### SQLite schema
 
 ```mermaid
-sequenceDiagram
-    participant R as Router
-    participant C as Caddy
-    participant S as Go Server
-    participant K as Redpanda
-    participant L as LuIS
-
-    Note over R,L: Event Flow
-    
-    R->>C: WebSocket connect :3843
-    C->>S: Proxy to server1
-    S->>K: Publish to router-events
-    
-    Note over R,L: Command Flow
-    
-    L->>K: Publish command
-    K->>S: Consume from router-commands
-    S->>R: WebSocket send command
-    R->>R: Execute (uci_set, shell, etc)
-    R->>S: Ack result
-    S->>K: Publish result
-    K->>L: Result stored
+erDiagram
+    users ||--o{ sessions : "issued"
+    events }o--|| routers : "logical"
+    commands }o--|| routers : "target"
+    alerts }o--|| routers : "subject"
+
+    users {
+        int id PK
+        text username UK
+        text password_hash
+        text role
+        timestamp created_at
+    }
+    events {
+        int id PK
+        text router_id
+        text event_type
+        text payload_json
+        timestamp created_at
+    }
+    commands {
+        text id PK
+        text router_id
+        text command
+        text args_json
+        text status
+        text result_json
+        timestamp created_at
+        timestamp completed_at
+    }
+    alerts {
+        int id PK
+        text router_id
+        text kind
+        text message
+        bool acknowledged
+        timestamp created_at
+        timestamp acknowledged_at
+    }
 ```
 
-## Component Details
+### Files
+
+| Concern | File | Notes |
+|---|---|---|
+| HTTP/WS routes | `server/main.go` | orchestration, hand-off to internal pkgs |
+| SQLite layer | `server/store.go` | `SetMaxOpenConns(1)` single-writer |
+| Auth | `server/auth.go` | JWT sign/verify, scrypt, role middleware |
+| Metrics | `server/metrics.go` | in-memory ring of 1-min buckets, 24h retention |
+| SSE | `server/sse.go` | broadcaster + per-client goroutine |
+| Kafka consumer | `server/consumer.go` | consumer group `client2server-persistor` |
+
+---
+
+## Error Handling & Resilience
 
 ```mermaid
-flowchart LR
-    subgraph Lua["Lua Client"]
-        WS[("WebSocket")]
-        Buf[("Buffer")]
-        DHCP[("DHCP")]
-        WAN[("WAN")]
-        CMD[("Commands")]
-    end
-    
-    subgraph GoServer["Go Server"]
-        Handler[("Handler")]
-        Redpanda[("Redpanda")]
-        RouterMgr[("Router State")]
-    end
-    
-    WS -->|buffer when offline| Buf
-    Buf -->|flush on reconnect| Handler
-    DHCP -->|events| Handler
-    WAN -->|events| Handler
-    Handler --> Redpanda
-    Redpanda --> RouterMgr
+flowchart TD
+    Start[("Event arrives")]
+    Auth{Auth OK?}
+    Auth -->|No| A401[401]
+    Auth -->|Yes| DBWrite[("INSERT INTO events")]
+    DBWrite -->|ok| Publish[("Kafka publish<br/>fire-and-forget<br/>100ms local queue")]
+    DBWrite -->|error| A500[500 + log]
+    Publish -->|queued ok| SSE[("Broadcast to SSE subscribers")]
+    Publish -->|broker down| LogErr[("log error, continue<br/>(local ack to client)")]
+    SSE --> End[("done")]
+    LogErr --> End
 ```
 
-## Ports
+```mermaid
+flowchart TD
+    Cmd[("POST /api/command")]
+    Cmd --> Check{Is router online?}
+    Check -->|Yes| Send[("Send via WS")]
+    Check -->|No| Queue[("Persist to commands table<br/>(status=pending)")]
+    Queue --> Wait[("Wait for router reconnect")]
+    Wait --> Send
+    Send --> Ack{Result?}
+    Ack -->|success| Done[("UPDATE status=completed")]
+    Ack -->|timeout| Retry[("Mark for retry<br/>(next reconnect)")]
+```
 
-| Service | Port | Protocol |
-|---------|------|----------|
-| WebSocket LB | 3843 | WS |
-| HTTP API + WS | 3843 | HTTP + WS | Unified port |
-| Redpanda Kafka | 9092 | Kafka |
-| Redpanda REST | 8082 | HTTP |
+```mermaid
+flowchart TD
+    Disconnect[("WS disconnected")]
+    Disconnect --> Timer[("Mark router offline after 60s")]
+    Timer --> Alert[("Create router_offline alert")]
+    Alert --> Watch[("30s offline-watcher tick")]
+    Watch --> Recv{Online again?}
+    Recv -->|Yes| Clear[("Auto-clear alert")]
+    Recv -->|No| Watch
+    Clear --> Flush[("Flush queued commands")]
+```
 
-## Config
+---
 
-```yaml
-# Environment
-TOKEN: ***              # Auth token
-REDPANDA_BROKERS: redpanda:9092   # Redpanda address
-PORT: 3843             # Listening port
-```
+## Topics & Ports
+
+### Redpanda topics
+
+| Topic | Partitions | Retention | Purpose |
+|-------|-----------|-----------|---------|
+| `router-events` | 3 | 7 days | All router events (DHCP, WiFi, WAN) |
+| `router-commands` | 3 | 1 hour | Commands to routers (consumed by server) |
+
+### Ports
+
+| Service | Port | Protocol | Notes |
+|---------|------|----------|-------|
+| Caddy (dashboard) | 80, 443 | HTTP(S) | Reverse-proxies `/api/*`, `/ws` to go server |
+| Caddy (API/WS) | 3843 | HTTP + WS | Unified: routers + dashboard API |
+| Go server (×2) | 3843 | HTTP + WS | Internal, behind Caddy |
+| Redpanda Kafka | 9092 | Kafka | Internal |
+| Redpanda REST | 8082 | HTTP | Schema/management (optional) |
+| Dashboard dev (Vite) | 5173 | HTTP | Local dev only |
+
+### docker-compose services
+
+| Service | Image | Purpose |
+|---------|-------|---------|
+| `redpanda` | `redpandadata/redpanda` | Kafka broker |
+| `caddy` | `caddy:2-alpine` | Reverse proxy + LB |
+| `server1`, `server2` | local `server/Dockerfile` | Go app, behind Caddy |
+| `dashboard` | local `dashboard/Dockerfile` | Static SPA behind Caddy |
+
+---
 
 ## Commands
 
-| Command | Example | Response |
-|---------|---------|-----------|
-| `uci_set` | `{"config":"network", "section":"lan", "option":"ipaddr", "value":"192.168.1.1"}` | Success |
-| `shell` | `{"command":"reboot"}` | Output |
-| `reboot` | `{}` | Scheduled |
-| `wifi_restart` | `{}` | Network restart |
-| `status` | `{}` | JSON status |
+13 commands accepted by the router. The first 5 are the "documented" ones surfaced
+in the dashboard UI; the rest are exposed for internal/advanced use via `/api/command`.
+
+| Command | Args | Notes | Surfaced in UI |
+|---------|------|-------|:-:|
+| `reboot` | — | dangerous | ✅ |
+| `wifi_restart` | — | | ✅ |
+| `status` | — | | ✅ |
+| `shell` | `{command}` | dangerous | ✅ |
+| `uci_set` | `{config, section, option, value}` | dangerous | ✅ |
+| `uci_commit` | `{config}` | | |
+| `uci_reload` | — | | |
+| `client2server_enable` | `{0\|1}` | toggle via UCI | |
+| `client2server_url` | `{url}` | update server URL | |
+| `client2server_token` | `{token}` | rotate token | |
+| `firewall_restart` | — | | |
+| `network_restart` | — | | |
+| `get_config` | — | dumps UCI config as JSON | |
+
+---
 
 ## Events
 
+Sent by router over WS, hotplug scripts, or `POST /api/events`.
+
 | Event | Source | Payload |
 |-------|--------|---------|
-| `dhcp_lease_new` | dnsmasq | mac, ip, hostname |
-| `dhcp_lease_expire` | dnsmasq | mac, old_ip |
-| `wan_link_up` | /sys/class/net/* | device |
-| `wan_link_down` | /sys/class/net/* | device |
-| `wan_dhcp_new` | ubus | new_ip |
-| `wan_dhcp_changed` | ubus | old_ip, new_ip |
+| `dhcp_lease_new` | dnsmasq (luv + hotplug) | `mac`, `ip`, `hostname` |
+| `dhcp_lease_expire` | dnsmasq (luv + hotplug) | `mac`, `old_ip` |
+| `wan_link_up` | /sys/class/net/* | `device` |
+| `wan_link_down` | /sys/class/net/* | `device` |
+| `wan_dhcp_new` | ubus | `new_ip` |
+| `wan_dhcp_changed` | ubus | `old_ip`, `new_ip` |
+| `wifi_connected` | hostapd hotplug | `mac`, `interface` |
+| `wifi_disconnected` | hostapd hotplug | `mac`, `interface` |
+| `command_result` | router | `command_id`, `success`, `output` |
 
-## Docker Run
+---
+
+## Build & Deploy
+
+### Go server
 
 ```bash
-TOKEN=*** docker-compose up -d
+cd server
+go build -o server .                       # local
+docker build -t client2server:dev .         # container
 ```
 
-## IPK Build
+### Dashboard
 
 ```bash
-# With OpenWrt SDK
+cd dashboard
+npm install
+npm run build                              # → dist/ (static)
+docker build -t client2server-dashboard:dev .
+```
+
+### Full stack
+
+```bash
+cd /path/to/client2server
+cat > .env <<EOF
+TOKEN=***
+JWT_SECRET=*** rand -hex 32)
+EOF
+docker-compose up -d
+# Dashboard at http://localhost/ (admin/admin)
+# API at http://localhost:3843
+```
+
+### OpenWrt IPK
+
+```bash
+./scripts/feeds update -a
+./scripts/feeds install client2server-unified
 make package/client2server-unified/compile
 make package/client2server-unified/ipk
-```
+```
+
+---
+
+## Source Layout
+
+```
+client2server/
+├── ARCHITECTURE.md    # this file
+├── README.md          # user-facing docs
+├── MEMORY.md          # project memory (AI/agent context)
+├── Caddyfile          # routes :80,:443 → dashboard, :3843 → go servers
+├── docker-compose.yml # redpanda, caddy, server1, server2, dashboard
+├── package/           # OpenWrt IPK build
+│   ├── src/client2server-unified.lua
+│   ├── files/etc/{init.d,config}/client2server
+│   └── hotplug/{01-wifi,02-dhcp}
+├── server/            # Go server (single binary, 6 files)
+│   ├── main.go        # HTTP/WS routes + ingest
+│   ├── auth.go        # JWT + scrypt
+│   ├── store.go       # SQLite layer
+│   ├── consumer.go    # Redpanda → SQLite
+│   ├── metrics.go     # time-series
+│   ├── sse.go         # live feed
+│   └── Dockerfile
+└── dashboard/         # React SPA
+    ├── src/
+    │   ├── App.tsx
+    │   ├── lib/{api,types,sse}.ts
+    │   ├── components/{Shell,ui}.tsx
+    │   └── pages/{Login,Overview,Routers,Events,Commands,Alerts}.tsx
+    ├── Dockerfile
+    └── Caddyfile.production
+```