Files
GoClaw/server/nodes.test.ts
Manus 0dcae37a78 Checkpoint: Phase 12: Real-time Docker Swarm monitoring for /nodes page
Реализовано:
- gateway/internal/docker/client.go: Docker API клиент через unix socket (/var/run/docker.sock)
  - IsSwarmActive(), GetSwarmInfo(), ListNodes(), ListContainers(), GetContainerStats()
  - CalcCPUPercent() для расчёта CPU%
- gateway/internal/api/handlers.go: новые endpoints
  - GET /api/nodes: список Swarm нод или standalone Docker хост
  - GET /api/nodes/stats: live CPU/RAM статистика контейнеров
  - POST /api/tools/execute: выполнение инструментов
- gateway/cmd/gateway/main.go: зарегистрированы новые маршруты
- server/gateway-proxy.ts: добавлены getGatewayNodes() и getGatewayNodeStats()
- server/routers.ts: добавлен nodes router (nodes.list, nodes.stats)
- client/src/pages/Nodes.tsx: полностью переписан на реальные данные
  - Auto-refresh: 10s для нод, 15s для статистики контейнеров
  - Swarm mode: показывает все ноды кластера
  - Standalone mode: показывает локальный Docker хост + контейнеры
  - CPU/RAM gauges из реальных docker stats
  - Error state при недоступном Gateway
  - Loading skeleton
- server/nodes.test.ts: 14 новых vitest тестов
- Все 51 тест пройдены
2026-03-20 20:12:57 -04:00

266 lines
7.6 KiB
TypeScript

/**
* Tests for nodes tRPC procedures (Docker Swarm monitoring).
* These tests mock the Go Gateway HTTP calls so they run without a real Gateway.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
getGatewayNodes,
getGatewayNodeStats,
type GatewayNodesResult,
type GatewayNodeStatsResult,
} from "./gateway-proxy";
// ─── Mock fetch ───────────────────────────────────────────────────────────────
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
function mockResponse(data: unknown, status = 200) {
return {
ok: status >= 200 && status < 300,
status,
json: async () => data,
text: async () => JSON.stringify(data),
};
}
// ─── Test data ────────────────────────────────────────────────────────────────
const MOCK_NODES_RESULT: GatewayNodesResult = {
nodes: [
{
id: "abc123def456",
hostname: "goclaw-manager-01",
role: "manager",
status: "ready",
availability: "active",
ip: "192.168.1.10",
os: "linux",
arch: "x86_64",
cpuCores: 4,
memTotalMB: 8192,
dockerVersion: "24.0.7",
isLeader: true,
managerAddr: "192.168.1.10:2377",
labels: { env: "production" },
updatedAt: "2026-03-21T10:00:00Z",
},
{
id: "def789abc012",
hostname: "goclaw-worker-01",
role: "worker",
status: "ready",
availability: "active",
ip: "192.168.1.11",
os: "linux",
arch: "x86_64",
cpuCores: 8,
memTotalMB: 16384,
dockerVersion: "24.0.7",
isLeader: false,
labels: {},
updatedAt: "2026-03-21T10:00:00Z",
},
],
count: 2,
swarmActive: true,
managers: 1,
totalNodes: 2,
fetchedAt: "2026-03-21T10:00:00Z",
};
const MOCK_STATS_RESULT: GatewayNodeStatsResult = {
stats: [
{
id: "abc123",
name: "goclaw-gateway",
cpuPct: 12.5,
memUseMB: 256.0,
memLimMB: 2048.0,
memPct: 12.5,
},
{
id: "def456",
name: "goclaw-control-center",
cpuPct: 5.2,
memUseMB: 128.0,
memLimMB: 1024.0,
memPct: 12.5,
},
],
count: 2,
fetchedAt: "2026-03-21T10:00:00Z",
};
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("getGatewayNodes", () => {
beforeEach(() => {
mockFetch.mockReset();
});
it("returns nodes list when gateway is available", async () => {
mockFetch.mockResolvedValueOnce(mockResponse(MOCK_NODES_RESULT));
const result = await getGatewayNodes();
expect(result).not.toBeNull();
expect(result!.nodes).toHaveLength(2);
expect(result!.swarmActive).toBe(true);
expect(result!.count).toBe(2);
});
it("returns manager node with isLeader=true", async () => {
mockFetch.mockResolvedValueOnce(mockResponse(MOCK_NODES_RESULT));
const result = await getGatewayNodes();
const manager = result!.nodes.find((n) => n.role === "manager");
expect(manager).toBeDefined();
expect(manager!.isLeader).toBe(true);
expect(manager!.hostname).toBe("goclaw-manager-01");
expect(manager!.managerAddr).toBe("192.168.1.10:2377");
});
it("returns worker node with correct resources", async () => {
mockFetch.mockResolvedValueOnce(mockResponse(MOCK_NODES_RESULT));
const result = await getGatewayNodes();
const worker = result!.nodes.find((n) => n.role === "worker");
expect(worker).toBeDefined();
expect(worker!.cpuCores).toBe(8);
expect(worker!.memTotalMB).toBe(16384);
expect(worker!.isLeader).toBe(false);
});
it("returns null when gateway is unreachable", async () => {
mockFetch.mockRejectedValueOnce(new Error("ECONNREFUSED"));
const result = await getGatewayNodes();
expect(result).toBeNull();
});
it("returns null on HTTP error status", async () => {
mockFetch.mockResolvedValueOnce(mockResponse({ error: "internal error" }, 500));
const result = await getGatewayNodes();
expect(result).toBeNull();
});
it("calls correct gateway endpoint", async () => {
mockFetch.mockResolvedValueOnce(mockResponse(MOCK_NODES_RESULT));
await getGatewayNodes();
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/nodes"),
expect.any(Object)
);
});
});
describe("getGatewayNodeStats", () => {
beforeEach(() => {
mockFetch.mockReset();
});
it("returns container stats when gateway is available", async () => {
mockFetch.mockResolvedValueOnce(mockResponse(MOCK_STATS_RESULT));
const result = await getGatewayNodeStats();
expect(result).not.toBeNull();
expect(result!.stats).toHaveLength(2);
expect(result!.count).toBe(2);
});
it("returns correct CPU and memory stats", async () => {
mockFetch.mockResolvedValueOnce(mockResponse(MOCK_STATS_RESULT));
const result = await getGatewayNodeStats();
const gateway = result!.stats.find((s) => s.name === "goclaw-gateway");
expect(gateway).toBeDefined();
expect(gateway!.cpuPct).toBe(12.5);
expect(gateway!.memUseMB).toBe(256.0);
expect(gateway!.memLimMB).toBe(2048.0);
expect(gateway!.memPct).toBe(12.5);
});
it("returns null when gateway is unreachable", async () => {
mockFetch.mockRejectedValueOnce(new Error("ECONNREFUSED"));
const result = await getGatewayNodeStats();
expect(result).toBeNull();
});
it("returns null on HTTP error status", async () => {
mockFetch.mockResolvedValueOnce(mockResponse({ error: "not found" }, 404));
const result = await getGatewayNodeStats();
expect(result).toBeNull();
});
it("calls correct gateway endpoint", async () => {
mockFetch.mockResolvedValueOnce(mockResponse(MOCK_STATS_RESULT));
await getGatewayNodeStats();
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/nodes/stats"),
expect.any(Object)
);
});
});
describe("nodes data validation", () => {
it("standalone mode returns swarmActive=false", async () => {
const standaloneResult: GatewayNodesResult = {
nodes: [
{
id: "local-01",
hostname: "localhost",
role: "standalone",
status: "ready",
availability: "active",
ip: "127.0.0.1",
os: "",
arch: "",
cpuCores: 0,
memTotalMB: 0,
dockerVersion: "unknown",
isLeader: false,
labels: {},
updatedAt: new Date().toISOString(),
},
],
count: 1,
swarmActive: false,
fetchedAt: new Date().toISOString(),
};
mockFetch.mockResolvedValueOnce(mockResponse(standaloneResult));
const result = await getGatewayNodes();
expect(result!.swarmActive).toBe(false);
expect(result!.nodes[0].role).toBe("standalone");
});
it("node labels are preserved correctly", async () => {
mockFetch.mockResolvedValueOnce(mockResponse(MOCK_NODES_RESULT));
const result = await getGatewayNodes();
const manager = result!.nodes[0];
expect(manager.labels).toEqual({ env: "production" });
});
it("worker node has empty labels object", async () => {
mockFetch.mockResolvedValueOnce(mockResponse(MOCK_NODES_RESULT));
const result = await getGatewayNodes();
const worker = result!.nodes[1];
expect(worker.labels).toEqual({});
});
});