diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx
index 12caeab..ecd6961 100644
--- a/packages/frontend/src/App.tsx
+++ b/packages/frontend/src/App.tsx
@@ -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={}
/>
} />
+ } />
diff --git a/packages/frontend/src/pages/Server.tsx b/packages/frontend/src/pages/Server.tsx
new file mode 100644
index 0000000..0208264
--- /dev/null
+++ b/packages/frontend/src/pages/Server.tsx
@@ -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 (
+
+
+ {label}:
+
+
+ {state}
+
+
+ );
+}
+
+function ControlsPanel() {
+ const [status, setStatus] = useState<{ nwserver: string; mariadb: string }>({
+ nwserver: "unknown",
+ mariadb: "unknown",
+ });
+ const [loading, setLoading] = useState(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 (
+
+
+ Server Controls
+
+
+
+
+
+
+ {(["start", "stop", "restart", "config"] as const).map((action) => (
+
+ ))}
+
+
+ );
+}
+
+function LogViewer() {
+ const { subscribe } = useWebSocket();
+ const [lines, setLines] = useState([]);
+ const [autoScroll, setAutoScroll] = useState(true);
+ const [filter, setFilter] = useState("");
+ const scrollRef = useRef(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 (
+
+
+
+ Server Logs
+
+
+
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",
+ }}
+ />
+
+
+
+
+ {filteredLines.length === 0 ? (
+
+ Waiting for log output...
+
+ ) : (
+ filteredLines.map((line, i) => (
+
+ {line}
+
+ ))
+ )}
+
+
+ );
+}
+
+function SQLConsole() {
+ const [query, setQuery] = useState("SELECT * FROM player_tracking LIMIT 10;");
+ const [result, setResult] = useState<{
+ columns: string[];
+ rows: Record[];
+ } | null>(null);
+ const [error, setError] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [history, setHistory] = useState([]);
+
+ 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 (
+
+
+
+ SQL Console
+
+
+ {history.length > 0 && (
+
+ )}
+
+
+
+
+ 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" },
+ }}
+ />
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {result && (
+
+ {result.columns.length === 0 ? (
+
+ Query executed successfully (no results)
+
+ ) : (
+
+
+
+ {result.columns.map((col) => (
+ |
+ {col}
+ |
+ ))}
+
+
+
+ {result.rows.map((row, i) => (
+
+ {result.columns.map((col) => (
+ |
+ {row[col]}
+ |
+ ))}
+
+ ))}
+
+
+ )}
+
+ {result.rows.length} row{result.rows.length !== 1 ? "s" : ""}
+
+
+ )}
+
+ );
+}
+
+export function Server() {
+ return (
+
+
+ Server Management
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/frontend/src/services/api.ts b/packages/frontend/src/services/api.ts
index 161b271..b2e8b06 100644
--- a/packages/frontend/src/services/api.ts
+++ b/packages/frontend/src/services/api.ts
@@ -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[] }>("/server/sql", {
+ method: "POST",
+ body: JSON.stringify({ query, allowWrite }),
+ }),
+ },
+
docker: {
containers: () => request>>("/docker/containers"),
pull: (image: string) =>