Files
GoClaw/server/routers.ts
bboxwtf e228e7a655 fix(agents): provider pre-selection, magic-wand auto-fill, maxTokens from Ollama API
1. AgentDetailModal – fix provider not being pre-selected on edit open:
   - Add resolveProviderValue() that does exact → case-insensitive → partial
     match between stored provider string and connectedProviders list
   - Re-resolve provider in a second useEffect once providers load from API
   - Add safety-net SelectItem for stored value not found in providers list

2. AgentCreateModal – refactor Deploy Agent form:
   - Fix Provider + Model fields layout (grid-cols-2 with w-full truncate to
     prevent overflow/merging)
   - Add Wand2 'Auto-fill' button next to Agent Name field that calls
     agentCompiler.compile (existing LLM endpoint) with name+description as
     spec — fills role, model, temperature, systemPrompt automatically
   - Add Sparkles hint text explaining the magic wand functionality
   - Auto-select first provider/model when data loads
   - All fields use font-mono + proper label spacing

3. Both modals – MaxTokens auto-fill from Ollama API:
   - Add getOllamaModelInfo() in gateway-proxy.ts: calls Ollama /api/show,
     extracts {arch}.context_length from model_info, returns contextLength +
     parameterSize, family, quantization, capabilities
   - Add ollama.modelInfo tRPC query endpoint in routers.ts (input: modelId)
   - Both modals query trpc.ollama.modelInfo on model selection change
   - Auto-set maxTokens to context_length from API (262144 for kimi-k2.5 etc.)
   - Show 'max N from API' hint + clickable link to set full context window
   - Loading spinner while fetching model info
2026-03-21 19:41:15 +00:00

1026 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { COOKIE_NAME } from "@shared/const";
import { z } from "zod";
import { getDb } from "./db";
import { getSessionCookieOptions } from "./_core/cookies";
import { systemRouter } from "./_core/systemRouter";
import { publicProcedure, router, protectedProcedure } from "./_core/trpc";
import { checkOllamaHealth, listModels, chatCompletion } from "./ollama";
import {
checkGatewayHealth,
gatewayChat,
getGatewayOrchestratorConfig,
getGatewayModels,
getGatewayTools,
executeGatewayTool,
isGatewayAvailable,
getGatewayNodes,
getGatewayNodeStats,
startChatSession,
getChatSession,
getChatEvents,
listChatSessions,
getSwarmInfo,
listSwarmNodes,
listSwarmServices,
getServiceTasks,
scaleSwarmService,
getSwarmJoinToken,
execSwarmShell,
addSwarmNodeLabel,
setNodeAvailability,
createAgentService,
getOllamaModelInfo,
} from "./gateway-proxy";
// Shared system user id for non-authenticated agent management
const SYSTEM_USER_ID = 1;
export const appRouter = router({
system: systemRouter,
auth: router({
me: publicProcedure.query(opts => opts.ctx.user),
logout: publicProcedure.mutation(({ ctx }) => {
const cookieOptions = getSessionCookieOptions(ctx.req);
ctx.res.clearCookie(COOKIE_NAME, { ...cookieOptions, maxAge: -1 });
return { success: true } as const;
}),
}),
/**
* LLM Providers — full CRUD backed by DB (llmProviders table).
* API keys stored encrypted with AES-256-GCM; never returned in plaintext to frontend.
*/
providers: router({
/** List all providers (keys masked). */
list: publicProcedure.query(async () => {
const { listProviders } = await import("./providers");
return listProviders();
}),
/** Create a new provider. */
create: publicProcedure
.input(z.object({
name: z.string().min(1),
baseUrl: z.string().url(),
apiKey: z.string(),
modelDefault: z.string().optional(),
notes: z.string().optional(),
setActive: z.boolean().default(false),
}))
.mutation(async ({ input }) => {
const { createProvider } = await import("./providers");
const id = await createProvider(input);
return { id };
}),
/** Update a provider (pass apiKey="" to keep existing key). */
update: publicProcedure
.input(z.object({
id: z.number(),
name: z.string().optional(),
baseUrl: z.string().url().optional(),
apiKey: z.string().optional(),
modelDefault: z.string().optional(),
notes: z.string().optional(),
isActive: z.boolean().optional(),
}))
.mutation(async ({ input }) => {
const { updateProvider } = await import("./providers");
const { id, ...rest } = input;
await updateProvider(id, rest);
return { ok: true };
}),
/** Delete a provider (cannot delete the active one). */
delete: publicProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
const { deleteProvider } = await import("./providers");
return deleteProvider(input.id);
}),
/** Activate a provider and signal the gateway to reload its config. */
activate: publicProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
const { activateProvider } = await import("./providers");
await activateProvider(input.id);
return { ok: true };
}),
}),
/**
* System config — returns active LLM provider config (key masked).
* Used by Settings page and AgentDetailModal.
*/
config: router({
providers: publicProcedure.query(async () => {
const { listProviders, getActiveProvider } = await import("./providers");
// Try DB first
try {
const rows = await listProviders();
if (rows.length > 0) {
return {
providers: rows.map((p) => ({
id: p.id.toString(),
name: p.name,
baseUrl: p.baseUrl,
hasKey: !!p.apiKeyHint,
maskedKey: p.apiKeyHint ? `${p.apiKeyHint}${"*".repeat(24)}` : "",
isActive: p.isActive,
modelDefault: p.modelDefault,
})),
};
}
} catch { /* fallback below */ }
// Fallback: read from env
const { ENV } = await import("./_core/env");
const baseUrl = ENV.ollamaBaseUrl || "https://ollama.com/v1";
const apiKey = ENV.ollamaApiKey || "";
const hasKey = apiKey.length > 0;
const maskedKey = hasKey ? `${apiKey.slice(0, 8)}${"*".repeat(Math.max(0, apiKey.length - 8))}` : "";
let providerName = "Ollama Cloud";
if (baseUrl.includes("openai.com")) providerName = "OpenAI";
else if (baseUrl.includes("anthropic.com")) providerName = "Anthropic";
else if (baseUrl.includes("groq.com")) providerName = "Groq";
else if (baseUrl.includes("mistral.ai")) providerName = "Mistral";
else if (!baseUrl.includes("ollama.com")) providerName = "Custom";
return {
providers: [{ id: "primary", name: providerName, baseUrl, hasKey, maskedKey, isActive: true, modelDefault: null }],
};
}),
}),
/**
* Ollama API — серверный прокси для безопасного доступа
* Приоритет: Go Gateway → прямой Ollama
*/
ollama: router({
health: publicProcedure.query(async () => {
// Try Go Gateway first, fall back to direct Ollama
const gwHealth = await checkGatewayHealth();
if (gwHealth.connected) {
return {
connected: true as const,
latencyMs: gwHealth.latencyMs,
source: "gateway" as const,
llm: gwHealth.llm,
error: undefined as string | undefined,
};
}
// Fallback: direct Ollama
const ollamaHealth = await checkOllamaHealth();
return { ...ollamaHealth, source: "direct" as const };
}),
models: publicProcedure.query(async () => {
// Try Go Gateway first
const gwModels = await getGatewayModels();
if (gwModels) {
return {
success: true as const,
models: gwModels.data ?? [],
source: "gateway" as const,
};
}
// Fallback: direct Ollama
try {
const result = await listModels();
return {
success: true as const,
models: result.data ?? [],
source: "direct" as const,
};
} catch (err: any) {
return {
success: false as const,
models: [],
error: err.message,
};
}
}),
/** Fetch model details (context_length, family, quantization…) from Ollama /api/show */
modelInfo: publicProcedure
.input(z.object({ modelId: z.string() }))
.query(async ({ input }) => {
const info = await getOllamaModelInfo(input.modelId);
return info ?? { contextLength: 0 };
}),
chat: publicProcedure
.input(
z.object({
model: z.string(),
messages: z.array(
z.object({
role: z.enum(["system", "user", "assistant"]),
content: z.string(),
})
),
temperature: z.number().optional(),
max_tokens: z.number().optional(),
})
)
.mutation(async ({ input }) => {
try {
const result = await chatCompletion(input.model, input.messages, {
temperature: input.temperature,
max_tokens: input.max_tokens,
});
return {
success: true as const,
response: result.choices[0]?.message?.content ?? "",
model: result.model,
usage: result.usage,
};
} catch (err: any) {
return {
success: false as const,
response: "",
error: err.message,
};
}
}),
}),
/**
* Agents — управление AI-агентами (public — внутренний инструмент)
*/
agents: router({
list: publicProcedure.query(async () => {
// getAllAgents returns both system agents (userId=0) and user-created agents
const { getAllAgents } = await import("./agents");
return getAllAgents();
}),
get: publicProcedure.input(z.object({ id: z.number() })).query(async ({ input }) => {
const { getAgentById } = await import("./agents");
return getAgentById(input.id);
}),
create: publicProcedure
.input(
z.object({
name: z.string().min(1),
description: z.string().optional(),
role: z.string(),
model: z.string(),
provider: z.string(),
temperature: z.number().min(0).max(2).default(0.7),
maxTokens: z.number().default(2048),
topP: z.number().min(0).max(1).default(1.0),
frequencyPenalty: z.number().min(-2).max(2).default(0.0),
presencePenalty: z.number().min(-2).max(2).default(0.0),
systemPrompt: z.string().optional(),
allowedTools: z.array(z.string()).default([]),
allowedDomains: z.array(z.string()).default([]),
tags: z.array(z.string()).default([]),
})
)
.mutation(async ({ input }) => {
const { createAgent } = await import("./agents");
return createAgent(SYSTEM_USER_ID, {
...input,
temperature: input.temperature.toString(),
topP: input.topP.toString(),
frequencyPenalty: input.frequencyPenalty.toString(),
presencePenalty: input.presencePenalty.toString(),
} as any);
}),
update: publicProcedure
.input(
z.object({
id: z.number(),
name: z.string().optional(),
description: z.string().optional(),
model: z.string().optional(),
provider: z.string().optional(),
temperature: z.number().min(0).max(2).optional(),
maxTokens: z.number().optional(),
topP: z.number().min(0).max(1).optional(),
frequencyPenalty: z.number().min(-2).max(2).optional(),
presencePenalty: z.number().min(-2).max(2).optional(),
systemPrompt: z.string().optional(),
allowedTools: z.array(z.string()).optional(),
allowedDomains: z.array(z.string()).optional(),
isActive: z.boolean().optional(),
tags: z.array(z.string()).optional(),
})
)
.mutation(async ({ input }) => {
const { updateAgent } = await import("./agents");
const { id, temperature, topP, frequencyPenalty, presencePenalty, ...rest } = input;
const updates: Record<string, any> = { ...rest };
if (temperature !== undefined) updates.temperature = temperature.toString();
if (topP !== undefined) updates.topP = topP.toString();
if (frequencyPenalty !== undefined) updates.frequencyPenalty = frequencyPenalty.toString();
if (presencePenalty !== undefined) updates.presencePenalty = presencePenalty.toString();
return updateAgent(id, updates as any);
}),
delete: publicProcedure.input(z.object({ id: z.number() })).mutation(async ({ input }) => {
const { deleteAgent } = await import("./agents");
return deleteAgent(input.id);
}),
stats: publicProcedure.input(z.object({ id: z.number(), hoursBack: z.number().default(24) })).query(async ({ input }) => {
const { getAgentStats } = await import("./agents");
return getAgentStats(input.id, input.hoursBack);
}),
metrics: publicProcedure.input(z.object({ id: z.number(), hoursBack: z.number().default(24) })).query(async ({ input }) => {
const { getAgentMetrics } = await import("./agents");
return getAgentMetrics(input.id, input.hoursBack);
}),
history: publicProcedure.input(z.object({ id: z.number(), limit: z.number().default(50) })).query(async ({ input }) => {
const { getAgentHistory } = await import("./agents");
return getAgentHistory(input.id, input.limit);
}),
accessControl: publicProcedure.input(z.object({ id: z.number() })).query(async ({ input }) => {
const { getAgentAccessControl } = await import("./agents");
return getAgentAccessControl(input.id);
}),
updateToolAccess: publicProcedure
.input(
z.object({
agentId: z.number(),
tool: z.string(),
isAllowed: z.boolean(),
maxExecutionsPerHour: z.number().optional(),
timeoutSeconds: z.number().optional(),
allowedPatterns: z.array(z.string()).optional(),
blockedPatterns: z.array(z.string()).optional(),
})
)
.mutation(async ({ input }) => {
const { updateToolAccess } = await import("./agents");
const { agentId, ...updates } = input;
return updateToolAccess(agentId, input.tool, updates);
}),
/**
* Chat with a specific agent using its configuration
*/
chat: publicProcedure
.input(
z.object({
agentId: z.number(),
message: z.string(),
conversationId: z.string().optional(),
})
)
.mutation(async ({ input }) => {
const { getAgentById, saveHistory } = await import("./agents");
const agent = await getAgentById(input.agentId);
if (!agent) {
return { success: false as const, response: "", error: "Agent not found" };
}
const messages: Array<{ role: "system" | "user" | "assistant"; content: string }> = [];
if (agent.systemPrompt) {
messages.push({ role: "system", content: agent.systemPrompt });
}
messages.push({ role: "user", content: input.message });
const startTime = Date.now();
try {
const result = await chatCompletion(
agent.model,
messages,
{
temperature: agent.temperature ? parseFloat(agent.temperature as string) : 0.7,
max_tokens: agent.maxTokens ?? 2048,
}
);
const processingTimeMs = Date.now() - startTime;
const response = result.choices[0]?.message?.content ?? "";
// Save to history
await saveHistory(input.agentId, {
userMessage: input.message,
agentResponse: response,
conversationId: input.conversationId,
status: "success",
});
// Save metric
const { saveMetric } = await import("./agents");
await saveMetric(input.agentId, {
userMessage: input.message,
agentResponse: response,
inputTokens: result.usage?.prompt_tokens ?? 0,
outputTokens: result.usage?.completion_tokens ?? 0,
totalTokens: result.usage?.total_tokens ?? 0,
processingTimeMs,
status: "success",
toolsCalled: [],
model: result.model ?? agent.model,
}).catch(() => {}); // non-fatal
return {
success: true as const,
response,
model: result.model,
usage: result.usage,
processingTimeMs,
};
} catch (err: any) {
const processingTimeMs = Date.now() - startTime;
await saveHistory(input.agentId, {
userMessage: input.message,
agentResponse: null,
conversationId: input.conversationId,
status: "error",
});
const { saveMetric } = await import("./agents");
saveMetric(input.agentId, {
userMessage: input.message,
processingTimeMs,
status: "error",
errorMessage: err.message,
toolsCalled: [],
model: agent.model,
}).catch(() => {}); // non-fatal
return {
success: false as const,
response: "",
error: err.message,
};
}
}),
}),
/**
* Tools — управление инструментами агентов
*/
tools: router({
list: publicProcedure.query(async () => {
const { getAllTools } = await import("./tools");
return getAllTools();
}),
execute: publicProcedure
.input(
z.object({
agentId: z.number(),
tool: z.string(),
params: z.record(z.string(), z.unknown()),
})
)
.mutation(async ({ input }) => {
const { executeTool } = await import("./tools");
return executeTool(input.agentId, input.tool, input.params);
}),
}),
/**
* Browser Agent — управление браузерными сессиями через Puppeteer
*/
browser: router({
createSession: publicProcedure
.input(z.object({ agentId: z.number() }))
.mutation(async ({ input }) => {
const { createBrowserSession } = await import("./browser-agent");
return createBrowserSession(input.agentId);
}),
execute: publicProcedure
.input(
z.object({
sessionId: z.string(),
action: z.object({
type: z.enum(["navigate", "click", "type", "extract", "screenshot", "scroll", "wait", "evaluate", "close"]),
params: z.record(z.string(), z.unknown()),
}),
})
)
.mutation(async ({ input }) => {
const { executeBrowserAction } = await import("./browser-agent");
return executeBrowserAction(input.sessionId, input.action as any);
}),
getSessions: publicProcedure
.input(z.object({ agentId: z.number() }))
.query(async ({ input }) => {
const { getAgentSessions } = await import("./browser-agent");
return getAgentSessions(input.agentId);
}),
closeSession: publicProcedure
.input(z.object({ sessionId: z.string() }))
.mutation(async ({ input }) => {
const { executeBrowserAction } = await import("./browser-agent");
return executeBrowserAction(input.sessionId, { type: "close", params: {} });
}),
closeAllSessions: publicProcedure
.input(z.object({ agentId: z.number() }))
.mutation(async ({ input }) => {
const { closeAllAgentSessions } = await import("./browser-agent");
await closeAllAgentSessions(input.agentId);
return { success: true };
}),
}),
/**
* Tool Builder — генерация и установка новых инструментов через LLM
*/
toolBuilder: router({
generate: publicProcedure
.input(
z.object({
name: z.string().min(1),
description: z.string().min(10),
category: z.string().optional(),
exampleInput: z.string().optional(),
exampleOutput: z.string().optional(),
dangerous: z.boolean().optional(),
})
)
.mutation(async ({ input }) => {
const { generateTool } = await import("./tool-builder");
return generateTool(input);
}),
install: publicProcedure
.input(
z.object({
toolId: z.string(),
name: z.string(),
description: z.string(),
category: z.string(),
dangerous: z.boolean(),
parameters: z.record(z.string(), z.object({
type: z.string(),
description: z.string(),
required: z.boolean().optional(),
})),
implementation: z.string(),
})
)
.mutation(async ({ input }) => {
const { installTool } = await import("./tool-builder");
return installTool(input);
}),
listCustom: publicProcedure.query(async () => {
const { getCustomTools } = await import("./tool-builder");
return getCustomTools();
}),
delete: publicProcedure
.input(z.object({ toolId: z.string() }))
.mutation(async ({ input }) => {
const { deleteTool } = await import("./tool-builder");
return deleteTool(input.toolId);
}),
test: publicProcedure
.input(
z.object({
toolId: z.string(),
params: z.record(z.string(), z.unknown()),
})
)
.mutation(async ({ input }) => {
const { testTool } = await import("./tool-builder");
return testTool(input.toolId, input.params);
}),
}),
/**
* Agent Compiler — компиляция агентов по ТЗ через LLM
*/
agentCompiler: router({
compile: publicProcedure
.input(
z.object({
specification: z.string().min(20),
name: z.string().optional(),
preferredProvider: z.string().optional(),
preferredModel: z.string().optional(),
})
)
.mutation(async ({ input }) => {
const { compileAgentConfig } = await import("./agent-compiler");
return compileAgentConfig({ ...input, userId: SYSTEM_USER_ID });
}),
deploy: publicProcedure
.input(
z.object({
config: z.object({
name: z.string(),
description: z.string(),
role: z.string(),
model: z.string(),
provider: z.string(),
temperature: z.number(),
maxTokens: z.number(),
topP: z.number(),
frequencyPenalty: z.number(),
presencePenalty: z.number(),
systemPrompt: z.string(),
allowedTools: z.array(z.string()),
allowedDomains: z.array(z.string()),
maxRequestsPerHour: z.number(),
tags: z.array(z.string()),
reasoning: z.string(),
}),
})
)
.mutation(async ({ input }) => {
const { deployCompiledAgent } = await import("./agent-compiler");
return deployCompiledAgent(input.config, SYSTEM_USER_ID);
}),
compileAndDeploy: publicProcedure
.input(
z.object({
specification: z.string().min(20),
name: z.string().optional(),
preferredProvider: z.string().optional(),
preferredModel: z.string().optional(),
})
)
.mutation(async ({ input }) => {
const { compileAndDeployAgent } = await import("./agent-compiler");
return compileAndDeployAgent({ ...input, userId: SYSTEM_USER_ID });
}),
}),
/**
* Orchestrator — main AI agent with tool-use loop
* Приоритет: Go Gateway → Node.js fallback
*/
orchestrator: router({
// Get orchestrator config — Go Gateway first, then Node.js DB
getConfig: publicProcedure.query(async () => {
const gwConfig = await getGatewayOrchestratorConfig();
if (gwConfig) {
return { ...gwConfig, source: "gateway" as const };
}
// Fallback: Node.js orchestrator reads from DB directly
const { getOrchestratorConfig } = await import("./orchestrator");
const config = await getOrchestratorConfig();
return { ...config, source: "direct" as const };
}),
chat: publicProcedure
.input(
z.object({
messages: z.array(
z.object({
role: z.enum(["user", "assistant", "system"]),
content: z.string(),
})
),
model: z.string().optional(),
maxIterations: z.number().min(1).max(20).optional(),
})
)
.mutation(async ({ input }) => {
// Try Go Gateway first (preferred — full Go tool-use loop)
const gwAvailable = await isGatewayAvailable();
if (gwAvailable) {
const result = await gatewayChat(
input.messages,
input.model,
input.maxIterations ?? 10
);
return { ...result, source: "gateway" as const };
}
// Fallback: Node.js orchestrator
const { orchestratorChat } = await import("./orchestrator");
const result = await orchestratorChat(
input.messages,
input.model,
input.maxIterations ?? 10
);
return { ...result, source: "direct" as const };
}),
// List available tools — Go Gateway first
tools: publicProcedure.query(async () => {
const gwTools = await getGatewayTools();
if (gwTools) {
return gwTools.map((t) => ({
name: t.name,
description: t.description,
parameters: t.parameters,
source: "gateway" as const,
}));
}
// Fallback: Node.js tool definitions
const { ORCHESTRATOR_TOOLS } = await import("./orchestrator");
return ORCHESTRATOR_TOOLS.map((t) => ({
name: t.function.name,
description: t.function.description,
parameters: t.function.parameters,
source: "direct" as const,
}));
}),
// Gateway health check
gatewayHealth: publicProcedure.query(async () => {
return checkGatewayHealth();
}),
// Execute a single tool via Go Gateway
executeTool: publicProcedure
.input(
z.object({
tool: z.string(),
args: z.record(z.string(), z.unknown()),
})
)
.mutation(async ({ input }) => {
return executeGatewayTool(input.tool, input.args);
}),
// ── Persistent Background Chat Sessions ──────────────────────────────────
// These routes start a session on the Go Gateway and return immediately.
// The Go Gateway runs the orchestrator in a detached goroutine — survives
// HTTP disconnects, page reloads, and laptop sleep.
// The client polls getEvents until status === "done" | "error".
/** Start a background session. Returns { sessionId, status:"running" }. */
startSession: publicProcedure
.input(
z.object({
messages: z.array(
z.object({
role: z.enum(["user", "assistant", "system"]),
content: z.string(),
})
),
sessionId: z.string(),
model: z.string().optional(),
maxIter: z.number().min(1).max(20).optional(),
})
)
.mutation(async ({ input }) => {
const result = await startChatSession(
input.messages,
input.sessionId,
input.model,
input.maxIter ?? 10
);
if (!result) throw new Error("Gateway unavailable — cannot start background session");
return result;
}),
/** Get session metadata (status, finalResponse, tokens, model…). */
getSession: publicProcedure
.input(z.object({ sessionId: z.string() }))
.query(async ({ input }) => {
const sess = await getChatSession(input.sessionId);
if (!sess) throw new Error("Session not found");
return sess;
}),
/** Get events for a session after a given seq number (incremental polling). */
getEvents: publicProcedure
.input(
z.object({
sessionId: z.string(),
afterSeq: z.number().min(0).default(0),
})
)
.query(async ({ input }) => {
const result = await getChatEvents(input.sessionId, input.afterSeq);
if (!result) return { sessionId: input.sessionId, status: "unknown", events: [] };
return result;
}),
/** List recent sessions (default last 50). */
listSessions: publicProcedure
.input(z.object({ limit: z.number().min(1).max(200).default(50) }))
.query(async ({ input }) => {
const result = await listChatSessions(input.limit);
return result?.sessions ?? [];
}),
}),
/**
* Dashboard — aggregated real-time stats for the top status bar
*/
dashboard: router({
/**
* Returns aggregated cluster stats:
* - uptime: server process uptime formatted as "Xd Yh Zm"
* - nodes: running container count from Docker
* - agents: active agent count from DB
* - cpu: average CPU% across all containers
* - mem: total RAM used in MB
*/
stats: publicProcedure.query(async () => {
// 1. Server uptime
const uptimeSec = Math.floor(process.uptime());
const days = Math.floor(uptimeSec / 86400);
const hours = Math.floor((uptimeSec % 86400) / 3600);
const mins = Math.floor((uptimeSec % 3600) / 60);
const uptime = days > 0
? `${days}d ${hours}h ${mins}m`
: hours > 0
? `${hours}h ${mins}m`
: `${mins}m`;
// 2. Container / node stats from Go Gateway
const [nodesResult, statsResult] = await Promise.allSettled([
getGatewayNodes(),
getGatewayNodeStats(),
]);
const nodes = nodesResult.status === "fulfilled" && nodesResult.value
? nodesResult.value
: null;
const statsData = statsResult.status === "fulfilled" && statsResult.value
? statsResult.value
: null;
const containerCount = nodes?.containers?.length ?? nodes?.count ?? 0;
const totalContainers = nodes?.containers?.length ?? 0;
// CPU: average across all containers
const cpuPct = statsData?.stats?.length
? statsData.stats.reduce((sum, s) => sum + s.cpuPct, 0) / statsData.stats.length
: 0;
// MEM: total used MB
const memUseMB = statsData?.stats?.length
? statsData.stats.reduce((sum, s) => sum + s.memUseMB, 0)
: 0;
// 3. Active agents from DB
let activeAgents = 0;
try {
const db = await getDb();
if (db) {
const { agents } = await import("../drizzle/schema");
const { count: drizzleCount, eq } = await import("drizzle-orm");
const [{ value }] = await db
.select({ value: drizzleCount() })
.from(agents)
.where(eq(agents.isActive, true));
activeAgents = Number(value);
}
} catch {
// non-fatal
}
return {
uptime,
nodes: `${containerCount} / ${totalContainers || containerCount}`,
agents: activeAgents,
cpuPct: Math.round(cpuPct * 10) / 10,
memUseMB: Math.round(memUseMB),
gatewayOnline: !!nodes,
fetchedAt: new Date().toISOString(),
};
}),
}),
/**
* Nodes — Docker Swarm / standalone Docker monitoring via Go Gateway
*/
nodes: router({
/**
* Full Docker Swarm info: status, node count, manager address, join tokens.
*/
swarmInfo: publicProcedure.query(async () => {
return getSwarmInfo();
}),
/**
* List real Swarm nodes with live state, resources, labels.
* Falls back to the old gateway nodes endpoint if swarm API unavailable.
*/
list: publicProcedure.query(async () => {
// Try real Swarm API first
const swarm = await listSwarmNodes();
if (swarm) return { ...swarm, swarmActive: true, fetchedAt: new Date().toISOString() };
// Fallback: old gateway nodes
const result = await getGatewayNodes();
if (!result) {
return {
nodes: [] as import("./gateway-proxy").GatewayNodeInfo[],
count: 0,
swarmActive: false,
fetchedAt: new Date().toISOString(),
error: "Gateway unavailable — is the Go Gateway running?",
};
}
return result;
}),
/**
* List all Swarm services with replica counts and running task status.
*/
services: publicProcedure.query(async () => {
const result = await listSwarmServices();
return result ?? { services: [], count: 0 };
}),
/**
* Get all tasks for a specific service (where each replica is running).
*/
serviceTasks: publicProcedure
.input(z.object({ serviceId: z.string() }))
.query(async ({ input }) => {
const result = await getServiceTasks(input.serviceId);
return result ?? { tasks: [] };
}),
/**
* Scale a service to N replicas.
*/
scaleService: publicProcedure
.input(z.object({ serviceId: z.string(), replicas: z.number().min(0).max(100) }))
.mutation(async ({ input }) => {
const ok = await scaleSwarmService(input.serviceId, input.replicas);
return { ok };
}),
/**
* Get join token and command for adding a new node.
*/
joinToken: publicProcedure
.input(z.object({ role: z.enum(["worker", "manager"]).default("worker") }))
.query(async ({ input }) => {
return getSwarmJoinToken(input.role);
}),
/**
* Execute a shell command on the HOST system via nsenter.
* Requires gateway container to run with privileged: true + pid: host.
*/
execShell: publicProcedure
.input(z.object({ command: z.string().min(1).max(4096) }))
.mutation(async ({ input }) => {
const result = await execSwarmShell(input.command);
if (!result) throw new Error("Gateway unavailable");
return result;
}),
/**
* Add a label to a swarm node.
*/
addNodeLabel: publicProcedure
.input(z.object({ nodeId: z.string(), key: z.string(), value: z.string() }))
.mutation(async ({ input }) => {
const ok = await addSwarmNodeLabel(input.nodeId, input.key, input.value);
return { ok };
}),
/**
* Set node availability (active | pause | drain).
*/
setAvailability: publicProcedure
.input(z.object({ nodeId: z.string(), availability: z.enum(["active", "pause", "drain"]) }))
.mutation(async ({ input }) => {
const ok = await setNodeAvailability(input.nodeId, input.availability);
return { ok };
}),
/**
* Deploy an agent as a new Swarm service.
*/
deployAgentService: publicProcedure
.input(z.object({
name: z.string().min(1),
image: z.string().min(1),
replicas: z.number().min(1).max(20).default(1),
env: z.array(z.string()).optional(),
port: z.number().optional(),
}))
.mutation(async ({ input }) => {
const result = await createAgentService(input);
if (!result) throw new Error("Failed to create service");
return result;
}),
/**
* Get live container stats (CPU%, RAM) for all running containers.
*/
stats: publicProcedure.query(async () => {
const result = await getGatewayNodeStats();
if (!result) {
return {
stats: [] as import("./gateway-proxy").GatewayContainerStat[],
count: 0,
fetchedAt: new Date().toISOString(),
error: "Gateway unavailable",
};
}
return result;
}),
}),
});
export type AppRouter = typeof appRouter;