Files
layonara-forge/packages/frontend/src/pages/Server.tsx
T

410 lines
12 KiB
TypeScript

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>
);
}