fix(canvas): close 4 gaps in WS status indicator (env, toast, tests)
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 <noreply@anthropic.com>
This commit is contained in:
parent
53374ca391
commit
7c52661280
8
canvas/.env.example
Normal file
8
canvas/.env.example
Normal file
@ -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
|
||||||
@ -18,6 +18,23 @@ export function Toolbar() {
|
|||||||
const [helpOpen, setHelpOpen] = useState(false);
|
const [helpOpen, setHelpOpen] = useState(false);
|
||||||
const helpRef = useRef<HTMLDivElement>(null);
|
const helpRef = useRef<HTMLDivElement>(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<string>("connecting");
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevWsStatus.current === "connecting" && wsStatus === "connected") {
|
||||||
|
if (mountedRef.current) {
|
||||||
|
showToast("Live updates restored", "success");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prevWsStatus.current = wsStatus;
|
||||||
|
}, [wsStatus]);
|
||||||
|
|
||||||
const counts = useMemo(() => {
|
const counts = useMemo(() => {
|
||||||
const c = { total: nodes.length, roots: 0, children: 0, online: 0, offline: 0, failed: 0, provisioning: 0, activeTasks: 0 };
|
const c = { total: nodes.length, roots: 0, children: 0, online: 0, offline: 0, failed: 0, provisioning: 0, activeTasks: 0 };
|
||||||
for (const n of nodes) {
|
for (const n of nodes) {
|
||||||
|
|||||||
74
canvas/src/store/__tests__/socket.url.test.ts
Normal file
74
canvas/src/store/__tests__/socket.url.test.ts
Normal file
@ -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<string, string | undefined>) {
|
||||||
|
vi.resetModules();
|
||||||
|
const saved: Record<string, string | undefined> = {};
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,6 +1,10 @@
|
|||||||
import { useCanvasStore } from "./canvas";
|
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 {
|
export interface WSMessage {
|
||||||
event: string;
|
event: string;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user