feat: adicionar 5 novos painéis ao dashboard (MCPs, n8n, Paperclip, IA, Operações)
Expansão do dashboard de 3 para 8 páginas com dados reais do stack: - MCPs: monitorização de 33 MCPs no gateway com ping e estado online/offline - n8n: 14 workflows com último run, duração e falhas 24h - Paperclip: 16 agentes operacionais, routines e issues (PostgreSQL) - IA/Claude: visão das 3 camadas (189 skills, 72 agents, 39 MCPs, CARL) - Operações: tickets Desk CRM por departamento + cobertura PROCs 16 ficheiros novos (3042 linhas), 3 existentes editados. Nova dependência: pg (PostgreSQL client para Paperclip). Audit: 0 vulnerabilidades (npm audit fix aplicado). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* MCPs Service
|
||||
* Ping paralelo aos MCPs via gateway com cache de 60 segundos
|
||||
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||
*/
|
||||
|
||||
export interface McpStatus {
|
||||
name: string
|
||||
port: number
|
||||
category: string
|
||||
enabled: boolean
|
||||
status: 'online' | 'offline' | 'disabled' | 'unknown'
|
||||
response_time_ms: number | null
|
||||
last_check: string
|
||||
tools_count?: number
|
||||
}
|
||||
|
||||
export interface McpDashboard {
|
||||
gateway_status: 'online' | 'offline'
|
||||
total: number
|
||||
online: number
|
||||
offline: number
|
||||
disabled: number
|
||||
mcps: McpStatus[]
|
||||
auth: {
|
||||
method: string
|
||||
token_expires: string | null
|
||||
}
|
||||
}
|
||||
|
||||
// Lista de MCPs com metadados (extraída de port-map.json e claude.json)
|
||||
const MCP_LIST: Array<{ name: string; port: number; category: string; enabled: boolean }> = [
|
||||
// CRM
|
||||
{ name: 'desk-crm-v3', port: 3150, category: 'crm', enabled: true },
|
||||
{ name: 'desk-project-minimal', port: 3153, category: 'crm', enabled: false },
|
||||
|
||||
// Infra
|
||||
{ name: 'ssh-unified', port: 3192, category: 'infra', enabled: true },
|
||||
{ name: 'filesystem', port: 0, category: 'infra', enabled: true },
|
||||
{ name: 'chrome-devtools', port: 0, category: 'infra', enabled: true },
|
||||
|
||||
// AI
|
||||
{ name: 'lightrag', port: 3160, category: 'ai', enabled: true },
|
||||
{ name: 'notebooklm', port: 3190, category: 'ai', enabled: false },
|
||||
{ name: 'context7', port: 3169, category: 'ai', enabled: false },
|
||||
{ name: 'replicate', port: 3176, category: 'ai', enabled: false },
|
||||
{ name: 'memory-supabase', port: 3151, category: 'ai', enabled: true },
|
||||
|
||||
// Tools
|
||||
{ name: 'mcp-time', port: 3155, category: 'tools', enabled: true },
|
||||
{ name: 'deepl', port: 3188, category: 'tools', enabled: true },
|
||||
{ name: 'pexels', port: 3175, category: 'tools', enabled: false },
|
||||
{ name: 'vimeo', port: 3177, category: 'tools', enabled: false },
|
||||
{ name: 'drawio', port: 3184, category: 'tools', enabled: false },
|
||||
|
||||
// External
|
||||
{ name: 'google-workspace', port: 3156, category: 'external', enabled: false },
|
||||
{ name: 'google-analytics', port: 3164, category: 'external', enabled: false },
|
||||
{ name: 'gsc', port: 3165, category: 'external', enabled: false },
|
||||
{ name: 'youtube', port: 3166, category: 'external', enabled: false },
|
||||
{ name: 'youtube-research', port: 3167, category: 'external', enabled: false },
|
||||
{ name: 'moloni', port: 3158, category: 'external', enabled: false },
|
||||
{ name: 'n8n', port: 3171, category: 'external', enabled: false },
|
||||
{ name: 'gitea', port: 3162, category: 'external', enabled: true },
|
||||
{ name: 'stitch', port: 0, category: 'external', enabled: false },
|
||||
{ name: 'design-systems', port: 0, category: 'external', enabled: false },
|
||||
|
||||
// Gateway
|
||||
{ name: 'authentik', port: 3191, category: 'gateway', enabled: false },
|
||||
{ name: 'spaceship', port: 3189, category: 'gateway', enabled: false },
|
||||
{ name: 'puppeteer', port: 3193, category: 'gateway', enabled: false },
|
||||
{ name: 'lighthouse', port: 3194, category: 'gateway', enabled: false },
|
||||
|
||||
// Project
|
||||
{ name: 'carl-mcp', port: 0, category: 'project', enabled: true },
|
||||
{ name: 'reonic', port: 3187, category: 'project', enabled: false },
|
||||
]
|
||||
|
||||
const GATEWAY_URL = process.env.MCP_GATEWAY_URL ?? 'https://gateway.descomplicar.pt'
|
||||
const GATEWAY_TOKEN = process.env.MCP_GATEWAY_TOKEN ?? ''
|
||||
const PING_TIMEOUT_MS = 5000
|
||||
const CACHE_TTL_MS = 60_000
|
||||
|
||||
// Cache em memória
|
||||
let cache: { data: McpDashboard; timestamp: number } | null = null
|
||||
|
||||
function buildHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
if (GATEWAY_TOKEN) {
|
||||
headers['Authorization'] = `Bearer ${GATEWAY_TOKEN}`
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
async function pingGatewayHealth(): Promise<boolean> {
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), PING_TIMEOUT_MS)
|
||||
const res = await fetch(`${GATEWAY_URL}/health`, { signal: controller.signal })
|
||||
clearTimeout(timer)
|
||||
return res.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function pingMcp(name: string): Promise<{ ok: boolean; response_time_ms: number | null }> {
|
||||
const start = Date.now()
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), PING_TIMEOUT_MS)
|
||||
const res = await fetch(`${GATEWAY_URL}/v1/${name}/mcp`, {
|
||||
method: 'GET',
|
||||
headers: buildHeaders(),
|
||||
signal: controller.signal,
|
||||
})
|
||||
clearTimeout(timer)
|
||||
const elapsed = Date.now() - start
|
||||
// Qualquer resposta HTTP não-5xx conta como online
|
||||
return { ok: res.status < 500, response_time_ms: elapsed }
|
||||
} catch {
|
||||
return { ok: false, response_time_ms: null }
|
||||
}
|
||||
}
|
||||
|
||||
export async function getMcpDashboard(): Promise<McpDashboard> {
|
||||
// Servir cache se ainda válido
|
||||
if (cache !== null && Date.now() - cache.timestamp < CACHE_TTL_MS) {
|
||||
return cache.data
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
|
||||
// Separar MCPs por tipo para determinar estratégia de ping
|
||||
const enabledWithPort = MCP_LIST.filter(m => m.enabled && m.port > 0)
|
||||
const enabledLocal = MCP_LIST.filter(m => m.enabled && m.port === 0)
|
||||
const disabledMcps = MCP_LIST.filter(m => !m.enabled)
|
||||
|
||||
// Ping gateway health + todos os MCPs com porta em paralelo
|
||||
const [gatewayResult, ...pingResults] = await Promise.allSettled([
|
||||
pingGatewayHealth(),
|
||||
...enabledWithPort.map(m => pingMcp(m.name)),
|
||||
])
|
||||
|
||||
const gatewayStatus: 'online' | 'offline' =
|
||||
gatewayResult.status === 'fulfilled' && gatewayResult.value ? 'online' : 'offline'
|
||||
|
||||
// MCPs enabled com porta gateway
|
||||
const enabledWithPortStatuses: McpStatus[] = enabledWithPort.map((m, idx) => {
|
||||
const result = pingResults[idx]
|
||||
const pong = result.status === 'fulfilled'
|
||||
? result.value
|
||||
: { ok: false, response_time_ms: null }
|
||||
return {
|
||||
name: m.name,
|
||||
port: m.port,
|
||||
category: m.category,
|
||||
enabled: true,
|
||||
status: pong.ok ? 'online' : 'offline',
|
||||
response_time_ms: pong.response_time_ms,
|
||||
last_check: now,
|
||||
} satisfies McpStatus
|
||||
})
|
||||
|
||||
// MCPs enabled locais (sem porta no gateway) — assumir online se enabled
|
||||
const enabledLocalStatuses: McpStatus[] = enabledLocal.map(m => ({
|
||||
name: m.name,
|
||||
port: m.port,
|
||||
category: m.category,
|
||||
enabled: true,
|
||||
status: 'online' as const,
|
||||
response_time_ms: null,
|
||||
last_check: now,
|
||||
}))
|
||||
|
||||
// MCPs disabled — sem ping, estado disabled
|
||||
const disabledStatuses: McpStatus[] = disabledMcps.map(m => ({
|
||||
name: m.name,
|
||||
port: m.port,
|
||||
category: m.category,
|
||||
enabled: false,
|
||||
status: 'disabled' as const,
|
||||
response_time_ms: null,
|
||||
last_check: now,
|
||||
}))
|
||||
|
||||
const allMcps: McpStatus[] = [
|
||||
...enabledWithPortStatuses,
|
||||
...enabledLocalStatuses,
|
||||
...disabledStatuses,
|
||||
]
|
||||
|
||||
// Ordenar por categoria e depois por nome
|
||||
allMcps.sort((a, b) => {
|
||||
if (a.category !== b.category) return a.category.localeCompare(b.category)
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
|
||||
const online = allMcps.filter(m => m.status === 'online').length
|
||||
const offline = allMcps.filter(m => m.status === 'offline').length
|
||||
const disabledCount = allMcps.filter(m => m.status === 'disabled').length
|
||||
|
||||
const data: McpDashboard = {
|
||||
gateway_status: gatewayStatus,
|
||||
total: allMcps.length,
|
||||
online,
|
||||
offline,
|
||||
disabled: disabledCount,
|
||||
mcps: allMcps,
|
||||
auth: {
|
||||
method: 'dual-layer: IP whitelist + Bearer token',
|
||||
token_expires: null,
|
||||
},
|
||||
}
|
||||
|
||||
cache = { data, timestamp: Date.now() }
|
||||
return data
|
||||
}
|
||||
Reference in New Issue
Block a user