feat(FN-1232): add remote node mesh dashboard integration
- Add core proxy API infrastructure (api-node.ts) with remote node communication - Create NodeContext for centralized node state management - Implement useNodeProxy hook for node operations (metrics, events, health) - Implement useRemoteNodeData hook with 10-second polling for live metrics - Implement useRemoteNodeEvents hook for real-time SSE event subscription - Add NodeStatusIndicator component with animated status indicators - Add comprehensive tests for all new hooks and components - Update dashboard styles for node status visualization
This commit is contained in:
67
packages/dashboard/app/components/NodeStatusIndicator.tsx
Normal file
67
packages/dashboard/app/components/NodeStatusIndicator.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* NodeStatusIndicator - displays the connection status of the currently viewed node.
|
||||
* Shows a green/red/yellow dot and optional text based on node state.
|
||||
*/
|
||||
|
||||
import type { NodeConfig } from "@fusion/core";
|
||||
|
||||
export interface NodeStatusIndicatorProps {
|
||||
/** The node to display, or null for local node */
|
||||
node: NodeConfig | null;
|
||||
/** Whether to show additional details (node name, type, status text) */
|
||||
showDetails?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display configuration for a node status
|
||||
*/
|
||||
function getStatusDisplay(status: NodeConfig["status"]): {
|
||||
label: string;
|
||||
dotClass: string;
|
||||
} {
|
||||
switch (status) {
|
||||
case "online":
|
||||
return { label: "Online", dotClass: "node-status-indicator__dot--online" };
|
||||
case "offline":
|
||||
return { label: "Offline", dotClass: "node-status-indicator__dot--offline" };
|
||||
case "connecting":
|
||||
return { label: "Connecting", dotClass: "node-status-indicator__dot--connecting" };
|
||||
case "error":
|
||||
return { label: "Error", dotClass: "node-status-indicator__dot--error" };
|
||||
default:
|
||||
return { label: "Unknown", dotClass: "node-status-indicator__dot--offline" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Status indicator component for nodes.
|
||||
* Shows connection status with a colored dot and optional details.
|
||||
*/
|
||||
export function NodeStatusIndicator({ node, showDetails = false }: NodeStatusIndicatorProps) {
|
||||
// Local or null node - show "Local" badge
|
||||
if (!node || node.type === "local") {
|
||||
return (
|
||||
<div className="node-status-indicator node-status-indicator--local">
|
||||
<span className="node-status-indicator__label">Local</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Remote node - show status with dot and optional details
|
||||
const { label: statusLabel, dotClass } = getStatusDisplay(node.status);
|
||||
const isConnecting = node.status === "connecting";
|
||||
|
||||
return (
|
||||
<div className="node-status-indicator node-status-indicator--remote">
|
||||
<span className={`node-status-indicator__dot ${dotClass}`}>
|
||||
{isConnecting && <span className="node-status-indicator__spinner" />}
|
||||
</span>
|
||||
<span className="node-status-indicator__name">{node.name}</span>
|
||||
{showDetails && (
|
||||
<span className="node-status-indicator__details">
|
||||
{node.type} · {statusLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { NodeStatusIndicator } from "../NodeStatusIndicator";
|
||||
import type { NodeConfig } from "@fusion/core";
|
||||
|
||||
describe("NodeStatusIndicator", () => {
|
||||
describe("when node is null", () => {
|
||||
it("renders Local text badge", () => {
|
||||
render(<NodeStatusIndicator node={null} />);
|
||||
|
||||
expect(screen.getByText("Local")).toBeInTheDocument();
|
||||
expect(screen.getByText("Local")).toHaveClass("node-status-indicator__label");
|
||||
});
|
||||
|
||||
it("does not show details when showDetails is true but node is null", () => {
|
||||
render(<NodeStatusIndicator node={null} showDetails />);
|
||||
|
||||
expect(screen.getByText("Local")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/·/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when node type is local", () => {
|
||||
const localNode: NodeConfig = {
|
||||
id: "node_local",
|
||||
name: "Local Node",
|
||||
type: "local",
|
||||
status: "online",
|
||||
maxConcurrent: 4,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
it("renders Local text badge", () => {
|
||||
render(<NodeStatusIndicator node={localNode} />);
|
||||
|
||||
expect(screen.getByText("Local")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show status dot for local nodes", () => {
|
||||
const { container } = render(<NodeStatusIndicator node={localNode} />);
|
||||
|
||||
const dots = container.querySelectorAll(".node-status-indicator__dot");
|
||||
expect(dots).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("renders with --local modifier class", () => {
|
||||
render(<NodeStatusIndicator node={localNode} />);
|
||||
|
||||
const container = screen.getByText("Local").closest(".node-status-indicator");
|
||||
expect(container).toHaveClass("node-status-indicator--local");
|
||||
});
|
||||
});
|
||||
|
||||
describe("when node type is remote", () => {
|
||||
const remoteNode: NodeConfig = {
|
||||
id: "node_abc123",
|
||||
name: "Remote Server",
|
||||
type: "remote",
|
||||
url: "http://remote:4040",
|
||||
status: "online",
|
||||
maxConcurrent: 2,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
it("renders node name", () => {
|
||||
render(<NodeStatusIndicator node={remoteNode} />);
|
||||
|
||||
expect(screen.getByText("Remote Server")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows green dot for online status", () => {
|
||||
const { container } = render(<NodeStatusIndicator node={remoteNode} />);
|
||||
|
||||
const dot = container.querySelector(".node-status-indicator__dot--online");
|
||||
expect(dot).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders with --remote modifier class", () => {
|
||||
render(<NodeStatusIndicator node={remoteNode} />);
|
||||
|
||||
const container = screen.getByText("Remote Server").closest(".node-status-indicator");
|
||||
expect(container).toHaveClass("node-status-indicator--remote");
|
||||
});
|
||||
|
||||
it("shows red dot for offline status", () => {
|
||||
const offlineNode = { ...remoteNode, status: "offline" as const };
|
||||
const { container } = render(<NodeStatusIndicator node={offlineNode} />);
|
||||
|
||||
const dot = container.querySelector(".node-status-indicator__dot--offline");
|
||||
expect(dot).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows red dot for error status", () => {
|
||||
const errorNode = { ...remoteNode, status: "error" as const };
|
||||
const { container } = render(<NodeStatusIndicator node={errorNode} />);
|
||||
|
||||
const dot = container.querySelector(".node-status-indicator__dot--error");
|
||||
expect(dot).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows yellow spinner for connecting status", () => {
|
||||
const connectingNode = { ...remoteNode, status: "connecting" as const };
|
||||
const { container } = render(<NodeStatusIndicator node={connectingNode} />);
|
||||
|
||||
const dot = container.querySelector(".node-status-indicator__dot--connecting");
|
||||
expect(dot).toBeInTheDocument();
|
||||
expect(dot?.querySelector(".node-status-indicator__spinner")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("showDetails option", () => {
|
||||
const remoteNode: NodeConfig = {
|
||||
id: "node_abc123",
|
||||
name: "Remote Server",
|
||||
type: "remote",
|
||||
url: "http://remote:4040",
|
||||
status: "online",
|
||||
maxConcurrent: 2,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
it("does not show details by default", () => {
|
||||
render(<NodeStatusIndicator node={remoteNode} />);
|
||||
|
||||
expect(screen.queryByText(/·/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows details when showDetails is true", () => {
|
||||
render(<NodeStatusIndicator node={remoteNode} showDetails />);
|
||||
|
||||
const details = screen.getByText(/remote · Online/);
|
||||
expect(details).toBeInTheDocument();
|
||||
expect(details).toHaveClass("node-status-indicator__details");
|
||||
});
|
||||
|
||||
it("shows correct details for offline status", () => {
|
||||
const offlineNode = { ...remoteNode, status: "offline" as const };
|
||||
render(<NodeStatusIndicator node={offlineNode} showDetails />);
|
||||
|
||||
const details = screen.getByText(/remote · Offline/);
|
||||
expect(details).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows correct details for connecting status", () => {
|
||||
const connectingNode = { ...remoteNode, status: "connecting" as const };
|
||||
render(<NodeStatusIndicator node={connectingNode} showDetails />);
|
||||
|
||||
const details = screen.getByText(/remote · Connecting/);
|
||||
expect(details).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user