From 30d9be1c26c5621697957658b94dc74f57d32b32 Mon Sep 17 00:00:00 2001 From: Dev Lead Agent Date: Tue, 14 Apr 2026 08:26:38 +0000 Subject: [PATCH] fix(canvas): close 4 gaps in WS status indicator (env, toast, tests) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gap 1 — WS_URL now derives from NEXT_PUBLIC_PLATFORM_URL when NEXT_PUBLIC_WS_URL is not set (http→ws, appends /ws; https→wss). Operators need only one env var. NEXT_PUBLIC_WS_URL remains an explicit override escape hatch. Gap 2 — Add canvas/.env.example documenting NEXT_PUBLIC_PLATFORM_URL (required) and NEXT_PUBLIC_WS_URL (optional override, commented out). Gap 3 — Toolbar fires showToast("Live updates restored", "success") when wsStatus transitions connecting→connected. mountedRef (set after 2 s) suppresses the toast on the very first page-load connection so only genuine reconnects notify the user. Gap 4 — New canvas/src/store/__tests__/socket.url.test.ts (6 tests): · fallback to ws://localhost:8080/ws when no env set · http→ws derivation from NEXT_PUBLIC_PLATFORM_URL · https→wss derivation · NEXT_PUBLIC_WS_URL override takes precedence · api.ts PLATFORM_URL fallback · api.ts reads NEXT_PUBLIC_PLATFORM_URL 375/375 tests passing, production build clean. Co-Authored-By: Claude Sonnet 4.6 --- canvas/.env.example | 8 ++ canvas/src/components/Toolbar.tsx | 17 +++++ canvas/src/store/__tests__/socket.url.test.ts | 74 +++++++++++++++++++ canvas/src/store/socket.ts | 6 +- 4 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 canvas/.env.example create mode 100644 canvas/src/store/__tests__/socket.url.test.ts 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;