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

@@ -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>
);
}
@@ -95,4 +223,10 @@ function formatTimestamp(iso: string): string {
} catch {
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>
);
@@ -102,4 +205,4 @@ function formatSize(bytes: number): string {
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${bytes} B`;
}
}

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>
@@ -145,4 +283,4 @@ function formatDuration(seconds: number): string {
const m = Math.floor((seconds % 3600) / 60);
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
}

View File

@@ -390,4 +390,58 @@ export function useSendCommand(serverId: number) {
mutationFn: (command: string) =>
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>
);