diff --git a/backend/adapters/arma3/log_parser.py b/backend/adapters/arma3/log_parser.py index eaa593d..4d7708e 100644 --- a/backend/adapters/arma3/log_parser.py +++ b/backend/adapters/arma3/log_parser.py @@ -62,6 +62,36 @@ class RPTParser: "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]: """Return a callable that finds the current RPT log file.""" def resolver(server_dir: Path) -> Path | None: diff --git a/backend/adapters/arma3/mod_manager.py b/backend/adapters/arma3/mod_manager.py index fb46122..3539432 100644 --- a/backend/adapters/arma3/mod_manager.py +++ b/backend/adapters/arma3/mod_manager.py @@ -15,6 +15,24 @@ logger = logging.getLogger(__name__) _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): """Mod data schema for Arma 3.""" workshop_id: str = "" @@ -60,6 +78,8 @@ class Arma3ModManager: "name": entry.name, "path": str(entry.resolve()), "size_bytes": size, + "display_name": _parse_mod_cpp(entry), + "workshop_id": _parse_meta_cpp(entry), }) except OSError as exc: raise AdapterError(f"Cannot scan mod directory: {exc}") from exc diff --git a/backend/core/dal/player_repository.py b/backend/core/dal/player_repository.py index 9439f27..290d702 100644 --- a/backend/core/dal/player_repository.py +++ b/backend/core/dal/player_repository.py @@ -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: self._execute("DELETE FROM players WHERE server_id = :sid", {"sid": server_id}) diff --git a/backend/core/servers/logfiles_router.py b/backend/core/servers/logfiles_router.py new file mode 100644 index 0000000..208ffa3 --- /dev/null +++ b/backend/core/servers/logfiles_router.py @@ -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"}) diff --git a/backend/core/servers/players_router.py b/backend/core/servers/players_router.py index 21d08a8..28386d4 100644 --- a/backend/core/servers/players_router.py +++ b/backend/core/servers/players_router.py @@ -5,18 +5,28 @@ import logging from typing import Annotated from fastapi import APIRouter, Depends +from pydantic import BaseModel from sqlalchemy.engine import Connection from core.dal.player_repository import PlayerRepository from core.servers.service import ServerService from database import get_db -from dependencies import get_current_user +from dependencies import get_current_user, require_admin logger = logging.getLogger(__name__) 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): return {"success": True, "data": data, "error": None} @@ -54,4 +64,31 @@ def player_history( total, rows = player_repo.get_history( server_id=server_id, limit=limit, offset=offset, search=search, ) - return _ok({"total": total, "items": rows}) \ No newline at end of file + 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) \ No newline at end of file diff --git a/backend/core/servers/service.py b/backend/core/servers/service.py index 8cc091a..3a6d52c 100644 --- a/backend/core/servers/service.py +++ b/backend/core/servers/service.py @@ -396,6 +396,56 @@ class ServerService: data[field] = "***" 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: server = self.get_server(server_id) adapter = GameAdapterRegistry.get(server["game_type"]) diff --git a/backend/core/threads/thread_registry.py b/backend/core/threads/thread_registry.py index 104f8ae..e06db30 100644 --- a/backend/core/threads/thread_registry.py +++ b/backend/core/threads/thread_registry.py @@ -90,6 +90,20 @@ class ThreadRegistry: if registry is not None: 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 ── def _start_server_threads(self, server_id: int, db) -> None: diff --git a/backend/main.py b/backend/main.py index 5c6ec66..8581af9 100644 --- a/backend/main.py +++ b/backend/main.py @@ -168,6 +168,7 @@ def create_app() -> FastAPI: from core.servers.bans_router import router as bans_router from core.servers.missions_router import router as missions_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 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(missions_router, prefix="/api") app.include_router(mods_router, prefix="/api") + app.include_router(logfiles_router, prefix="/api") app.include_router(ws_router) return app diff --git a/frontend/src/__tests__/Phase345.test.tsx b/frontend/src/__tests__/Phase345.test.tsx new file mode 100644 index 0000000..62af998 --- /dev/null +++ b/frontend/src/__tests__/Phase345.test.tsx @@ -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 {children}; + }; +} + +// ── 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", + ); + }); +}); diff --git a/frontend/src/components/servers/LogViewer.tsx b/frontend/src/components/servers/LogViewer.tsx index e62fe44..3991dfa 100644 --- a/frontend/src/components/servers/LogViewer.tsx +++ b/frontend/src/components/servers/LogViewer.tsx @@ -1,6 +1,11 @@ -import { useState, useRef, useCallback } from "react"; +import { useState, useRef } from "react"; 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 { timestamp: string; level: "info" | "warning" | "error"; @@ -9,6 +14,7 @@ interface LogEntry { interface LogViewerProps { logs: LogEntry[]; + serverId: number; } const LEVEL_COLORS = { @@ -17,9 +23,15 @@ const LEVEL_COLORS = { error: "text-status-crashed", }; -export function LogViewer({ logs }: LogViewerProps) { +export function LogViewer({ logs, serverId }: LogViewerProps) { const [levelFilter, setLevelFilter] = useState("all"); + const [showFiles, setShowFiles] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); const logRef = useRef(null); + const addNotification = useUIStore((s) => s.addNotification); + + const { data: logFiles, isLoading: filesLoading } = useServerLogFiles(serverId); + const deleteLogFile = useDeleteLogFile(serverId); const filteredLogs = levelFilter === "all" ? logs @@ -31,13 +43,63 @@ export function LogViewer({ logs }: LogViewerProps) { error: logs.filter((l) => l.level === "error").length, }; - // Auto-scroll to bottom if (logRef.current) { 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 (
+ {/* Delete confirmation modal */} + {deleteTarget && ( +
+
+

Delete {deleteTarget}?

+

This action cannot be undone.

+
+ + +
+
+
+ )} + + {/* Live stream section */}

Server Logs ({logs.length}) @@ -85,6 +147,72 @@ export function LogViewer({ logs }: LogViewerProps) { )) )}

+ + {/* Log Files section */} +
+ + + {showFiles && ( +
+ {filesLoading ? ( +
Loading log files...
+ ) : !logFiles || logFiles.length === 0 ? ( +
No log files found.
+ ) : ( +
+ + + + + + + + + + + {logFiles.map((file) => ( + + + + + + + ))} + +
FilenameSizeModifiedActions
{file.filename} + {formatBytes(file.size_bytes)} + + {new Date(file.modified_at * 1000).toLocaleString()} + +
+ + +
+
+
+ )} +
+ )} +
); } @@ -95,4 +223,10 @@ function formatTimestamp(iso: string): string { } catch { return iso; } -} \ No newline at end of file +} + +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`; +} diff --git a/frontend/src/components/servers/ModList.tsx b/frontend/src/components/servers/ModList.tsx index 50a9aaf..6b99e5e 100644 --- a/frontend/src/components/servers/ModList.tsx +++ b/frontend/src/components/servers/ModList.tsx @@ -1,7 +1,8 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { Save } from "lucide-react"; import { useServerMods, useSetEnabledMods } from "@/hooks/useServerDetail"; +import type { Mod } from "@/hooks/useServerDetail"; import { useAuthStore } from "@/store/auth.store"; import { useUIStore } from "@/store/ui.store"; import { logger } from "@/lib/logger"; @@ -15,83 +16,185 @@ export function ModList({ serverId }: ModListProps) { const addNotification = useUIStore((s) => s.addNotification); const { data: modsData, isLoading } = useServerMods(serverId); const setEnabledMods = useSetEnabledMods(serverId); - const [enabledSet, setEnabledSet] = useState | null>(null); + + const [available, setAvailable] = useState([]); + const [selected, setSelected] = useState([]); + 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) { return
Loading mods...
; } - 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 ( -
-
+
+

- Mods ({modsData?.enabled_count ?? 0}/{mods.length} enabled) + Mods ({selected.length} selected / {(modsData?.mods.length ?? 0)} total)

{isAdmin && hasChanges && ( - )}
- {mods.length === 0 ? ( -
No mods found
- ) : ( -
- {mods.map((mod) => ( -
- {isAdmin ? ( - handleToggle(mod.name)} - className="w-4 h-4 accent-accent" - aria-label={`Toggle ${mod.name}`} - /> - ) : ( - - )} -
-

{mod.name}

-

{mod.path}

+ {isAdmin && hasChanges && ( +

+ {selected.length} mod(s) selected. Server restart required for changes to take effect. +

+ )} + +
+ {/* Available pane */} +
+
+ Available ({filterMods(available, availSearch).length}) +
+ setAvailSearch(e.target.value)} + className="neu-input w-full text-sm mb-2" + /> +
+ {filterMods(available, availSearch).length === 0 ? ( +
+ {available.length === 0 ? "All mods selected" : "No matches"}
- - {formatSize(mod.size_bytes)} - -
- ))} + ) : ( + filterMods(available, availSearch).map((mod) => ( + moveToSelected(mod) : undefined} + /> + )) + )} +
+ + {/* Selected pane */} +
+
+ Selected ({filterMods(selected, selSearch).length}) +
+ setSelSearch(e.target.value)} + className="neu-input w-full text-sm mb-2" + /> +
+ {filterMods(selected, selSearch).length === 0 ? ( +
+ {selected.length === 0 ? "No mods selected" : "No matches"} +
+ ) : ( + filterMods(selected, selSearch).map((mod) => ( + moveToAvailable(mod) : undefined} + selected + /> + )) + )} +
+
+
+
+ ); +} + +function ModRow({ + mod, + actionLabel, + onAction, + selected = false, +}: { + mod: Mod; + actionLabel: string; + onAction?: () => void; + selected?: boolean; +}) { + return ( +
+
+

+ {mod.display_name ?? mod.name} +

+ {mod.display_name && ( +

{mod.name}

+ )} +
+ {mod.workshop_id && ( + + Workshop + + )} + {formatSize(mod.size_bytes)} +
+
+ {onAction && ( + )}
); @@ -102,4 +205,4 @@ function formatSize(bytes: number): string { if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${bytes} B`; -} \ No newline at end of file +} diff --git a/frontend/src/components/servers/PlayerTable.tsx b/frontend/src/components/servers/PlayerTable.tsx index 64a8c3d..a333224 100644 --- a/frontend/src/components/servers/PlayerTable.tsx +++ b/frontend/src/components/servers/PlayerTable.tsx @@ -1,32 +1,154 @@ 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 { 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 kickPlayer = useKickPlayer(serverId); + const banPlayer = useBanPlayer(serverId); const [showHistory, setShowHistory] = useState(false); + // Modal state + const [kickTarget, setKickTarget] = useState(null); + const [kickReason, setKickReason] = useState("Kicked by admin"); + const [banTarget, setBanTarget] = useState(null); + const [banReason, setBanReason] = useState("Banned by admin"); + const [banDuration, setBanDuration] = useState(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) { return
Loading players...
; } const players = playersData?.players ?? []; const playerCount = playersData?.player_count ?? 0; + const colSpan = isAdmin ? 6 : 5; return (
+ {/* Kick modal */} + {kickTarget && ( +
+
+

Kick {kickTarget.name}

+