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(),
}
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:

View File

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

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:
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 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}
@@ -55,3 +65,30 @@ def player_history(
server_id=server_id, limit=limit, offset=offset, search=search,
)
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] = "***"
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"])

View File

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

View File

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

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 { 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<string>("all");
const [showFiles, setShowFiles] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<string | null>(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"
? 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 (
<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">
<h3 className="text-text-primary font-semibold">
Server Logs ({logs.length})
@@ -85,6 +147,72 @@ export function LogViewer({ logs }: LogViewerProps) {
))
)}
</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>
);
}
@@ -96,3 +224,9 @@ function formatTimestamp(iso: string): string {
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 { 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<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) {
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 (
<div data-testid="mod-list">
<div className="flex items-center justify-between mb-4">
<div data-testid="mod-list" className="space-y-4">
<div className="flex items-center justify-between">
<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>
{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} />
{setEnabledMods.isPending ? "Saving..." : "Save Changes"}
{setEnabledMods.isPending ? "Applying..." : "Apply Selection"}
</button>
)}
</div>
{mods.length === 0 ? (
<div className="text-text-muted text-sm text-center py-6">No mods found</div>
) : (
<div className="space-y-2">
{mods.map((mod) => (
<div
key={mod.name}
className="flex items-center gap-3 px-3 py-2.5 rounded-lg bg-surface-recessed shadow-neu-recessed"
>
{isAdmin ? (
<input
type="checkbox"
checked={activeEnabled.has(mod.name)}
onChange={() => handleToggle(mod.name)}
className="w-4 h-4 accent-accent"
aria-label={`Toggle ${mod.name}`}
/>
) : (
<span className={`w-4 h-4 rounded border ${mod.enabled ? "bg-accent border-accent" : "border-text-muted"}`} />
)}
<div className="flex-1 min-w-0">
<p className="text-text-primary text-sm font-medium truncate">{mod.name}</p>
<p className="text-text-muted text-xs font-mono truncate">{mod.path}</p>
{isAdmin && hasChanges && (
<p className="text-text-muted text-xs">
{selected.length} mod(s) selected. Server restart required for changes to take effect.
</p>
)}
<div className="flex flex-col md:flex-row gap-4">
{/* Available pane */}
<div className="flex-1 min-w-0">
<div className="font-medium text-text-secondary text-sm mb-2">
Available ({filterMods(available, availSearch).length})
</div>
<input
type="text"
placeholder="Search..."
value={availSearch}
onChange={(e) => setAvailSearch(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(available, availSearch).length === 0 ? (
<div className="text-text-muted text-xs text-center py-4">
{available.length === 0 ? "All mods selected" : "No matches"}
</div>
<span className="text-text-muted text-xs font-mono">
{formatSize(mod.size_bytes)}
</span>
</div>
))}
) : (
filterMods(available, availSearch).map((mod) => (
<ModRow
key={mod.name}
mod={mod}
actionLabel="→"
onAction={isAdmin ? () => moveToSelected(mod) : undefined}
/>
))
)}
</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>
);

View File

@@ -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<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) {
return <div className="text-text-muted text-sm p-4">Loading players...</div>;
}
const players = playersData?.players ?? [];
const playerCount = playersData?.player_count ?? 0;
const colSpan = isAdmin ? 6 : 5;
return (
<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">
<h3 className="text-text-primary font-semibold">
Online Players ({playerCount})
</h3>
<button
onClick={() => setShowHistory(!showHistory)}
className="btn-ghost text-sm"
>
<button onClick={() => setShowHistory(!showHistory)} className="btn-ghost text-sm">
{showHistory ? "Current Players" : "Player History"}
</button>
</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">IP</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>
</thead>
<tbody>
{players.length === 0 ? (
<tr>
<td colSpan={5} className="text-text-muted text-center py-6">
No players online
</td>
<td colSpan={colSpan} className="text-text-muted text-center py-6">No players online</td>
</tr>
) : (
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.ip}</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>
))
)}
@@ -95,7 +238,6 @@ function PlayerHistorySection({ serverId }: { serverId: number }) {
className="neu-input w-full text-sm"
/>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
@@ -110,9 +252,7 @@ function PlayerHistorySection({ serverId }: { serverId: number }) {
<tbody>
{entries.length === 0 ? (
<tr>
<td colSpan={5} className="text-text-muted text-center py-6">
No player history
</td>
<td colSpan={5} className="text-text-muted text-center py-6">No player history</td>
</tr>
) : (
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="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">
{entry.left_at ? formatTime(entry.left_at) : "--"}
</td>
<td className="text-text-secondary text-xs px-3 py-2">{entry.left_at ? formatTime(entry.left_at) : "--"}</td>
<td className="text-right font-mono text-text-secondary px-3 py-2">
{entry.session_duration_seconds ? formatDuration(entry.session_duration_seconds) : "--"}
</td>

View File

@@ -391,3 +391,57 @@ export function useSendCommand(serverId: number) {
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">
{activeTab === "overview" && <OverviewTab 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 === "missions" && <MissionList serverId={id} />}
{activeTab === "mods" && <ModList serverId={id} />}
{activeTab === "logs" && <LogViewer logs={logs} />}
{activeTab === "logs" && <LogViewer logs={logs} serverId={id} />}
</div>
</div>
);