# Frontend Architecture
## Tech Stack
| Package | Version | Purpose |
|---|---|---|
| React | 19.2 | UI framework |
| TypeScript | 6.0 | Type safety |
| Vite | 8.0 | Build tool and dev server |
| TanStack React Query | 5.99 | Server state management, caching, mutations |
| Zustand | 5.0 | Client state (auth, UI) |
| Axios | 1.15 | HTTP client with interceptors |
| React Router | 7.14 | Client-side routing |
| React Hook Form | 7.72 | Form state management |
| Zod | 4.3 | Schema validation |
| Tailwind CSS | 3.4 | Utility-first CSS framework |
| Lucide React | 1.8 | Icon library |
| clsx | 2.1 | Conditional class merging |
### Dev Dependencies
| Package | Purpose |
|---|---|
| Vitest 4.1 + jsdom | Unit testing |
| @testing-library/react 16 | Component testing |
| @testing-library/jest-dom 6 | DOM matchers |
| @testing-library/user-event 14 | User interaction simulation |
| Playwright 1.59 | E2E testing |
| ESLint 9 | Linting |
| TypeScript ESLint 8 | TypeScript lint rules |
## Project Structure
```
frontend/src/
├── main.tsx # Entry point, renders
├── App.tsx # Router, QueryClientProvider, auth guard
├── index.css # Tailwind directives, neumorphic classes
├── vite-env.d.ts # Vite env type declarations
│
├── lib/
│ └── api.ts # Axios client, auth interceptors
│
├── store/
│ ├── auth.store.ts # Auth state (token, user, isAuthenticated)
│ └── ui.store.ts # UI state (sidebar, notifications)
│
├── hooks/
│ ├── useServers.ts # TanStack Query hooks for server CRUD + lifecycle
│ ├── useServerDetail.ts # TanStack Query hooks for config, players, bans, missions, mods, RCon
│ ├── useAuth.ts # Auth management hooks (users, password, logout)
│ ├── useGames.ts # Game type hooks (list, detail, schema, defaults)
│ └── useWebSocket.ts # WebSocket connection with backoff + onEvent callback
│
├── pages/
│ ├── LoginPage.tsx # Login form with Zod validation
│ ├── DashboardPage.tsx # Server grid with lifecycle controls
│ ├── ServerDetailPage.tsx # Server detail with 7 tabs (overview, config, players, bans, missions, mods, logs)
│ ├── CreateServerPage.tsx # 4-step server creation wizard (admin only)
│ └── SettingsPage.tsx # Account (password change) + Users (admin user management)
│
├── components/
│ ├── layout/
│ │ └── Sidebar.tsx # Left nav with server list
│ ├── servers/
│ │ ├── ServerCard.tsx # Server card with actions
│ │ ├── ServerHeader.tsx # Server name, status, stats grid, lifecycle buttons
│ │ ├── ConfigEditor.tsx # Tabbed config section editor; per-field widgets via useServerConfigSchema
│ │ ├── PlayerTable.tsx # Current players + history with search
│ │ ├── BanTable.tsx # Ban list + create/revoke form
│ │ ├── MissionList.tsx # Available missions + Mission Rotation sections; multi-file upload
│ │ ├── ModList.tsx # Mod list with enable/disable checkboxes
│ │ └── LogViewer.tsx # Log display with level filter (receives logs as props)
│ ├── settings/
│ │ ├── PasswordChange.tsx # Password change form
│ │ └── UserManager.tsx # User CRUD table (admin only)
│ └── ui/
│ ├── StatusLed.tsx # Colored status indicator dot
│ └── TagListEditor.tsx # Dynamic string-list editor (add/remove items)
│
└── __tests__/
├── api.test.ts # Axios interceptor tests
├── auth.store.test.ts # Auth store tests
├── ui.store.test.ts # UI store tests
├── StatusLed.test.tsx # StatusLed component tests
├── LoginPage.test.tsx # Login page tests
├── DashboardPage.test.tsx # Dashboard page tests
├── ServerCard.test.tsx # Server card rendering tests
├── ServerCard.handlers.test.tsx # Server card interaction tests
├── Sidebar.test.tsx # Sidebar tests
├── useWebSocket.test.tsx # WebSocket hook tests
└── useServers.test.tsx # Server hooks tests
```
## Routes
| Path | Component | Auth | Status |
|---|---|---|---|
| `/login` | `LoginPage` | Public | Implemented |
| `/` | `DashboardPage` | Protected | Implemented |
| `/servers/:serverId` | `ServerDetailPage` | Protected | Implemented (7 tabs) |
| `/servers/new` | `CreateServerPage` | Protected (admin only) | Implemented (4-step wizard) |
| `/settings` | `SettingsPage` | Protected | Implemented (Account + Users tabs) |
`ProtectedLayout` wraps all non-login routes. When `isAuthenticated` is false, it redirects to `/login`.
## Component Tree
```
App
├── QueryClientProvider
│ ├── BrowserRouter
│ │ ├── /login → LoginPage
│ │ └── /* → ProtectedLayout
│ │ ├── Sidebar
│ │ │ ├── Languard branding
│ │ │ ├── Dashboard link
│ │ │ ├── Server list (from useServers)
│ │ │ └── Settings link
│ │ └── main content area
│ │ ├── / → DashboardPage
│ │ │ ├── "2 servers configured" heading
│ │ │ ├── Add Server button
│ │ │ └── Server grid
│ │ │ └── ServerCard × N
│ │ │ ├── StatusLed + name + game type
│ │ │ ├── Stats: Players, Port, Restarts
│ │ │ └── Action buttons: Start/Stop/Restart
│ │ ├── /servers/:id → ServerDetailPage
│ │ │ ├── ServerHeader (status, stats, lifecycle buttons)
│ │ │ ├── Tab bar (Overview, Config, Players, Bans, Missions, Mods, Logs)
│ │ │ ├── OverviewTab (stats grid, executable path)
│ │ │ ├── ConfigEditor (section tabs, per-field widgets from schema, optimistic locking)
│ │ │ ├── PlayerTable (current + history with search)
│ │ │ ├── BanTable (ban list + create/revoke)
│ │ │ ├── MissionList (Available + Rotation sections, multi-file upload)
│ │ │ ├── ModList (enable/disable checkboxes)
│ │ │ └── LogViewer (level filter, real-time via WebSocket onEvent)
│ │ ├── /servers/new → CreateServerPage
│ │ │ └── 4-step wizard (Game Type → Info → Options → Review)
│ │ └── /settings → SettingsPage
│ │ ├── Account tab → PasswordChange
│ │ └── Users tab → UserManager (admin only)
│ └── ReactQueryDevtools
```
## State Management
### Server State (TanStack Query)
All server data flows through TanStack Query hooks:
**Server CRUD** (`useServers.ts`):
| Hook | Type | Endpoint | Cache Key |
|---|---|---|---|
| `useServers()` | Query | `GET /api/servers` | `["servers"]` (refetch every 30s) |
| `useServer(id)` | Query | `GET /api/servers/:id` | `["servers", id]` |
| `useStartServer()` | Mutation | `POST /api/servers/:id/start` | Invalidates `["servers", id]`, `["servers"]` |
| `useStopServer()` | Mutation | `POST /api/servers/:id/stop` | Invalidates both caches |
| `useRestartServer()` | Mutation | `POST /api/servers/:id/restart` | Invalidates `["servers", id]` |
| `useCreateServer()` | Mutation | `POST /api/servers` | Invalidates `["servers"]` |
| `useDeleteServer()` | Mutation | `DELETE /api/servers/:id` | Invalidates `["servers"]` |
| `useUpdateServer(id)` | Mutation | `PUT /api/servers/:id` | Invalidates `["servers", id]`, `["servers"]` |
| `useKillServer()` | Mutation | `POST /api/servers/:id/kill` | Invalidates `["servers", id]`, `["servers"]` |
**Server Detail** (`useServerDetail.ts`):
| Hook | Type | Endpoint | Cache Key |
|---|---|---|---|
| `useServerConfig(id)` | Query | `GET /api/servers/:id/config` | `["servers", id, "config"]` |
| `useServerConfigSection(id, section)` | Query | `GET /api/servers/:id/config/:section` | `["servers", id, "config", section]` |
| `useServerConfigSchema(id)` | Query | `GET /api/servers/:id/config/schema` | `["servers", id, "config", "schema"]` |
| `useServerConfigPreview(id)` | Query | `GET /api/servers/:id/config/preview` | `["servers", id, "config", "preview"]` |
| `useServerPlayers(id)` | Query | `GET /api/servers/:id/players` | `["players", id]` |
| `useServerPlayerHistory(id, opts?)` | Query | `GET /api/servers/:id/players/history` | `["players", id, "history", opts]` |
| `useServerBans(id)` | Query | `GET /api/servers/:id/bans` | `["bans", id]` |
| `useServerMissions(id)` | Query | `GET /api/servers/:id/missions` | `["missions", id]` |
| `useServerMissionRotation(id)` | Query | `GET /api/servers/:id/missions/rotation` | `["missions", id, "rotation"]` |
| `useServerMods(id)` | Query | `GET /api/servers/:id/mods` | `["mods", id]` |
| `useUpdateConfigSection(id, section)` | Mutation | `PUT /api/servers/:id/config/:section` | Invalidates config keys |
| `useCreateBan(id)` | Mutation | `POST /api/servers/:id/bans` | Invalidates `["bans", id]` |
| `useRevokeBan(id)` | Mutation | `DELETE /api/servers/:id/bans/:banId` | Invalidates `["bans", id]` |
| `useUploadMission(id)` | Mutation | `POST /api/servers/:id/missions` (multipart, `File[]`) | Invalidates `["missions", id]` |
| `useUpdateMissionRotation(id)` | Mutation | `PUT /api/servers/:id/missions/rotation` | Invalidates rotation + server config |
| `useDeleteMission(id)` | Mutation | `DELETE /api/servers/:id/missions/:filename` | Invalidates `["missions", id]` |
| `useSetEnabledMods(id)` | Mutation | `PUT /api/servers/:id/mods/enabled` | Invalidates `["mods", id]` |
| `useSendCommand(id)` | Mutation | `POST /api/servers/:id/rcon/command` | No invalidation |
**Auth** (`useAuth.ts`):
| Hook | Type | Endpoint |
|---|---|---|
| `useCurrentUser()` | Query | `GET /api/auth/me` |
| `useUsers()` | Query | `GET /api/auth/users` |
| `useChangePassword()` | Mutation | `PUT /api/auth/password` |
| `useCreateUser()` | Mutation | `POST /api/auth/users` |
| `useDeleteUser()` | Mutation | `DELETE /api/auth/users/:id` |
| `useLogout()` | Mutation | `POST /api/auth/logout` + clear auth state |
**Games** (`useGames.ts`):
| Hook | Type | Endpoint |
|---|---|---|
| `useGamesList()` | Query | `GET /api/games` |
| `useGameDetail(gameType)` | Query | `GET /api/games/:gameType` |
| `useGameConfigSchema(gameType)` | Query | `GET /api/games/:gameType/config-schema` |
| `useGameDefaults(gameType)` | Query | `GET /api/games/:gameType/defaults` |
**Key type notes**:
- `Server` type in `useServers.ts` uses `game_port`, `current_players`, `max_players` (matches enriched API response)
- `Mission` type: `{ name, filename, size_bytes, terrain }` — terrain parsed from filename
- `Mod` type: `{ name, path, size_bytes, enabled, display_name, workshop_id }` — `display_name`/`workshop_id` from mod.cpp/meta.cpp (planned Phase 3)
- `Ban` type: `{ id, server_id, guid, name, reason, banned_by, banned_at, expires_at, is_active, game_data }` (matches API)
- There is no REST endpoint for logs — logs are only pushed via WebSocket events
**Planned hooks (UX Enhancement Plan — remaining):**
| Hook | Phase | Endpoint |
|---|---|---|
| `useKickPlayer(id)` | Phase 4 | `POST /api/servers/:id/players/:slot_id/kick` |
| `useBanPlayer(id)` | Phase 4 | `POST /api/servers/:id/players/:slot_id/ban` |
| `useLogFiles(id)` | Phase 5 | `GET /api/servers/:id/logfiles` |
QueryClient defaults: `staleTime: 10s`, `retry: 2`, `refetchOnWindowFocus: false`.
### Client State (Zustand)
**auth.store.ts** — Persisted to localStorage under `languard-auth`:
- `token: string | null` — JWT access token
- `user: { id, username, role } | null` — Current user
- `isAuthenticated: boolean` — Derived on rehydration
- `setAuth(token, user)` — Sets token, user, and writes `languard_token` to localStorage
- `clearAuth()` — Clears all state and localStorage keys
**ui.store.ts** — In-memory only:
- `sidebarOpen: boolean` — Sidebar collapse state
- `activeServerId: number | null` — Highlighted server in sidebar
- `notifications: Notification[]` — Toast notifications (auto-remove after 5s)
- `addNotification(type, message)` — Add toast with auto-dismiss
- `removeNotification(id)` — Manual dismiss
## API Client
`src/lib/api.ts` configures Axios with:
- **Base URL**: `VITE_API_URL` env var, defaults to `http://localhost:8000`
- **Timeout**: 30 seconds
- **Request interceptor**: Reads `languard_token` from localStorage, adds `Authorization: Bearer ` header
- **Response interceptor**: On 401, checks if URL starts with `/api/auth/` — if NOT an auth endpoint, clears token and redirects to `/login`. Auth endpoint 401s are left for the calling component to handle.
Standard response envelope: `{ success: boolean, data: T | null, error?: string }`
## WebSocket
`useWebSocket.ts` connects to `VITE_WS_URL` (default `ws://localhost:8000`):
- Accepts `UseWebSocketOptions` (or `number[]` for backward compat): `{ serverIds?, onEvent? }`
- Passes JWT token as query parameter: `/ws?token=...&server_id=...`
- Exponential backoff reconnect: starts 2s, doubles up to 30s max
- Close code 4001 = explicit disconnect (no reconnect)
- `onEvent` callback: receives raw WebSocket events for custom handling (e.g., log accumulation in ServerDetailPage)
- Event handlers invalidate TanStack Query caches:
- `server_status` → `["servers", id]` + `["servers"]`
- `metrics` → `["metrics", id]`
- `log` → no cache key (no REST endpoint for logs; use onEvent callback instead)
- `players` → `["players", id]`
**Important**: There is no REST endpoint for server logs. Logs are only available via WebSocket push events. Components that need log data must use the `onEvent` callback to accumulate log entries in local state, then pass them to `LogViewer` as props.
## Design System
Dark neumorphic theme defined in `tailwind.config.js`:
**Colors:**
- Surface: base `#1a1a2e`, raised `#1e1e35`, recessed `#16162a`, overlay `#22223a`
- Accent: amber `#f59e0b` with bright/dim/glow variants
- Status: running green, stopped gray, crashed red, starting amber, restarting blue
- Text: primary `#e2e8f0`, secondary `#94a3b8`, muted `#475569`
**Neumorphic classes:** `neu-card`, `neu-input`, `btn-primary`, `btn-danger`, `btn-ghost`, `status-led-*`
**Custom shadows:** `neu-raised`, `neu-raised-lg`, `neu-recessed`, plus glow variants
**Fonts:** Inter (sans), JetBrains Mono (mono)
## Testing
### Unit Tests (120 tests, Vitest + React Testing Library)
| Test File | Tests | Coverage |
|---|---|---|
| `api.test.ts` | 4 | Interceptors: token header, 401 redirect (non-auth), 401 no-redirect (auth) |
| `auth.store.test.ts` | 3 | Init state, setAuth, clearAuth, localStorage sync |
| `ui.store.test.ts` | 5 | Init state, toggleSidebar, setActiveServer, add/remove notifications |
| `StatusLed.test.tsx` | 8 | Status classes, showLabel, sizes |
| `LoginPage.test.tsx` | 4 | Form render, validation, API call, error display |
| `DashboardPage.test.tsx` | 5 | Loading/error/empty states, card rendering |
| `ServerCard.test.tsx` | 10 | Card rendering, button visibility, disabled states, actions |
| `ServerCard.handlers.test.tsx` | 9 | Start/stop/restart success/failure notifications |
| `Sidebar.test.tsx` | 6 | Branding, links, loading state, server list, active highlight |
| `useWebSocket.test.tsx` | 5 | No-connect without token, connect, token in URL, invalidation, cleanup |
| `useServers.test.tsx` | 10 | Server CRUD + lifecycle hooks, cache invalidation |
| `useServerDetail.test.tsx` | 20+ | Config, players, bans, missions, mods, mutations, cache invalidation |
| `useAuth.test.tsx` | 7 | Current user, users, change password, create/delete user, logout |
| `useGames.test.tsx` | 5 | Games list, detail, config schema, defaults |
### E2E Tests (23 tests, Playwright)
**Login Flow** (6 tests):
- Display login form, branding, validation errors
- Error on invalid credentials (mocked 401)
- Navigation to dashboard on successful login
- Loading state ("Signing in...")
**Dashboard** (12 tests):
- Header, server count, server cards, server names
- Add Server button, sidebar with server list
- Stop button for running server, Start button for stopped
- Player count display, server detail navigation
- Empty state, error state
**Full Stack Integration** (5 tests):
- Login + see A3Master on dashboard (real backend)
- A3Master server details in card (real backend)
- Server detail navigation (real backend)
- Unauthenticated redirect to login
- API response shape validation
## Configuration
### Vite (`vite.config.ts`)
- Path alias `@` → `./src`
- Dev server on port 5173
- Proxy: `/api` → `http://localhost:8000`, `/ws` → `ws://localhost:8000`
### Vitest (`vitest.config.ts`)
- `jsdom` environment
- Global test APIs
- Setup: `src/__tests__/setup.ts` (imports jest-dom matchers)
- Excludes `tests-e2e` directory
### Playwright (`playwright.config.ts`)
- Chromium only
- `baseURL: http://localhost:5173`
- Traces on first retry, screenshots on failure, video on failure
- Dev server auto-started via `npm run dev`
- CI: retries=2, single worker, `forbidOnly`