Реализовано: - 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 тест пройдены
266 lines
7.6 KiB
TypeScript
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({});
|
|
});
|
|
});
|