diff --git a/canvas/.env.example b/canvas/.env.example new file mode 100644 index 00000000..977948ba --- /dev/null +++ b/canvas/.env.example @@ -0,0 +1,8 @@ +# Platform API base URL — used by the canvas for all REST calls and WebSocket connection. +# Set this to your deployed platform URL. +NEXT_PUBLIC_PLATFORM_URL=http://localhost:8080 + +# WebSocket URL override — optional. +# If not set, derived automatically from NEXT_PUBLIC_PLATFORM_URL (http→ws, appends /ws). +# Only set this if your WS endpoint is at a different host/path than the REST API. +# NEXT_PUBLIC_WS_URL=ws://localhost:8080/ws diff --git a/canvas/src/components/Toolbar.tsx b/canvas/src/components/Toolbar.tsx index 6b5c3bd7..11ed276c 100644 --- a/canvas/src/components/Toolbar.tsx +++ b/canvas/src/components/Toolbar.tsx @@ -18,6 +18,23 @@ export function Toolbar() { const [helpOpen, setHelpOpen] = useState(false); const helpRef = useRef(null); + // Suppress toast on the very first connect at page load; only fire on reconnects. + const mountedRef = useRef(false); + useEffect(() => { + const t = setTimeout(() => { mountedRef.current = true; }, 2000); + return () => clearTimeout(t); + }, []); + + const prevWsStatus = useRef("connecting"); + useEffect(() => { + if (prevWsStatus.current === "connecting" && wsStatus === "connected") { + if (mountedRef.current) { + showToast("Live updates restored", "success"); + } + } + prevWsStatus.current = wsStatus; + }, [wsStatus]); + const counts = useMemo(() => { const c = { total: nodes.length, roots: 0, children: 0, online: 0, offline: 0, failed: 0, provisioning: 0, activeTasks: 0 }; for (const n of nodes) { diff --git a/canvas/src/store/__tests__/socket.url.test.ts b/canvas/src/store/__tests__/socket.url.test.ts new file mode 100644 index 00000000..563005ab --- /dev/null +++ b/canvas/src/store/__tests__/socket.url.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; + +// Helper: reset modules, set env vars, import module, then restore env. +async function importWsUrl(env: Record) { + vi.resetModules(); + const saved: Record = {}; + for (const [k, v] of Object.entries(env)) { + saved[k] = process.env[k]; + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + const mod = await import("@/store/socket"); + // Restore env + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + return mod; +} + +describe("socket WS_URL derivation", () => { + afterEach(() => { + vi.resetModules(); + delete process.env.NEXT_PUBLIC_PLATFORM_URL; + delete process.env.NEXT_PUBLIC_WS_URL; + }); + + it("falls back to ws://localhost:8080/ws when no env vars are set", async () => { + const mod = await importWsUrl({ + NEXT_PUBLIC_PLATFORM_URL: undefined, + NEXT_PUBLIC_WS_URL: undefined, + }); + expect(mod.WS_URL).toBe("ws://localhost:8080/ws"); + }); + + it("derives WS_URL from NEXT_PUBLIC_PLATFORM_URL by replacing http→ws and appending /ws", async () => { + const mod = await importWsUrl({ + NEXT_PUBLIC_PLATFORM_URL: "http://api.example.com", + NEXT_PUBLIC_WS_URL: undefined, + }); + expect(mod.WS_URL).toBe("ws://api.example.com/ws"); + }); + + it("handles https→wss correctly", async () => { + const mod = await importWsUrl({ + NEXT_PUBLIC_PLATFORM_URL: "https://api.example.com", + NEXT_PUBLIC_WS_URL: undefined, + }); + expect(mod.WS_URL).toBe("wss://api.example.com/ws"); + }); + + it("NEXT_PUBLIC_WS_URL takes precedence over derived value", async () => { + const mod = await importWsUrl({ + NEXT_PUBLIC_PLATFORM_URL: "http://api.example.com", + NEXT_PUBLIC_WS_URL: "wss://ws.example.com/custom", + }); + expect(mod.WS_URL).toBe("wss://ws.example.com/custom"); + }); + + it("PLATFORM_URL in api.ts falls back to localhost:8080", async () => { + vi.resetModules(); + delete process.env.NEXT_PUBLIC_PLATFORM_URL; + const mod = await import("@/lib/api"); + expect(mod.PLATFORM_URL).toBe("http://localhost:8080"); + }); + + it("PLATFORM_URL in api.ts reads from NEXT_PUBLIC_PLATFORM_URL", async () => { + vi.resetModules(); + process.env.NEXT_PUBLIC_PLATFORM_URL = "http://prod.example.com"; + const apiMod = await import("@/lib/api"); + expect(apiMod.PLATFORM_URL).toBe("http://prod.example.com"); + delete process.env.NEXT_PUBLIC_PLATFORM_URL; + }); +}); diff --git a/canvas/src/store/socket.ts b/canvas/src/store/socket.ts index f6283660..3362bcad 100644 --- a/canvas/src/store/socket.ts +++ b/canvas/src/store/socket.ts @@ -1,6 +1,10 @@ import { useCanvasStore } from "./canvas"; -export const WS_URL = process.env.NEXT_PUBLIC_WS_URL || "ws://localhost:8080/ws"; +export const WS_URL = + process.env.NEXT_PUBLIC_WS_URL ?? + (process.env.NEXT_PUBLIC_PLATFORM_URL ?? "http://localhost:8080") + .replace(/^http/, "ws") + .concat("/ws"); export interface WSMessage { event: string;