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) => ( + + ))} + + + + {result.rows.map((row, i) => ( + + {result.columns.map((col) => ( + + ))} + + ))} + +
+ {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) =>