feat: add Build page UI with real-time output streaming
This commit is contained in:
@@ -2,6 +2,7 @@ import { BrowserRouter, Routes, Route } from "react-router-dom";
|
|||||||
import { useState, useCallback } from "react";
|
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 { 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";
|
||||||
@@ -48,6 +49,7 @@ export function App() {
|
|||||||
path="/editor"
|
path="/editor"
|
||||||
element={<Editor editorState={editorState} />}
|
element={<Editor editorState={editorState} />}
|
||||||
/>
|
/>
|
||||||
|
<Route path="build" element={<Build />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -0,0 +1,324 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
import { api } from "../services/api";
|
||||||
|
import { useWebSocket } from "../hooks/useWebSocket";
|
||||||
|
|
||||||
|
type BuildStatus = "idle" | "building" | "success" | "failed";
|
||||||
|
|
||||||
|
interface BuildSectionState {
|
||||||
|
status: BuildStatus;
|
||||||
|
output: string[];
|
||||||
|
collapsed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: BuildStatus }) {
|
||||||
|
const colors: Record<BuildStatus, string> = {
|
||||||
|
idle: "bg-gray-500/20 text-gray-400",
|
||||||
|
building: "bg-yellow-500/20 text-yellow-400",
|
||||||
|
success: "bg-green-500/20 text-green-400",
|
||||||
|
failed: "bg-red-500/20 text-red-400",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${colors[status]}`}>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BuildOutput({
|
||||||
|
lines,
|
||||||
|
collapsed,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
lines: string[];
|
||||||
|
collapsed: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}) {
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!collapsed && scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [lines, collapsed]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2">
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
className="flex items-center gap-1 text-xs transition-colors hover:opacity-80"
|
||||||
|
style={{ color: "var(--forge-text-secondary)" }}
|
||||||
|
>
|
||||||
|
<span>{collapsed ? "\u25B6" : "\u25BC"}</span>
|
||||||
|
<span>Output ({lines.length} lines)</span>
|
||||||
|
</button>
|
||||||
|
{!collapsed && (
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className="mt-1 max-h-64 overflow-auto rounded p-3"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#0d1117",
|
||||||
|
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||||
|
fontSize: "12px",
|
||||||
|
lineHeight: "1.5",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{lines.length === 0 ? (
|
||||||
|
<span style={{ color: "var(--forge-text-secondary)" }}>No output yet</span>
|
||||||
|
) : (
|
||||||
|
lines.map((line, i) => (
|
||||||
|
<div key={i} style={{ color: "#c9d1d9" }}>
|
||||||
|
{line}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionButton({
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
disabled,
|
||||||
|
variant = "default",
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
variant?: "default" | "primary" | "warning";
|
||||||
|
}) {
|
||||||
|
const styles = {
|
||||||
|
default: {
|
||||||
|
backgroundColor: "var(--forge-surface)",
|
||||||
|
borderColor: "var(--forge-border)",
|
||||||
|
color: "var(--forge-text)",
|
||||||
|
},
|
||||||
|
primary: {
|
||||||
|
backgroundColor: "var(--forge-accent)",
|
||||||
|
borderColor: "var(--forge-accent)",
|
||||||
|
color: "#fff",
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
backgroundColor: "#854d0e",
|
||||||
|
borderColor: "#a16207",
|
||||||
|
color: "#fef08a",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
style={styles[variant]}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Build() {
|
||||||
|
const { subscribe } = useWebSocket();
|
||||||
|
|
||||||
|
const [module, setModule] = useState<BuildSectionState>({
|
||||||
|
status: "idle",
|
||||||
|
output: [],
|
||||||
|
collapsed: true,
|
||||||
|
});
|
||||||
|
const [haks, setHaks] = useState<BuildSectionState>({
|
||||||
|
status: "idle",
|
||||||
|
output: [],
|
||||||
|
collapsed: true,
|
||||||
|
});
|
||||||
|
const [nwnx, setNwnx] = useState<BuildSectionState>({
|
||||||
|
status: "idle",
|
||||||
|
output: [],
|
||||||
|
collapsed: true,
|
||||||
|
});
|
||||||
|
const [nwnxTarget, setNwnxTarget] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubs = [
|
||||||
|
subscribe("build:start", (event) => {
|
||||||
|
const data = event.data as Record<string, string>;
|
||||||
|
const section = data?.section;
|
||||||
|
const setter =
|
||||||
|
section === "haks" ? setHaks : section === "nwnx" ? setNwnx : setModule;
|
||||||
|
setter((prev) => ({ ...prev, status: "building", output: [], collapsed: false }));
|
||||||
|
}),
|
||||||
|
subscribe("build:complete", (event) => {
|
||||||
|
const data = event.data as Record<string, string>;
|
||||||
|
const section = data?.section;
|
||||||
|
const setter =
|
||||||
|
section === "haks" ? setHaks : section === "nwnx" ? setNwnx : setModule;
|
||||||
|
setter((prev) => ({ ...prev, status: "success" }));
|
||||||
|
}),
|
||||||
|
subscribe("build:failed", (event) => {
|
||||||
|
const data = event.data as Record<string, string>;
|
||||||
|
const section = data?.section;
|
||||||
|
const setter =
|
||||||
|
section === "haks" ? setHaks : section === "nwnx" ? setNwnx : setModule;
|
||||||
|
setter((prev) => ({ ...prev, status: "failed" }));
|
||||||
|
}),
|
||||||
|
subscribe("build:output", (event) => {
|
||||||
|
const data = event.data as { text?: string; section?: string };
|
||||||
|
if (!data?.text) return;
|
||||||
|
const section = data.section;
|
||||||
|
const setter =
|
||||||
|
section === "haks" ? setHaks : section === "nwnx" ? setNwnx : setModule;
|
||||||
|
setter((prev) => ({
|
||||||
|
...prev,
|
||||||
|
output: [...prev.output, ...data.text!.split("\n").filter(Boolean)],
|
||||||
|
}));
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
return () => unsubs.forEach((u) => u());
|
||||||
|
}, [subscribe]);
|
||||||
|
|
||||||
|
const handleAction = useCallback(
|
||||||
|
async (action: () => Promise<unknown>, section: "module" | "haks" | "nwnx") => {
|
||||||
|
const setter =
|
||||||
|
section === "haks" ? setHaks : section === "nwnx" ? setNwnx : setModule;
|
||||||
|
setter((prev) => ({ ...prev, status: "building", output: [], collapsed: false }));
|
||||||
|
try {
|
||||||
|
await action();
|
||||||
|
} catch {
|
||||||
|
setter((prev) => ({ ...prev, status: "failed" }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isBuilding = module.status === "building" || haks.status === "building" || nwnx.status === "building";
|
||||||
|
|
||||||
|
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)" }}>
|
||||||
|
Build Pipeline
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Module Section */}
|
||||||
|
<section
|
||||||
|
className="mb-6 rounded-lg border p-4"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--forge-surface)",
|
||||||
|
borderColor: "var(--forge-border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-semibold">Module</h3>
|
||||||
|
<StatusBadge status={module.status} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<ActionButton
|
||||||
|
label="Compile"
|
||||||
|
variant="primary"
|
||||||
|
disabled={isBuilding}
|
||||||
|
onClick={() => handleAction(() => api.build.compileModule(), "module")}
|
||||||
|
/>
|
||||||
|
<ActionButton
|
||||||
|
label="Pack Module"
|
||||||
|
disabled={isBuilding}
|
||||||
|
onClick={() => handleAction(() => api.build.packModule(), "module")}
|
||||||
|
/>
|
||||||
|
<ActionButton
|
||||||
|
label="Deploy to Server"
|
||||||
|
variant="warning"
|
||||||
|
disabled={isBuilding}
|
||||||
|
onClick={() => handleAction(() => api.build.deploy(), "module")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<BuildOutput
|
||||||
|
lines={module.output}
|
||||||
|
collapsed={module.collapsed}
|
||||||
|
onToggle={() => setModule((prev) => ({ ...prev, collapsed: !prev.collapsed }))}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Haks Section */}
|
||||||
|
<section
|
||||||
|
className="mb-6 rounded-lg border p-4"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--forge-surface)",
|
||||||
|
borderColor: "var(--forge-border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-semibold">Haks</h3>
|
||||||
|
<StatusBadge status={haks.status} />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<ActionButton
|
||||||
|
label="Build Haks"
|
||||||
|
variant="primary"
|
||||||
|
disabled={isBuilding}
|
||||||
|
onClick={() => handleAction(() => api.build.buildHaks(), "haks")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<BuildOutput
|
||||||
|
lines={haks.output}
|
||||||
|
collapsed={haks.collapsed}
|
||||||
|
onToggle={() => setHaks((prev) => ({ ...prev, collapsed: !prev.collapsed }))}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* NWNX Section */}
|
||||||
|
<section
|
||||||
|
className="rounded-lg border p-4"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--forge-surface)",
|
||||||
|
borderColor: "var(--forge-border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-semibold">
|
||||||
|
NWNX <span className="text-xs font-normal opacity-60">(Advanced)</span>
|
||||||
|
</h3>
|
||||||
|
<StatusBadge status={nwnx.status} />
|
||||||
|
</div>
|
||||||
|
<div className="mb-3 flex flex-wrap gap-2">
|
||||||
|
<ActionButton
|
||||||
|
label="Build All"
|
||||||
|
variant="primary"
|
||||||
|
disabled={isBuilding}
|
||||||
|
onClick={() => handleAction(() => api.build.buildNwnx(), "nwnx")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={nwnxTarget}
|
||||||
|
onChange={(e) => setNwnxTarget(e.target.value)}
|
||||||
|
placeholder="Target (e.g. Item, Creature)"
|
||||||
|
className="rounded border px-3 py-1.5 text-sm"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--forge-bg)",
|
||||||
|
borderColor: "var(--forge-border)",
|
||||||
|
color: "var(--forge-text)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ActionButton
|
||||||
|
label="Build Target"
|
||||||
|
disabled={isBuilding || !nwnxTarget.trim()}
|
||||||
|
onClick={() =>
|
||||||
|
handleAction(() => api.build.buildNwnx(nwnxTarget.trim()), "nwnx")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className="mt-2 text-xs"
|
||||||
|
style={{ color: "#f59e0b" }}
|
||||||
|
>
|
||||||
|
⚠ Requires server restart to pick up changes
|
||||||
|
</p>
|
||||||
|
<BuildOutput
|
||||||
|
lines={nwnx.output}
|
||||||
|
collapsed={nwnx.collapsed}
|
||||||
|
onToggle={() => setNwnx((prev) => ({ ...prev, collapsed: !prev.collapsed }))}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -48,6 +48,19 @@ export const api = {
|
|||||||
init: () => request("/workspace/init", { method: "POST" }),
|
init: () => request("/workspace/init", { method: "POST" }),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
build: {
|
||||||
|
compileModule: (target?: string) =>
|
||||||
|
request("/build/module/compile", { method: "POST", body: JSON.stringify({ target }) }),
|
||||||
|
packModule: (target?: string) =>
|
||||||
|
request("/build/module/pack", { method: "POST", body: JSON.stringify({ target }) }),
|
||||||
|
deploy: () => request("/build/deploy", { method: "POST" }),
|
||||||
|
compileSingle: (filePath: string) =>
|
||||||
|
request("/build/compile-single", { method: "POST", body: JSON.stringify({ filePath }) }),
|
||||||
|
buildHaks: () => request("/build/haks", { method: "POST" }),
|
||||||
|
buildNwnx: (target?: string) =>
|
||||||
|
request("/build/nwnx", { method: "POST", body: JSON.stringify({ target }) }),
|
||||||
|
},
|
||||||
|
|
||||||
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