docs: finalise Arma 3 UX enhancement plan and update project docs
- .claude/plan/arma3-ux-enhancement.md: full plan review pass
- Add Progress Tracker table for session handoff
- Fix Phase 1 field names to match ServerConfig model (password_admin,
battleye, disable_von)
- Fix Phase 2 rotation endpoints to use ServerService(db) inline pattern
- Fix Phase 4 router/service: add get_by_slot() to PlayerRepository,
add get_rcon_client() to ThreadRegistry, fix BanRepository.create()
signature (expires_at not duration_minutes), correct router pattern
- Fix Phase 6: already implemented, mark as SKIP
- Fix CSS class names: btn-secondary→btn-ghost, input-base→neu-input
- Add 19 implementation decisions from Q&A session to Coding Conventions
- CLAUDE.md: update status table, type mapping table, add plan summary
and new endpoint list, add key implementation gotchas section
- frontend/README.md: replace Vite boilerplate with project README
- frontend/tests-e2e: E2E test improvements from previous session
(mock-based login error test, full dashboard mock coverage)
This commit is contained in:
@@ -2,7 +2,23 @@
|
|||||||
|
|
||||||
**Status:** APPROVED — Ready to implement
|
**Status:** APPROVED — Ready to implement
|
||||||
**Branch:** main
|
**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 |
|
| 4 | Player Kick/Ban | MUST | ~3h |
|
||||||
| 2 | Mission Rotation + Multi-file upload | MUST | ~5h |
|
| 2 | Mission Rotation + Multi-file upload | MUST | ~5h |
|
||||||
| 3 | Mod Display Names + Split Pane | GOOD | ~4h |
|
| 3 | Mod Display Names + Split Pane | GOOD | ~4h |
|
||||||
| 6 | Server Card Quick Actions | GOOD | ~1h |
|
|
||||||
| 5 | Log File Browser + Level Filter | GOOD | ~3h |
|
| 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:
|
def get_ui_schema(self) -> dict:
|
||||||
return {
|
return {
|
||||||
"server": {
|
"server": {
|
||||||
|
# Field names MUST match Arma3ConfigGenerator.ServerConfig exactly
|
||||||
"hostname": {"widget": "text", "label": "Server Hostname"},
|
"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"},
|
"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)"},
|
"motd_lines": {"widget": "textarea", "label": "Message of the Day (one line per row)"},
|
||||||
"forced_difficulty": {"widget": "select", "label": "Difficulty Preset",
|
"forced_difficulty": {"widget": "select", "label": "Difficulty Preset",
|
||||||
"options": ["", "Recruit", "Regular", "Veteran", "Custom"]},
|
"options": ["Recruit", "Regular", "Veteran", "Custom"]},
|
||||||
"battle_eye": {"widget": "toggle", "label": "BattleEye Anti-Cheat"},
|
"battleye": {"widget": "toggle", "label": "BattleEye Anti-Cheat"}, # NOT battle_eye
|
||||||
"von": {"widget": "toggle", "label": "Voice over Net (VoN)"},
|
"disable_von": {"widget": "toggle", "label": "Disable Voice over Net (VoN)"}, # NOT von — and it's inverted
|
||||||
"verify_signatures": {"widget": "toggle", "label": "Verify Addon Signatures"},
|
"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)"},
|
"persistent": {"widget": "toggle", "label": "Persistent (keep running when empty)"},
|
||||||
"admin_uids": {"widget": "tag-list", "label": "Admin Steam UIDs",
|
"admin_uids": {"widget": "tag-list", "label": "Admin Steam UIDs",
|
||||||
"placeholder": "76561198000000000"},
|
"placeholder": "76561198000000000"},
|
||||||
@@ -207,10 +224,11 @@ def get_ui_schema(self) -> dict:
|
|||||||
|
|
||||||
### 1.2 `backend/core/servers/service.py` — add `get_config_schema()`
|
### 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
|
```python
|
||||||
async def get_config_schema(self, server_id: int, db: Session) -> dict:
|
def get_config_schema(self, server_id: int) -> dict:
|
||||||
server = self.server_repo.get_by_id(server_id, db)
|
server = self.get_server(server_id) # raises 404 if not found, uses self._server_repo
|
||||||
adapter = self.adapter_registry.get(server.game_type)
|
adapter = GameAdapterRegistry.get(server["game_type"])
|
||||||
config_gen = adapter.get_config_generator()
|
config_gen = adapter.get_config_generator()
|
||||||
if hasattr(config_gen, "get_ui_schema"):
|
if hasattr(config_gen, "get_ui_schema"):
|
||||||
return 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
|
### 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
|
```python
|
||||||
@router.get("/{server_id}/config/schema")
|
@router.get("/{server_id}/config/schema")
|
||||||
async def get_config_schema(
|
def get_config_schema(
|
||||||
server_id: int,
|
server_id: int,
|
||||||
db: Session = Depends(get_db),
|
db: Annotated[Connection, Depends(get_db)],
|
||||||
current_user: User = Depends(get_current_user),
|
_user: Annotated[dict, Depends(get_current_user)],
|
||||||
):
|
) -> dict:
|
||||||
schema = await server_service.get_config_schema(server_id, db)
|
schema = ServerService(db).get_config_schema(server_id)
|
||||||
return {"success": True, "data": schema}
|
return {"success": True, "data": schema, "error": None}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 1.4 `frontend/src/hooks/useServerDetail.ts` — add schema types + hook
|
### 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) => (
|
{value.map((item, idx) => (
|
||||||
<div key={idx} className="flex gap-2">
|
<div key={idx} className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
className="flex-1 input-base"
|
className="flex-1 neu-input"
|
||||||
value={item}
|
value={item}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@@ -298,7 +316,7 @@ export function TagListEditor({ value, onChange, placeholder, disabled }: TagLis
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<button type="button" onClick={add} disabled={disabled} className="btn-secondary text-sm">
|
<button type="button" onClick={add} disabled={disabled} className="btn-ghost text-sm">
|
||||||
+ Add
|
+ Add
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -351,31 +369,38 @@ class MissionRotationUpdate(BaseModel):
|
|||||||
config_version: int
|
config_version: int
|
||||||
```
|
```
|
||||||
|
|
||||||
Add endpoints:
|
Add endpoints — follow the `ServerService(db)` inline pattern used by all existing routers:
|
||||||
```python
|
```python
|
||||||
@router.get("/{server_id}/missions/rotation")
|
from typing import Annotated
|
||||||
async def get_mission_rotation(
|
from sqlalchemy.engine import Connection
|
||||||
server_id: int, db=Depends(get_db), user=Depends(get_current_user)
|
from core.servers.service import ServerService
|
||||||
):
|
|
||||||
# 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}}
|
|
||||||
|
|
||||||
@router.put("/{server_id}/missions/rotation")
|
@router.get("/rotation") # prefix already includes /{server_id}/missions
|
||||||
async def update_mission_rotation(
|
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,
|
server_id: int,
|
||||||
body: MissionRotationUpdate,
|
body: MissionRotationUpdate,
|
||||||
db=Depends(get_db),
|
db: Annotated[Connection, Depends(get_db)],
|
||||||
user=Depends(require_admin),
|
_admin: Annotated[dict, Depends(require_admin)],
|
||||||
):
|
) -> dict:
|
||||||
# Update "missions" field in server config section with optimistic locking
|
# ServerService.update_config_section() handles load-merge-upsert + 409 on conflict
|
||||||
config_repo.update_field(
|
updated = ServerService(db).update_config_section(
|
||||||
server_id, "server", "missions",
|
server_id=server_id,
|
||||||
[e.model_dump() for e in body.missions],
|
section="server",
|
||||||
body.config_version, db
|
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
|
### 2.3 `frontend/src/hooks/useServerDetail.ts` — rotation hooks + updated types
|
||||||
@@ -415,8 +440,11 @@ export function useUpdateMissionRotation(serverId: number) {
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (data: { missions: MissionRotationEntry[]; config_version: number }) =>
|
mutationFn: (data: { missions: MissionRotationEntry[]; config_version: number }) =>
|
||||||
apiClient.put(`/api/servers/${serverId}/missions/rotation`, data),
|
apiClient.put(`/api/servers/${serverId}/missions/rotation`, data),
|
||||||
onSuccess: () =>
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["missions", serverId, "rotation"] }),
|
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:
|
State:
|
||||||
- `rotation: MissionRotationEntry[]` — local state, synced from query on load
|
- `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
|
### 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
|
```python
|
||||||
import re
|
import re
|
||||||
|
|
||||||
@@ -551,6 +581,12 @@ Bottom of component:
|
|||||||
|
|
||||||
### 4.1 `backend/core/servers/players_router.py` — new action endpoints
|
### 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:
|
Add Pydantic schemas:
|
||||||
```python
|
```python
|
||||||
class KickRequest(BaseModel):
|
class KickRequest(BaseModel):
|
||||||
@@ -561,56 +597,103 @@ class BanFromPlayerRequest(BaseModel):
|
|||||||
duration_minutes: int | None = None # None = permanent
|
duration_minutes: int | None = None # None = permanent
|
||||||
```
|
```
|
||||||
|
|
||||||
Add endpoints:
|
Add endpoints — follow existing `ServerService(db)` inline pattern:
|
||||||
```python
|
```python
|
||||||
@router.post("/{server_id}/players/{slot_id}/kick")
|
@router.post("/{slot_id}/kick") # prefix already is /servers/{server_id}/players
|
||||||
async def kick_player(
|
def kick_player(
|
||||||
server_id: int, slot_id: int,
|
server_id: int, slot_id: int,
|
||||||
body: KickRequest,
|
body: KickRequest,
|
||||||
db=Depends(get_db), user=Depends(require_admin)
|
db: Annotated[Connection, Depends(get_db)],
|
||||||
):
|
_admin: Annotated[dict, Depends(require_admin)],
|
||||||
await server_service.kick_player(server_id, slot_id, body.reason, db)
|
) -> dict:
|
||||||
return {"success": True, "data": {"message": f"Player {slot_id} kicked"}}
|
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")
|
@router.post("/{slot_id}/ban")
|
||||||
async def ban_player_from_list(
|
def ban_player_from_list(
|
||||||
server_id: int, slot_id: int,
|
server_id: int, slot_id: int,
|
||||||
body: BanFromPlayerRequest,
|
body: BanFromPlayerRequest,
|
||||||
db=Depends(get_db), user=Depends(require_admin)
|
db: Annotated[Connection, Depends(get_db)],
|
||||||
):
|
admin: Annotated[dict, Depends(require_admin)],
|
||||||
ban = await server_service.ban_from_player(
|
) -> dict:
|
||||||
server_id, slot_id, body.reason, body.duration_minutes, db
|
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
|
### 4.2 `backend/core/servers/service.py` — new service methods
|
||||||
|
|
||||||
```python
|
```python
|
||||||
async def kick_player(self, server_id: int, slot_id: int, reason: str, db: Session):
|
def kick_player(self, server_id: int, slot_id: int, reason: str) -> None:
|
||||||
ra = self.thread_registry.get_remote_admin(server_id)
|
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():
|
if not ra or not ra.is_connected():
|
||||||
raise HTTPException(400, "RCon not connected — server must be running")
|
raise HTTPException(status.HTTP_400_BAD_REQUEST,
|
||||||
success = ra.kick_player(slot_id, reason)
|
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:
|
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,
|
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:
|
) -> 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:
|
if not player:
|
||||||
raise HTTPException(404, "Player not found")
|
raise HTTPException(status.HTTP_404_NOT_FOUND,
|
||||||
ra = self.thread_registry.get_remote_admin(server_id)
|
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():
|
if ra and ra.is_connected():
|
||||||
ra.ban_player(player.guid, duration_minutes or 0, reason)
|
ra.ban_player(player["guid"], duration_minutes or 0, reason)
|
||||||
ban = self.ban_repo.create(
|
ban_repo = BanRepository(self._db)
|
||||||
server_id=server_id, guid=player.guid, name=player.name,
|
ban_id = ban_repo.create(
|
||||||
reason=reason, banned_by=current_user.username,
|
server_id=server_id, guid=player["guid"], name=player["name"],
|
||||||
duration_minutes=duration_minutes, db=db
|
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
|
### 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
|
### 5.1 `backend/adapters/arma3/log_parser.py` — add file listing
|
||||||
|
|
||||||
|
> **Log path:** Arma 3 writes `.rpt` files to `C:\Users\<username>\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
|
```python
|
||||||
def list_log_files(self, server_dir: Path) -> list[dict]:
|
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():
|
if not log_dir.exists():
|
||||||
return []
|
return []
|
||||||
files = sorted(log_dir.glob("*.rpt"), key=lambda f: f.stat().st_mtime, reverse=True)
|
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
|
### 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
|
```python
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from fastapi.responses import FileResponse
|
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.
|
**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.
|
||||||
|
|
||||||
### 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") && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.preventDefault(); startServer(); }}
|
|
||||||
disabled={isStarting}
|
|
||||||
className="btn-primary text-xs px-2 py-1"
|
|
||||||
aria-label="Start server"
|
|
||||||
>
|
|
||||||
{isStarting ? <Spinner size="xs" /> : "▶ Start"}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
// Show Stop when running or starting
|
|
||||||
{(server.status === "running" || server.status === "starting") && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.preventDefault(); stopServer(); }}
|
|
||||||
disabled={isStopping}
|
|
||||||
className="btn-secondary text-xs px-2 py-1"
|
|
||||||
aria-label="Stop server"
|
|
||||||
>
|
|
||||||
{isStopping ? <Spinner size="xs" /> : "■ Stop"}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
```
|
|
||||||
|
|
||||||
Note: `e.preventDefault()` is needed if the card itself is a link/clickable element — prevents navigation when clicking the action button.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -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.
|
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.
|
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.
|
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 `<input type="password" />` 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
|
## 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
|
- [ ] Click mod in Available pane → moves to Selected; click Apply → `enabled = true` in response
|
||||||
- [ ] Search filter narrows visible mods in each pane
|
- [ ] Search filter narrows visible mods in each pane
|
||||||
|
|
||||||
### Phase 6 (Quick Actions)
|
### Phase 6 (Quick Actions) — ALREADY IMPLEMENTED, no tests needed
|
||||||
- [ ] Stopped server card shows ▶ Start; click → status transitions to running without navigation
|
|
||||||
- [ ] Running server card shows ■ Stop; click → status transitions to stopped
|
|
||||||
|
|
||||||
### Phase 5 (Log Files)
|
### Phase 5 (Log Files)
|
||||||
- [ ] After server runs ≥1 min: Log Files section shows `.rpt` file
|
- [ ] After server runs ≥1 min: Log Files section shows `.rpt` file
|
||||||
|
|||||||
46
CLAUDE.md
46
CLAUDE.md
@@ -30,26 +30,55 @@ FastAPI + SQLite backend, React 19 + TypeScript + Vite frontend. See ARCHITECTUR
|
|||||||
### Backend: Fully implemented (42+ endpoints)
|
### Backend: Fully implemented (42+ endpoints)
|
||||||
All routers, services, repositories, game adapter system, WebSocket, background threads, and scheduled cleanup are complete.
|
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 |
|
| Route | Status | Notes |
|
||||||
|-------|--------|-------|
|
|-------|--------|-------|
|
||||||
| `/login` | Complete | Zod + react-hook-form validation |
|
| `/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/: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()` |
|
| `/servers/new` | Complete | 4-step wizard with per-step validation via `trigger()` |
|
||||||
| `/settings` | Complete | Password change + admin user management |
|
| `/settings` | Complete | Password change + admin user management |
|
||||||
|
|
||||||
### Frontend Type Mapping (API → Frontend)
|
### 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 |
|
| API Resource | Frontend Type | Key Fields |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Server (enriched) | `Server` in useServers.ts | `game_port`, `current_players`, `max_players`, `cpu_percent`, `ram_mb` |
|
| 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` |
|
| Mission | `Mission` in useServerDetail.ts | `name`, `filename`, `size_bytes`, `terrain` *(planned)* |
|
||||||
| Mod | `Mod` in useServerDetail.ts | `name`, `path`, `size_bytes`, `enabled` |
|
| 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` |
|
| 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` |
|
| 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
|
## Test Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -62,8 +91,9 @@ cd frontend && npx tsc --noEmit
|
|||||||
# Backend (no test suite yet)
|
# Backend (no test suite yet)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Future Enhancements (user requested)
|
## Key Implementation Notes
|
||||||
|
|
||||||
- Config sub-tab redesign for user-friendliness (non-technical users)
|
- `BanRepository.create()` takes `expires_at` (ISO string), not `duration_minutes` — convert in service
|
||||||
- "Choose mission" button that auto-selects mission for server config
|
- `slot_id` is stored as a string in the `players` table — cast with `str(slot_id)` in queries
|
||||||
- Mission rotation management
|
- 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`
|
||||||
@@ -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)
|
- **React 19** with hooks
|
||||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
- **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).
|
```bash
|
||||||
|
# From this directory
|
||||||
## Expanding the ESLint configuration
|
npx vite --host
|
||||||
|
# → http://localhost:5173
|
||||||
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...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
```bash
|
||||||
// eslint.config.js
|
npx vitest run # run once
|
||||||
import reactX from 'eslint-plugin-react-x'
|
npx vitest # watch mode
|
||||||
import reactDom from 'eslint-plugin-react-dom'
|
npx tsc --noEmit # type check only
|
||||||
|
|
||||||
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...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 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`.
|
||||||
|
|||||||
@@ -29,11 +29,18 @@ test.describe("Login Flow", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("should show error on invalid credentials", async ({ page }) => {
|
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 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 });
|
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({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
@@ -66,8 +82,14 @@ test.describe("Login Flow", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("should show loading state while submitting", async ({ page }) => {
|
test("should show loading state while submitting", async ({ page }) => {
|
||||||
await page.route("**/api/auth/login", (route) =>
|
let resolveLogin: (value: unknown) => void;
|
||||||
route.fulfill({
|
const loginPromise = new Promise((resolve) => {
|
||||||
|
resolveLogin = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route("**/api/auth/login", async (route) => {
|
||||||
|
await loginPromise;
|
||||||
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -77,19 +99,19 @@ test.describe("Login Flow", () => {
|
|||||||
user: { id: 1, username: "admin", role: "admin" },
|
user: { id: 1, username: "admin", role: "admin" },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
delay: 500,
|
});
|
||||||
}),
|
});
|
||||||
);
|
|
||||||
|
|
||||||
await page.route("**/api/servers*", (route) =>
|
await loginPage.usernameInput.fill("admin");
|
||||||
route.fulfill({
|
await loginPage.passwordInput.fill("password");
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify({ success: true, data: [] }),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await loginPage.login("admin", "password");
|
// Click submit and immediately check for loading state
|
||||||
await expect(loginPage.submitButton).toContainText("Signing in...");
|
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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,138 +1,224 @@
|
|||||||
import { test, expect } from "@playwright/test";
|
import { test, expect } from "@playwright/test";
|
||||||
import { DashboardPage } from "../pages/DashboardPage";
|
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", () => {
|
test.describe("Dashboard", () => {
|
||||||
let dashboardPage: DashboardPage;
|
let dashboardPage: DashboardPage;
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
// Set up auth token so we're logged in
|
await setupDashboardMocks(page);
|
||||||
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",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
dashboardPage = new DashboardPage(page);
|
dashboardPage = new DashboardPage(page);
|
||||||
await dashboardPage.goto();
|
await dashboardPage.goto();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should display dashboard header", async () => {
|
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");
|
await expect(dashboardPage.content.locator("h1")).toContainText("Dashboard");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should show server count", async () => {
|
test("should show server count", async () => {
|
||||||
|
await expect(dashboardPage.content).toBeVisible({ timeout: 10_000 });
|
||||||
await expect(dashboardPage.content.locator("text=2 servers configured")).toBeVisible();
|
await expect(dashboardPage.content.locator("text=2 servers configured")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should render server cards", async () => {
|
test("should render server cards", async () => {
|
||||||
|
await expect(dashboardPage.serverCards.first()).toBeVisible({ timeout: 10_000 });
|
||||||
const count = await dashboardPage.getServerCount();
|
const count = await dashboardPage.getServerCount();
|
||||||
expect(count).toBe(2);
|
expect(count).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should display server names in cards", async () => {
|
test("should display server names in cards", async () => {
|
||||||
|
await expect(dashboardPage.serverCards.first()).toBeVisible({ timeout: 10_000 });
|
||||||
const name = await dashboardPage.getServerCardName(0);
|
const name = await dashboardPage.getServerCardName(0);
|
||||||
expect(name).toContain("Arma3 Main Server");
|
expect(name).toContain("A3Master");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should show Add Server button", async () => {
|
test("should show Add Server button", async () => {
|
||||||
|
await expect(dashboardPage.content).toBeVisible({ timeout: 10_000 });
|
||||||
await expect(dashboardPage.addServerButton).toBeVisible();
|
await expect(dashboardPage.addServerButton).toBeVisible();
|
||||||
await expect(dashboardPage.addServerButton).toContainText("Add Server");
|
await expect(dashboardPage.addServerButton).toContainText("Add Server");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should show sidebar with server list", async () => {
|
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=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 () => {
|
test("should show Stop button for running server", async () => {
|
||||||
|
await expect(dashboardPage.serverCards.first()).toBeVisible({ timeout: 10_000 });
|
||||||
const firstCard = dashboardPage.serverCards.nth(0);
|
const firstCard = dashboardPage.serverCards.nth(0);
|
||||||
await expect(firstCard.locator('button[aria-label^="Stop"]')).toBeVisible();
|
await expect(firstCard.locator('button[aria-label^="Stop"]')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should show Start button for stopped server", async () => {
|
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);
|
const secondCard = dashboardPage.serverCards.nth(1);
|
||||||
await expect(secondCard.locator('button[aria-label^="Start"]')).toBeVisible();
|
await expect(secondCard.locator('button[aria-label^="Start"]')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should display player count in server card", async () => {
|
test("should display player count in server card", async () => {
|
||||||
|
await expect(dashboardPage.serverCards.first()).toBeVisible({ timeout: 10_000 });
|
||||||
const firstCard = dashboardPage.serverCards.nth(0);
|
const firstCard = dashboardPage.serverCards.nth(0);
|
||||||
await expect(firstCard.locator("text=32/64")).toBeVisible();
|
await expect(firstCard.locator("text=32/64")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should navigate to server detail on card click", async ({ page }) => {
|
test("should navigate to server detail on card click", async ({ page }) => {
|
||||||
const firstCard = dashboardPage.serverCards.nth(0);
|
await expect(dashboardPage.serverCards.first()).toBeVisible({ timeout: 10_000 });
|
||||||
const link = firstCard.locator("xpath=ancestor::a");
|
// Click the server card link (not the sidebar link) — use .first() to avoid strict mode
|
||||||
await link.click();
|
const serverLink = page.locator('[data-testid="dashboard-content"] a[href="/servers/1"]');
|
||||||
await expect(page).toHaveURL(/\/servers\/1/);
|
await serverLink.click();
|
||||||
|
await expect(page).toHaveURL(/\/servers\/1/, { timeout: 5_000 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("Dashboard - Empty State", () => {
|
test.describe("Dashboard - Empty State", () => {
|
||||||
test("should show empty state when no servers", async ({ page }) => {
|
test("should show empty state when no servers", async ({ page }) => {
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(({ token, user }) => {
|
||||||
localStorage.setItem("languard_token", "mock-jwt-token");
|
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({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
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);
|
const dashboardPage = new DashboardPage(page);
|
||||||
await dashboardPage.goto();
|
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();
|
await expect(dashboardPage.emptyState.locator("text=No servers configured yet")).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("Dashboard - Error State", () => {
|
test.describe("Dashboard - Error State", () => {
|
||||||
test("should show error state when API fails", async ({ page }) => {
|
test("should show error state when API fails", async ({ page }) => {
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(({ token, user }) => {
|
||||||
localStorage.setItem("languard_token", "mock-jwt-token");
|
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({
|
route.fulfill({
|
||||||
status: 500,
|
status: 500,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
|
|||||||
Reference in New Issue
Block a user