feat: implement phases 3-5 of Arma 3 UX enhancement plan
Phase 3 - Mod display names + split-pane selector:
- Parse mod.cpp/meta.cpp for display_name and workshop_id
- Rewrite ModList as two-pane available/selected interface
Phase 4 - Player kick/ban from Players tab:
- Add get_by_slot() to PlayerRepository
- Add get_rcon_client() class method to ThreadRegistry
- Add /players/{slot_id}/kick and /ban endpoints
- Rewrite PlayerTable with kick/ban modals and ban presets
Phase 5 - Historical log file browser:
- Add list_log_files() and get_log_file_path() to RPTParser
- Add logfiles_router with GET/download/DELETE endpoints
- Update LogViewer with collapsible log files section (download + delete)
This commit is contained in:
@@ -62,6 +62,36 @@ class RPTParser:
|
|||||||
"message": (message or "").strip(),
|
"message": (message or "").strip(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def list_log_files(self, server_dir: Path) -> list[dict]:
|
||||||
|
"""Return all .rpt log files in server_dir/server/, newest first."""
|
||||||
|
profile_dir = server_dir / "server"
|
||||||
|
if not profile_dir.exists():
|
||||||
|
return []
|
||||||
|
files = []
|
||||||
|
for p in profile_dir.glob("*.rpt"):
|
||||||
|
try:
|
||||||
|
stat = p.stat()
|
||||||
|
files.append({
|
||||||
|
"filename": p.name,
|
||||||
|
"size_bytes": stat.st_size,
|
||||||
|
"modified_at": stat.st_mtime,
|
||||||
|
})
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
files.sort(key=lambda f: f["modified_at"], reverse=True)
|
||||||
|
return files
|
||||||
|
|
||||||
|
def get_log_file_path(self, server_dir: Path, filename: str) -> Path | None:
|
||||||
|
"""Return the Path for a specific log file, or None if not found / path traversal attempt."""
|
||||||
|
import os
|
||||||
|
profile_dir = server_dir / "server"
|
||||||
|
target = (profile_dir / filename).resolve()
|
||||||
|
if not str(target).startswith(str(profile_dir.resolve())):
|
||||||
|
return None
|
||||||
|
if not target.exists() or target.suffix != ".rpt":
|
||||||
|
return None
|
||||||
|
return target
|
||||||
|
|
||||||
def get_log_file_resolver(self, server_id: int) -> Callable[[Path], Path | None]:
|
def get_log_file_resolver(self, server_id: int) -> Callable[[Path], Path | None]:
|
||||||
"""Return a callable that finds the current RPT log file."""
|
"""Return a callable that finds the current RPT log file."""
|
||||||
def resolver(server_dir: Path) -> Path | None:
|
def resolver(server_dir: Path) -> Path | None:
|
||||||
|
|||||||
@@ -15,6 +15,24 @@ logger = logging.getLogger(__name__)
|
|||||||
_MOD_DIR_PATTERN = re.compile(r"^@.+", re.IGNORECASE)
|
_MOD_DIR_PATTERN = re.compile(r"^@.+", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_mod_cpp(mod_dir: Path) -> str | None:
|
||||||
|
mod_cpp = mod_dir / "mod.cpp"
|
||||||
|
if not mod_cpp.exists():
|
||||||
|
return None
|
||||||
|
text = mod_cpp.read_text(errors="ignore")
|
||||||
|
m = re.search(r'name\s*=\s*"([^"]+)"', text, re.IGNORECASE)
|
||||||
|
return m.group(1) if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_meta_cpp(mod_dir: Path) -> str | None:
|
||||||
|
meta_cpp = mod_dir / "meta.cpp"
|
||||||
|
if not meta_cpp.exists():
|
||||||
|
return None
|
||||||
|
text = meta_cpp.read_text(errors="ignore")
|
||||||
|
m = re.search(r'publishedid\s*=\s*(\d+)', text, re.IGNORECASE)
|
||||||
|
return m.group(1) if m else None
|
||||||
|
|
||||||
|
|
||||||
class Arma3ModData(BaseModel):
|
class Arma3ModData(BaseModel):
|
||||||
"""Mod data schema for Arma 3."""
|
"""Mod data schema for Arma 3."""
|
||||||
workshop_id: str = ""
|
workshop_id: str = ""
|
||||||
@@ -60,6 +78,8 @@ class Arma3ModManager:
|
|||||||
"name": entry.name,
|
"name": entry.name,
|
||||||
"path": str(entry.resolve()),
|
"path": str(entry.resolve()),
|
||||||
"size_bytes": size,
|
"size_bytes": size,
|
||||||
|
"display_name": _parse_mod_cpp(entry),
|
||||||
|
"workshop_id": _parse_meta_cpp(entry),
|
||||||
})
|
})
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
raise AdapterError(f"Cannot scan mod directory: {exc}") from exc
|
raise AdapterError(f"Cannot scan mod directory: {exc}") from exc
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ class PlayerRepository(BaseRepository):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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)},
|
||||||
|
)
|
||||||
|
|
||||||
def clear(self, server_id: int) -> None:
|
def clear(self, server_id: int) -> None:
|
||||||
self._execute("DELETE FROM players WHERE server_id = :sid", {"sid": server_id})
|
self._execute("DELETE FROM players WHERE server_id = :sid", {"sid": server_id})
|
||||||
|
|
||||||
|
|||||||
80
backend/core/servers/logfiles_router.py
Normal file
80
backend/core/servers/logfiles_router.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""Log file endpoints — list, download, and delete historical RPT log files."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from sqlalchemy.engine import Connection
|
||||||
|
|
||||||
|
from adapters.registry import GameAdapterRegistry
|
||||||
|
from core.dal.server_repository import ServerRepository
|
||||||
|
from core.utils.file_utils import get_server_dir
|
||||||
|
from database import get_db
|
||||||
|
from dependencies import get_current_user, require_admin
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/servers/{server_id}/logfiles", tags=["logfiles"])
|
||||||
|
|
||||||
|
|
||||||
|
def _ok(data):
|
||||||
|
return {"success": True, "data": data, "error": None}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_rpt_parser(server_id: int, db: Connection):
|
||||||
|
server = ServerRepository(db).get_by_id(server_id)
|
||||||
|
if server is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Server not found")
|
||||||
|
adapter = GameAdapterRegistry.get(server["game_type"])
|
||||||
|
if not adapter.has_capability("log_parser"):
|
||||||
|
raise HTTPException(status_code=404, detail="Server does not support log files")
|
||||||
|
return adapter.get_log_parser(), get_server_dir(server_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def list_log_files(
|
||||||
|
server_id: int,
|
||||||
|
db: Annotated[Connection, Depends(get_db)],
|
||||||
|
_user: Annotated[dict, Depends(get_current_user)],
|
||||||
|
) -> dict:
|
||||||
|
parser, server_dir = _get_rpt_parser(server_id, db)
|
||||||
|
files = parser.list_log_files(server_dir)
|
||||||
|
return _ok(files)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{filename}/download")
|
||||||
|
def download_log_file(
|
||||||
|
server_id: int,
|
||||||
|
filename: str,
|
||||||
|
db: Annotated[Connection, Depends(get_db)],
|
||||||
|
_user: Annotated[dict, Depends(get_current_user)],
|
||||||
|
):
|
||||||
|
parser, server_dir = _get_rpt_parser(server_id, db)
|
||||||
|
path = parser.get_log_file_path(server_dir, filename)
|
||||||
|
if path is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Log file not found")
|
||||||
|
return FileResponse(
|
||||||
|
path=str(path),
|
||||||
|
filename=filename,
|
||||||
|
media_type="text/plain",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{filename}")
|
||||||
|
def delete_log_file(
|
||||||
|
server_id: int,
|
||||||
|
filename: str,
|
||||||
|
db: Annotated[Connection, Depends(get_db)],
|
||||||
|
_admin: Annotated[dict, Depends(require_admin)],
|
||||||
|
) -> dict:
|
||||||
|
parser, server_dir = _get_rpt_parser(server_id, db)
|
||||||
|
path = parser.get_log_file_path(server_dir, filename)
|
||||||
|
if path is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Log file not found")
|
||||||
|
try:
|
||||||
|
path.unlink()
|
||||||
|
except OSError as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Could not delete file: {exc}") from exc
|
||||||
|
return _ok({"message": f"{filename} deleted"})
|
||||||
@@ -5,18 +5,28 @@ import logging
|
|||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.engine import Connection
|
from sqlalchemy.engine import Connection
|
||||||
|
|
||||||
from core.dal.player_repository import PlayerRepository
|
from core.dal.player_repository import PlayerRepository
|
||||||
from core.servers.service import ServerService
|
from core.servers.service import ServerService
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from dependencies import get_current_user
|
from dependencies import get_current_user, require_admin
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/servers/{server_id}/players", tags=["players"])
|
router = APIRouter(prefix="/servers/{server_id}/players", tags=["players"])
|
||||||
|
|
||||||
|
|
||||||
|
class KickRequest(BaseModel):
|
||||||
|
reason: str = "Kicked by admin"
|
||||||
|
|
||||||
|
|
||||||
|
class BanFromPlayerRequest(BaseModel):
|
||||||
|
reason: str = "Banned by admin"
|
||||||
|
duration_minutes: int | None = None
|
||||||
|
|
||||||
|
|
||||||
def _ok(data):
|
def _ok(data):
|
||||||
return {"success": True, "data": data, "error": None}
|
return {"success": True, "data": data, "error": None}
|
||||||
|
|
||||||
@@ -55,3 +65,30 @@ def player_history(
|
|||||||
server_id=server_id, limit=limit, offset=offset, search=search,
|
server_id=server_id, limit=limit, offset=offset, search=search,
|
||||||
)
|
)
|
||||||
return _ok({"total": total, "items": rows})
|
return _ok({"total": total, "items": rows})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{slot_id}/kick")
|
||||||
|
def kick_player(
|
||||||
|
server_id: int,
|
||||||
|
slot_id: int,
|
||||||
|
body: KickRequest,
|
||||||
|
db: Annotated[Connection, Depends(get_db)],
|
||||||
|
_admin: Annotated[dict, Depends(require_admin)],
|
||||||
|
) -> dict:
|
||||||
|
ServerService(db).kick_player(server_id, slot_id, body.reason)
|
||||||
|
return _ok({"message": f"Player {slot_id} kicked"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{slot_id}/ban")
|
||||||
|
def ban_player_from_list(
|
||||||
|
server_id: int,
|
||||||
|
slot_id: int,
|
||||||
|
body: BanFromPlayerRequest,
|
||||||
|
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 _ok(ban)
|
||||||
@@ -396,6 +396,56 @@ class ServerService:
|
|||||||
data[field] = "***"
|
data[field] = "***"
|
||||||
return sections
|
return sections
|
||||||
|
|
||||||
|
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)
|
||||||
|
if not ra or not ra.is_connected():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=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)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail={"code": "KICK_FAILED", "message": "Kick command failed"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def ban_from_player(
|
||||||
|
self,
|
||||||
|
server_id: int,
|
||||||
|
slot_id: int,
|
||||||
|
reason: str,
|
||||||
|
duration_minutes: int | None,
|
||||||
|
banned_by: str,
|
||||||
|
) -> dict:
|
||||||
|
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, slot_id)
|
||||||
|
if not player:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={"code": "NOT_FOUND", "message": "Player not found"},
|
||||||
|
)
|
||||||
|
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_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,
|
||||||
|
)
|
||||||
|
return dict(ban_repo.get_by_id(ban_id))
|
||||||
|
|
||||||
def get_config_schema(self, server_id: int) -> dict:
|
def get_config_schema(self, server_id: int) -> dict:
|
||||||
server = self.get_server(server_id)
|
server = self.get_server(server_id)
|
||||||
adapter = GameAdapterRegistry.get(server["game_type"])
|
adapter = GameAdapterRegistry.get(server["game_type"])
|
||||||
|
|||||||
@@ -90,6 +90,20 @@ class ThreadRegistry:
|
|||||||
if registry is not None:
|
if registry is not None:
|
||||||
registry._stop_all()
|
registry._stop_all()
|
||||||
|
|
||||||
|
@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)
|
||||||
|
|
||||||
# ── Instance methods ──
|
# ── Instance methods ──
|
||||||
|
|
||||||
def _start_server_threads(self, server_id: int, db) -> None:
|
def _start_server_threads(self, server_id: int, db) -> None:
|
||||||
|
|||||||
@@ -168,6 +168,7 @@ def create_app() -> FastAPI:
|
|||||||
from core.servers.bans_router import router as bans_router
|
from core.servers.bans_router import router as bans_router
|
||||||
from core.servers.missions_router import router as missions_router
|
from core.servers.missions_router import router as missions_router
|
||||||
from core.servers.mods_router import router as mods_router
|
from core.servers.mods_router import router as mods_router
|
||||||
|
from core.servers.logfiles_router import router as logfiles_router
|
||||||
from core.websocket.router import router as ws_router
|
from core.websocket.router import router as ws_router
|
||||||
|
|
||||||
app.include_router(auth_router, prefix="/api")
|
app.include_router(auth_router, prefix="/api")
|
||||||
@@ -178,6 +179,7 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(bans_router, prefix="/api")
|
app.include_router(bans_router, prefix="/api")
|
||||||
app.include_router(missions_router, prefix="/api")
|
app.include_router(missions_router, prefix="/api")
|
||||||
app.include_router(mods_router, prefix="/api")
|
app.include_router(mods_router, prefix="/api")
|
||||||
|
app.include_router(logfiles_router, prefix="/api")
|
||||||
app.include_router(ws_router)
|
app.include_router(ws_router)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
134
frontend/src/__tests__/Phase345.test.tsx
Normal file
134
frontend/src/__tests__/Phase345.test.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
useKickPlayer,
|
||||||
|
useBanPlayer,
|
||||||
|
useServerLogFiles,
|
||||||
|
useDeleteLogFile,
|
||||||
|
} from "@/hooks/useServerDetail";
|
||||||
|
import type { Mod, LogFile } from "@/hooks/useServerDetail";
|
||||||
|
|
||||||
|
vi.mock("@/lib/api", () => ({
|
||||||
|
apiClient: {
|
||||||
|
get: vi.fn(),
|
||||||
|
post: vi.fn(),
|
||||||
|
put: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { apiClient } from "@/lib/api";
|
||||||
|
|
||||||
|
function createWrapper() {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false } },
|
||||||
|
});
|
||||||
|
return function Wrapper({ children }: { children: ReactNode }) {
|
||||||
|
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 3: Mod type has display_name + workshop_id ──
|
||||||
|
describe("Mod type includes display_name and workshop_id", () => {
|
||||||
|
it("Mod type fields are correct", () => {
|
||||||
|
const mod: Mod = {
|
||||||
|
name: "@CBA_A3",
|
||||||
|
path: "/srv/arma3/@CBA_A3",
|
||||||
|
size_bytes: 50000000,
|
||||||
|
enabled: true,
|
||||||
|
display_name: "Community Base Addons A3",
|
||||||
|
workshop_id: "450814997",
|
||||||
|
};
|
||||||
|
expect(mod.display_name).toBe("Community Base Addons A3");
|
||||||
|
expect(mod.workshop_id).toBe("450814997");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows null display_name and workshop_id", () => {
|
||||||
|
const mod: Mod = {
|
||||||
|
name: "@LocalMod",
|
||||||
|
path: "/srv/@LocalMod",
|
||||||
|
size_bytes: 1000,
|
||||||
|
enabled: false,
|
||||||
|
display_name: null,
|
||||||
|
workshop_id: null,
|
||||||
|
};
|
||||||
|
expect(mod.display_name).toBeNull();
|
||||||
|
expect(mod.workshop_id).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Phase 4: Kick / Ban hooks ──
|
||||||
|
describe("useKickPlayer", () => {
|
||||||
|
beforeEach(() => vi.mocked(apiClient.post).mockReset());
|
||||||
|
|
||||||
|
it("posts to /players/:slotId/kick", async () => {
|
||||||
|
vi.mocked(apiClient.post).mockResolvedValue({ data: { success: true } });
|
||||||
|
const { result } = renderHook(() => useKickPlayer(1), { wrapper: createWrapper() });
|
||||||
|
await result.current.mutateAsync({ slotId: 3, reason: "AFK" });
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith(
|
||||||
|
"/api/servers/1/players/3/kick",
|
||||||
|
{ reason: "AFK" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useBanPlayer", () => {
|
||||||
|
beforeEach(() => vi.mocked(apiClient.post).mockReset());
|
||||||
|
|
||||||
|
it("posts to /players/:slotId/ban with reason and duration", async () => {
|
||||||
|
vi.mocked(apiClient.post).mockResolvedValue({ data: { success: true, data: {} } });
|
||||||
|
const { result } = renderHook(() => useBanPlayer(1), { wrapper: createWrapper() });
|
||||||
|
await result.current.mutateAsync({ slotId: 5, reason: "Cheating", durationMinutes: 60 });
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith(
|
||||||
|
"/api/servers/1/players/5/ban",
|
||||||
|
{ reason: "Cheating", duration_minutes: 60 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends null duration_minutes for permanent ban", async () => {
|
||||||
|
vi.mocked(apiClient.post).mockResolvedValue({ data: { success: true, data: {} } });
|
||||||
|
const { result } = renderHook(() => useBanPlayer(1), { wrapper: createWrapper() });
|
||||||
|
await result.current.mutateAsync({ slotId: 5, reason: "Cheating" });
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith(
|
||||||
|
"/api/servers/1/players/5/ban",
|
||||||
|
{ reason: "Cheating", duration_minutes: null },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Phase 5: Log file hooks ──
|
||||||
|
describe("useServerLogFiles", () => {
|
||||||
|
beforeEach(() => vi.mocked(apiClient.get).mockReset());
|
||||||
|
|
||||||
|
it("fetches from /servers/:id/logfiles", async () => {
|
||||||
|
const mockFiles: LogFile[] = [
|
||||||
|
{ filename: "arma3.rpt", size_bytes: 1024, modified_at: 1700000000 },
|
||||||
|
];
|
||||||
|
vi.mocked(apiClient.get).mockResolvedValue({ data: { success: true, data: mockFiles } });
|
||||||
|
const { result } = renderHook(() => useServerLogFiles(1), { wrapper: createWrapper() });
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
expect(result.current.data).toEqual(mockFiles);
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith("/api/servers/1/logfiles");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is disabled when serverId is 0", () => {
|
||||||
|
const { result } = renderHook(() => useServerLogFiles(0), { wrapper: createWrapper() });
|
||||||
|
expect(result.current.fetchStatus).toBe("idle");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useDeleteLogFile", () => {
|
||||||
|
beforeEach(() => vi.mocked(apiClient.delete).mockReset());
|
||||||
|
|
||||||
|
it("deletes with URL-encoded filename", async () => {
|
||||||
|
vi.mocked(apiClient.delete).mockResolvedValue({ data: { success: true } });
|
||||||
|
const { result } = renderHook(() => useDeleteLogFile(1), { wrapper: createWrapper() });
|
||||||
|
await result.current.mutateAsync("arma3 server.rpt");
|
||||||
|
expect(apiClient.delete).toHaveBeenCalledWith(
|
||||||
|
"/api/servers/1/logfiles/arma3%20server.rpt",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import { useState, useRef, useCallback } from "react";
|
import { useState, useRef } from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
import { useServerLogFiles, useDeleteLogFile } from "@/hooks/useServerDetail";
|
||||||
|
import { apiClient } from "@/lib/api";
|
||||||
|
import { useUIStore } from "@/store/ui.store";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
interface LogEntry {
|
interface LogEntry {
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
level: "info" | "warning" | "error";
|
level: "info" | "warning" | "error";
|
||||||
@@ -9,6 +14,7 @@ interface LogEntry {
|
|||||||
|
|
||||||
interface LogViewerProps {
|
interface LogViewerProps {
|
||||||
logs: LogEntry[];
|
logs: LogEntry[];
|
||||||
|
serverId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LEVEL_COLORS = {
|
const LEVEL_COLORS = {
|
||||||
@@ -17,9 +23,15 @@ const LEVEL_COLORS = {
|
|||||||
error: "text-status-crashed",
|
error: "text-status-crashed",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function LogViewer({ logs }: LogViewerProps) {
|
export function LogViewer({ logs, serverId }: LogViewerProps) {
|
||||||
const [levelFilter, setLevelFilter] = useState<string>("all");
|
const [levelFilter, setLevelFilter] = useState<string>("all");
|
||||||
|
const [showFiles, setShowFiles] = useState(false);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||||
const logRef = useRef<HTMLDivElement>(null);
|
const logRef = useRef<HTMLDivElement>(null);
|
||||||
|
const addNotification = useUIStore((s) => s.addNotification);
|
||||||
|
|
||||||
|
const { data: logFiles, isLoading: filesLoading } = useServerLogFiles(serverId);
|
||||||
|
const deleteLogFile = useDeleteLogFile(serverId);
|
||||||
|
|
||||||
const filteredLogs = levelFilter === "all"
|
const filteredLogs = levelFilter === "all"
|
||||||
? logs
|
? logs
|
||||||
@@ -31,13 +43,63 @@ export function LogViewer({ logs }: LogViewerProps) {
|
|||||||
error: logs.filter((l) => l.level === "error").length,
|
error: logs.filter((l) => l.level === "error").length,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Auto-scroll to bottom
|
|
||||||
if (logRef.current) {
|
if (logRef.current) {
|
||||||
logRef.current.scrollTop = logRef.current.scrollHeight;
|
logRef.current.scrollTop = logRef.current.scrollHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleDownload = async (filename: string) => {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get(
|
||||||
|
`/api/servers/${serverId}/logfiles/${encodeURIComponent(filename)}/download`,
|
||||||
|
{ responseType: "blob" },
|
||||||
|
);
|
||||||
|
const url = URL.createObjectURL(res.data as Blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("LogViewer", "Download failed: %s", err);
|
||||||
|
addNotification({ type: "error", message: `Failed to download ${filename}` });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteConfirm = async () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
try {
|
||||||
|
await deleteLogFile.mutateAsync(deleteTarget);
|
||||||
|
addNotification({ type: "success", message: `${deleteTarget} deleted` });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("LogViewer", "Delete failed: %s", err);
|
||||||
|
addNotification({ type: "error", message: `Failed to delete ${deleteTarget}` });
|
||||||
|
}
|
||||||
|
setDeleteTarget(null);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid="log-viewer">
|
<div data-testid="log-viewer">
|
||||||
|
{/* Delete confirmation modal */}
|
||||||
|
{deleteTarget && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="neu-card p-6 w-full max-w-sm space-y-4">
|
||||||
|
<h4 className="text-text-primary font-semibold">Delete {deleteTarget}?</h4>
|
||||||
|
<p className="text-text-secondary text-sm">This action cannot be undone.</p>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<button onClick={() => setDeleteTarget(null)} className="btn-ghost text-sm">Cancel</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDeleteConfirm}
|
||||||
|
disabled={deleteLogFile.isPending}
|
||||||
|
className="btn-danger text-sm"
|
||||||
|
>
|
||||||
|
{deleteLogFile.isPending ? "Deleting..." : "Delete"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Live stream section */}
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h3 className="text-text-primary font-semibold">
|
<h3 className="text-text-primary font-semibold">
|
||||||
Server Logs ({logs.length})
|
Server Logs ({logs.length})
|
||||||
@@ -85,6 +147,72 @@ export function LogViewer({ logs }: LogViewerProps) {
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Log Files section */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowFiles(!showFiles)}
|
||||||
|
className="flex items-center gap-2 text-text-primary font-semibold text-sm hover:text-accent transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-text-muted">{showFiles ? "▾" : "▸"}</span>
|
||||||
|
Log Files
|
||||||
|
{logFiles && logFiles.length > 0 && (
|
||||||
|
<span className="text-text-muted font-normal">({logFiles.length})</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showFiles && (
|
||||||
|
<div className="mt-3">
|
||||||
|
{filesLoading ? (
|
||||||
|
<div className="text-text-muted text-sm p-4">Loading log files...</div>
|
||||||
|
) : !logFiles || logFiles.length === 0 ? (
|
||||||
|
<div className="text-text-muted text-sm text-center py-4">No log files found.</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-surface-overlay">
|
||||||
|
<th className="text-left text-text-muted font-medium px-3 py-2">Filename</th>
|
||||||
|
<th className="text-right text-text-muted font-medium px-3 py-2">Size</th>
|
||||||
|
<th className="text-left text-text-muted font-medium px-3 py-2">Modified</th>
|
||||||
|
<th className="text-right text-text-muted font-medium px-3 py-2">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{logFiles.map((file) => (
|
||||||
|
<tr key={file.filename} className="border-b border-surface-overlay/50 hover:bg-surface-overlay/30">
|
||||||
|
<td className="font-mono text-text-primary text-xs px-3 py-2">{file.filename}</td>
|
||||||
|
<td className="text-right font-mono text-text-secondary text-xs px-3 py-2">
|
||||||
|
{formatBytes(file.size_bytes)}
|
||||||
|
</td>
|
||||||
|
<td className="text-text-secondary text-xs px-3 py-2">
|
||||||
|
{new Date(file.modified_at * 1000).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="text-right px-3 py-2">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => handleDownload(file.filename)}
|
||||||
|
className="btn-ghost text-xs px-2"
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteTarget(file.filename)}
|
||||||
|
className="btn-ghost text-xs px-2 text-status-crashed"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -96,3 +224,9 @@ function formatTimestamp(iso: string): string {
|
|||||||
return iso;
|
return iso;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Save } from "lucide-react";
|
import { Save } from "lucide-react";
|
||||||
|
|
||||||
import { useServerMods, useSetEnabledMods } from "@/hooks/useServerDetail";
|
import { useServerMods, useSetEnabledMods } from "@/hooks/useServerDetail";
|
||||||
|
import type { Mod } from "@/hooks/useServerDetail";
|
||||||
import { useAuthStore } from "@/store/auth.store";
|
import { useAuthStore } from "@/store/auth.store";
|
||||||
import { useUIStore } from "@/store/ui.store";
|
import { useUIStore } from "@/store/ui.store";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
@@ -15,83 +16,185 @@ export function ModList({ serverId }: ModListProps) {
|
|||||||
const addNotification = useUIStore((s) => s.addNotification);
|
const addNotification = useUIStore((s) => s.addNotification);
|
||||||
const { data: modsData, isLoading } = useServerMods(serverId);
|
const { data: modsData, isLoading } = useServerMods(serverId);
|
||||||
const setEnabledMods = useSetEnabledMods(serverId);
|
const setEnabledMods = useSetEnabledMods(serverId);
|
||||||
const [enabledSet, setEnabledSet] = useState<Set<string> | null>(null);
|
|
||||||
|
const [available, setAvailable] = useState<Mod[]>([]);
|
||||||
|
const [selected, setSelected] = useState<Mod[]>([]);
|
||||||
|
const [availSearch, setAvailSearch] = useState("");
|
||||||
|
const [selSearch, setSelSearch] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!modsData) return;
|
||||||
|
setAvailable(modsData.mods.filter((m) => !m.enabled));
|
||||||
|
setSelected(modsData.mods.filter((m) => m.enabled));
|
||||||
|
}, [modsData]);
|
||||||
|
|
||||||
|
const moveToSelected = (mod: Mod) => {
|
||||||
|
setAvailable((prev) => prev.filter((m) => m.name !== mod.name));
|
||||||
|
setSelected((prev) => [...prev, { ...mod, enabled: true }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveToAvailable = (mod: Mod) => {
|
||||||
|
setSelected((prev) => prev.filter((m) => m.name !== mod.name));
|
||||||
|
setAvailable((prev) => [...prev, { ...mod, enabled: false }].sort((a, b) => a.name.localeCompare(b.name)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasChanges = modsData !== undefined && (
|
||||||
|
selected.map((m) => m.name).sort().join(",") !==
|
||||||
|
(modsData.mods.filter((m) => m.enabled).map((m) => m.name).sort().join(","))
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleApply = async () => {
|
||||||
|
try {
|
||||||
|
await setEnabledMods.mutateAsync(selected.map((m) => m.name));
|
||||||
|
addNotification({ type: "success", message: `${selected.length} mod(s) enabled. Server restart required.` });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("ModList", "Failed to apply mods: %s", err);
|
||||||
|
addNotification({ type: "error", message: "Failed to apply mod selection" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filterMods = (mods: Mod[], search: string) =>
|
||||||
|
search
|
||||||
|
? mods.filter((m) =>
|
||||||
|
(m.display_name ?? m.name).toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
m.name.toLowerCase().includes(search.toLowerCase()),
|
||||||
|
)
|
||||||
|
: mods;
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <div className="text-text-muted text-sm p-4">Loading mods...</div>;
|
return <div className="text-text-muted text-sm p-4">Loading mods...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mods = modsData?.mods ?? [];
|
|
||||||
const serverEnabled = new Set(mods.filter((m) => m.enabled).map((m) => m.name));
|
|
||||||
const activeEnabled = enabledSet ?? serverEnabled;
|
|
||||||
|
|
||||||
const handleToggle = (modName: string) => {
|
|
||||||
const next = new Set(activeEnabled);
|
|
||||||
if (next.has(modName)) {
|
|
||||||
next.delete(modName);
|
|
||||||
} else {
|
|
||||||
next.add(modName);
|
|
||||||
}
|
|
||||||
setEnabledSet(next);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
try {
|
|
||||||
await setEnabledMods.mutateAsync(Array.from(activeEnabled));
|
|
||||||
addNotification({ type: "success", message: "Mods updated" });
|
|
||||||
setEnabledSet(null);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error("ModList", "Failed to update mods: %s", err);
|
|
||||||
addNotification({ type: "error", message: "Failed to update mods" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasChanges = enabledSet !== null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid="mod-list">
|
<div data-testid="mod-list" className="space-y-4">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-text-primary font-semibold">
|
<h3 className="text-text-primary font-semibold">
|
||||||
Mods ({modsData?.enabled_count ?? 0}/{mods.length} enabled)
|
Mods ({selected.length} selected / {(modsData?.mods.length ?? 0)} total)
|
||||||
</h3>
|
</h3>
|
||||||
{isAdmin && hasChanges && (
|
{isAdmin && hasChanges && (
|
||||||
<button onClick={handleSave} disabled={setEnabledMods.isPending} className="btn-primary flex items-center gap-1.5 text-sm">
|
<button
|
||||||
|
onClick={handleApply}
|
||||||
|
disabled={setEnabledMods.isPending}
|
||||||
|
className="btn-primary flex items-center gap-1.5 text-sm"
|
||||||
|
>
|
||||||
<Save size={14} />
|
<Save size={14} />
|
||||||
{setEnabledMods.isPending ? "Saving..." : "Save Changes"}
|
{setEnabledMods.isPending ? "Applying..." : "Apply Selection"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{mods.length === 0 ? (
|
{isAdmin && hasChanges && (
|
||||||
<div className="text-text-muted text-sm text-center py-6">No mods found</div>
|
<p className="text-text-muted text-xs">
|
||||||
) : (
|
{selected.length} mod(s) selected. Server restart required for changes to take effect.
|
||||||
<div className="space-y-2">
|
</p>
|
||||||
{mods.map((mod) => (
|
)}
|
||||||
<div
|
|
||||||
key={mod.name}
|
<div className="flex flex-col md:flex-row gap-4">
|
||||||
className="flex items-center gap-3 px-3 py-2.5 rounded-lg bg-surface-recessed shadow-neu-recessed"
|
{/* Available pane */}
|
||||||
>
|
<div className="flex-1 min-w-0">
|
||||||
{isAdmin ? (
|
<div className="font-medium text-text-secondary text-sm mb-2">
|
||||||
<input
|
Available ({filterMods(available, availSearch).length})
|
||||||
type="checkbox"
|
</div>
|
||||||
checked={activeEnabled.has(mod.name)}
|
<input
|
||||||
onChange={() => handleToggle(mod.name)}
|
type="text"
|
||||||
className="w-4 h-4 accent-accent"
|
placeholder="Search..."
|
||||||
aria-label={`Toggle ${mod.name}`}
|
value={availSearch}
|
||||||
/>
|
onChange={(e) => setAvailSearch(e.target.value)}
|
||||||
) : (
|
className="neu-input w-full text-sm mb-2"
|
||||||
<span className={`w-4 h-4 rounded border ${mod.enabled ? "bg-accent border-accent" : "border-text-muted"}`} />
|
/>
|
||||||
)}
|
<div className="space-y-1 max-h-80 overflow-y-auto pr-1">
|
||||||
<div className="flex-1 min-w-0">
|
{filterMods(available, availSearch).length === 0 ? (
|
||||||
<p className="text-text-primary text-sm font-medium truncate">{mod.name}</p>
|
<div className="text-text-muted text-xs text-center py-4">
|
||||||
<p className="text-text-muted text-xs font-mono truncate">{mod.path}</p>
|
{available.length === 0 ? "All mods selected" : "No matches"}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-text-muted text-xs font-mono">
|
) : (
|
||||||
{formatSize(mod.size_bytes)}
|
filterMods(available, availSearch).map((mod) => (
|
||||||
</span>
|
<ModRow
|
||||||
</div>
|
key={mod.name}
|
||||||
))}
|
mod={mod}
|
||||||
|
actionLabel="→"
|
||||||
|
onAction={isAdmin ? () => moveToSelected(mod) : undefined}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Selected pane */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-medium text-text-secondary text-sm mb-2">
|
||||||
|
Selected ({filterMods(selected, selSearch).length})
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search..."
|
||||||
|
value={selSearch}
|
||||||
|
onChange={(e) => setSelSearch(e.target.value)}
|
||||||
|
className="neu-input w-full text-sm mb-2"
|
||||||
|
/>
|
||||||
|
<div className="space-y-1 max-h-80 overflow-y-auto pr-1">
|
||||||
|
{filterMods(selected, selSearch).length === 0 ? (
|
||||||
|
<div className="text-text-muted text-xs text-center py-4">
|
||||||
|
{selected.length === 0 ? "No mods selected" : "No matches"}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filterMods(selected, selSearch).map((mod) => (
|
||||||
|
<ModRow
|
||||||
|
key={mod.name}
|
||||||
|
mod={mod}
|
||||||
|
actionLabel="←"
|
||||||
|
onAction={isAdmin ? () => moveToAvailable(mod) : undefined}
|
||||||
|
selected
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModRow({
|
||||||
|
mod,
|
||||||
|
actionLabel,
|
||||||
|
onAction,
|
||||||
|
selected = false,
|
||||||
|
}: {
|
||||||
|
mod: Mod;
|
||||||
|
actionLabel: string;
|
||||||
|
onAction?: () => void;
|
||||||
|
selected?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 rounded-lg bg-surface-recessed shadow-neu-recessed"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-text-primary text-sm font-medium truncate">
|
||||||
|
{mod.display_name ?? mod.name}
|
||||||
|
</p>
|
||||||
|
{mod.display_name && (
|
||||||
|
<p className="text-text-muted text-xs font-mono truncate">{mod.name}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
|
{mod.workshop_id && (
|
||||||
|
<span className="bg-blue-500/20 text-blue-400 text-xs px-1.5 py-0.5 rounded">
|
||||||
|
Workshop
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-text-muted text-xs">{formatSize(mod.size_bytes)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{onAction && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onAction}
|
||||||
|
className={`btn-ghost text-sm px-2 shrink-0 ${selected ? "text-status-crashed" : "text-accent"}`}
|
||||||
|
title={selected ? "Remove from selection" : "Add to selection"}
|
||||||
|
>
|
||||||
|
{actionLabel}
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,32 +1,154 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { useServerPlayers, useServerPlayerHistory } from "@/hooks/useServerDetail";
|
import { useServerPlayers, useServerPlayerHistory, useKickPlayer, useBanPlayer } from "@/hooks/useServerDetail";
|
||||||
|
import type { Player } from "@/hooks/useServerDetail";
|
||||||
|
import { useAuthStore } from "@/store/auth.store";
|
||||||
|
import { useUIStore } from "@/store/ui.store";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
interface PlayerTableProps {
|
interface PlayerTableProps {
|
||||||
serverId: number;
|
serverId: number;
|
||||||
|
serverStatus?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PlayerTable({ serverId }: PlayerTableProps) {
|
const BAN_PRESETS = [
|
||||||
|
{ label: "1h", minutes: 60 },
|
||||||
|
{ label: "24h", minutes: 1440 },
|
||||||
|
{ label: "7d", minutes: 10080 },
|
||||||
|
{ label: "Permanent", minutes: null },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function PlayerTable({ serverId, serverStatus }: PlayerTableProps) {
|
||||||
|
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
|
||||||
|
const addNotification = useUIStore((s) => s.addNotification);
|
||||||
const { data: playersData, isLoading } = useServerPlayers(serverId);
|
const { data: playersData, isLoading } = useServerPlayers(serverId);
|
||||||
|
const kickPlayer = useKickPlayer(serverId);
|
||||||
|
const banPlayer = useBanPlayer(serverId);
|
||||||
const [showHistory, setShowHistory] = useState(false);
|
const [showHistory, setShowHistory] = useState(false);
|
||||||
|
|
||||||
|
// Modal state
|
||||||
|
const [kickTarget, setKickTarget] = useState<Player | null>(null);
|
||||||
|
const [kickReason, setKickReason] = useState("Kicked by admin");
|
||||||
|
const [banTarget, setBanTarget] = useState<Player | null>(null);
|
||||||
|
const [banReason, setBanReason] = useState("Banned by admin");
|
||||||
|
const [banDuration, setBanDuration] = useState<number | null>(null);
|
||||||
|
const [banDurationInput, setBanDurationInput] = useState("");
|
||||||
|
|
||||||
|
const isRunning = serverStatus === "running";
|
||||||
|
|
||||||
|
const handleKickConfirm = async () => {
|
||||||
|
if (!kickTarget) return;
|
||||||
|
try {
|
||||||
|
await kickPlayer.mutateAsync({ slotId: kickTarget.slot_id, reason: kickReason });
|
||||||
|
addNotification({ type: "success", message: `Player ${kickTarget.name} kicked` });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("PlayerTable", "Kick failed: %s", err);
|
||||||
|
addNotification({ type: "error", message: "Failed to kick player" });
|
||||||
|
}
|
||||||
|
setKickTarget(null);
|
||||||
|
setKickReason("Kicked by admin");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBanConfirm = async () => {
|
||||||
|
if (!banTarget) return;
|
||||||
|
const duration = banDurationInput ? parseInt(banDurationInput, 10) : banDuration;
|
||||||
|
try {
|
||||||
|
await banPlayer.mutateAsync({ slotId: banTarget.slot_id, reason: banReason, durationMinutes: duration ?? undefined });
|
||||||
|
addNotification({ type: "success", message: `Player ${banTarget.name} banned` });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("PlayerTable", "Ban failed: %s", err);
|
||||||
|
addNotification({ type: "error", message: "Failed to ban player" });
|
||||||
|
}
|
||||||
|
setBanTarget(null);
|
||||||
|
setBanReason("Banned by admin");
|
||||||
|
setBanDuration(null);
|
||||||
|
setBanDurationInput("");
|
||||||
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <div className="text-text-muted text-sm p-4">Loading players...</div>;
|
return <div className="text-text-muted text-sm p-4">Loading players...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const players = playersData?.players ?? [];
|
const players = playersData?.players ?? [];
|
||||||
const playerCount = playersData?.player_count ?? 0;
|
const playerCount = playersData?.player_count ?? 0;
|
||||||
|
const colSpan = isAdmin ? 6 : 5;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid="player-table">
|
<div data-testid="player-table">
|
||||||
|
{/* Kick modal */}
|
||||||
|
{kickTarget && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="neu-card p-6 w-full max-w-sm space-y-4">
|
||||||
|
<h4 className="text-text-primary font-semibold">Kick {kickTarget.name}</h4>
|
||||||
|
<textarea
|
||||||
|
className="neu-input w-full text-sm"
|
||||||
|
rows={2}
|
||||||
|
value={kickReason}
|
||||||
|
onChange={(e) => setKickReason(e.target.value)}
|
||||||
|
placeholder="Reason..."
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<button onClick={() => setKickTarget(null)} className="btn-ghost text-sm">Cancel</button>
|
||||||
|
<button onClick={handleKickConfirm} disabled={kickPlayer.isPending} className="btn-primary text-sm">
|
||||||
|
{kickPlayer.isPending ? "Kicking..." : "Confirm Kick"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Ban modal */}
|
||||||
|
{banTarget && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="neu-card p-6 w-full max-w-sm space-y-4">
|
||||||
|
<h4 className="text-text-primary font-semibold">Ban {banTarget.name}</h4>
|
||||||
|
<textarea
|
||||||
|
className="neu-input w-full text-sm"
|
||||||
|
rows={2}
|
||||||
|
value={banReason}
|
||||||
|
onChange={(e) => setBanReason(e.target.value)}
|
||||||
|
placeholder="Reason..."
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p className="text-text-secondary text-xs mb-2">Duration preset:</p>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{BAN_PRESETS.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.label}
|
||||||
|
onClick={() => { setBanDuration(p.minutes); setBanDurationInput(""); }}
|
||||||
|
className={`btn-ghost text-xs px-2 py-1 ${banDuration === p.minutes && !banDurationInput ? "bg-accent text-text-inverse" : ""}`}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="neu-input text-sm w-28"
|
||||||
|
placeholder="Custom (min)"
|
||||||
|
value={banDurationInput}
|
||||||
|
onChange={(e) => { setBanDurationInput(e.target.value); setBanDuration(null); }}
|
||||||
|
min={1}
|
||||||
|
/>
|
||||||
|
<span className="text-text-muted text-xs">minutes</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<button onClick={() => setBanTarget(null)} className="btn-ghost text-sm">Cancel</button>
|
||||||
|
<button onClick={handleBanConfirm} disabled={banPlayer.isPending} className="btn-danger text-sm">
|
||||||
|
{banPlayer.isPending ? "Banning..." : "Confirm Ban"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="text-text-primary font-semibold">
|
<h3 className="text-text-primary font-semibold">
|
||||||
Online Players ({playerCount})
|
Online Players ({playerCount})
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button onClick={() => setShowHistory(!showHistory)} className="btn-ghost text-sm">
|
||||||
onClick={() => setShowHistory(!showHistory)}
|
|
||||||
className="btn-ghost text-sm"
|
|
||||||
>
|
|
||||||
{showHistory ? "Current Players" : "Player History"}
|
{showHistory ? "Current Players" : "Player History"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -43,14 +165,13 @@ export function PlayerTable({ serverId }: PlayerTableProps) {
|
|||||||
<th className="text-left text-text-muted font-medium px-3 py-2">GUID</th>
|
<th className="text-left text-text-muted font-medium px-3 py-2">GUID</th>
|
||||||
<th className="text-left text-text-muted font-medium px-3 py-2">IP</th>
|
<th className="text-left text-text-muted font-medium px-3 py-2">IP</th>
|
||||||
<th className="text-right text-text-muted font-medium px-3 py-2">Ping</th>
|
<th className="text-right text-text-muted font-medium px-3 py-2">Ping</th>
|
||||||
|
{isAdmin && <th className="text-right text-text-muted font-medium px-3 py-2">Actions</th>}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{players.length === 0 ? (
|
{players.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} className="text-text-muted text-center py-6">
|
<td colSpan={colSpan} className="text-text-muted text-center py-6">No players online</td>
|
||||||
No players online
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
players.map((player) => (
|
players.map((player) => (
|
||||||
@@ -60,6 +181,28 @@ export function PlayerTable({ serverId }: PlayerTableProps) {
|
|||||||
<td className="font-mono text-text-muted text-xs px-3 py-2">{player.guid}</td>
|
<td className="font-mono text-text-muted text-xs px-3 py-2">{player.guid}</td>
|
||||||
<td className="font-mono text-text-muted text-xs px-3 py-2">{player.ip}</td>
|
<td className="font-mono text-text-muted text-xs px-3 py-2">{player.ip}</td>
|
||||||
<td className="text-right font-mono text-text-secondary px-3 py-2">{player.ping}ms</td>
|
<td className="text-right font-mono text-text-secondary px-3 py-2">{player.ping}ms</td>
|
||||||
|
{isAdmin && (
|
||||||
|
<td className="text-right px-3 py-2">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => { setKickTarget(player); setKickReason("Kicked by admin"); }}
|
||||||
|
disabled={!isRunning}
|
||||||
|
title={!isRunning ? "Server must be running" : "Kick player"}
|
||||||
|
className="btn-ghost text-xs px-2"
|
||||||
|
>
|
||||||
|
Kick
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setBanTarget(player); setBanReason("Banned by admin"); setBanDuration(null); setBanDurationInput(""); }}
|
||||||
|
disabled={!isRunning}
|
||||||
|
title={!isRunning ? "Server must be running" : "Ban player"}
|
||||||
|
className="btn-ghost text-xs px-2 text-status-crashed"
|
||||||
|
>
|
||||||
|
Ban
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@@ -95,7 +238,6 @@ function PlayerHistorySection({ serverId }: { serverId: number }) {
|
|||||||
className="neu-input w-full text-sm"
|
className="neu-input w-full text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -110,9 +252,7 @@ function PlayerHistorySection({ serverId }: { serverId: number }) {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{entries.length === 0 ? (
|
{entries.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} className="text-text-muted text-center py-6">
|
<td colSpan={5} className="text-text-muted text-center py-6">No player history</td>
|
||||||
No player history
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
entries.map((entry) => (
|
entries.map((entry) => (
|
||||||
@@ -120,9 +260,7 @@ function PlayerHistorySection({ serverId }: { serverId: number }) {
|
|||||||
<td className="text-text-primary px-3 py-2">{entry.name}</td>
|
<td className="text-text-primary px-3 py-2">{entry.name}</td>
|
||||||
<td className="font-mono text-text-muted text-xs px-3 py-2">{entry.guid}</td>
|
<td className="font-mono text-text-muted text-xs px-3 py-2">{entry.guid}</td>
|
||||||
<td className="text-text-secondary text-xs px-3 py-2">{formatTime(entry.joined_at)}</td>
|
<td className="text-text-secondary text-xs px-3 py-2">{formatTime(entry.joined_at)}</td>
|
||||||
<td className="text-text-secondary text-xs px-3 py-2">
|
<td className="text-text-secondary text-xs px-3 py-2">{entry.left_at ? formatTime(entry.left_at) : "--"}</td>
|
||||||
{entry.left_at ? formatTime(entry.left_at) : "--"}
|
|
||||||
</td>
|
|
||||||
<td className="text-right font-mono text-text-secondary px-3 py-2">
|
<td className="text-right font-mono text-text-secondary px-3 py-2">
|
||||||
{entry.session_duration_seconds ? formatDuration(entry.session_duration_seconds) : "--"}
|
{entry.session_duration_seconds ? formatDuration(entry.session_duration_seconds) : "--"}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -391,3 +391,57 @@ export function useSendCommand(serverId: number) {
|
|||||||
apiClient.post(`/api/servers/${serverId}/rcon/command`, { command }),
|
apiClient.post(`/api/servers/${serverId}/rcon/command`, { command }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useKickPlayer(serverId: number) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ slotId, reason }: { slotId: number; reason: string }) =>
|
||||||
|
apiClient.post(`/api/servers/${serverId}/players/${slotId}/kick`, { reason }),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["players", serverId] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBanPlayer(serverId: number) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ slotId, reason, durationMinutes }: { slotId: number; reason: string; durationMinutes?: number }) =>
|
||||||
|
apiClient.post(`/api/servers/${serverId}/players/${slotId}/ban`, {
|
||||||
|
reason,
|
||||||
|
duration_minutes: durationMinutes ?? null,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["players", serverId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["bans", serverId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogFile {
|
||||||
|
filename: string;
|
||||||
|
size_bytes: number;
|
||||||
|
modified_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useServerLogFiles(serverId: number) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["servers", serverId, "logfiles"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiClient.get<{ success: boolean; data: LogFile[] }>(
|
||||||
|
`/api/servers/${serverId}/logfiles`,
|
||||||
|
);
|
||||||
|
return res.data.data;
|
||||||
|
},
|
||||||
|
enabled: serverId > 0,
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteLogFile(serverId: number) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (filename: string) =>
|
||||||
|
apiClient.delete(`/api/servers/${serverId}/logfiles/${encodeURIComponent(filename)}`),
|
||||||
|
onSuccess: () =>
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["servers", serverId, "logfiles"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -92,11 +92,11 @@ export function ServerDetailPage() {
|
|||||||
<div className="neu-card p-5">
|
<div className="neu-card p-5">
|
||||||
{activeTab === "overview" && <OverviewTab serverId={id} />}
|
{activeTab === "overview" && <OverviewTab serverId={id} />}
|
||||||
{activeTab === "config" && <ConfigEditor serverId={id} />}
|
{activeTab === "config" && <ConfigEditor serverId={id} />}
|
||||||
{activeTab === "players" && <PlayerTable serverId={id} />}
|
{activeTab === "players" && <PlayerTable serverId={id} serverStatus={server?.status} />}
|
||||||
{activeTab === "bans" && <BanTable serverId={id} />}
|
{activeTab === "bans" && <BanTable serverId={id} />}
|
||||||
{activeTab === "missions" && <MissionList serverId={id} />}
|
{activeTab === "missions" && <MissionList serverId={id} />}
|
||||||
{activeTab === "mods" && <ModList serverId={id} />}
|
{activeTab === "mods" && <ModList serverId={id} />}
|
||||||
{activeTab === "logs" && <LogViewer logs={logs} />}
|
{activeTab === "logs" && <LogViewer logs={logs} serverId={id} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user