feat: implement phases 3-5 of Arma 3 UX enhancement plan
Phase 3 - Mod display names + split-pane selector:
- Parse mod.cpp/meta.cpp for display_name and workshop_id
- Rewrite ModList as two-pane available/selected interface
Phase 4 - Player kick/ban from Players tab:
- Add get_by_slot() to PlayerRepository
- Add get_rcon_client() class method to ThreadRegistry
- Add /players/{slot_id}/kick and /ban endpoints
- Rewrite PlayerTable with kick/ban modals and ban presets
Phase 5 - Historical log file browser:
- Add list_log_files() and get_log_file_path() to RPTParser
- Add logfiles_router with GET/download/DELETE endpoints
- Update LogViewer with collapsible log files section (download + delete)
This commit is contained in:
@@ -62,6 +62,36 @@ class RPTParser:
|
||||
"message": (message or "").strip(),
|
||||
}
|
||||
|
||||
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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
80
backend/core/servers/logfiles_router.py
Normal file
80
backend/core/servers/logfiles_router.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""Log file endpoints — list, download, and delete historical RPT log files."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.engine import Connection
|
||||
|
||||
from adapters.registry import GameAdapterRegistry
|
||||
from core.dal.server_repository import ServerRepository
|
||||
from core.utils.file_utils import get_server_dir
|
||||
from database import get_db
|
||||
from dependencies import get_current_user, require_admin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/servers/{server_id}/logfiles", tags=["logfiles"])
|
||||
|
||||
|
||||
def _ok(data):
|
||||
return {"success": True, "data": data, "error": None}
|
||||
|
||||
|
||||
def _get_rpt_parser(server_id: int, db: Connection):
|
||||
server = ServerRepository(db).get_by_id(server_id)
|
||||
if server is None:
|
||||
raise HTTPException(status_code=404, detail="Server not found")
|
||||
adapter = GameAdapterRegistry.get(server["game_type"])
|
||||
if not adapter.has_capability("log_parser"):
|
||||
raise HTTPException(status_code=404, detail="Server does not support log files")
|
||||
return adapter.get_log_parser(), get_server_dir(server_id)
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_log_files(
|
||||
server_id: int,
|
||||
db: Annotated[Connection, Depends(get_db)],
|
||||
_user: Annotated[dict, Depends(get_current_user)],
|
||||
) -> dict:
|
||||
parser, server_dir = _get_rpt_parser(server_id, db)
|
||||
files = parser.list_log_files(server_dir)
|
||||
return _ok(files)
|
||||
|
||||
|
||||
@router.get("/{filename}/download")
|
||||
def download_log_file(
|
||||
server_id: int,
|
||||
filename: str,
|
||||
db: Annotated[Connection, Depends(get_db)],
|
||||
_user: Annotated[dict, Depends(get_current_user)],
|
||||
):
|
||||
parser, server_dir = _get_rpt_parser(server_id, db)
|
||||
path = parser.get_log_file_path(server_dir, filename)
|
||||
if path is None:
|
||||
raise HTTPException(status_code=404, detail="Log file not found")
|
||||
return FileResponse(
|
||||
path=str(path),
|
||||
filename=filename,
|
||||
media_type="text/plain",
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{filename}")
|
||||
def delete_log_file(
|
||||
server_id: int,
|
||||
filename: str,
|
||||
db: Annotated[Connection, Depends(get_db)],
|
||||
_admin: Annotated[dict, Depends(require_admin)],
|
||||
) -> dict:
|
||||
parser, server_dir = _get_rpt_parser(server_id, db)
|
||||
path = parser.get_log_file_path(server_dir, filename)
|
||||
if path is None:
|
||||
raise HTTPException(status_code=404, detail="Log file not found")
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Could not delete file: {exc}") from exc
|
||||
return _ok({"message": f"{filename} deleted"})
|
||||
@@ -5,18 +5,28 @@ import logging
|
||||
from typing import Annotated
|
||||
|
||||
from 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)
|
||||
@@ -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"])
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
134
frontend/src/__tests__/Phase345.test.tsx
Normal file
134
frontend/src/__tests__/Phase345.test.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import {
|
||||
useKickPlayer,
|
||||
useBanPlayer,
|
||||
useServerLogFiles,
|
||||
useDeleteLogFile,
|
||||
} from "@/hooks/useServerDetail";
|
||||
import type { Mod, LogFile } from "@/hooks/useServerDetail";
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
apiClient: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { apiClient } from "@/lib/api";
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||
};
|
||||
}
|
||||
|
||||
// ── Phase 3: Mod type has display_name + workshop_id ──
|
||||
describe("Mod type includes display_name and workshop_id", () => {
|
||||
it("Mod type fields are correct", () => {
|
||||
const mod: Mod = {
|
||||
name: "@CBA_A3",
|
||||
path: "/srv/arma3/@CBA_A3",
|
||||
size_bytes: 50000000,
|
||||
enabled: true,
|
||||
display_name: "Community Base Addons A3",
|
||||
workshop_id: "450814997",
|
||||
};
|
||||
expect(mod.display_name).toBe("Community Base Addons A3");
|
||||
expect(mod.workshop_id).toBe("450814997");
|
||||
});
|
||||
|
||||
it("allows null display_name and workshop_id", () => {
|
||||
const mod: Mod = {
|
||||
name: "@LocalMod",
|
||||
path: "/srv/@LocalMod",
|
||||
size_bytes: 1000,
|
||||
enabled: false,
|
||||
display_name: null,
|
||||
workshop_id: null,
|
||||
};
|
||||
expect(mod.display_name).toBeNull();
|
||||
expect(mod.workshop_id).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Phase 4: Kick / Ban hooks ──
|
||||
describe("useKickPlayer", () => {
|
||||
beforeEach(() => vi.mocked(apiClient.post).mockReset());
|
||||
|
||||
it("posts to /players/:slotId/kick", async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: { success: true } });
|
||||
const { result } = renderHook(() => useKickPlayer(1), { wrapper: createWrapper() });
|
||||
await result.current.mutateAsync({ slotId: 3, reason: "AFK" });
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
"/api/servers/1/players/3/kick",
|
||||
{ reason: "AFK" },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useBanPlayer", () => {
|
||||
beforeEach(() => vi.mocked(apiClient.post).mockReset());
|
||||
|
||||
it("posts to /players/:slotId/ban with reason and duration", async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: { success: true, data: {} } });
|
||||
const { result } = renderHook(() => useBanPlayer(1), { wrapper: createWrapper() });
|
||||
await result.current.mutateAsync({ slotId: 5, reason: "Cheating", durationMinutes: 60 });
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
"/api/servers/1/players/5/ban",
|
||||
{ reason: "Cheating", duration_minutes: 60 },
|
||||
);
|
||||
});
|
||||
|
||||
it("sends null duration_minutes for permanent ban", async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: { success: true, data: {} } });
|
||||
const { result } = renderHook(() => useBanPlayer(1), { wrapper: createWrapper() });
|
||||
await result.current.mutateAsync({ slotId: 5, reason: "Cheating" });
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
"/api/servers/1/players/5/ban",
|
||||
{ reason: "Cheating", duration_minutes: null },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Phase 5: Log file hooks ──
|
||||
describe("useServerLogFiles", () => {
|
||||
beforeEach(() => vi.mocked(apiClient.get).mockReset());
|
||||
|
||||
it("fetches from /servers/:id/logfiles", async () => {
|
||||
const mockFiles: LogFile[] = [
|
||||
{ filename: "arma3.rpt", size_bytes: 1024, modified_at: 1700000000 },
|
||||
];
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: { success: true, data: mockFiles } });
|
||||
const { result } = renderHook(() => useServerLogFiles(1), { wrapper: createWrapper() });
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(result.current.data).toEqual(mockFiles);
|
||||
expect(apiClient.get).toHaveBeenCalledWith("/api/servers/1/logfiles");
|
||||
});
|
||||
|
||||
it("is disabled when serverId is 0", () => {
|
||||
const { result } = renderHook(() => useServerLogFiles(0), { wrapper: createWrapper() });
|
||||
expect(result.current.fetchStatus).toBe("idle");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useDeleteLogFile", () => {
|
||||
beforeEach(() => vi.mocked(apiClient.delete).mockReset());
|
||||
|
||||
it("deletes with URL-encoded filename", async () => {
|
||||
vi.mocked(apiClient.delete).mockResolvedValue({ data: { success: true } });
|
||||
const { result } = renderHook(() => useDeleteLogFile(1), { wrapper: createWrapper() });
|
||||
await result.current.mutateAsync("arma3 server.rpt");
|
||||
expect(apiClient.delete).toHaveBeenCalledWith(
|
||||
"/api/servers/1/logfiles/arma3%20server.rpt",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,11 @@
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { useState, useRef } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
import { 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`;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"] }),
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user