Files
GoClaw/server/gateway-proxy.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

372 lines
11 KiB
TypeScript

/**
* GoClaw Gateway Proxy
*
* Forwards orchestrator/agent/tool requests from the Node.js tRPC server
* to the Go Gateway running on :18789.
*
* The Go Gateway handles:
* - LLM orchestration (tool-use loop)
* - Tool execution (shell, file, docker, http)
* - Agent listing from DB
* - Model listing from LLM provider
*
* When GATEWAY_URL is not set or the gateway is unreachable, all functions
* return a structured error so callers can fall back gracefully.
*/
const GATEWAY_BASE_URL = process.env.GATEWAY_URL ?? "http://localhost:18789";
const GATEWAY_TIMEOUT_MS = 180_000; // 3 min — LLM can be slow
const QUICK_TIMEOUT_MS = 5_000;
// ─── Types ────────────────────────────────────────────────────────────────────
export interface GatewayMessage {
role: "user" | "assistant" | "system";
content: string;
}
export interface GatewayToolCallStep {
tool: string;
args: Record<string, unknown>;
result: unknown; // required — matches ToolCallStep interface in Chat.tsx / Skills.tsx
error?: string;
success: boolean;
durationMs: number;
}
export interface GatewayChatResult {
success: boolean;
response: string;
toolCalls: GatewayToolCallStep[];
model?: string;
usage?: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
error?: string;
}
export interface GatewayOrchestratorConfig {
id: number | null;
name: string;
model: string;
temperature: number;
maxTokens: number;
allowedTools: string[];
systemPromptPreview: string;
}
export interface GatewayToolDef {
name: string;
description: string;
parameters: Record<string, unknown>;
}
export interface GatewayToolResult {
success: boolean;
output?: unknown;
error?: string;
durationMs: number;
}
export interface GatewayHealthResult {
connected: boolean;
latencyMs: number;
llm?: { connected: boolean; latencyMs: number };
error?: string;
}
// ─── Health ───────────────────────────────────────────────────────────────────
/**
* Check if the Go Gateway is running and healthy.
* Also returns LLM provider health (Ollama / cloud API).
*/
export async function checkGatewayHealth(): Promise<GatewayHealthResult> {
const start = Date.now();
try {
const res = await fetch(`${GATEWAY_BASE_URL}/health`, {
signal: AbortSignal.timeout(QUICK_TIMEOUT_MS),
});
const latencyMs = Date.now() - start;
if (res.ok) {
const data = await res.json();
return {
connected: true,
latencyMs,
// Go Gateway may return "ollama" or "llm" key depending on version
llm: data.llm ?? data.ollama,
};
}
return { connected: false, latencyMs, error: `HTTP ${res.status}` };
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
return { connected: false, latencyMs: Date.now() - start, error: msg };
}
}
/**
* Returns true if the Go Gateway is reachable (fast check, no LLM ping).
*/
export async function isGatewayAvailable(): Promise<boolean> {
try {
const res = await fetch(`${GATEWAY_BASE_URL}/health`, {
signal: AbortSignal.timeout(2_000),
});
return res.ok;
} catch {
return false;
}
}
// ─── Orchestrator ─────────────────────────────────────────────────────────────
/**
* Send a chat message to the Go Orchestrator.
* Includes full tool-use loop — the Go side handles all LLM ↔ tool iterations.
*/
export async function gatewayChat(
messages: GatewayMessage[],
model?: string,
maxIter?: number
): Promise<GatewayChatResult> {
try {
const res = await fetch(`${GATEWAY_BASE_URL}/api/orchestrator/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages, model, maxIter }),
signal: AbortSignal.timeout(GATEWAY_TIMEOUT_MS),
});
if (!res.ok) {
const text = await res.text();
return {
success: false,
response: "",
toolCalls: [],
error: `Gateway error (${res.status}): ${text}`,
};
}
return res.json();
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
return {
success: false,
response: "",
toolCalls: [],
error: `Gateway unreachable: ${msg}. Is the Go Gateway running on ${GATEWAY_BASE_URL}?`,
};
}
}
/**
* Get orchestrator config from Go Gateway (reads from DB).
*/
export async function getGatewayOrchestratorConfig(): Promise<GatewayOrchestratorConfig | null> {
try {
const res = await fetch(`${GATEWAY_BASE_URL}/api/orchestrator/config`, {
signal: AbortSignal.timeout(QUICK_TIMEOUT_MS),
});
if (!res.ok) return null;
return res.json();
} catch {
return null;
}
}
// ─── Models ───────────────────────────────────────────────────────────────────
/**
* Get list of models from Go Gateway (proxied from LLM provider).
*/
export async function getGatewayModels(): Promise<{ data: { id: string }[] } | null> {
try {
const res = await fetch(`${GATEWAY_BASE_URL}/api/models`, {
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) return null;
return res.json();
} catch {
return null;
}
}
// ─── Agents ───────────────────────────────────────────────────────────────────
/**
* Get list of agents from Go Gateway (reads from DB).
*/
export async function getGatewayAgents(): Promise<{ agents: unknown[]; count: number } | null> {
try {
const res = await fetch(`${GATEWAY_BASE_URL}/api/agents`, {
signal: AbortSignal.timeout(QUICK_TIMEOUT_MS),
});
if (!res.ok) return null;
return res.json();
} catch {
return null;
}
}
/**
* Get a single agent by ID from Go Gateway.
*/
export async function getGatewayAgent(id: number): Promise<unknown | null> {
try {
const res = await fetch(`${GATEWAY_BASE_URL}/api/agents/${id}`, {
signal: AbortSignal.timeout(QUICK_TIMEOUT_MS),
});
if (!res.ok) return null;
return res.json();
} catch {
return null;
}
}
// ─── Tools ────────────────────────────────────────────────────────────────────
/**
* Get list of available tools from Go Gateway.
*/
export async function getGatewayTools(): Promise<GatewayToolDef[] | null> {
try {
const res = await fetch(`${GATEWAY_BASE_URL}/api/tools`, {
signal: AbortSignal.timeout(QUICK_TIMEOUT_MS),
});
if (!res.ok) return null;
const data = await res.json();
// Go returns OpenAI format: { tools: [{type: "function", function: {name, description, parameters}}, ...], count: N }
if (!Array.isArray(data.tools)) return null;
return data.tools.map((t: { type?: string; function?: { name: string; description: string; parameters: Record<string, unknown> }; name?: string; description?: string; parameters?: Record<string, unknown> }) => {
// Handle OpenAI format: {type: "function", function: {name, ...}}
if (t.function) {
return {
name: t.function.name,
description: t.function.description,
parameters: t.function.parameters,
} as GatewayToolDef;
}
// Handle flat format: {name, description, parameters}
return t as GatewayToolDef;
});
} catch {
return null;
}
}
/**
* Execute a single tool via Go Gateway.
*/
export async function executeGatewayTool(
toolName: string,
args: Record<string, unknown>
): Promise<GatewayToolResult> {
try {
const res = await fetch(`${GATEWAY_BASE_URL}/api/tools/execute`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tool: toolName, args }),
signal: AbortSignal.timeout(30_000),
});
if (!res.ok) {
const text = await res.text();
return {
success: false,
error: `Gateway error (${res.status}): ${text}`,
durationMs: 0,
};
}
return res.json();
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
return {
success: false,
error: `Gateway unreachable: ${msg}`,
durationMs: 0,
};
}
}
// ─── Nodes / Docker Swarm ─────────────────────────────────────────────────────
export interface GatewayNodeInfo {
id: string;
hostname: string;
role: string;
status: string;
availability: string;
ip: string;
os: string;
arch: string;
cpuCores: number;
memTotalMB: number;
dockerVersion: string;
isLeader: boolean;
managerAddr?: string;
labels: Record<string, string>;
updatedAt: string;
}
export interface GatewayContainerInfo {
id: string;
name: string;
image: string;
state: string;
status: string;
}
export interface GatewayNodesResult {
nodes: GatewayNodeInfo[];
count: number;
swarmActive: boolean;
managers?: number;
totalNodes?: number;
containers?: GatewayContainerInfo[];
fetchedAt: string;
}
export interface GatewayContainerStat {
id: string;
name: string;
cpuPct: number;
memUseMB: number;
memLimMB: number;
memPct: number;
}
export interface GatewayNodeStatsResult {
stats: GatewayContainerStat[];
count: number;
fetchedAt: string;
}
/**
* Get Docker Swarm nodes (or standalone Docker host) from Go Gateway.
*/
export async function getGatewayNodes(): Promise<GatewayNodesResult | null> {
try {
const res = await fetch(`${GATEWAY_BASE_URL}/api/nodes`, {
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) return null;
return res.json();
} catch {
return null;
}
}
/**
* Get live container CPU/RAM stats from Go Gateway.
*/
export async function getGatewayNodeStats(): Promise<GatewayNodeStatsResult | null> {
try {
const res = await fetch(`${GATEWAY_BASE_URL}/api/nodes/stats`, {
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) return null;
return res.json();
} catch {
return null;
}
}