- Fix logfiles_router and thread_registry to resolve .rpt log files from Path(server["exe_path"]).parent/server/ instead of the languard data dir, which never contained log files — log list and live tail both now work correctly - Rewrite get_ui_schema() in config_generator to cover all ~80 fields across all 5 sections (server/basic/profile/launch/rcon) with proper toggle/select/number/password/tag-list/hidden widgets and labels; missions field is hidden (managed by Missions tab) - Add formatSelectDisplay() to ConfigEditor so select fields show descriptive text (e.g. "0 - Never") instead of raw numbers in view mode - Add ToggleDisplay for boolean fields (Enabled/Disabled with indicator dot) - Add section tab labels and descriptions to ConfigEditor - Add MissionList UX hints and dynamic Add/In Rotation button labels - Add "hidden" to FieldSchema widget union type - Update API.md, ARCHITECTURE.md, CLAUDE.md, FRONTEND.md, MODULES.md, THREADING.md to document log path fix and schema coverage
358 lines
19 KiB
Markdown
358 lines
19 KiB
Markdown
# 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 />
|
||
├── 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` | 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, display_name, workshop_id }` — `display_name`/`workshop_id` from mod.cpp/meta.cpp
|
||
- `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 <token>` 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` |