Merge pull request #37 from Molecule-AI/fix/audit-run9
feat(canvas): WebSocket connection status indicator in Toolbar
This commit is contained in:
commit
ea6fdd58a6
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
|
||||
@ -10,6 +10,7 @@ import { showToast } from "@/components/Toaster";
|
||||
|
||||
export function Toolbar() {
|
||||
const nodes = useCanvasStore((s) => s.nodes);
|
||||
const wsStatus = useCanvasStore((s) => s.wsStatus);
|
||||
|
||||
const [stopping, setStopping] = useState(false);
|
||||
const [restartingAll, setRestartingAll] = useState(false);
|
||||
@ -17,6 +18,23 @@ export function Toolbar() {
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
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 c = { total: nodes.length, roots: 0, children: 0, online: 0, offline: 0, failed: 0, provisioning: 0, activeTasks: 0 };
|
||||
for (const n of nodes) {
|
||||
@ -122,6 +140,11 @@ export function Toolbar() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* WebSocket connection status */}
|
||||
<div className="pl-3 border-l border-zinc-800/60">
|
||||
<WsStatusPill status={wsStatus} />
|
||||
</div>
|
||||
|
||||
{/* Stop All — visible when agents have active tasks */}
|
||||
{counts.activeTasks > 0 && (
|
||||
<button
|
||||
@ -231,6 +254,31 @@ function StatusPill({ color, count, label }: { color: string; count: number; lab
|
||||
);
|
||||
}
|
||||
|
||||
function WsStatusPill({ status }: { status: "connected" | "connecting" | "disconnected" }) {
|
||||
if (status === "connected") {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5" title="Real-time updates: connected">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
|
||||
<span className="text-[10px] text-zinc-500">Live</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (status === "connecting") {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5" title="Real-time updates: reconnecting…">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse" />
|
||||
<span className="text-[10px] text-zinc-500">Reconnecting</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-1.5" title="Real-time updates: disconnected">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-red-400" />
|
||||
<span className="text-[10px] text-zinc-500">Offline</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HelpRow({ shortcut, text }: { shortcut: string; text: string }) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-zinc-800/70 bg-zinc-900/45 px-3 py-2">
|
||||
|
||||
@ -8,6 +8,7 @@ vi.mock("../canvas", () => ({
|
||||
getState: vi.fn(() => ({
|
||||
applyEvent: vi.fn(),
|
||||
hydrate: vi.fn(),
|
||||
setWsStatus: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
@ -80,6 +81,7 @@ beforeEach(() => {
|
||||
vi.mocked(useCanvasStore.getState).mockReturnValue({
|
||||
applyEvent: vi.fn(),
|
||||
hydrate: vi.fn(),
|
||||
setWsStatus: vi.fn(),
|
||||
} as unknown as ReturnType<typeof useCanvasStore.getState>);
|
||||
});
|
||||
|
||||
@ -180,6 +182,7 @@ describe("WebSocket onmessage", () => {
|
||||
vi.mocked(useCanvasStore.getState).mockReturnValue({
|
||||
applyEvent,
|
||||
hydrate: vi.fn(),
|
||||
setWsStatus: vi.fn(),
|
||||
} as unknown as ReturnType<typeof useCanvasStore.getState>);
|
||||
|
||||
const msg = {
|
||||
|
||||
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;
|
||||
});
|
||||
});
|
||||
@ -70,6 +70,9 @@ interface CanvasState {
|
||||
/** Agent-pushed messages keyed by workspace ID. ChatTab consumes and clears these. */
|
||||
agentMessages: Record<string, Array<{ id: string; content: string; timestamp: string }>>;
|
||||
consumeAgentMessages: (workspaceId: string) => Array<{ id: string; content: string; timestamp: string }>;
|
||||
/** WebSocket connection status — drives the live indicator in the Toolbar. */
|
||||
wsStatus: "connected" | "connecting" | "disconnected";
|
||||
setWsStatus: (status: "connected" | "connecting" | "disconnected") => void;
|
||||
}
|
||||
|
||||
export const useCanvasStore = create<CanvasState>((set, get) => ({
|
||||
@ -79,6 +82,8 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
|
||||
panelTab: "chat",
|
||||
dragOverNodeId: null,
|
||||
contextMenu: null,
|
||||
wsStatus: "connecting",
|
||||
setWsStatus: (status) => set({ wsStatus: status }),
|
||||
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
|
||||
|
||||
@ -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;
|
||||
@ -21,11 +25,13 @@ class ReconnectingSocket {
|
||||
}
|
||||
|
||||
connect() {
|
||||
useCanvasStore.getState().setWsStatus("connecting");
|
||||
this.ws = new WebSocket(this.url);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.attempt = 0;
|
||||
this.lastEventTime = Date.now();
|
||||
useCanvasStore.getState().setWsStatus("connected");
|
||||
this.rehydrate();
|
||||
this.startHealthCheck();
|
||||
};
|
||||
@ -42,6 +48,7 @@ class ReconnectingSocket {
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.stopHealthCheck();
|
||||
useCanvasStore.getState().setWsStatus("connecting");
|
||||
const delay = Math.min(1000 * 2 ** this.attempt, 30000);
|
||||
this.attempt++;
|
||||
setTimeout(() => this.connect(), delay);
|
||||
@ -90,6 +97,7 @@ class ReconnectingSocket {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
useCanvasStore.getState().setWsStatus("disconnected");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user