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:
Tran G. (Revernomad) Khoa
2026-04-17 20:47:37 +07:00
parent fe3bd81cae
commit 5a62d21def
14 changed files with 890 additions and 88 deletions

View File

@@ -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:

View File

@@ -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

View File

@@ -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})

View 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"})

View File

@@ -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)

View File

@@ -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"])

View File

@@ -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:

View File

@@ -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

View 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",
);
});
});

View File

@@ -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`;
}

View File

@@ -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>
); );

View File

@@ -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>

View File

@@ -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"] }),
});
}

View File

@@ -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>
); );