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 { Editor } from "./pages/Editor";
|
||||
import { Build } from "./pages/Build";
|
||||
import { Server } from "./pages/Server";
|
||||
import { IDELayout } from "./layouts/IDELayout";
|
||||
import { FileExplorer } from "./components/editor/FileExplorer";
|
||||
import { api } from "./services/api";
|
||||
@@ -50,6 +51,7 @@ export function App() {
|
||||
element={<Editor editorState={editorState} />}
|
||||
/>
|
||||
<Route path="build" element={<Build />} />
|
||||
<Route path="server" element={<Server />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</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 }) }),
|
||||
},
|
||||
|
||||
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: {
|
||||
containers: () => request<Array<Record<string, string>>>("/docker/containers"),
|
||||
pull: (image: string) =>
|
||||
|
||||
Reference in New Issue
Block a user