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