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