feat: add Server page with log viewer, SQL console, and config editor
This commit is contained in:
@@ -3,6 +3,7 @@ import { useState, useCallback } from "react";
|
|||||||
import { Dashboard } from "./pages/Dashboard";
|
import { Dashboard } from "./pages/Dashboard";
|
||||||
import { Editor } from "./pages/Editor";
|
import { Editor } from "./pages/Editor";
|
||||||
import { Build } from "./pages/Build";
|
import { Build } from "./pages/Build";
|
||||||
|
import { Server } from "./pages/Server";
|
||||||
import { IDELayout } from "./layouts/IDELayout";
|
import { IDELayout } from "./layouts/IDELayout";
|
||||||
import { FileExplorer } from "./components/editor/FileExplorer";
|
import { FileExplorer } from "./components/editor/FileExplorer";
|
||||||
import { api } from "./services/api";
|
import { api } from "./services/api";
|
||||||
@@ -50,6 +51,7 @@ export function App() {
|
|||||||
element={<Editor editorState={editorState} />}
|
element={<Editor editorState={editorState} />}
|
||||||
/>
|
/>
|
||||||
<Route path="build" element={<Build />} />
|
<Route path="build" element={<Build />} />
|
||||||
|
<Route path="server" element={<Server />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -0,0 +1,409 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
import { Editor as ReactMonacoEditor } from "@monaco-editor/react";
|
||||||
|
import { api } from "../services/api";
|
||||||
|
import { useWebSocket } from "../hooks/useWebSocket";
|
||||||
|
|
||||||
|
type ServerState = "running" | "exited" | "not created" | string;
|
||||||
|
|
||||||
|
function StatusBadge({ label, state }: { label: string; state: ServerState }) {
|
||||||
|
const color =
|
||||||
|
state === "running"
|
||||||
|
? "bg-green-500/20 text-green-400"
|
||||||
|
: state === "exited"
|
||||||
|
? "bg-red-500/20 text-red-400"
|
||||||
|
: "bg-gray-500/20 text-gray-400";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium" style={{ color: "var(--forge-text)" }}>
|
||||||
|
{label}:
|
||||||
|
</span>
|
||||||
|
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${color}`}>
|
||||||
|
{state}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ControlsPanel() {
|
||||||
|
const [status, setStatus] = useState<{ nwserver: string; mariadb: string }>({
|
||||||
|
nwserver: "unknown",
|
||||||
|
mariadb: "unknown",
|
||||||
|
});
|
||||||
|
const [loading, setLoading] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchStatus = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const s = await api.server.status();
|
||||||
|
setStatus(s);
|
||||||
|
} catch {
|
||||||
|
/* server might not be reachable */
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStatus();
|
||||||
|
const interval = setInterval(fetchStatus, 10000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [fetchStatus]);
|
||||||
|
|
||||||
|
const handleAction = async (action: string) => {
|
||||||
|
setLoading(action);
|
||||||
|
try {
|
||||||
|
if (action === "start") await api.server.start();
|
||||||
|
else if (action === "stop") await api.server.stop();
|
||||||
|
else if (action === "restart") await api.server.restart();
|
||||||
|
else if (action === "config") await api.server.generateConfig();
|
||||||
|
await fetchStatus();
|
||||||
|
} catch {
|
||||||
|
/* error handled by status refresh */
|
||||||
|
}
|
||||||
|
setLoading(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="rounded-lg border p-4"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--forge-surface)",
|
||||||
|
borderColor: "var(--forge-border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3 className="mb-3 text-lg font-semibold" style={{ color: "var(--forge-text)" }}>
|
||||||
|
Server Controls
|
||||||
|
</h3>
|
||||||
|
<div className="mb-4 flex gap-4">
|
||||||
|
<StatusBadge label="NWN Server" state={status.nwserver} />
|
||||||
|
<StatusBadge label="MariaDB" state={status.mariadb} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{(["start", "stop", "restart", "config"] as const).map((action) => (
|
||||||
|
<button
|
||||||
|
key={action}
|
||||||
|
onClick={() => handleAction(action)}
|
||||||
|
disabled={loading !== null}
|
||||||
|
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
style={{
|
||||||
|
backgroundColor:
|
||||||
|
action === "start"
|
||||||
|
? "var(--forge-accent)"
|
||||||
|
: action === "stop"
|
||||||
|
? "#991b1b"
|
||||||
|
: "var(--forge-surface)",
|
||||||
|
borderColor:
|
||||||
|
action === "start"
|
||||||
|
? "var(--forge-accent)"
|
||||||
|
: action === "stop"
|
||||||
|
? "#dc2626"
|
||||||
|
: "var(--forge-border)",
|
||||||
|
color: action === "start" || action === "stop" ? "#fff" : "var(--forge-text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading === action
|
||||||
|
? "..."
|
||||||
|
: action === "config"
|
||||||
|
? "Generate Config"
|
||||||
|
: action.charAt(0).toUpperCase() + action.slice(1)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LogViewer() {
|
||||||
|
const { subscribe } = useWebSocket();
|
||||||
|
const [lines, setLines] = useState<string[]>([]);
|
||||||
|
const [autoScroll, setAutoScroll] = useState(true);
|
||||||
|
const [filter, setFilter] = useState("");
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return subscribe("log:server", (event) => {
|
||||||
|
const data = event.data as { text?: string };
|
||||||
|
if (!data?.text) return;
|
||||||
|
setLines((prev) => [
|
||||||
|
...prev,
|
||||||
|
...data.text!.split("\n").filter(Boolean),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}, [subscribe]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoScroll && scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [lines, autoScroll]);
|
||||||
|
|
||||||
|
const filteredLines = filter
|
||||||
|
? lines.filter((l) => l.toLowerCase().includes(filter.toLowerCase()))
|
||||||
|
: lines;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="flex flex-col rounded-lg border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--forge-surface)",
|
||||||
|
borderColor: "var(--forge-border)",
|
||||||
|
height: "350px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex shrink-0 items-center gap-2 px-4 py-2"
|
||||||
|
style={{ borderBottom: "1px solid var(--forge-border)" }}
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-semibold" style={{ color: "var(--forge-text)" }}>
|
||||||
|
Server Logs
|
||||||
|
</h3>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={filter}
|
||||||
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
|
placeholder="Filter logs..."
|
||||||
|
className="rounded border px-2 py-1 text-xs"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--forge-bg)",
|
||||||
|
borderColor: "var(--forge-border)",
|
||||||
|
color: "var(--forge-text)",
|
||||||
|
width: "200px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => setAutoScroll((v) => !v)}
|
||||||
|
className="rounded border px-2 py-1 text-xs"
|
||||||
|
style={{
|
||||||
|
backgroundColor: autoScroll ? "var(--forge-accent)" : "var(--forge-bg)",
|
||||||
|
borderColor: "var(--forge-border)",
|
||||||
|
color: autoScroll ? "#fff" : "var(--forge-text-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Auto-scroll
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setLines([])}
|
||||||
|
className="rounded border px-2 py-1 text-xs"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--forge-bg)",
|
||||||
|
borderColor: "var(--forge-border)",
|
||||||
|
color: "var(--forge-text-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className="flex-1 overflow-auto p-3"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#0d1117",
|
||||||
|
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||||
|
fontSize: "12px",
|
||||||
|
lineHeight: "1.5",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filteredLines.length === 0 ? (
|
||||||
|
<span style={{ color: "var(--forge-text-secondary)" }}>
|
||||||
|
Waiting for log output...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
filteredLines.map((line, i) => (
|
||||||
|
<div key={i} style={{ color: "#c9d1d9" }}>
|
||||||
|
{line}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SQLConsole() {
|
||||||
|
const [query, setQuery] = useState("SELECT * FROM player_tracking LIMIT 10;");
|
||||||
|
const [result, setResult] = useState<{
|
||||||
|
columns: string[];
|
||||||
|
rows: Record<string, string>[];
|
||||||
|
} | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [history, setHistory] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const execute = async () => {
|
||||||
|
if (!query.trim()) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await api.server.sql(query);
|
||||||
|
setResult(res);
|
||||||
|
setHistory((prev) => [query, ...prev.filter((q) => q !== query)].slice(0, 10));
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
setResult(null);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="flex flex-col rounded-lg border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--forge-surface)",
|
||||||
|
borderColor: "var(--forge-border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex shrink-0 items-center gap-2 px-4 py-2"
|
||||||
|
style={{ borderBottom: "1px solid var(--forge-border)" }}
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-semibold" style={{ color: "var(--forge-text)" }}>
|
||||||
|
SQL Console
|
||||||
|
</h3>
|
||||||
|
<div className="flex-1" />
|
||||||
|
{history.length > 0 && (
|
||||||
|
<select
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
className="rounded border px-2 py-1 text-xs"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--forge-bg)",
|
||||||
|
borderColor: "var(--forge-border)",
|
||||||
|
color: "var(--forge-text-secondary)",
|
||||||
|
maxWidth: "200px",
|
||||||
|
}}
|
||||||
|
value=""
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
History ({history.length})
|
||||||
|
</option>
|
||||||
|
{history.map((q, i) => (
|
||||||
|
<option key={i} value={q}>
|
||||||
|
{q.length > 60 ? q.slice(0, 60) + "..." : q}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={execute}
|
||||||
|
disabled={loading || !query.trim()}
|
||||||
|
className="rounded border px-3 py-1 text-sm font-medium transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--forge-accent)",
|
||||||
|
borderColor: "var(--forge-accent)",
|
||||||
|
color: "#fff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? "Running..." : "Execute"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ height: "100px" }}>
|
||||||
|
<ReactMonacoEditor
|
||||||
|
value={query}
|
||||||
|
language="sql"
|
||||||
|
theme="vs-dark"
|
||||||
|
onChange={(v) => setQuery(v ?? "")}
|
||||||
|
options={{
|
||||||
|
minimap: { enabled: false },
|
||||||
|
fontSize: 13,
|
||||||
|
lineNumbers: "off",
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
automaticLayout: true,
|
||||||
|
wordWrap: "on",
|
||||||
|
padding: { top: 4, bottom: 4 },
|
||||||
|
renderLineHighlight: "none",
|
||||||
|
overviewRulerLanes: 0,
|
||||||
|
hideCursorInOverviewRuler: true,
|
||||||
|
scrollbar: { vertical: "hidden", horizontal: "hidden" },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
className="px-4 py-2 text-sm"
|
||||||
|
style={{ color: "#ef4444", borderTop: "1px solid var(--forge-border)" }}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<div
|
||||||
|
className="max-h-64 overflow-auto"
|
||||||
|
style={{ borderTop: "1px solid var(--forge-border)" }}
|
||||||
|
>
|
||||||
|
{result.columns.length === 0 ? (
|
||||||
|
<div className="px-4 py-3 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
|
||||||
|
Query executed successfully (no results)
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-left text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{result.columns.map((col) => (
|
||||||
|
<th
|
||||||
|
key={col}
|
||||||
|
className="sticky top-0 px-3 py-2 font-medium"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--forge-surface)",
|
||||||
|
borderBottom: "1px solid var(--forge-border)",
|
||||||
|
color: "var(--forge-accent)",
|
||||||
|
fontFamily: "'JetBrains Mono', monospace",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{col}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{result.rows.map((row, i) => (
|
||||||
|
<tr
|
||||||
|
key={i}
|
||||||
|
className="transition-colors hover:bg-white/5"
|
||||||
|
style={{
|
||||||
|
borderBottom: "1px solid var(--forge-border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{result.columns.map((col) => (
|
||||||
|
<td
|
||||||
|
key={col}
|
||||||
|
className="px-3 py-1.5"
|
||||||
|
style={{
|
||||||
|
color: "var(--forge-text)",
|
||||||
|
fontFamily: "'JetBrains Mono', monospace",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row[col]}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="px-3 py-1 text-xs"
|
||||||
|
style={{ color: "var(--forge-text-secondary)" }}
|
||||||
|
>
|
||||||
|
{result.rows.length} row{result.rows.length !== 1 ? "s" : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Server() {
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-auto p-6" style={{ color: "var(--forge-text)" }}>
|
||||||
|
<h2 className="mb-6 text-2xl font-bold" style={{ color: "var(--forge-accent)" }}>
|
||||||
|
Server Management
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<ControlsPanel />
|
||||||
|
<LogViewer />
|
||||||
|
<SQLConsole />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -61,6 +61,24 @@ export const api = {
|
|||||||
request("/build/nwnx", { method: "POST", body: JSON.stringify({ target }) }),
|
request("/build/nwnx", { method: "POST", body: JSON.stringify({ target }) }),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
server: {
|
||||||
|
status: () => request<{ nwserver: string; mariadb: string }>("/server/status"),
|
||||||
|
start: () => request("/server/start", { method: "POST" }),
|
||||||
|
stop: () => request("/server/stop", { method: "POST" }),
|
||||||
|
restart: () => request("/server/restart", { method: "POST" }),
|
||||||
|
generateConfig: () => request("/server/generate-config", { method: "POST" }),
|
||||||
|
seedDb: (cdKey: string, playerName: string) =>
|
||||||
|
request("/server/seed-db", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ cdKey, playerName }),
|
||||||
|
}),
|
||||||
|
sql: (query: string, allowWrite?: boolean) =>
|
||||||
|
request<{ columns: string[]; rows: Record<string, string>[] }>("/server/sql", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ query, allowWrite }),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
docker: {
|
docker: {
|
||||||
containers: () => request<Array<Record<string, string>>>("/docker/containers"),
|
containers: () => request<Array<Record<string, string>>>("/docker/containers"),
|
||||||
pull: (image: string) =>
|
pull: (image: string) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user