# 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 # Split-pane mod selector (Available vs Selected); Apply Selection button │ │ └── LogViewer.tsx # Log display with level filter + collapsible Log Files browser (download/delete) │ ├── 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 ├── logger.test.ts # Logger level-filtering 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 (useServers, useServer, lifecycle, useUpdateServer, useKillServer) ├── useServerDetail.test.tsx # Server detail hooks (config, players, bans, missions, mods, RCon, logfiles) ├── useAuth.test.tsx # Auth hooks tests ├── useGames.test.tsx # Games hooks tests └── CreateServerPage.test.tsx # Create server wizard (steps, validation, submit, edge cases) ``` ## 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 (split-pane: Available | Selected; Apply Selection) │ │ │ └── LogViewer (level filter, real-time stream + Log Files browser) │ │ ├── /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` (per-field widget hints for ~80 fields: text, toggle, select, number, password, tag-list, hidden, textarea) | `["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` body: `EnabledModEntry[]` | Invalidates `["mods", id]` | | `useSendCommand(id)` | Mutation | `POST /api/servers/:id/rcon/command` | No invalidation | | `useKickPlayer(id)` | Mutation | `POST /api/servers/:id/players/:slot_id/kick` | Invalidates `["players", id]` | | `useBanPlayer(id)` | Mutation | `POST /api/servers/:id/players/:slot_id/ban` | Invalidates players + bans | | `useServerLogFiles(id)` | Query | `GET /api/servers/:id/logfiles` | `["servers", id, "logfiles"]` (refetch 30s) | | `useDeleteLogFile(id)` | Mutation | `DELETE /api/servers/:id/logfiles/:filename` | Invalidates logfiles | **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, is_server_mod, display_name, workshop_id }` — `display_name`/`workshop_id` from mod.cpp/meta.cpp; `is_server_mod` controls `-serverMod=` vs `-mod=` - `EnabledModEntry` type: `{ name: string, is_server_mod: boolean }` — used as `useSetEnabledMods` mutation input - `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 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 (167 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` | 8 | Init state, setAuth, clearAuth, localStorage sync, rehydration, partialize | | `ui.store.test.ts` | 5 | Init state, toggleSidebar, setActiveServer, add/remove notifications | | `logger.test.ts` | 10 | All 4 log methods, level filtering (debug/warn/error), message format | | `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` | 12 | Server CRUD + lifecycle hooks, useUpdateServer, useKillServer | | `useServerDetail.test.tsx` | 20+ | Config, players, bans, missions, mods, mutations, logfiles, 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 | | `CreateServerPage.test.tsx` | 14 | All 4 wizard steps, validation, submit, non-admin gate, API error handling | ### E2E Tests (38 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 **Server Detail — 5 UX phases** (15 tests, fully mocked): - Overview: server name/status, all 6 tabs visible - Config: field labels rendered (Hostname, BattlEye) - Missions: mission names, terrain names, Upload button - Mods: display names, enabled/disabled state - Players: player list, ping values, Kick buttons - Logs: collapsible Log Files section, Download buttons, live log viewer area **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`