diff --git a/.claude/plan/arma3-ux-enhancement.md b/.claude/plan/arma3-ux-enhancement.md index c70d940..56e498f 100644 --- a/.claude/plan/arma3-ux-enhancement.md +++ b/.claude/plan/arma3-ux-enhancement.md @@ -2,7 +2,23 @@ **Status:** APPROVED — Ready to implement **Branch:** main -**Estimated effort:** ~20h total (6 phases) +**Estimated effort:** ~20h total (5 active phases) + +--- + +## Progress Tracker + +> **IMPLEMENTING AGENT:** Update this section at the start and end of each session. Mark each phase `[x]` when ALL its checklist items pass. This is the only reliable way for the next session to know where to pick up. + +| Phase | Status | Last session note | +|-------|--------|------------------| +| 1 — Config UI Schema | `[ ] not started` | | +| 2 — Mission Rotation | `[ ] not started` | | +| 3 — Mod Display Names + Split Pane | `[ ] not started` | | +| 4 — Player Kick/Ban | `[ ] not started` | | +| 5 — Log File Browser | `[ ] not started` | | + +**How to resume:** Read this table first. Find the first phase that is not `[x] done`. Read only that phase section — do not re-read earlier phases. Run `cd frontend && npx tsc --noEmit` to confirm the build is clean before making any changes. --- @@ -159,8 +175,8 @@ frontend/src/ | 4 | Player Kick/Ban | MUST | ~3h | | 2 | Mission Rotation + Multi-file upload | MUST | ~5h | | 3 | Mod Display Names + Split Pane | GOOD | ~4h | -| 6 | Server Card Quick Actions | GOOD | ~1h | | 5 | Log File Browser + Level Filter | GOOD | ~3h | +| 6 | Server Card Quick Actions | ~~GOOD~~ | **DONE** | --- @@ -176,16 +192,17 @@ Add this method to `Arma3ConfigGenerator`: def get_ui_schema(self) -> dict: return { "server": { + # Field names MUST match Arma3ConfigGenerator.ServerConfig exactly "hostname": {"widget": "text", "label": "Server Hostname"}, - "max_players": {"widget": "number", "label": "Max Players", "min": 1, "max": 256}, + "max_players": {"widget": "number", "label": "Max Players", "min": 1, "max": 1000}, "password": {"widget": "password", "label": "Player Password"}, - "admin_password": {"widget": "password", "label": "Admin Password"}, + "password_admin": {"widget": "password", "label": "Admin Password"}, # NOT admin_password "motd_lines": {"widget": "textarea", "label": "Message of the Day (one line per row)"}, "forced_difficulty": {"widget": "select", "label": "Difficulty Preset", - "options": ["", "Recruit", "Regular", "Veteran", "Custom"]}, - "battle_eye": {"widget": "toggle", "label": "BattleEye Anti-Cheat"}, - "von": {"widget": "toggle", "label": "Voice over Net (VoN)"}, - "verify_signatures": {"widget": "toggle", "label": "Verify Addon Signatures"}, + "options": ["Recruit", "Regular", "Veteran", "Custom"]}, + "battleye": {"widget": "toggle", "label": "BattleEye Anti-Cheat"}, # NOT battle_eye + "disable_von": {"widget": "toggle", "label": "Disable Voice over Net (VoN)"}, # NOT von — and it's inverted + "verify_signatures": {"widget": "number", "label": "Verify Signatures (0=off, 1=on, 2=strict)", "min": 0, "max": 2}, "persistent": {"widget": "toggle", "label": "Persistent (keep running when empty)"}, "admin_uids": {"widget": "tag-list", "label": "Admin Steam UIDs", "placeholder": "76561198000000000"}, @@ -207,10 +224,11 @@ def get_ui_schema(self) -> dict: ### 1.2 `backend/core/servers/service.py` — add `get_config_schema()` +Follow the existing pattern: `ServerService.__init__` already stores `self._server_repo` and `self._config_repo`. No `db` param needed: ```python -async def get_config_schema(self, server_id: int, db: Session) -> dict: - server = self.server_repo.get_by_id(server_id, db) - adapter = self.adapter_registry.get(server.game_type) +def get_config_schema(self, server_id: int) -> dict: + server = self.get_server(server_id) # raises 404 if not found, uses self._server_repo + adapter = GameAdapterRegistry.get(server["game_type"]) config_gen = adapter.get_config_generator() if hasattr(config_gen, "get_ui_schema"): return config_gen.get_ui_schema() @@ -219,16 +237,16 @@ async def get_config_schema(self, server_id: int, db: Session) -> dict: ### 1.3 `backend/core/servers/router.py` — new endpoint -Add after existing config routes: +Add after existing config routes, following the `ServerService(db)` inline pattern: ```python @router.get("/{server_id}/config/schema") -async def get_config_schema( +def get_config_schema( server_id: int, - db: Session = Depends(get_db), - current_user: User = Depends(get_current_user), -): - schema = await server_service.get_config_schema(server_id, db) - return {"success": True, "data": schema} + db: Annotated[Connection, Depends(get_db)], + _user: Annotated[dict, Depends(get_current_user)], +) -> dict: + schema = ServerService(db).get_config_schema(server_id) + return {"success": True, "data": schema, "error": None} ``` ### 1.4 `frontend/src/hooks/useServerDetail.ts` — add schema types + hook @@ -282,7 +300,7 @@ export function TagListEditor({ value, onChange, placeholder, disabled }: TagLis {value.map((item, idx) => (
))} - @@ -351,31 +369,38 @@ class MissionRotationUpdate(BaseModel): config_version: int ``` -Add endpoints: +Add endpoints — follow the `ServerService(db)` inline pattern used by all existing routers: ```python -@router.get("/{server_id}/missions/rotation") -async def get_mission_rotation( - server_id: int, db=Depends(get_db), user=Depends(get_current_user) -): - # Read server config section "server", field "missions" - config = config_repo.get_section(server_id, "server", db) - missions = config.get("missions", []) - return {"success": True, "data": {"missions": missions}} +from typing import Annotated +from sqlalchemy.engine import Connection +from core.servers.service import ServerService -@router.put("/{server_id}/missions/rotation") -async def update_mission_rotation( +@router.get("/rotation") # prefix already includes /{server_id}/missions +def get_mission_rotation( + server_id: int, + db: Annotated[Connection, Depends(get_db)], + _user: Annotated[dict, Depends(get_current_user)], +) -> dict: + # "server" section is always seeded on create — never None for existing server + config = ServerService(db).get_config_section(server_id, "server") + missions = config.get("missions", []) + return {"success": True, "data": {"missions": missions}, "error": None} + +@router.put("/rotation") +def update_mission_rotation( server_id: int, body: MissionRotationUpdate, - db=Depends(get_db), - user=Depends(require_admin), -): - # Update "missions" field in server config section with optimistic locking - config_repo.update_field( - server_id, "server", "missions", - [e.model_dump() for e in body.missions], - body.config_version, db + db: Annotated[Connection, Depends(get_db)], + _admin: Annotated[dict, Depends(require_admin)], +) -> dict: + # ServerService.update_config_section() handles load-merge-upsert + 409 on conflict + updated = ServerService(db).update_config_section( + server_id=server_id, + section="server", + data={"missions": [e.model_dump() for e in body.missions]}, + expected_version=body.config_version, ) - return {"success": True, "data": {"missions": [e.model_dump() for e in body.missions]}} + return {"success": True, "data": {"missions": updated.get("missions", [])}, "error": None} ``` ### 2.3 `frontend/src/hooks/useServerDetail.ts` — rotation hooks + updated types @@ -415,8 +440,11 @@ export function useUpdateMissionRotation(serverId: number) { return useMutation({ mutationFn: (data: { missions: MissionRotationEntry[]; config_version: number }) => apiClient.put(`/api/servers/${serverId}/missions/rotation`, data), - onSuccess: () => - queryClient.invalidateQueries({ queryKey: ["missions", serverId, "rotation"] }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["missions", serverId, "rotation"] }); + // Invalidate server config section too — missions are stored inside it + queryClient.invalidateQueries({ queryKey: ["servers", serverId, "config", "server"] }); + }, }); } ``` @@ -464,7 +492,9 @@ Component layout — two sections: State: - `rotation: MissionRotationEntry[]` — local state, synced from query on load -- `uploadProgress: { filename: string; done: boolean }[]` — per-file status +- `uploadProgress: { filename: string; done: boolean }[]` — per-file status (sequential uploads) + +**config_version source:** Call `useServerConfigSection(serverId, "server")` inside `MissionList`. Read `sectionData._meta.config_version` and pass it as `config_version` when calling `useUpdateMissionRotation`. This hook already exists in `useServerDetail.ts`. --- @@ -474,7 +504,7 @@ State: ### 3.1 `backend/adapters/arma3/mod_manager.py` — add display_name + workshop_id -Add private helpers: +Add as **module-level functions** (not class methods — pure `Path → str | None`, no state needed, easier to test): ```python import re @@ -551,6 +581,12 @@ Bottom of component: ### 4.1 `backend/core/servers/players_router.py` — new action endpoints +Add imports at top of file: +```python +from pydantic import BaseModel +from dependencies import require_admin +``` + Add Pydantic schemas: ```python class KickRequest(BaseModel): @@ -561,56 +597,103 @@ class BanFromPlayerRequest(BaseModel): duration_minutes: int | None = None # None = permanent ``` -Add endpoints: +Add endpoints — follow existing `ServerService(db)` inline pattern: ```python -@router.post("/{server_id}/players/{slot_id}/kick") -async def kick_player( +@router.post("/{slot_id}/kick") # prefix already is /servers/{server_id}/players +def kick_player( server_id: int, slot_id: int, body: KickRequest, - db=Depends(get_db), user=Depends(require_admin) -): - await server_service.kick_player(server_id, slot_id, body.reason, db) - return {"success": True, "data": {"message": f"Player {slot_id} kicked"}} + db: Annotated[Connection, Depends(get_db)], + _admin: Annotated[dict, Depends(require_admin)], +) -> dict: + ServerService(db).kick_player(server_id, slot_id, body.reason) + return {"success": True, "data": {"message": f"Player {slot_id} kicked"}, "error": None} -@router.post("/{server_id}/players/{slot_id}/ban") -async def ban_player_from_list( +@router.post("/{slot_id}/ban") +def ban_player_from_list( server_id: int, slot_id: int, body: BanFromPlayerRequest, - db=Depends(get_db), user=Depends(require_admin) -): - ban = await server_service.ban_from_player( - server_id, slot_id, body.reason, body.duration_minutes, db + db: Annotated[Connection, Depends(get_db)], + admin: Annotated[dict, Depends(require_admin)], +) -> dict: + ban = ServerService(db).ban_from_player( + server_id, slot_id, body.reason, body.duration_minutes, + banned_by=admin["username"], ) - return {"success": True, "data": ban} + return {"success": True, "data": ban, "error": None} +``` + +### 4.2 `backend/core/dal/player_repository.py` — add `get_by_slot()` + +`get_by_slot()` does not exist yet. Add it. Note: slot_id is stored as a string in the DB (see `upsert()` which calls `str(player.get("slot_id", ""))`): +```python +def get_by_slot(self, server_id: int, slot_id: int) -> dict | None: + return self._fetchone( + "SELECT * FROM players WHERE server_id = :sid AND slot_id = :slot", + {"sid": server_id, "slot": str(slot_id)}, # cast to str — stored as string in DB + ) +``` + +### 4.2 `backend/core/threads/thread_registry.py` — add `get_rcon_client()` + +`ThreadRegistry.get_remote_admin()` does not exist. Add this class method. The RCon client lives on `bundle["rcon_poller"]._client` (verified from `RemoteAdminPollerThread.__init__`): +```python +@classmethod +def get_rcon_client(cls, server_id: int): + """Return the live Arma3RemoteAdmin client for a running server, or None.""" + registry = cls._get_instance() + if registry is None: + return None + bundle = registry._bundles.get(server_id) + if bundle is None: + return None + poller = bundle.get("rcon_poller") + if poller is None or not poller.is_alive(): + return None + return getattr(poller, "_client", None) ``` ### 4.2 `backend/core/servers/service.py` — new service methods ```python -async def kick_player(self, server_id: int, slot_id: int, reason: str, db: Session): - ra = self.thread_registry.get_remote_admin(server_id) +def kick_player(self, server_id: int, slot_id: int, reason: str) -> None: + from core.threads.thread_registry import ThreadRegistry + ra = ThreadRegistry.get_rcon_client(server_id) # use get_rcon_client, NOT get_remote_admin if not ra or not ra.is_connected(): - raise HTTPException(400, "RCon not connected — server must be running") - success = ra.kick_player(slot_id, reason) + raise HTTPException(status.HTTP_400_BAD_REQUEST, + detail={"code": "RCON_NOT_CONNECTED", "message": "RCon not connected — server must be running"}) + success = ra.kick_player(int(slot_id), reason) # kick_player takes int, slot stored as str if not success: - raise HTTPException(500, "Kick command failed") + raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={"code": "KICK_FAILED", "message": "Kick command failed"}) -async def ban_from_player( +def ban_from_player( self, server_id: int, slot_id: int, - reason: str, duration_minutes: int | None, db: Session + reason: str, duration_minutes: int | None, + banned_by: str, # pass admin["username"] from router — service never accepts User objects ) -> dict: - player = self.player_repo.get_by_slot(server_id, slot_id, db) + from datetime import datetime, timezone, timedelta + from core.dal.player_repository import PlayerRepository + from core.dal.ban_repository import BanRepository + player = PlayerRepository(self._db).get_by_slot(server_id, int(slot_id)) if not player: - raise HTTPException(404, "Player not found") - ra = self.thread_registry.get_remote_admin(server_id) + raise HTTPException(status.HTTP_404_NOT_FOUND, + detail={"code": "NOT_FOUND", "message": "Player not found"}) + # Convert duration_minutes → expires_at ISO string (None = permanent) + expires_at = None + if duration_minutes is not None and duration_minutes > 0: + expires_at = (datetime.now(timezone.utc) + timedelta(minutes=duration_minutes)).isoformat() + from core.threads.thread_registry import ThreadRegistry + ra = ThreadRegistry.get_rcon_client(server_id) if ra and ra.is_connected(): - ra.ban_player(player.guid, duration_minutes or 0, reason) - ban = self.ban_repo.create( - server_id=server_id, guid=player.guid, name=player.name, - reason=reason, banned_by=current_user.username, - duration_minutes=duration_minutes, db=db + ra.ban_player(player["guid"], duration_minutes or 0, reason) + ban_repo = BanRepository(self._db) + ban_id = ban_repo.create( + server_id=server_id, guid=player["guid"], name=player["name"], + reason=reason, banned_by=banned_by, + expires_at=expires_at, # BanRepository.create() takes expires_at, NOT duration_minutes ) - return ban.to_dict() + return dict(ban_repo.get_by_id(ban_id)) # create() returns int id, get_by_id() returns dict ``` ### 4.3 `frontend/src/hooks/useServerDetail.ts` — kick/ban mutations @@ -660,9 +743,12 @@ export function useBanPlayer(serverId: number) { ### 5.1 `backend/adapters/arma3/log_parser.py` — add file listing +> **Log path:** Arma 3 writes `.rpt` files to `C:\Users\\AppData\Local\Arma 3` by default on Windows. The log directory should be configurable. Use a `log_dir` setting from server config (fall back to `server_dir / "logs"` if not set). The implementation below uses `server_dir / "logs"` as the convention for this app; the `ARMA3_LOG_DIR` env var or a config field can override it per-server. + ```python def list_log_files(self, server_dir: Path) -> list[dict]: - log_dir = server_dir / "server" + import os + log_dir = Path(os.environ.get("ARMA3_LOG_DIR", str(server_dir / "logs"))) if not log_dir.exists(): return [] files = sorted(log_dir.glob("*.rpt"), key=lambda f: f.stat().st_mtime, reverse=True) @@ -688,6 +774,8 @@ def get_log_file_path(self, server_dir: Path, filename: str) -> Path: ### 5.2 `backend/core/servers/logfiles_router.py` — NEW file +> **Note:** `adapter.get_log_parser()` already exists on `Arma3Adapter` (returns `RPTParser()`). No adapter changes needed — just call `adapter.get_log_parser()` directly. + ```python from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import FileResponse @@ -794,42 +882,9 @@ Filter buttons: `[All] [Info] [Warning] [Error]` — active button uses `bg-acce --- -## Phase 6 — Server Card Quick Actions (GOOD TO HAVE, ~1h) +## Phase 6 — Server Card Quick Actions ~~(GOOD TO HAVE, ~1h)~~ **ALREADY IMPLEMENTED — SKIP** -**Goal:** Start/Stop from dashboard without navigating to detail page. - -### 6.1 `frontend/src/components/servers/ServerCard.tsx` - -Check `useServers.ts` for existing start/stop mutation hooks (look for `useStartServer`, `useStopServer`, or `useServerAction`). Import and use them. - -Add to card footer: -```typescript -// Show Start when stopped or crashed -{(server.status === "stopped" || server.status === "crashed") && ( - -)} - -// Show Stop when running or starting -{(server.status === "running" || server.status === "starting") && ( - -)} -``` - -Note: `e.preventDefault()` is needed if the card itself is a link/clickable element — prevents navigation when clicking the action button. +**Verified:** `frontend/src/components/servers/ServerCard.tsx` already has full Start/Stop/Restart quick-action buttons (lines 71–105), including `e.preventDefault()` + `e.stopPropagation()`, pending states, lucide icons (`Play`, `Square`, `RotateCcw`), and error notifications via `useUIStore`. Nothing to do here. --- @@ -843,7 +898,7 @@ Note: `e.preventDefault()` is needed if the card itself is a link/clickable elem 4. **Optimistic locking** — Config PUT endpoints require `config_version` in the body (from `_meta.config_version` in the fetched config). A 409 response = conflict; display an error message to user. -5. **CSS classes** — Use existing utility classes: `neu-card`, `btn-primary`, `btn-secondary`, `btn-ghost`, `input-base`, `text-text-primary`, `text-text-muted`, `text-status-crashed`, `bg-accent`, `bg-surface-recessed`, `shadow-neu-recessed`, `shadow-neu-raised`. Do NOT add new CSS files. +5. **CSS classes** — Existing utility classes: `neu-card`, `btn-primary`, `btn-ghost`, `btn-danger`, `neu-input`, `text-text-primary`, `text-text-muted`, `text-status-crashed`, `bg-accent`, `bg-surface-recessed`, `shadow-neu-recessed`, `shadow-neu-raised`. `btn-secondary` and `input-base` do NOT exist — use `btn-ghost` and `neu-input` respectively. Do NOT add new CSS files. 6. **Test file location** — `frontend/src/__tests__/`. Mock hooks with `vi.mock("@/hooks/...")`. Follow `CreateServerPage.test.tsx` and existing test patterns. @@ -851,6 +906,28 @@ Note: `e.preventDefault()` is needed if the card itself is a link/clickable elem 8. **Immutability** — Never mutate state directly. Use spread (`[...arr]`, `{...obj}`) for all state updates. +9. **Missing config fields** — If a field exists in `get_ui_schema()` but is absent from the current section data, render it with an empty/default value (not hidden). + +10. **Boolean config fields** — Send as string `"true"` / `"false"`. The backend converts to actual Python bool. + +11. **Password fields** — Render as editable `` in edit mode with a show/hide toggle button. Toggle state is component-local (resets to hidden on navigation/reload). + +12. **Multi-file upload** — Sequential, one file at a time. Show per-file `{ filename, done }` progress. + +13. **Responsive layout** — Split-pane components (mods, potentially others) stack vertically on small screens using `flex-col` below `md:` breakpoint (`md:flex-row`). + +14. **Mod folder names** — Show `display_name` when available; fall back to `name` (the raw folder path, e.g. `@CBA_A3`) as-is without stripping `@`. + +15. **Kick/Ban buttons when offline** — Both buttons always visible for admins, disabled with `title="Server must be running"` tooltip when `server.status !== "running"`. + +16. **Kick UX** — Use a modal dialog (same pattern as Ban) for consistency. Do not use inline row expansion. + +17. **Ban duration** — Both presets (1h / 24h / 7d / Permanent) AND a free-text minutes input. Permanent = send `duration_minutes: null`. + +18. **Log file download** — Blob fetch via `apiClient` → `URL.createObjectURL()` → programmatic anchor click. Never open in new tab (auth header not sent by browser for new-tab navigations). + +19. **Delete confirmations** — Use a small modal dialog component, not `window.confirm()` (blocks browser events). + --- ## Testing Checklist @@ -881,9 +958,7 @@ cd frontend && npx tsc --noEmit - [ ] Click mod in Available pane → moves to Selected; click Apply → `enabled = true` in response - [ ] Search filter narrows visible mods in each pane -### Phase 6 (Quick Actions) -- [ ] Stopped server card shows ▶ Start; click → status transitions to running without navigation -- [ ] Running server card shows ■ Stop; click → status transitions to stopped +### Phase 6 (Quick Actions) — ALREADY IMPLEMENTED, no tests needed ### Phase 5 (Log Files) - [ ] After server runs ≥1 min: Log Files section shows `.rpt` file diff --git a/CLAUDE.md b/CLAUDE.md index 80d3916..0d2d31e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,26 +30,55 @@ FastAPI + SQLite backend, React 19 + TypeScript + Vite frontend. See ARCHITECTUR ### Backend: Fully implemented (42+ endpoints) All routers, services, repositories, game adapter system, WebSocket, background threads, and scheduled cleanup are complete. -### Frontend: Mostly implemented +### Frontend: Fully implemented (baseline) | Route | Status | Notes | |-------|--------|-------| | `/login` | Complete | Zod + react-hook-form validation | -| `/` | Complete | Dashboard with server grid | +| `/` | Complete | Dashboard with server grid + Start/Stop/Restart quick actions | | `/servers/:id` | Complete | 7-tab detail page (overview, config, players, bans, missions, mods, logs) | | `/servers/new` | Complete | 4-step wizard with per-step validation via `trigger()` | | `/settings` | Complete | Password change + admin user management | ### Frontend Type Mapping (API → Frontend) +Types below reflect the **current** API shape. Fields marked `(planned)` will be added during the UX enhancement plan. + | API Resource | Frontend Type | Key Fields | |---|---|---| | Server (enriched) | `Server` in useServers.ts | `game_port`, `current_players`, `max_players`, `cpu_percent`, `ram_mb` | -| Mission | `Mission` in useServerDetail.ts | `name`, `filename`, `size_bytes` | -| Mod | `Mod` in useServerDetail.ts | `name`, `path`, `size_bytes`, `enabled` | +| Mission | `Mission` in useServerDetail.ts | `name`, `filename`, `size_bytes`, `terrain` *(planned)* | +| Mod | `Mod` in useServerDetail.ts | `name`, `path`, `size_bytes`, `enabled`, `display_name` *(planned)*, `workshop_id` *(planned)* | | Ban | `Ban` in useServerDetail.ts | `id`, `server_id`, `guid`, `name`, `reason`, `banned_by`, `banned_at`, `expires_at`, `is_active`, `game_data` | | Player | `Player` in useServerDetail.ts | `id`, `slot_id`, `name`, `guid`, `ip`, `ping` | +### Upcoming: UX Enhancement Plan + +**Plan file:** `.claude/plan/arma3-ux-enhancement.md` — approved, ready to implement. + +| Phase | Feature | Status | +|-------|---------|--------| +| 1 | Config field UI widgets (textarea/toggle/select/tag-list per field) | Pending | +| 2 | Mission rotation table + multi-file upload | Pending | +| 3 | Mod display names (mod.cpp) + split-pane selector | Pending | +| 4 | Player Kick/Ban from Players tab via RCon | Pending | +| 5 | Historical log file browser + live log level filter | Pending | + +**New endpoints added by the plan:** +- `GET /api/servers/{id}/config/schema` — per-field widget hints +- `GET|PUT /api/servers/{id}/missions/rotation` — mission rotation +- `POST /api/servers/{id}/players/{slot_id}/kick` +- `POST /api/servers/{id}/players/{slot_id}/ban` +- `GET /api/servers/{id}/logfiles` +- `GET /api/servers/{id}/logfiles/{filename}/download` +- `DELETE /api/servers/{id}/logfiles/{filename}` + +**New backend additions:** +- `Arma3ConfigGenerator.get_ui_schema()` — widget schema per config field +- `PlayerRepository.get_by_slot()` — lookup player by slot_id +- `ThreadRegistry.get_rcon_client()` — expose live RCon client for kick/ban +- `RPTParser.list_log_files()` / `get_log_file_path()` — historical log access + ## Test Commands ```bash @@ -62,8 +91,9 @@ cd frontend && npx tsc --noEmit # Backend (no test suite yet) ``` -## Future Enhancements (user requested) +## Key Implementation Notes -- Config sub-tab redesign for user-friendliness (non-technical users) -- "Choose mission" button that auto-selects mission for server config -- Mission rotation management \ No newline at end of file +- `BanRepository.create()` takes `expires_at` (ISO string), not `duration_minutes` — convert in service +- `slot_id` is stored as a string in the `players` table — cast with `str(slot_id)` in queries +- Config field names in `ServerConfig` Pydantic model: `password_admin` (not `admin_password`), `battleye` (not `battle_eye`), `disable_von` (not `von`) +- Log directory defaults to `ARMA3_LOG_DIR` env var, falls back to `{server_dir}/logs` \ No newline at end of file diff --git a/frontend/README.md b/frontend/README.md index 7dbf7eb..a539d42 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,73 +1,72 @@ -# React + TypeScript + Vite +# Languard Server Manager — Frontend -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +React 19 + TypeScript + Vite frontend for the Languard game server management panel. -Currently, two official plugins are available: +## Stack -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) +- **React 19** with hooks +- **TypeScript** strict mode +- **Vite** dev server + build +- **TanStack Query** for server state (all API calls) +- **Zustand** for client state (auth, UI notifications) +- **react-hook-form + Zod** for form validation +- **Tailwind CSS** with custom neumorphic design tokens +- **Vitest** for unit tests -## React Compiler +## Dev Server -The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). - -## Expanding the ESLint configuration - -If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: - -```js -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - - // Remove tseslint.configs.recommended and replace with this - tseslint.configs.recommendedTypeChecked, - // Alternatively, use this for stricter rules - tseslint.configs.strictTypeChecked, - // Optionally, add this for stylistic rules - tseslint.configs.stylisticTypeChecked, - - // Other configs... - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) +```bash +# From this directory +npx vite --host +# → http://localhost:5173 ``` -You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: +## Tests -```js -// eslint.config.js -import reactX from 'eslint-plugin-react-x' -import reactDom from 'eslint-plugin-react-dom' - -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - // Enable lint rules for React - reactX.configs['recommended-typescript'], - // Enable lint rules for React DOM - reactDom.configs.recommended, - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) +```bash +npx vitest run # run once +npx vitest # watch mode +npx tsc --noEmit # type check only ``` + +## Project Structure + +``` +src/ +├── components/ +│ ├── layout/ # Sidebar +│ ├── servers/ # ServerCard, ConfigEditor, PlayerTable, MissionList, ModList, LogViewer, BanTable +│ ├── settings/ # PasswordChange, UserManager +│ └── ui/ # StatusLed, (planned) TagListEditor, ConfirmModal +├── hooks/ +│ ├── useServers.ts # Dashboard server list + start/stop/restart mutations +│ ├── useServerDetail.ts # All per-server queries and mutations +│ ├── useAuth.ts +│ └── useWebSocket.ts # Real-time events (logs, status changes) +├── pages/ +│ ├── LoginPage.tsx +│ ├── DashboardPage.tsx +│ ├── ServerDetailPage.tsx +│ ├── CreateServerPage.tsx +│ └── SettingsPage.tsx +├── store/ +│ ├── auth.store.ts # JWT + user role +│ └── ui.store.ts # Notification queue +└── lib/ + ├── api.ts # Axios instance with JWT interceptor + 401 redirect + └── logger.ts +``` + +## CSS Conventions + +Custom utility classes defined in `src/index.css` (do not add new CSS files): + +| Class | Use | +|-------|-----| +| `neu-card` | Card surface with neumorphic raised shadow | +| `neu-input` | Input with recessed shadow | +| `btn-primary` | Amber accent button | +| `btn-ghost` | Text-only button with hover background | +| `btn-danger` | Red destructive button | + +Tailwind design tokens in `tailwind.config.js`: `surface-{base,raised,recessed,overlay}`, `text-{primary,secondary,muted}`, `status-{running,stopped,crashed,starting,restarting}`, `accent`. diff --git a/frontend/tests-e2e/auth/login.spec.ts b/frontend/tests-e2e/auth/login.spec.ts index 9fc9180..18e51bf 100644 --- a/frontend/tests-e2e/auth/login.spec.ts +++ b/frontend/tests-e2e/auth/login.spec.ts @@ -29,11 +29,18 @@ test.describe("Login Flow", () => { }); test("should show error on invalid credentials", async ({ page }) => { + // Mock the backend to return 401 for invalid login + await page.route("**/api/auth/login", (route) => + route.fulfill({ + status: 401, + contentType: "application/json", + body: JSON.stringify({ + detail: "Invalid credentials", + }), + }), + ); + await loginPage.login("invalid", "credentials"); - await page.waitForResponse( - (resp) => resp.url().includes("/api/auth/login"), - { timeout: 10_000 }, - ).catch(() => {}); await expect(loginPage.errorMessage).toBeVisible({ timeout: 10_000 }); }); @@ -52,7 +59,16 @@ test.describe("Login Flow", () => { }), ); - await page.route("**/api/servers*", (route) => + // Mock auth/me and servers so the dashboard loads + await page.route("**/api/auth/me", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ success: true, data: { id: 1, username: "admin", role: "admin" }, error: null }), + }), + ); + + await page.route("**/api/servers**", (route) => route.fulfill({ status: 200, contentType: "application/json", @@ -66,8 +82,14 @@ test.describe("Login Flow", () => { }); test("should show loading state while submitting", async ({ page }) => { - await page.route("**/api/auth/login", (route) => - route.fulfill({ + let resolveLogin: (value: unknown) => void; + const loginPromise = new Promise((resolve) => { + resolveLogin = resolve; + }); + + await page.route("**/api/auth/login", async (route) => { + await loginPromise; + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ @@ -77,19 +99,19 @@ test.describe("Login Flow", () => { user: { id: 1, username: "admin", role: "admin" }, }, }), - delay: 500, - }), - ); + }); + }); - await page.route("**/api/servers*", (route) => - route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ success: true, data: [] }), - }), - ); + await loginPage.usernameInput.fill("admin"); + await loginPage.passwordInput.fill("password"); - await loginPage.login("admin", "password"); - await expect(loginPage.submitButton).toContainText("Signing in..."); + // Click submit and immediately check for loading state + await loginPage.submitButton.click(); + + // The button should show "Signing in..." while the request is pending + await expect(loginPage.submitButton).toContainText("Signing in...", { timeout: 5_000 }); + + // Resolve the login to let the test finish + resolveLogin!("done"); }); }); \ No newline at end of file diff --git a/frontend/tests-e2e/dashboard/dashboard.spec.ts b/frontend/tests-e2e/dashboard/dashboard.spec.ts index f96d521..e0f2a94 100644 --- a/frontend/tests-e2e/dashboard/dashboard.spec.ts +++ b/frontend/tests-e2e/dashboard/dashboard.spec.ts @@ -1,138 +1,224 @@ import { test, expect } from "@playwright/test"; import { DashboardPage } from "../pages/DashboardPage"; +const MOCK_TOKEN = "mock-jwt-token"; + +const MOCK_USER = { id: 1, username: "admin", role: "admin" }; + +const MOCK_SERVERS = [ + { + id: 1, + name: "A3Master", + game_type: "arma3", + status: "running", + port: 2302, + max_players: 64, + current_players: 32, + restart_count: 2, + auto_restart: true, + created_at: "2026-01-01T00:00:00Z", + }, + { + id: 2, + name: "Arma3 Test Server", + game_type: "arma3", + status: "stopped", + port: 2303, + max_players: 32, + current_players: 0, + restart_count: 0, + auto_restart: false, + created_at: "2026-01-02T00:00:00Z", + }, +]; + +/** + * Set up auth + mock all API calls the dashboard needs. + * IMPORTANT: Playwright checks routes in reverse registration order (last registered = first checked). + * So we register the catch-all FIRST, then specific routes AFTER so they take priority. + */ +async function setupDashboardMocks(page: import("@playwright/test").Page, servers = MOCK_SERVERS) { + // Set mock auth state in localStorage for both: + // 1) The Zustand persist store (key: languard-auth) so ProtectedLayout sees isAuthenticated: true + // 2) The raw token (key: languard_token) so the Axios interceptor adds the Bearer header + await page.addInitScript(({ token, user }) => { + localStorage.setItem("languard_token", token); + localStorage.setItem( + "languard-auth", + JSON.stringify({ state: { token, user }, version: 0 }), + ); + }, { token: MOCK_TOKEN, user: MOCK_USER }); + + // Catch-all for unhandled API calls — register FIRST so it has lowest priority + await page.route("**/api/**", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ success: true, data: null, error: null }), + }), + ); + + // Specific routes — register AFTER catch-all so they take priority + await page.route("**/api/auth/me", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ success: true, data: MOCK_USER, error: null }), + }), + ); + + await page.route("**/api/servers**", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ success: true, data: servers, error: null }), + }), + ); +} + test.describe("Dashboard", () => { let dashboardPage: DashboardPage; test.beforeEach(async ({ page }) => { - // Set up auth token so we're logged in - await page.addInitScript(() => { - localStorage.setItem("languard_token", "mock-jwt-token"); - }); - - // Mock the servers API - await page.route("**/api/servers*", (route) => - route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - success: true, - data: [ - { - id: 1, - name: "Arma3 Main Server", - game_type: "arma3", - status: "running", - port: 2302, - max_players: 64, - current_players: 32, - restart_count: 2, - auto_restart: true, - created_at: "2026-01-01T00:00:00Z", - }, - { - id: 2, - name: "Arma3 Test Server", - game_type: "arma3", - status: "stopped", - port: 2303, - max_players: 32, - current_players: 0, - restart_count: 0, - auto_restart: false, - created_at: "2026-01-02T00:00:00Z", - }, - ], - }), - }), - ); - + await setupDashboardMocks(page); dashboardPage = new DashboardPage(page); await dashboardPage.goto(); }); test("should display dashboard header", async () => { - await expect(dashboardPage.content).toBeVisible(); + await expect(dashboardPage.content).toBeVisible({ timeout: 10_000 }); await expect(dashboardPage.content.locator("h1")).toContainText("Dashboard"); }); test("should show server count", async () => { + await expect(dashboardPage.content).toBeVisible({ timeout: 10_000 }); await expect(dashboardPage.content.locator("text=2 servers configured")).toBeVisible(); }); test("should render server cards", async () => { + await expect(dashboardPage.serverCards.first()).toBeVisible({ timeout: 10_000 }); const count = await dashboardPage.getServerCount(); expect(count).toBe(2); }); test("should display server names in cards", async () => { + await expect(dashboardPage.serverCards.first()).toBeVisible({ timeout: 10_000 }); const name = await dashboardPage.getServerCardName(0); - expect(name).toContain("Arma3 Main Server"); + expect(name).toContain("A3Master"); }); test("should show Add Server button", async () => { + await expect(dashboardPage.content).toBeVisible({ timeout: 10_000 }); await expect(dashboardPage.addServerButton).toBeVisible(); await expect(dashboardPage.addServerButton).toContainText("Add Server"); }); test("should show sidebar with server list", async () => { - await expect(dashboardPage.sidebar).toBeVisible(); + await expect(dashboardPage.sidebar).toBeVisible({ timeout: 10_000 }); await expect(dashboardPage.sidebar.locator("text=Servers")).toBeVisible(); - await expect(dashboardPage.sidebar.locator("text=Arma3 Main Server")).toBeVisible(); + await expect(dashboardPage.sidebar.locator("text=A3Master")).toBeVisible(); }); test("should show Stop button for running server", async () => { + await expect(dashboardPage.serverCards.first()).toBeVisible({ timeout: 10_000 }); const firstCard = dashboardPage.serverCards.nth(0); await expect(firstCard.locator('button[aria-label^="Stop"]')).toBeVisible(); }); test("should show Start button for stopped server", async () => { + await expect(dashboardPage.serverCards.nth(1)).toBeVisible({ timeout: 10_000 }); const secondCard = dashboardPage.serverCards.nth(1); await expect(secondCard.locator('button[aria-label^="Start"]')).toBeVisible(); }); test("should display player count in server card", async () => { + await expect(dashboardPage.serverCards.first()).toBeVisible({ timeout: 10_000 }); const firstCard = dashboardPage.serverCards.nth(0); await expect(firstCard.locator("text=32/64")).toBeVisible(); }); test("should navigate to server detail on card click", async ({ page }) => { - const firstCard = dashboardPage.serverCards.nth(0); - const link = firstCard.locator("xpath=ancestor::a"); - await link.click(); - await expect(page).toHaveURL(/\/servers\/1/); + await expect(dashboardPage.serverCards.first()).toBeVisible({ timeout: 10_000 }); + // Click the server card link (not the sidebar link) — use .first() to avoid strict mode + const serverLink = page.locator('[data-testid="dashboard-content"] a[href="/servers/1"]'); + await serverLink.click(); + await expect(page).toHaveURL(/\/servers\/1/, { timeout: 5_000 }); }); }); test.describe("Dashboard - Empty State", () => { test("should show empty state when no servers", async ({ page }) => { - await page.addInitScript(() => { - localStorage.setItem("languard_token", "mock-jwt-token"); - }); + await page.addInitScript(({ token, user }) => { + localStorage.setItem("languard_token", token); + localStorage.setItem( + "languard-auth", + JSON.stringify({ state: { token, user }, version: 0 }), + ); + }, { token: MOCK_TOKEN, user: MOCK_USER }); - await page.route("**/api/servers*", (route) => + // Catch-all first (lowest priority) + await page.route("**/api/**", (route) => route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify({ success: true, data: [] }), + body: JSON.stringify({ success: true, data: null, error: null }), + }), + ); + + // Specific routes after (higher priority) + await page.route("**/api/auth/me", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ success: true, data: MOCK_USER, error: null }), + }), + ); + + await page.route("**/api/servers**", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ success: true, data: [], error: null }), }), ); const dashboardPage = new DashboardPage(page); await dashboardPage.goto(); - await expect(dashboardPage.emptyState).toBeVisible(); + await expect(dashboardPage.emptyState).toBeVisible({ timeout: 10_000 }); await expect(dashboardPage.emptyState.locator("text=No servers configured yet")).toBeVisible(); }); }); test.describe("Dashboard - Error State", () => { test("should show error state when API fails", async ({ page }) => { - await page.addInitScript(() => { - localStorage.setItem("languard_token", "mock-jwt-token"); - }); + await page.addInitScript(({ token, user }) => { + localStorage.setItem("languard_token", token); + localStorage.setItem( + "languard-auth", + JSON.stringify({ state: { token, user }, version: 0 }), + ); + }, { token: MOCK_TOKEN, user: MOCK_USER }); - await page.route("**/api/servers*", (route) => + // Catch-all first (lowest priority) + await page.route("**/api/**", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ success: true, data: null, error: null }), + }), + ); + + // Specific routes after (higher priority) + await page.route("**/api/auth/me", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ success: true, data: MOCK_USER, error: null }), + }), + ); + + await page.route("**/api/servers**", (route) => route.fulfill({ status: 500, contentType: "application/json",