From 12f688ff7c98e09652876f8c5decc23f670e3a0e Mon Sep 17 00:00:00 2001 From: Emanuel Almeida Date: Mon, 6 Apr 2026 20:58:48 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20adicionar=205=20novos=20pain=C3=A9is=20?= =?UTF-8?q?ao=20dashboard=20(MCPs,=20n8n,=20Paperclip,=20IA,=20Opera=C3=A7?= =?UTF-8?q?=C3=B5es)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- api/routes/ai.ts | 22 + api/routes/mcps.ts | 23 + api/routes/n8n.ts | 23 + api/routes/operations.ts | 25 + api/routes/paperclip.ts | 28 ++ api/server.ts | 10 + api/services/ai.ts | 83 ++++ api/services/mcps.ts | 218 +++++++++ api/services/n8n.ts | 182 +++++++ api/services/operations.ts | 122 +++++ api/services/paperclip-db.ts | 47 ++ api/services/paperclip.ts | 209 ++++++++ docs/PLAN-conductor-dashboard-expansion.md | 396 +++++++++++++++ docs/SPEC-dashboard-expansion-q2-2026.md | 473 ++++++++++++++++++ package-lock.json | 201 +++++++- package.json | 2 + src/components/Layout.tsx | 10 + src/main.tsx | 10 + src/pages/AiOverview.tsx | 451 +++++++++++++++++ src/pages/McpMonitor.tsx | 361 ++++++++++++++ src/pages/N8nMonitor.tsx | 381 ++++++++++++++ src/pages/Operations.tsx | 322 ++++++++++++ src/pages/Paperclip.tsx | 545 +++++++++++++++++++++ 23 files changed, 4123 insertions(+), 21 deletions(-) create mode 100644 api/routes/ai.ts create mode 100644 api/routes/mcps.ts create mode 100644 api/routes/n8n.ts create mode 100644 api/routes/operations.ts create mode 100644 api/routes/paperclip.ts create mode 100644 api/services/ai.ts create mode 100644 api/services/mcps.ts create mode 100644 api/services/n8n.ts create mode 100644 api/services/operations.ts create mode 100644 api/services/paperclip-db.ts create mode 100644 api/services/paperclip.ts create mode 100644 docs/PLAN-conductor-dashboard-expansion.md create mode 100644 docs/SPEC-dashboard-expansion-q2-2026.md create mode 100644 src/pages/AiOverview.tsx create mode 100644 src/pages/McpMonitor.tsx create mode 100644 src/pages/N8nMonitor.tsx create mode 100644 src/pages/Operations.tsx create mode 100644 src/pages/Paperclip.tsx diff --git a/api/routes/ai.ts b/api/routes/ai.ts new file mode 100644 index 0000000..a345901 --- /dev/null +++ b/api/routes/ai.ts @@ -0,0 +1,22 @@ +/** + * AI Stack API Route + * GET /api/ai + * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 + */ +import { Router } from 'express' +import type { Request, Response } from 'express' +import { getAiDashboard } from '../services/ai.js' + +const router = Router() + +router.get('/', async (_req: Request, res: Response) => { + try { + const data = await getAiDashboard() + res.json(data) + } catch (error: unknown) { + console.error('AI API error:', error) + res.status(500).json({ error: 'Internal server error' }) + } +}) + +export default router diff --git a/api/routes/mcps.ts b/api/routes/mcps.ts new file mode 100644 index 0000000..ecffa9c --- /dev/null +++ b/api/routes/mcps.ts @@ -0,0 +1,23 @@ +/** + * MCPs API Route + * GET /api/mcps - Estado de todos os MCPs via gateway + * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 + */ +import { Router } from 'express' +import type { Request, Response } from 'express' +import * as mcpsService from '../services/mcps.js' + +const router = Router() + +// Obter estado de todos os MCPs +router.get('/', async (_req: Request, res: Response) => { + try { + const data = await mcpsService.getMcpDashboard() + res.json(data) + } catch (error: unknown) { + console.error('MCPs API error:', error) + res.status(500).json({ error: 'Internal server error' }) + } +}) + +export default router diff --git a/api/routes/n8n.ts b/api/routes/n8n.ts new file mode 100644 index 0000000..77bc077 --- /dev/null +++ b/api/routes/n8n.ts @@ -0,0 +1,23 @@ +/** + * n8n API Route + * GET /api/n8n + * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 + */ +import { Router } from 'express' +import type { Request, Response } from 'express' +import { getN8nDashboard } from '../services/n8n.js' + +const router = Router() + +router.get('/', async (_req: Request, res: Response) => { + try { + const data = await getN8nDashboard() + res.json(data) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Erro interno do servidor' + console.error('n8n API error:', error) + res.status(500).json({ error: message }) + } +}) + +export default router diff --git a/api/routes/operations.ts b/api/routes/operations.ts new file mode 100644 index 0000000..7d83d54 --- /dev/null +++ b/api/routes/operations.ts @@ -0,0 +1,25 @@ +/** + * Rota /api/operations — Painel de Operações + * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 + */ +import { Router } from 'express' +import { getOperationsDashboard } from '../services/operations.js' + +const router = Router() + +/** + * GET /api/operations + * Retorna dados operacionais: tickets abertos, alta prioridade, + * tempo médio de resposta, tickets por departamento e cobertura de PROCs. + */ +router.get('/', async (_req, res) => { + try { + const data = await getOperationsDashboard() + res.json(data) + } catch (err) { + console.error('[operations] Erro ao obter dados:', err) + res.status(500).json({ error: 'Erro interno ao obter dados de operações' }) + } +}) + +export default router diff --git a/api/routes/paperclip.ts b/api/routes/paperclip.ts new file mode 100644 index 0000000..40931bd --- /dev/null +++ b/api/routes/paperclip.ts @@ -0,0 +1,28 @@ +/** + * Paperclip Router — GET /api/paperclip + * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 + */ +import { Router, Request, Response } from 'express' +import { getPaperclipDashboard } from '../services/paperclip.js' + +const router = Router() + +/** + * GET /api/paperclip + * Retorna dados dos agentes, routines e issues do Paperclip. + * Se a BD não estiver configurada ou acessível, retorna dados de fallback (zeros) sem erro 500. + */ +router.get('/', async (_req: Request, res: Response) => { + try { + const data = await getPaperclipDashboard() + res.json(data) + } catch (err) { + console.error('[route/paperclip] Erro inesperado:', (err as Error).message) + res.status(500).json({ + error: 'Erro interno ao obter dados do Paperclip', + message: (err as Error).message, + }) + } +}) + +export default router diff --git a/api/server.ts b/api/server.ts index 940947d..4046f87 100755 --- a/api/server.ts +++ b/api/server.ts @@ -16,6 +16,11 @@ import hetznerRouter from './routes/hetzner.js' import wpMonitorRouter from './routes/wp-monitor.js' import serverMetricsRouter from './routes/server-metrics.js' import financialRouter from './routes/financial.js' +import mcpsRouter from './routes/mcps.js' +import n8nRouter from './routes/n8n.js' +import paperclipRouter from './routes/paperclip.js' +import aiRouter from './routes/ai.js' +import operationsRouter from './routes/operations.js' import { collectAllServerMetrics } from './services/server-metrics.js' import { collectMonitoringData } from './services/monitoring-collector.js' @@ -122,6 +127,11 @@ app.use('/api/hetzner', hetznerRouter) app.use('/api/wp-monitor', wpMonitorRouter) app.use('/api/server-metrics', serverMetricsRouter) app.use('/api/financial', financialRouter) +app.use('/api/mcps', mcpsRouter) +app.use('/api/n8n', n8nRouter) +app.use('/api/paperclip', paperclipRouter) +app.use('/api/ai', aiRouter) +app.use('/api/operations', operationsRouter) // Serve static files in production if (isProduction) { diff --git a/api/services/ai.ts b/api/services/ai.ts new file mode 100644 index 0000000..aea2dc2 --- /dev/null +++ b/api/services/ai.ts @@ -0,0 +1,83 @@ +/** + * AI Stack Service — dados estáticos do stack Descomplicar® 3 camadas + * Fonte: STK-Estado-Actual.md (snapshot 06-04-2026) + * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 + */ + +export interface AiLayerItem { + metric: string + value: number + detail: string +} + +export interface AiLayer { + name: string + label: string + items: AiLayerItem[] +} + +export interface TransversalSystem { + name: string + status: 'active' | 'warning' | 'inactive' + detail: string +} + +export interface CarlConfig { + domains: string[] + total_rules: number +} + +export interface AiDashboard { + layers: AiLayer[] + transversal: TransversalSystem[] + carl: CarlConfig + notebooks: number + last_updated: string +} + +export async function getAiDashboard(): Promise { + return { + layers: [ + { + name: 'Claude Code', + label: 'Camada 1 — Interactivo', + items: [ + { metric: 'Skills', value: 189, detail: '31 directas + 158 plugins' }, + { metric: 'Agents CC', value: 72, detail: '18 directos + 54 plugins' }, + { metric: 'MCPs', value: 39, detail: '10 enabled, 33 gateway, 2 locais' }, + { metric: 'Hooks', value: 9, detail: '9 activos de 26 ficheiros' }, + { metric: 'Plugins', value: 23, detail: '14 Descomplicar + 6 oficiais + 3 terceiros' }, + ], + }, + { + name: 'n8n', + label: 'Camada 2 — Determinístico', + items: [ + { metric: 'Workflows', value: 14, detail: '14 activos de 17 total' }, + ], + }, + { + name: 'Paperclip', + label: 'Camada 3 — Autónomo', + items: [ + { metric: 'Agentes', value: 16, detail: '9 active + 7 idle' }, + { metric: 'Routines', value: 5, detail: '5 activas' }, + { metric: 'Company Skills', value: 92, detail: 'Atribuídas a agentes' }, + ], + }, + ], + transversal: [ + { name: 'RAG/Contexto', status: 'active', detail: 'CARL v2 (7 domínios) + memory-supabase' }, + { name: 'Anti-alucinação', status: 'active', detail: 'Regra factual <80% confiança' }, + { name: 'Knowledge Graph', status: 'active', detail: 'LightRAG v1.4.13 (1612 docs)' }, + { name: 'Auto-melhoria', status: 'warning', detail: 'Corrections hook + evals (3 cenários)' }, + { name: 'Design', status: 'active', detail: 'design.json + PROC-Design-Brief' }, + ], + carl: { + domains: ['GLOBAL', 'CRM', 'DEVELOPMENT', 'WORDPRESS', 'HUB', 'INFRASTRUCTURE', 'QUALITY', 'SKILLS'], + total_rules: 45, + }, + notebooks: 58, + last_updated: '2026-04-06', + } +} diff --git a/api/services/mcps.ts b/api/services/mcps.ts new file mode 100644 index 0000000..0dbb5fb --- /dev/null +++ b/api/services/mcps.ts @@ -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 { + const headers: Record = { 'Content-Type': 'application/json' } + if (GATEWAY_TOKEN) { + headers['Authorization'] = `Bearer ${GATEWAY_TOKEN}` + } + return headers +} + +async function pingGatewayHealth(): Promise { + 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 { + // 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 +} diff --git a/api/services/n8n.ts b/api/services/n8n.ts new file mode 100644 index 0000000..af66f2f --- /dev/null +++ b/api/services/n8n.ts @@ -0,0 +1,182 @@ +/** + * n8n Workflows Service + * Consulta a API REST do n8n e agrega dados de workflows e execuções. + * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 + */ + +// --- Tipos --- + +interface N8nWorkflowRaw { + id: string + name: string + active: boolean + tags?: { id: string; name: string }[] +} + +interface N8nExecutionRaw { + id: string + workflowId: string + status: 'success' | 'error' | 'running' | 'canceled' | 'waiting' + startedAt: string | null + stoppedAt: string | null +} + +export interface N8nWorkflow { + id: string + name: string + active: boolean + last_execution: { + status: 'success' | 'error' | 'running' | null + started_at: string | null + finished_at: string | null + duration_ms: number | null + } | null + tags: string[] +} + +export interface N8nDashboard { + total: number + active: number + inactive: number + failed_24h: number + workflows: N8nWorkflow[] + last_updated: string +} + +// --- Cache em memória --- + +interface CacheEntry { + data: N8nDashboard + timestamp: number +} + +const CACHE_TTL_MS = 300 * 1000 // 300 segundos +let cache: CacheEntry | null = null + +// --- Helpers --- + +function buildHeaders(): Record { + const apiKey = process.env.N8N_API_KEY + if (!apiKey) { + throw new Error( + 'N8N_API_KEY não está configurada. Defina a variável de ambiente N8N_API_KEY com a chave de API do n8n.' + ) + } + return { + 'X-N8N-API-KEY': apiKey, + 'Content-Type': 'application/json', + } +} + +function getBaseUrl(): string { + return process.env.N8N_API_URL || 'https://automator.descomplicar.pt/api/v1' +} + +async function fetchJson(url: string, headers: Record): Promise { + const response = await fetch(url, { headers }) + if (!response.ok) { + throw new Error(`n8n API respondeu com ${response.status} ${response.statusText} para ${url}`) + } + return response.json() as Promise +} + +function normaliseStatus( + raw: N8nExecutionRaw['status'] +): 'success' | 'error' | 'running' | null { + if (raw === 'success') return 'success' + if (raw === 'error' || raw === 'canceled') return 'error' + if (raw === 'running' || raw === 'waiting') return 'running' + return null +} + +function calcDurationMs(started: string | null, stopped: string | null): number | null { + if (!started || !stopped) return null + const diff = new Date(stopped).getTime() - new Date(started).getTime() + return isNaN(diff) ? null : diff +} + +// --- Lógica principal --- + +export async function getN8nDashboard(): Promise { + // Servir do cache se ainda válido + if (cache && Date.now() - cache.timestamp < CACHE_TTL_MS) { + return cache.data + } + + const baseUrl = getBaseUrl() + const headers = buildHeaders() // lança erro se sem API key + + // Buscar workflows e execuções em paralelo + const [workflowsResp, executionsResp] = await Promise.all([ + fetchJson<{ data: N8nWorkflowRaw[] } | N8nWorkflowRaw[]>( + `${baseUrl}/workflows`, + headers + ), + fetchJson<{ data: N8nExecutionRaw[] } | N8nExecutionRaw[]>( + `${baseUrl}/executions?limit=50`, + headers + ), + ]) + + // n8n pode retornar array directo ou { data: [...] } + const workflowsRaw: N8nWorkflowRaw[] = Array.isArray(workflowsResp) + ? workflowsResp + : (workflowsResp as { data: N8nWorkflowRaw[] }).data ?? [] + + const executionsRaw: N8nExecutionRaw[] = Array.isArray(executionsResp) + ? executionsResp + : (executionsResp as { data: N8nExecutionRaw[] }).data ?? [] + + // Mapear última execução por workflowId (a mais recente) + const lastExecByWorkflow = new Map() + for (const exec of executionsRaw) { + const existing = lastExecByWorkflow.get(exec.workflowId) + if (!existing || new Date(exec.startedAt ?? 0) > new Date(existing.startedAt ?? 0)) { + lastExecByWorkflow.set(exec.workflowId, exec) + } + } + + // Calcular falhas nas últimas 24h + const cutoff24h = Date.now() - 24 * 60 * 60 * 1000 + let failed_24h = 0 + for (const exec of executionsRaw) { + if ( + (exec.status === 'error' || exec.status === 'canceled') && + exec.startedAt && + new Date(exec.startedAt).getTime() >= cutoff24h + ) { + failed_24h++ + } + } + + // Construir lista de workflows enriquecida + const workflows: N8nWorkflow[] = workflowsRaw.map((wf) => { + const lastExec = lastExecByWorkflow.get(wf.id) ?? null + return { + id: wf.id, + name: wf.name, + active: wf.active, + last_execution: lastExec + ? { + status: normaliseStatus(lastExec.status), + started_at: lastExec.startedAt, + finished_at: lastExec.stoppedAt, + duration_ms: calcDurationMs(lastExec.startedAt, lastExec.stoppedAt), + } + : null, + tags: (wf.tags ?? []).map((t) => t.name), + } + }) + + const dashboard: N8nDashboard = { + total: workflows.length, + active: workflows.filter((w) => w.active).length, + inactive: workflows.filter((w) => !w.active).length, + failed_24h, + workflows, + last_updated: new Date().toISOString(), + } + + cache = { data: dashboard, timestamp: Date.now() } + return dashboard +} diff --git a/api/services/operations.ts b/api/services/operations.ts new file mode 100644 index 0000000..02aa13c --- /dev/null +++ b/api/services/operations.ts @@ -0,0 +1,122 @@ +/** + * Operations Dashboard Service — Tickets e Cobertura de PROCs + * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 + */ +import db from '../db.js' +import type { RowDataPacket } from 'mysql2' + +// --------------------------------------------------------------------------- +// Tipos +// --------------------------------------------------------------------------- + +export interface OperationsDashboard { + tickets: { + open: number + high_priority: number + avg_response_hours: number + by_department: { dept: string; count: number }[] + } + procedures: { + total: number + departments: number + coverage: { dept: string; procs: number; total_expected: number; pct: number }[] + } +} + +// --------------------------------------------------------------------------- +// Dados estáticos — cobertura de PROCs por departamento +// --------------------------------------------------------------------------- + +const PROC_COVERAGE = [ + { dept: 'D1 — Comercial', procs: 5, total_expected: 8 }, + { dept: 'D2 — Suporte', procs: 3, total_expected: 6 }, + { dept: 'D3 — Contabilidade', procs: 3, total_expected: 5 }, + { dept: 'D4 — RH', procs: 1, total_expected: 4 }, + { dept: 'D5 — Marketing', procs: 5, total_expected: 8 }, + { dept: 'D6 — Gestão', procs: 8, total_expected: 10 }, + { dept: 'D7 — Tecnologia', procs: 18, total_expected: 20 }, + { dept: 'Cross-Departamental', procs: 5, total_expected: 6 }, +] + +// --------------------------------------------------------------------------- +// Query principal +// --------------------------------------------------------------------------- + +export async function getOperationsDashboard(): Promise { + // Queries paralelas para melhor performance + const [ + ticketsAbertosResult, + ticketsAltaPrioridadeResult, + ticketsPorDepartamentoResult, + tempoMedioRespostaResult, + ] = await Promise.all([ + // a) Total de tickets abertos (status 1=Open, 2=In Progress, 3=Answered) + db.query( + `SELECT COUNT(*) as count + FROM tbltickets + WHERE status IN ('1','2','3')` + ), + + // b) Tickets de alta prioridade ainda abertos (priority 2=High, 3=Urgent) + db.query( + `SELECT COUNT(*) as count + FROM tbltickets + WHERE priority IN (2,3) + AND status IN ('1','2','3')` + ), + + // c) Tickets abertos agrupados por departamento + db.query( + `SELECT d.name as dept, COUNT(t.ticketid) as count + FROM tbltickets t + LEFT JOIN tbldepartments d ON t.department = d.departmentid + WHERE t.status IN ('1','2','3') + GROUP BY d.departmentid, d.name + ORDER BY count DESC` + ), + + // d) Tempo médio de resposta em horas (últimos 90 dias) + db.query( + `SELECT ROUND(AVG(TIMESTAMPDIFF(HOUR, t.date, t.lastreply)), 1) as avg_hours + FROM tbltickets t + WHERE t.lastreply IS NOT NULL + AND t.lastreply != '0000-00-00 00:00:00' + AND t.date > DATE_SUB(CURDATE(), INTERVAL 90 DAY)` + ), + ]) + + // Extrair valores das queries + const ticketsAbertos = (ticketsAbertosResult[0][0] as RowDataPacket)?.count ?? 0 + const ticketsAltaPrioridade = (ticketsAltaPrioridadeResult[0][0] as RowDataPacket)?.count ?? 0 + const avgResponseHours = (tempoMedioRespostaResult[0][0] as RowDataPacket)?.avg_hours ?? 0 + + const byDepartment = (ticketsPorDepartamentoResult[0] as RowDataPacket[]).map(row => ({ + dept: (row.dept as string) || 'Sem departamento', + count: Number(row.count) || 0, + })) + + // Calcular cobertura em percentagem + const coverage = PROC_COVERAGE.map(item => ({ + dept: item.dept, + procs: item.procs, + total_expected: item.total_expected, + pct: Math.round((item.procs / item.total_expected) * 100), + })) + + const totalProcs = PROC_COVERAGE.reduce((sum, d) => sum + d.procs, 0) + const totalDepartments = PROC_COVERAGE.filter(d => d.dept.startsWith('D')).length + + return { + tickets: { + open: Number(ticketsAbertos), + high_priority: Number(ticketsAltaPrioridade), + avg_response_hours: Number(avgResponseHours), + by_department: byDepartment, + }, + procedures: { + total: totalProcs, + departments: totalDepartments, + coverage, + }, + } +} diff --git a/api/services/paperclip-db.ts b/api/services/paperclip-db.ts new file mode 100644 index 0000000..159726a --- /dev/null +++ b/api/services/paperclip-db.ts @@ -0,0 +1,47 @@ +/** + * Paperclip PostgreSQL Connection Pool + * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 + */ +import 'dotenv/config' +import pg from 'pg' + +const { Pool } = pg + +// Validação de credenciais — sem elas, pool não é criado (fallback gracioso) +const user = process.env.PAPERCLIP_DB_USER +const password = process.env.PAPERCLIP_DB_PASS + +if (!user || !password) { + console.warn( + '[paperclip-db] PAPERCLIP_DB_USER ou PAPERCLIP_DB_PASS não definidos — ' + + 'pool PostgreSQL não será criado. A página Paperclip usará dados de fallback.' + ) +} + +// Configuração do pool PostgreSQL +const pool: pg.Pool | null = (user && password) + ? new Pool({ + host: process.env.PAPERCLIP_DB_HOST || 'clip.descomplicar.pt', + port: parseInt(process.env.PAPERCLIP_DB_PORT || '54329', 10), + database: process.env.PAPERCLIP_DB_NAME || 'paperclip', + user, + password, + max: 5, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 10000, + }) + : null + +// Teste de conexão (apenas se pool configurado) +if (pool) { + pool.connect() + .then(client => { + console.log('[paperclip-db] PostgreSQL conectado com sucesso') + client.release() + }) + .catch(err => { + console.error('[paperclip-db] Erro ao conectar ao PostgreSQL:', err.message) + }) +} + +export default pool diff --git a/api/services/paperclip.ts b/api/services/paperclip.ts new file mode 100644 index 0000000..40d62db --- /dev/null +++ b/api/services/paperclip.ts @@ -0,0 +1,209 @@ +/** + * Paperclip Service — Queries ao PostgreSQL do Paperclip + * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 + * + * NOTA SOBRE ESQUEMA DA BD: + * Os nomes de tabelas e colunas abaixo são baseados na spec (SPEC-dashboard-expansion-q2-2026.md §4.3). + * Se o esquema real da BD Paperclip diferir, ajustar as queries: + * - Tabela de agentes: 'agents' (colunas: name, role, status, last_heartbeat) + * - Tabela de routines: 'routines' (colunas: name, cron_expression, enabled, last_run_at, last_run_status) + * - Tabela de issues: 'issues' (colunas: status, closed_at) + * Verificar schema real com: \dt e \d no psql. + */ +import pool from './paperclip-db.js' + +// --------------------------------------------------------------------------- +// Types (espelham a spec) +// --------------------------------------------------------------------------- + +export interface PaperclipAgent { + id: string + name: string + role: string + status: 'active' | 'idle' | 'error' | 'archived' + last_heartbeat: string | null + last_run: string | null + total_runs: number +} + +export interface PaperclipRoutine { + id: string + name: string + cron: string + active: boolean + last_run: string | null + last_status: 'success' | 'error' | null + next_run: string | null +} + +export interface PaperclipDashboard { + agents: { + total: number + active: number + idle: number + error: number + list: PaperclipAgent[] + } + routines: { + total: number + active: number + list: PaperclipRoutine[] + } + issues: { + open: number + in_progress: number + closed_7d: number + } +} + +// --------------------------------------------------------------------------- +// Dados de fallback (quando BD não está configurada ou inacessível) +// --------------------------------------------------------------------------- + +const FALLBACK: PaperclipDashboard = { + agents: { + total: 0, + active: 0, + idle: 0, + error: 0, + list: [], + }, + routines: { + total: 0, + active: 0, + list: [], + }, + issues: { + open: 0, + in_progress: 0, + closed_7d: 0, + }, +} + +// --------------------------------------------------------------------------- +// Função principal — todas as queries em paralelo +// --------------------------------------------------------------------------- + +export async function getPaperclipDashboard(): Promise { + // Se pool não foi configurado (credenciais em falta), retornar fallback + if (!pool) { + return { ...FALLBACK } + } + + try { + const [agentsResult, routinesResult, issuesOpenResult, issuesInProgressResult, issuesClosedResult] = + await Promise.all([ + // Query A: Agentes activos (excluindo archived) + // NOTA: colunas last_run e total_runs podem não existir — ajustar se necessário + pool.query<{ + id: string + name: string + role: string + status: string + last_heartbeat: string | null + last_run: string | null + total_runs: number + }>( + `SELECT + id, + name, + role, + status, + last_heartbeat, + last_run_at AS last_run, + COALESCE(total_runs, 0) AS total_runs + FROM agents + WHERE status != 'archived' + ORDER BY role, name` + ), + + // Query B: Routines ordenadas por nome + pool.query<{ + id: string + name: string + cron: string + active: boolean + last_run: string | null + last_status: string | null + next_run: string | null + }>( + `SELECT + id, + name, + cron_expression AS cron, + enabled AS active, + last_run_at AS last_run, + last_run_status AS last_status, + next_run_at AS next_run + FROM routines + ORDER BY name` + ), + + // Query C1: Issues abertas + pool.query<{ count: string }>( + `SELECT COUNT(*) AS count FROM issues WHERE status = 'open'` + ), + + // Query C2: Issues em progresso + pool.query<{ count: string }>( + `SELECT COUNT(*) AS count FROM issues WHERE status = 'in_progress'` + ), + + // Query C3: Issues fechadas nos últimos 7 dias + pool.query<{ count: string }>( + `SELECT COUNT(*) AS count FROM issues WHERE status = 'closed' AND closed_at > NOW() - INTERVAL '7 days'` + ), + ]) + + const agents = agentsResult.rows + const routines = routinesResult.rows + + // Contagens de agentes por estado + const activeCount = agents.filter(a => a.status === 'active').length + const idleCount = agents.filter(a => a.status === 'idle').length + const errorCount = agents.filter(a => a.status === 'error').length + + // Contagens de routines activas + const activeRoutines = routines.filter(r => r.active).length + + return { + agents: { + total: agents.length, + active: activeCount, + idle: idleCount, + error: errorCount, + list: agents.map(a => ({ + id: a.id ?? '', + name: a.name, + role: a.role, + status: a.status as PaperclipAgent['status'], + last_heartbeat: a.last_heartbeat ?? null, + last_run: a.last_run ?? null, + total_runs: Number(a.total_runs) || 0, + })), + }, + routines: { + total: routines.length, + active: activeRoutines, + list: routines.map(r => ({ + id: r.id ?? '', + name: r.name, + cron: r.cron, + active: Boolean(r.active), + last_run: r.last_run ?? null, + last_status: (r.last_status as PaperclipRoutine['last_status']) ?? null, + next_run: r.next_run ?? null, + })), + }, + issues: { + open: parseInt(issuesOpenResult.rows[0]?.count ?? '0', 10), + in_progress: parseInt(issuesInProgressResult.rows[0]?.count ?? '0', 10), + closed_7d: parseInt(issuesClosedResult.rows[0]?.count ?? '0', 10), + }, + } + } catch (err) { + console.error('[paperclip] Erro ao obter dados da BD Paperclip:', (err as Error).message) + // Retornar fallback em caso de falha de BD (sem rebentar a API) + return { ...FALLBACK } + } +} diff --git a/docs/PLAN-conductor-dashboard-expansion.md b/docs/PLAN-conductor-dashboard-expansion.md new file mode 100644 index 0000000..123fc07 --- /dev/null +++ b/docs/PLAN-conductor-dashboard-expansion.md @@ -0,0 +1,396 @@ +--- +title: PLAN Conductor — Expansão DashDescomplicar +date: 2026-04-06 +type: plan +status: active +spec: SPEC-dashboard-expansion-q2-2026.md +tags: [conductor, plan, dashboard, parallel] +--- + +# Plano Conductor — Expansão DashDescomplicar + +**Spec:** `docs/SPEC-dashboard-expansion-q2-2026.md` +**Projecto:** `/media/ealmeida/Dados/Dev/DashDescomplicar/` +**Método:** Conductor parallel sprints — agentes independentes por domínio + +--- + +## Estratégia de paralelização + +Cada painel (backend + frontend) é **independente** — ficheiros distintos, sem dependências cruzadas. A integração (Layout, App, server.ts) é feita no final por um único agente coordenador. + +``` + ┌─────────────┐ + │ Coordenador │ + │ (Sprint 4) │ + └──────┬──────┘ + ┌──────────────┼──────────────┐ + Sprint 1 │ Sprint 3 + ┌────┴────┐ Sprint 2 ┌────┴────┐ + │ │ │ │ │ + Agent A Agent B Agent C Agent D Agent E + MCPs n8n Paperclip IA Operações +``` + +**Agentes A-E correm em paralelo.** Agente F (coordenador) corre depois, integrando tudo. + +--- + +## Pré-requisitos (executar antes do conductor) + +1. Instalar `pg` (PostgreSQL client): + ```bash + cd /media/ealmeida/Dados/Dev/DashDescomplicar && npm install pg @types/pg + ``` + +2. Verificar env vars disponíveis no EasyPanel (MCP_GATEWAY_TOKEN, N8N_API_KEY, PAPERCLIP_DB_*) + +--- + +## Agent A — Painel MCPs + +**Tipo:** `javascript-fullstack-specialist` +**Isolation:** worktree +**Ficheiros a criar:** +- `api/services/mcps.ts` +- `api/routes/mcps.ts` +- `src/pages/McpMonitor.tsx` + +**Prompt:** + +``` +Estás a trabalhar no projecto DashDescomplicar (/media/ealmeida/Dados/Dev/DashDescomplicar/). +Stack: React 19 + Vite + Tailwind CSS 4 + Express 4 + TypeScript. + +Lê a spec completa em docs/SPEC-dashboard-expansion-q2-2026.md, secção 4.1 (Painel MCPs). + +Cria 3 ficheiros: + +1. api/services/mcps.ts — Service que: + - Tem lista hardcoded de 35 MCPs (nome, porta, categoria, enabled) + - Faz ping paralelo (Promise.allSettled, timeout 5s) aos MCPs enabled via HTTP GET + - Gateway URL: process.env.MCP_GATEWAY_URL || 'https://gateway.descomplicar.pt' + - Bearer token: process.env.MCP_GATEWAY_TOKEN + - Cache de 60 segundos (variável em memória com timestamp) + - Retorna McpDashboard conforme spec + +2. api/routes/mcps.ts — Router Express com GET / + - Chama o service e retorna JSON + - try/catch com 500 error handling (padrão dos outros routers no projecto) + +3. src/pages/McpMonitor.tsx — Página React que: + - Faz fetch a /api/mcps com useEffect + - Mostra header com stats (total/online/offline/disabled) + - Grid de cards agrupados por categoria (crm, infra, ai, tools, external) + - Card com: nome, porta, status (cor), response_time + - Botão refresh manual + - Loading state e error state + - Segue estilo visual do projecto: ler src/pages/Monitor.tsx como referência (dark theme, motion.div, lucide icons, containerVariants/itemVariants) + +Lista dos MCPs para hardcoding: +- CRM: desk-crm-v3 (3150), desk-project-minimal (3153) +- Infra: ssh-unified (3192), filesystem (local), chrome-devtools (local) +- AI: lightrag (3160), notebooklm (3190), context7 (3169), replicate (3176), memory-supabase (3151) +- Tools: mcp-time (3155), deepl (3188), pexels (3175), vimeo (3177), drawio (3184) +- External: google-workspace (3156), google-analytics (3164), gsc (3165), youtube (3166), youtube-research (3167), moloni (3158), n8n (3171), gitea (3162), stitch (external), design-systems (external) +- Gateway: authentik (3191), spaceship (3189), puppeteer (3193), lighthouse (3194) +- Locais (disabled ping): filesystem, chrome-devtools +- MCPs em .mcp.json: carl-mcp (local, porta variável), reonic (3187) + +Não alteres nenhum ficheiro existente. Apenas cria os 3 novos. +``` + +--- + +## Agent B — Painel n8n + +**Tipo:** `javascript-fullstack-specialist` +**Isolation:** worktree +**Ficheiros a criar:** +- `api/services/n8n.ts` +- `api/routes/n8n.ts` +- `src/pages/N8nMonitor.tsx` + +**Prompt:** + +``` +Estás a trabalhar no projecto DashDescomplicar (/media/ealmeida/Dados/Dev/DashDescomplicar/). +Stack: React 19 + Vite + Tailwind CSS 4 + Express 4 + TypeScript. + +Lê a spec completa em docs/SPEC-dashboard-expansion-q2-2026.md, secção 4.2 (Painel n8n). + +Cria 3 ficheiros: + +1. api/services/n8n.ts — Service que: + - Chama API REST n8n: GET /workflows e GET /executions?limit=50 + - URL base: process.env.N8N_API_URL || 'https://automator.descomplicar.pt/api/v1' + - Auth: header X-N8N-API-KEY com process.env.N8N_API_KEY + - Cache de 300 segundos + - Retorna N8nDashboard conforme spec (total, active, failed_24h, workflows com last_execution) + +2. api/routes/n8n.ts — Router Express com GET / + - try/catch com 500 error handling + +3. src/pages/N8nMonitor.tsx — Página React que: + - Faz fetch a /api/n8n + - Stats cards: total workflows, activos, falhas 24h (destaque vermelho se >0) + - Tabela com: nome, activo (badge), último run (data + status com cor), duração + - Filtro toggle: activos/todos + - Segue estilo visual: ler src/pages/Financial.tsx como referência + +Não alteres nenhum ficheiro existente. Apenas cria os 3 novos. +``` + +--- + +## Agent C — Painel Paperclip + +**Tipo:** `javascript-fullstack-specialist` +**Isolation:** worktree +**Ficheiros a criar:** +- `api/services/paperclip-db.ts` +- `api/services/paperclip.ts` +- `api/routes/paperclip.ts` +- `src/pages/Paperclip.tsx` + +**Prompt:** + +``` +Estás a trabalhar no projecto DashDescomplicar (/media/ealmeida/Dados/Dev/DashDescomplicar/). +Stack: React 19 + Vite + Tailwind CSS 4 + Express 4 + TypeScript. + +Lê a spec completa em docs/SPEC-dashboard-expansion-q2-2026.md, secção 4.3 (Painel Paperclip). + +O pacote `pg` já foi instalado (npm install pg @types/pg). + +Cria 4 ficheiros: + +1. api/services/paperclip-db.ts — Pool PostgreSQL: + - Host: process.env.PAPERCLIP_DB_HOST || 'clip.descomplicar.pt' + - Port: process.env.PAPERCLIP_DB_PORT || 54329 + - Database: process.env.PAPERCLIP_DB_NAME || 'paperclip' + - User/Pass: process.env.PAPERCLIP_DB_USER, process.env.PAPERCLIP_DB_PASS + - Pool com max 5 conexões + - Validação de credenciais obrigatória (throw se não definidas) + - Export default pool + +2. api/services/paperclip.ts — Queries: + - getAgents(): SELECT id, name, role, status, last_heartbeat, total_runs FROM agents WHERE status != 'archived' + - getRoutines(): SELECT id, name, cron, active, last_run, last_status FROM routines + - getIssueStats(): COUNT por estado (open, in_progress, closed últimos 7 dias) + - Retorna PaperclipDashboard conforme spec + - NOTA: os nomes exactos das tabelas e colunas podem variar — usar nomes razoáveis e documentar com comentário que podem precisar de ajuste + +3. api/routes/paperclip.ts — Router Express com GET / + - try/catch, se BD inacessível retornar dados fallback (zeros) + +4. src/pages/Paperclip.tsx — Página React: + - Stats cards: agentes activos/idle/error, routines activas + - Grid de cards para agentes, agrupados por role (C-Level, Director, Specialist) + - Cor do card por status: active=verde, idle=amarelo, error=vermelho + - Tabela de routines: nome, cron, activa, último run, status + - Segue estilo visual do projecto + +Não alteres nenhum ficheiro existente. Apenas cria os 4 novos. +``` + +--- + +## Agent D — Painel IA / Claude Code + +**Tipo:** `javascript-fullstack-specialist` +**Isolation:** worktree +**Ficheiros a criar:** +- `api/services/ai.ts` +- `api/routes/ai.ts` +- `src/pages/AiOverview.tsx` + +**Prompt:** + +``` +Estás a trabalhar no projecto DashDescomplicar (/media/ealmeida/Dados/Dev/DashDescomplicar/). +Stack: React 19 + Vite + Tailwind CSS 4 + Express 4 + TypeScript. + +Lê a spec completa em docs/SPEC-dashboard-expansion-q2-2026.md, secção 4.4 (Painel Claude Code / IA). + +Cria 3 ficheiros: + +1. api/services/ai.ts — Service que: + - Retorna dados maioritariamente estáticos (actualizados manualmente) + - Dados reais do stack (fonte: STK-Estado-Actual.md de 04-04-2026): + - Skills: 189 total (31 directas + 158 plugins) + - Agents CC: 72 (18 directos + 54 plugins) + - MCPs: 39 (10 enabled, 29 disabled, 33 gateway, 2 locais) + - Hooks: 26 ficheiros, 9 activos + - Plugins: 14 Descomplicar + 6 oficiais + 3 terceiros, 6 activos + - CARL: 7 domínios, ~45 regras + - Paperclip: 16 operacionais + - n8n: 14 workflows + - NotebookLM: 58 notebooks + - Cache infinito (dados estáticos) + +2. api/routes/ai.ts — Router Express com GET / + +3. src/pages/AiOverview.tsx — Página React: + - Layout de "stack overview" com 3 secções: + a) Camada 1 (Claude Code): cards para skills, agents, MCPs, hooks, plugins + b) Camada 2 (n8n): card simples com contagem workflows + c) Camada 3 (Paperclip): card simples com contagem agentes + - Card grande central: "3 Camadas de Execução" com diagrama visual (CSS, não SVG) + - Secção CARL: 7 domínios listados (GLOBAL, CRM, DEVELOPMENT, WORDPRESS, HUB, INFRASTRUCTURE, QUALITY, SKILLS) + - Tons roxos/violeta para diferenciar das outras páginas + - Segue estilo visual do projecto + +Não alteres nenhum ficheiro existente. Apenas cria os 3 novos. +``` + +--- + +## Agent E — Painel Operações + +**Tipo:** `javascript-fullstack-specialist` +**Isolation:** worktree +**Ficheiros a criar:** +- `api/services/operations.ts` +- `api/routes/operations.ts` +- `src/pages/Operations.tsx` + +**Prompt:** + +``` +Estás a trabalhar no projecto DashDescomplicar (/media/ealmeida/Dados/Dev/DashDescomplicar/). +Stack: React 19 + Vite + Tailwind CSS 4 + Express 4 + TypeScript. + +Lê a spec em docs/SPEC-dashboard-expansion-q2-2026.md, secção 4.5 (Painel Operações). + +A BD MySQL (Desk CRM) já tem conexão configurada em api/db.ts (export default pool mysql2/promise). + +Cria 3 ficheiros: + +1. api/services/operations.ts — Service que: + - Queries ao MySQL (Desk CRM): + a) Tickets abertos: SELECT COUNT(*) FROM tbltickets WHERE status IN ('Open','In Progress','Answered') + b) Tickets alta prioridade: WHERE priority IN (2,3) + c) Tickets por departamento: JOIN tbldepartments GROUP BY department + d) Tempo médio resposta: AVG(TIMESTAMPDIFF(HOUR, date, lastreply)) dos tickets com resposta + - Dados estáticos PROCs: + - 48 procedimentos, 8 departamentos + - Cobertura por dept: D1(5), D2(3), D3(3), D4(1), D5(5), D6(8), D7(18), Cross(5) + - import db from '../db.js' + +2. api/routes/operations.ts — Router Express com GET / + +3. src/pages/Operations.tsx — Página React: + - Stats cards: tickets abertos, alta prioridade, tempo médio resposta + - Gráfico barras horizontal (recharts BarChart): tickets por departamento + - Tabela cobertura PROCs: departamento, número PROCs, % cobertura + - Segue estilo visual: ler src/pages/Financial.tsx como referência (usa recharts) + +Não alteres nenhum ficheiro existente. Apenas cria os 3 novos. +``` + +--- + +## Agent F — Coordenador / Integração (executa após A-E) + +**Tipo:** `javascript-fullstack-specialist` +**Isolation:** nenhum (merge directo) +**Ficheiros a alterar:** +- `src/components/Layout.tsx` +- `src/App.tsx` (secção de routing) +- `api/server.ts` (registar routers) + +**Prompt:** + +``` +Estás a trabalhar no projecto DashDescomplicar (/media/ealmeida/Dados/Dev/DashDescomplicar/). + +Os seguintes ficheiros novos já foram criados por agentes anteriores: +- api/routes/mcps.ts, api/services/mcps.ts, src/pages/McpMonitor.tsx +- api/routes/n8n.ts, api/services/n8n.ts, src/pages/N8nMonitor.tsx +- api/routes/paperclip.ts, api/services/paperclip.ts, api/services/paperclip-db.ts, src/pages/Paperclip.tsx +- api/routes/ai.ts, api/services/ai.ts, src/pages/AiOverview.tsx +- api/routes/operations.ts, api/services/operations.ts, src/pages/Operations.tsx + +Faz a integração: + +1. src/components/Layout.tsx — Adicionar 5 itens ao array NAV_ITEMS: + - { to: '/mcps', label: 'MCPs', icon: Network } + - { to: '/n8n', label: 'Automações', icon: Workflow } + - { to: '/paperclip', label: 'Paperclip', icon: Bot } + - { to: '/ai', label: 'IA / Claude', icon: Brain } + - { to: '/operations', label: 'Operações', icon: ClipboardList } + Adicionar os imports de ícones do lucide-react: Network, Workflow, Bot, Brain, ClipboardList + NOTA: verificar se 'Workflow' existe em lucide-react, se não usar 'GitBranch' ou 'Repeat' + +2. src/App.tsx — Na secção de routing (BrowserRouter/Routes), adicionar: + - import McpMonitor from './pages/McpMonitor' + - import N8nMonitor from './pages/N8nMonitor' + - import Paperclip from './pages/Paperclip' + - import AiOverview from './pages/AiOverview' + - import Operations from './pages/Operations' + E as respectivas dentro do }> + +3. api/server.ts — Adicionar imports e app.use: + - import mcpsRouter from './routes/mcps.js' + - import n8nRouter from './routes/n8n.js' + - import paperclipRouter from './routes/paperclip.js' + - import aiRouter from './routes/ai.js' + - import operationsRouter from './routes/operations.js' + - app.use('/api/mcps', mcpsRouter) + - app.use('/api/n8n', n8nRouter) + - app.use('/api/paperclip', paperclipRouter) + - app.use('/api/ai', aiRouter) + - app.use('/api/operations', operationsRouter) + +Lê cada ficheiro antes de editar. Mantém o estilo existente. Não alteres mais nada. +``` + +--- + +## Sequência de execução + +``` +Fase 1 (paralelo): Agent A + Agent B + Agent C + Agent D + Agent E + ↓ todos concluídos +Fase 2 (sequencial): Merge worktrees → main + ↓ +Fase 3 (sequencial): Agent F (integração) + ↓ +Fase 4 (validação): npm run build + teste manual +``` + +--- + +## Comando conductor + +```bash +# Fase 1 — lançar 5 agentes em paralelo (worktree isolation) +# Usar: Agent tool com isolation: "worktree" e run_in_background: true + +# Fase 2 — após todos concluírem, merge dos worktrees +# git merge --no-edit +# git merge --no-edit +# ... + +# Fase 3 — Agent F integração +# Sem worktree, directo no main + +# Fase 4 — validar +# npm run build +# npm run dev (testar manualmente) +``` + +--- + +## Checklist de validação final + +- [ ] `npm run build` sem erros +- [ ] Todas as 8 páginas navegáveis na sidebar +- [ ] GET /api/mcps retorna dados +- [ ] GET /api/n8n retorna dados (ou erro claro se API key não configurada) +- [ ] GET /api/paperclip retorna dados (ou fallback zeros se BD inacessível) +- [ ] GET /api/ai retorna dados estáticos +- [ ] GET /api/operations retorna dados do Desk CRM +- [ ] Mobile: sidebar colapsa correctamente com 8 itens +- [ ] Zero erros na consola do browser diff --git a/docs/SPEC-dashboard-expansion-q2-2026.md b/docs/SPEC-dashboard-expansion-q2-2026.md new file mode 100644 index 0000000..03d14e0 --- /dev/null +++ b/docs/SPEC-dashboard-expansion-q2-2026.md @@ -0,0 +1,473 @@ +--- +title: SPEC — Expansão DashDescomplicar Q2 2026 +date: 2026-04-06 +type: spec +status: active +desk_project: 65 +tags: [dashboard, expansion, mcps, paperclip, n8n, claude-code] +--- + +# SPEC — Expansão DashDescomplicar Q2 2026 + +## 1. Contexto + +O DashDescomplicar é o painel de gestão interno da Descomplicar®. Stack: React 19 + Vite + Tailwind CSS 4 + Express 4 + MySQL (Desk CRM BD). Deploy em EasyPanel via Dockerfile. + +### Estado actual (3 páginas) + +| Página | Rotas API | Dados | +|--------|-----------|-------| +| **Dashboard** (App.tsx) | `/api/dashboard` | Tarefas (urgente/alta/vencidas/em teste), leads (contactar/followup/proposta), projectos, billing 360, calendário Google, timesheet | +| **Monitor** | `/api/monitor`, `/api/hetzner`, `/api/server-metrics`, `/api/wp-monitor`, `/api/diagnostic` | Servidores Hetzner (CPU/rede/disco), serviços HTTP (11 URLs), EasyPanel containers, sites WordPress (CWP) | +| **Financeiro** | `/api/financial` | Vendas/despesas mês e ano, lucro, categorias, evolução mensal 12 meses | + +### O que falta (referência: plano-migracao-mcps-gateway-auth.md, Fase 5) + +Novos painéis para reflectir o stack completo: MCPs (33 no gateway), n8n (14 workflows), Paperclip (16 agentes operacionais), Claude Code/IA (189 skills, 72 agents, 9 hooks), LightRAG (knowledge graph), Desk CRM expandido (tickets, SLAs), e Operações (worklogs, PROCs, calendário). + +### Auth + +OIDC com Authentik **já implementado no frontend** (react-oidc-context, AuthWrapper.tsx, config.ts). Backend tem placeholder (`OIDC_ENABLED=true`). Falta activar em produção. + +--- + +## 2. Objectivos + +1. Expandir o dashboard de 3 para 8 páginas +2. Cada novo painel usa dados reais via API (gateway MCPs, BD MySQL, APIs HTTP) +3. Manter a consistência visual (dark theme, Tailwind, framer-motion, lucide-react) +4. Zero dependências novas — usar as existentes (recharts, framer-motion, lucide) +5. Cada painel é independente — pode ser implementado e testado isoladamente + +--- + +## 3. Arquitectura de dados (fontes por painel) + +``` +Express API (porta 3001) +├── /api/dashboard → MySQL (Desk CRM) + Google Calendar API [EXISTE] +├── /api/monitor → MySQL (tbl_eal_monitoring) + Hetzner API [EXISTE] +├── /api/financial → MySQL (Desk CRM invoices/expenses) [EXISTE] +├── /api/mcps → HTTP GET gateway.descomplicar.pt/health + per-MCP ping [NOVO] +├── /api/n8n → HTTP GET automator.descomplicar.pt/api/v1 (API key) [NOVO] +├── /api/paperclip → PostgreSQL clip.descomplicar.pt (porta 54329) [NOVO] +├── /api/ai → Ficheiros locais + MySQL (stats) [NOVO] +└── /api/operations → MySQL (Desk CRM) + Google Calendar [NOVO] +``` + +--- + +## 4. Especificação por painel + +### 4.1 Painel MCPs (nova página `/mcps`) + +**Objectivo:** Ver todos os 33+2 MCPs com estado online/offline em tempo real. + +**Rota API:** `GET /api/mcps` + +**Fonte de dados:** +- Gateway health: `GET https://gateway.descomplicar.pt/health` (sem auth, já existe) +- Per-MCP ping: `GET https://gateway.descomplicar.pt/v1//mcp` com Bearer token (resposta 200 = online) +- Lista estática dos MCPs com metadados (nome, porta, categoria, enabled/disabled) + +**Dados retornados:** +```typescript +interface McpStatus { + name: string // "desk-crm-v3" + port: number // 3150 + category: string // "crm" | "infra" | "ai" | "tools" | "external" + enabled: boolean // true/false em claude.json + status: 'online' | 'offline' | 'disabled' | 'unknown' + response_time_ms: number | null + last_check: string // ISO timestamp + tools_count?: number // número de tools (se conhecido) +} + +interface McpDashboard { + gateway_status: 'online' | 'offline' + total: number + online: number + offline: number + disabled: number + mcps: McpStatus[] + auth: { + method: string // "dual-layer: IP whitelist + Bearer token" + token_expires: string | null + } +} +``` + +**UI (página McpMonitor.tsx):** +- Header com stats gerais (total/online/offline/disabled) +- Grid de cards por MCP, agrupados por categoria +- Indicador de cor: verde (online), vermelho (offline), cinza (disabled) +- Response time badge em cada card +- Filtros: por categoria, por estado +- Botão refresh manual + +**Implementação backend (api/services/mcps.ts):** +- Lista hardcoded de MCPs com metadados (extraída de port-map.json e claude.json) +- Ping paralelo a cada MCP enabled (Promise.allSettled com timeout 5s) +- Cache de 60 segundos (evitar spam ao gateway) + +--- + +### 4.2 Painel n8n (nova página `/n8n`) + +**Objectivo:** Ver 14 workflows operacionais com estado, último run, próximo run. + +**Rota API:** `GET /api/n8n` + +**Fonte de dados:** +- n8n API REST: `https://automator.descomplicar.pt/api/v1/workflows` (API key em env) +- n8n API REST: `https://automator.descomplicar.pt/api/v1/executions?limit=50` + +**Dados retornados:** +```typescript +interface N8nWorkflow { + id: string + name: string + active: boolean + last_execution: { + status: 'success' | 'error' | 'running' | null + started_at: string | null + finished_at: string | null + duration_ms: number | null + } | null + tags: string[] +} + +interface N8nDashboard { + total: number + active: number + inactive: number + failed_24h: number + workflows: N8nWorkflow[] + last_updated: string +} +``` + +**UI (página N8nMonitor.tsx):** +- Stats cards: total/activos/falhas 24h +- Tabela de workflows: nome, activo, último run (com cor: verde/vermelho), duração +- Filtro: activos/todos +- Alerta visual se algum workflow falhou nas últimas 24h + +**Env vars necessárias:** +- `N8N_API_URL` (default: `https://automator.descomplicar.pt/api/v1`) +- `N8N_API_KEY` + +--- + +### 4.3 Painel Paperclip (nova página `/paperclip`) + +**Objectivo:** Ver os 16 agentes operacionais, routines, e issues. + +**Rota API:** `GET /api/paperclip` + +**Fonte de dados:** +- PostgreSQL do Paperclip: `clip.descomplicar.pt:54329` (credenciais em env) +- Tabelas: `agents`, `agent_runs`, `routines`, `routine_executions`, `issues` + +**Dados retornados:** +```typescript +interface PaperclipAgent { + id: string + name: string + role: string // "CEO", "CTO", "Director", "Specialist" + status: 'active' | 'idle' | 'error' | 'archived' + last_heartbeat: string | null + last_run: string | null + total_runs: number +} + +interface PaperclipRoutine { + id: string + name: string + cron: string + active: boolean + last_run: string | null + last_status: 'success' | 'error' | null + next_run: string | null +} + +interface PaperclipDashboard { + agents: { + total: number + active: number + idle: number + error: number + list: PaperclipAgent[] + } + routines: { + total: number + active: number + list: PaperclipRoutine[] + } + issues: { + open: number + in_progress: number + closed_7d: number + } +} +``` + +**UI (página Paperclip.tsx):** +- Stats cards: agentes activos/idle/error, routines activas +- Grid de cards para agentes (agrupados por role/nível hierárquico) +- Tabela de routines com cron, último/próximo run +- Contador de issues (open/in-progress/closed) + +**Env vars necessárias:** +- `PAPERCLIP_DB_HOST` (default: clip.descomplicar.pt) +- `PAPERCLIP_DB_PORT` (default: 54329) +- `PAPERCLIP_DB_NAME` +- `PAPERCLIP_DB_USER` +- `PAPERCLIP_DB_PASS` + +**Dependência adicional:** `pg` (PostgreSQL client para Node.js) — **única nova dependência no projecto** + +--- + +### 4.4 Painel Claude Code / IA (nova página `/ai`) + +**Objectivo:** Ver skills top, agents, MCPs activos, e CARL. + +**Rota API:** `GET /api/ai` + +**Fonte de dados:** +- Contagens estáticas derivadas do STK-Estado-Actual.md (actualizar periodicamente) +- CARL config: leitura de `/media/ealmeida/Dados/.carl/carl.json` (se acessível via EasyPanel volume mount, senão estático) +- Hooks: contagem de ficheiros em `~/.claude/hooks/` + +**Dados retornados:** +```typescript +interface AiDashboard { + skills: { + total: number // 189 + directas: number // 31 + plugins: number // 158 + top_10: string[] // nomes das 10 mais usadas + } + agents: { + total: number // 72 + directos: number // 18 + plugins: number // 54 + } + mcps: { + total: number // 39 + enabled: number // 10 + gateway: number // 33 + local: number // 2 + } + hooks: { + total_files: number // 26 + active: number // 9 + } + carl: { + domains: number // 7 + rules: number // ~45 + decisions: number + } + plugins: { + total: number // 14 descomplicar + 6 oficiais + 3 terceiros + active: number // 6 + } +} +``` + +**UI (página AiOverview.tsx):** +- Cards grandes com métricas (skills, agents, MCPs, hooks, plugins) +- Secção CARL: domínios com contagem de regras +- Sem interactividade complexa — é um painel informativo/snapshot + +**Nota:** Este painel é maioritariamente estático. Os dados mudam raramente (quando se adicionam skills/agents). Actualização via endpoint manual ou ficheiro JSON servido estáticamente. + +--- + +### 4.5 Painel Operações (nova página `/operations`) + +**Objectivo:** Visão operacional — tickets, SLAs, procedimentos. + +**Rota API:** `GET /api/operations` + +**Fonte de dados:** +- MySQL (Desk CRM): tickets abertos, por prioridade, SLAs +- Contagens estáticas: PROCs (48), departamentos (7) + +**Dados retornados:** +```typescript +interface OperationsDashboard { + tickets: { + open: number + high_priority: number + avg_response_hours: number + by_department: { dept: string; count: number }[] + } + procedures: { + total: number // 48 + departments: number // 7 (D1-D7) + coverage: { dept: string; procs: number; pct: number }[] + } +} +``` + +**UI (página Operations.tsx):** +- Cards: tickets abertos, alta prioridade, tempo médio resposta +- Gráfico barras: tickets por departamento +- Tabela cobertura PROCs por departamento + +--- + +## 5. Alterações ao código existente + +### 5.1 Layout.tsx — adicionar itens de navegação + +```typescript +const NAV_ITEMS: NavItem[] = [ + { to: '/', label: 'Dashboard', icon: LayoutDashboard }, + { to: '/monitor', label: 'Monitor', icon: Activity }, + { to: '/financial', label: 'Financeiro', icon: CreditCard }, + // NOVOS: + { to: '/mcps', label: 'MCPs', icon: Network }, + { to: '/n8n', label: 'Automações', icon: Workflow }, + { to: '/paperclip', label: 'Paperclip', icon: Bot }, + { to: '/ai', label: 'IA / Claude', icon: Brain }, + { to: '/operations', label: 'Operações', icon: ClipboardList }, +] +``` + +### 5.2 App.tsx — adicionar rotas + +Novas rotas no React Router para cada página. + +### 5.3 server.ts — registar novas rotas API + +```typescript +import mcpsRouter from './routes/mcps.js' +import n8nRouter from './routes/n8n.js' +import paperclipRouter from './routes/paperclip.js' +import aiRouter from './routes/ai.js' +import operationsRouter from './routes/operations.js' + +app.use('/api/mcps', mcpsRouter) +app.use('/api/n8n', n8nRouter) +app.use('/api/paperclip', paperclipRouter) +app.use('/api/ai', aiRouter) +app.use('/api/operations', operationsRouter) +``` + +--- + +## 6. Ficheiros a criar (por sprint) + +### Sprint 1 — MCPs + n8n (estimativa: 8-10h) +``` +api/routes/mcps.ts # Rota GET /api/mcps +api/services/mcps.ts # Ping gateway, lista MCPs, cache +api/routes/n8n.ts # Rota GET /api/n8n +api/services/n8n.ts # Chamadas API n8n +src/pages/McpMonitor.tsx # Página frontend MCPs +src/pages/N8nMonitor.tsx # Página frontend n8n +``` + +### Sprint 2 — Paperclip (estimativa: 6-8h) +``` +api/services/paperclip-db.ts # Conexão PostgreSQL Paperclip +api/routes/paperclip.ts # Rota GET /api/paperclip +api/services/paperclip.ts # Queries aos agentes/routines/issues +src/pages/Paperclip.tsx # Página frontend Paperclip +``` + +### Sprint 3 — IA + Operações (estimativa: 4-6h) +``` +api/routes/ai.ts # Rota GET /api/ai +api/services/ai.ts # Dados estáticos/contagens IA +api/routes/operations.ts # Rota GET /api/operations +api/services/operations.ts # Queries tickets + PROCs +src/pages/AiOverview.tsx # Página frontend IA +src/pages/Operations.tsx # Página frontend Operações +``` + +### Sprint 4 — Integração + polish (estimativa: 2-3h) +``` +# Alterações a ficheiros existentes: +src/components/Layout.tsx # Adicionar 5 itens nav +src/App.tsx # Adicionar 5 rotas (no routing section) +api/server.ts # Registar 5 routers novos +.env.example # Novas env vars documentadas +README.md # Actualizar documentação +``` + +--- + +## 7. Env vars novas necessárias + +```env +# MCPs Gateway +MCP_GATEWAY_URL=https://gateway.descomplicar.pt +MCP_GATEWAY_TOKEN= + +# n8n +N8N_API_URL=https://automator.descomplicar.pt/api/v1 +N8N_API_KEY= + +# Paperclip PostgreSQL +PAPERCLIP_DB_HOST=clip.descomplicar.pt +PAPERCLIP_DB_PORT=54329 +PAPERCLIP_DB_NAME=paperclip +PAPERCLIP_DB_USER= +PAPERCLIP_DB_PASS= +``` + +--- + +## 8. Dependências + +| Pacote | Razão | Sprint | +|--------|-------|--------| +| `pg` | PostgreSQL client (Paperclip BD) | Sprint 2 | + +Todas as outras dependências já existem no projecto (recharts, framer-motion, lucide-react, express, mysql2, zod). + +--- + +## 9. Padrões a seguir (consistência) + +- **Backend:** Router Express separado por domínio. Service file com queries. Async/await com try/catch. +- **Frontend:** Página como componente único com useState/useEffect/useCallback. motion.div com containerVariants/itemVariants. Cards com gradientes e lucide icons. +- **Estilo:** Dark theme (bg-zinc-950, borders white/10, text zinc-400/white). Gradientes brand-500/violet-600. Rounded-2xl nos cards. +- **Error handling:** try/catch no backend → 500 JSON. Frontend → estado de loading/error com retry. +- **Cache:** Backend cache simples com timestamp (60s para MCPs, 300s para n8n). + +--- + +## 10. Riscos e mitigações + +| Risco | Mitigação | +|-------|-----------| +| n8n API key expirar | Documentar no .env.example, alertar na UI | +| Paperclip BD inacessível do EasyPanel | Verificar rede Docker Swarm, fallback com dados estáticos | +| Gateway health check lento (33 MCPs) | Promise.allSettled com timeout 5s + cache 60s | +| Página MCPs demasiado pesada | Ping apenas MCPs enabled (10), disabled mostrados como cinza sem ping | + +--- + +## 11. Critérios de aceitação + +- [ ] Todas as 8 páginas carregam sem erros +- [ ] Cada API retorna dados reais (não mocks) +- [ ] Navegação sidebar mostra todas as 8 páginas +- [ ] Mobile responsive (sidebar colapsável funciona com 8 itens) +- [ ] Build de produção compila sem erros (`npm run build`) +- [ ] Zero vulnerabilidades de segurança (`npm audit`) + +--- + +## 12. Fora de escopo + +- Auth OIDC em produção (já implementado, activar separadamente) +- LightRAG painel visual com grafo D3.js (fase futura, complexidade alta) +- Painel Desk CRM expandido com Kanban (já tem dados no Dashboard principal) +- Botões de acção (enable/disable MCP, restart workflow) — apenas visualização +- Testes unitários (existem mas não são prioritários para esta expansão) diff --git a/package-lock.json b/package-lock.json index 21ec94f..1b7b223 100755 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "dash-descomplicar", "version": "1.0.0", "dependencies": { + "@types/pg": "^8.20.0", "clsx": "^2.1.1", "cors": "^2.8.5", "dotenv": "^16.6.1", @@ -19,6 +20,7 @@ "lucide-react": "^0.563.0", "mysql2": "^3.11.5", "oidc-client-ts": "^3.0.1", + "pg": "^8.20.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-oidc-context": "^3.1.1", @@ -2372,6 +2374,17 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", @@ -2658,9 +2671,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "dev": true, "license": "MIT", "dependencies": { @@ -3278,9 +3291,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -4964,9 +4977,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -7288,9 +7301,9 @@ } }, "node_modules/path-to-regexp": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", - "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, "node_modules/pathe": { @@ -7300,6 +7313,95 @@ "dev": true, "license": "MIT" }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -7308,9 +7410,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -7365,6 +7467,45 @@ "dev": true, "license": "MIT" }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -8193,6 +8334,15 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sqlstring": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", @@ -8736,9 +8886,9 @@ } }, "node_modules/undici": { - "version": "7.22.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", - "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz", + "integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==", "dev": true, "license": "MIT", "engines": { @@ -8885,9 +9035,9 @@ } }, "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "dev": true, "license": "MIT", "dependencies": { @@ -9245,6 +9395,15 @@ "dev": true, "license": "MIT" }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 2b86d83..9a5a91a 100755 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "test:coverage": "vitest --coverage" }, "dependencies": { + "@types/pg": "^8.20.0", "clsx": "^2.1.1", "cors": "^2.8.5", "dotenv": "^16.6.1", @@ -27,6 +28,7 @@ "lucide-react": "^0.563.0", "mysql2": "^3.11.5", "oidc-client-ts": "^3.0.1", + "pg": "^8.20.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-oidc-context": "^3.1.1", diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 5303d50..88e80ad 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -5,6 +5,11 @@ import { LayoutDashboard, Activity, CreditCard, + Network, + GitBranch, + Bot, + Brain, + ClipboardList, Zap, ChevronLeft, ChevronRight, @@ -25,6 +30,11 @@ const NAV_ITEMS: NavItem[] = [ { to: '/', label: 'Dashboard', icon: LayoutDashboard }, { to: '/monitor', label: 'Monitor', icon: Activity }, { to: '/financial', label: 'Financeiro', icon: CreditCard }, + { to: '/mcps', label: 'MCPs', icon: Network }, + { to: '/n8n', label: 'Automações', icon: GitBranch }, + { to: '/paperclip', label: 'Paperclip', icon: Bot }, + { to: '/ai', label: 'IA / Claude', icon: Brain }, + { to: '/operations', label: 'Operações', icon: ClipboardList }, ] function useIsMobile() { diff --git a/src/main.tsx b/src/main.tsx index ceab3e4..4483a20 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -6,6 +6,11 @@ import './index.css' import App from './App.tsx' import Monitor from './pages/Monitor.tsx' import Financial from './pages/Financial.tsx' +import McpMonitor from './pages/McpMonitor.tsx' +import N8nMonitor from './pages/N8nMonitor.tsx' +import Paperclip from './pages/Paperclip.tsx' +import AiOverview from './pages/AiOverview.tsx' +import Operations from './pages/Operations.tsx' import Layout from './components/Layout.tsx' import { oidcConfig } from './auth/config.ts' import { AuthWrapper } from './auth/AuthWrapper.tsx' @@ -20,6 +25,11 @@ createRoot(document.getElementById('root')!).render( } /> } /> } /> + } /> + } /> + } /> + } /> + } /> } /> diff --git a/src/pages/AiOverview.tsx b/src/pages/AiOverview.tsx new file mode 100644 index 0000000..0171330 --- /dev/null +++ b/src/pages/AiOverview.tsx @@ -0,0 +1,451 @@ +/** + * AiOverview — Painel Stack IA (3 Camadas de Execução) + * Rota: /ai + * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 + */ +import { useState, useEffect, useCallback } from 'react' +import { motion } from 'framer-motion' +import { + Brain, + Cpu, + Bot, + Zap, + Shield, + Database, + BookOpen, + Layers, + RefreshCw, + CheckCircle2, + AlertTriangle, + Circle, +} from 'lucide-react' + +// --- Types --- + +interface AiLayerItem { + metric: string + value: number + detail: string +} + +interface AiLayer { + name: string + label: string + items: AiLayerItem[] +} + +interface TransversalSystem { + name: string + status: 'active' | 'warning' | 'inactive' + detail: string +} + +interface CarlConfig { + domains: string[] + total_rules: number +} + +interface AiDashboard { + layers: AiLayer[] + transversal: TransversalSystem[] + carl: CarlConfig + notebooks: number + last_updated: string +} + +// --- Animation variants --- + +const containerVariants = { + hidden: { opacity: 0 }, + show: { opacity: 1, transition: { staggerChildren: 0.06 } }, +} + +const itemVariants = { + hidden: { opacity: 0, y: 20 }, + show: { + opacity: 1, + y: 0, + transition: { type: 'spring' as const, stiffness: 300, damping: 30 }, + }, +} + +// --- Configuração visual por camada --- + +interface LayerConfig { + gradient: string + border: string + badge: string + valueCls: string + glow: string + icon: React.ElementType +} + +const LAYER_CONFIG: Record = { + 'Claude Code': { + gradient: 'from-violet-500/15 to-purple-500/10', + border: 'border-violet-500/25', + badge: 'bg-violet-500/20 text-violet-300 border border-violet-500/30', + valueCls: 'text-violet-300', + glow: 'shadow-[0_0_20px_rgba(139,92,246,0.15)]', + icon: Brain, + }, + 'n8n': { + gradient: 'from-amber-500/15 to-orange-500/10', + border: 'border-amber-500/25', + badge: 'bg-amber-500/20 text-amber-300 border border-amber-500/30', + valueCls: 'text-amber-300', + glow: 'shadow-[0_0_20px_rgba(245,158,11,0.15)]', + icon: Zap, + }, + 'Paperclip': { + gradient: 'from-cyan-500/15 to-teal-500/10', + border: 'border-cyan-500/25', + badge: 'bg-cyan-500/20 text-cyan-300 border border-cyan-500/30', + valueCls: 'text-cyan-300', + glow: 'shadow-[0_0_20px_rgba(6,182,212,0.15)]', + icon: Bot, + }, +} + +// --- Sub-components --- + +const MetricCard = ({ + item, + valueCls, +}: { + item: AiLayerItem + valueCls: string +}) => ( + + + {item.metric} + + {item.value} + {item.detail} + +) + +const LayerSection = ({ layer }: { layer: AiLayer }) => { + const cfg = LAYER_CONFIG[layer.name] ?? LAYER_CONFIG['Claude Code'] + const Icon = cfg.icon + + return ( + + {/* Cabeçalho da camada */} +
+
+
+ +
+
+

+ {layer.name} +

+

{layer.label}

+
+
+
+ + {/* Cards de métricas */} +
+ + {layer.items.map((item) => ( + + ))} + +
+
+ ) +} + +const TransversalStatusIcon = ({ status }: { status: string }) => { + if (status === 'active') { + return + } + if (status === 'warning') { + return + } + return +} + +const TransversalCard = ({ sys }: { sys: TransversalSystem }) => { + const borderCls = + sys.status === 'active' + ? 'border-emerald-500/20' + : sys.status === 'warning' + ? 'border-amber-500/20' + : 'border-white/8' + + return ( + + +
+

{sys.name}

+

{sys.detail}

+
+
+ ) +} + +const DomainPill = ({ name }: { name: string }) => ( + + {name} + +) + +// --- Página principal --- + +export default function AiOverview() { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [refreshing, setRefreshing] = useState(false) + + const fetchData = useCallback(async () => { + setRefreshing(true) + try { + const response = await fetch('/api/ai') + if (!response.ok) throw new Error('Failed to fetch /api/ai') + const json = await response.json() + setData(json) + } catch { + console.error('Falha ao carregar dados do stack IA') + } finally { + setLoading(false) + setRefreshing(false) + } + }, []) + + useEffect(() => { fetchData() }, [fetchData]) + + const formatDate = (iso: string) => { + // Converter YYYY-MM-DD para DD-MM-YYYY + const [y, m, d] = iso.split('-') + return `${d}-${m}-${y}` + } + + if (loading) { + return ( +
+
+
+

A carregar stack IA...

+
+
+ ) + } + + if (!data) { + return ( +
+

Não foi possível carregar os dados.

+
+ ) + } + + return ( +
+ + {/* Cabeçalho */} + +
+
+ +
+
+

Stack IA — 3 Camadas de Execução

+

+ Inventário completo do sistema IA Descomplicar® +

+
+
+ +
+ + Última actualização: {formatDate(data.last_updated)} + + +
+
+ + {/* Sumário global — pill badges */} + + + {data.notebooks} + Notebooks NotebookLM + + + {data.carl.total_rules} + Regras CARL + + + {data.carl.domains.length} + Domínios contextuais + + + + {/* 3 Camadas */} + + {data.layers.map((layer) => ( + + ))} + + + {/* Sistemas Transversais */} +
+ + +

+ Sistemas Transversais +

+ + {data.transversal.length} sistemas + +
+ + + {data.transversal.map((sys) => ( + + ))} + +
+ + {/* CARL — domínios contextuais */} +
+ + +

+ CARL — Contexto Adaptativo +

+
+ + +
+ {/* Métricas CARL */} +
+
+

{data.carl.domains.length}

+

Domínios

+
+
+
+

{data.carl.total_rules}

+

Regras

+
+
+ + {/* Divisor */} +
+ + {/* Domínios como pills */} + + {data.carl.domains.map((domain) => ( + + ))} + +
+ +
+ + {/* Knowledge Notebooks */} +
+ + +

+ Knowledge Base +

+
+ + + +
+

{data.notebooks}

+

+ Notebooks NotebookLM activos — fonte de conhecimento para todos os agentes +

+
+
+
+ +
+ ) +} diff --git a/src/pages/McpMonitor.tsx b/src/pages/McpMonitor.tsx new file mode 100644 index 0000000..dcbac17 --- /dev/null +++ b/src/pages/McpMonitor.tsx @@ -0,0 +1,361 @@ +/** + * McpMonitor — Painel de estado dos MCPs + * Visualização em tempo real de todos os MCPs via gateway + * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 + */ +import { useState, useEffect, useCallback } from 'react' +import { motion } from 'framer-motion' +import { + Network, + Wifi, + WifiOff, + RefreshCw, + Server, + CheckCircle2, + XCircle, + Clock, +} from 'lucide-react' + +// --- Types --- + +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 +} + +interface McpDashboard { + gateway_status: 'online' | 'offline' + total: number + online: number + offline: number + disabled: number + mcps: McpStatus[] + auth: { + method: string + token_expires: string | null + } +} + +// Mapeamento de categorias para etiquetas legíveis +const CATEGORY_LABELS: Record = { + ai: 'Inteligência Artificial', + crm: 'CRM', + external: 'Externos', + gateway: 'Gateway', + infra: 'Infraestrutura', + project: 'Projecto', + tools: 'Ferramentas', +} + +// Ordem de apresentação das categorias +const CATEGORY_ORDER = ['crm', 'infra', 'ai', 'tools', 'external', 'gateway', 'project'] + +// --- Animation variants --- + +const containerVariants = { + hidden: { opacity: 0 }, + show: { + opacity: 1, + transition: { staggerChildren: 0.06 }, + }, +} + +const itemVariants = { + hidden: { opacity: 0, y: 20 }, + show: { + opacity: 1, + y: 0, + transition: { type: 'spring' as const, stiffness: 300, damping: 30 }, + }, +} + +// --- Sub-components --- + +const StatusIndicator = ({ status }: { status: McpStatus['status'] }) => { + if (status === 'online') { + return ( +
+
+ online +
+ ) + } + if (status === 'offline') { + return ( +
+
+ offline +
+ ) + } + return ( +
+
+ disabled +
+ ) +} + +const McpCard = ({ mcp }: { mcp: McpStatus }) => { + const isDisabled = mcp.status === 'disabled' + const isOffline = mcp.status === 'offline' + + return ( + +
+
+ + + {mcp.name} + +
+ +
+ +
+ {mcp.port > 0 ? ( + :{mcp.port} + ) : ( + local + )} + + {mcp.response_time_ms !== null && mcp.status === 'online' ? ( +
+ + {mcp.response_time_ms}ms +
+ ) : null} +
+
+ ) +} + +const StatCard = ({ + label, + value, + icon: Icon, + gradient, + textColor, +}: { + label: string + value: number | string + icon: React.ElementType + gradient: string + textColor: string +}) => ( + +
+ {label} +
+ +
+
+
{value}
+
+) + +// --- Main Component --- + +export default function McpMonitor() { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [refreshing, setRefreshing] = useState(false) + + const fetchData = useCallback(async () => { + setRefreshing(true) + setError(null) + try { + const res = await fetch('/api/mcps') + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const json: McpDashboard = await res.json() + setData(json) + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Erro desconhecido' + setError(`Não foi possível carregar os dados: ${msg}`) + } finally { + setLoading(false) + setRefreshing(false) + } + }, []) + + useEffect(() => { + fetchData() + const interval = setInterval(fetchData, 60_000) + return () => clearInterval(interval) + }, [fetchData]) + + // --- Loading state --- + if (loading) { + return ( +
+ + +

A verificar MCPs...

+
+
+ ) + } + + // --- Error state --- + if (error) { + return ( +
+ + +

Erro ao carregar

+

{error}

+ + Tentar novamente + +
+
+ ) + } + + if (!data) return null + + // Agrupar MCPs por categoria na ordem definida + const grouped: Record = {} + for (const cat of CATEGORY_ORDER) { + const items = data.mcps.filter(m => m.category === cat) + if (items.length > 0) grouped[cat] = items + } + // Categorias não previstas na ordem + for (const mcp of data.mcps) { + if (!CATEGORY_ORDER.includes(mcp.category)) { + if (!grouped[mcp.category]) grouped[mcp.category] = [] + grouped[mcp.category].push(mcp) + } + } + + return ( +
+ + {/* Header */} +
+
+

MCPs

+

+ Gateway:{' '} + + {data.gateway_status} + + {' '}— gateway.descomplicar.pt +

+
+ + + +
+ + + + {/* Stat cards */} +
+ + + 0 ? 'text-red-400' : 'text-zinc-500'} + /> + +
+ + {/* Grid por categoria */} + {Object.entries(grouped).map(([category, mcps]) => { + const onlineCount = mcps.filter(m => m.status === 'online').length + const offlineCount = mcps.filter(m => m.status === 'offline').length + const label = CATEGORY_LABELS[category] ?? category + + return ( + + {/* Cabeçalho de categoria */} +
+

+ {label} +

+
+ {onlineCount > 0 && ( + {onlineCount} online + )} + {offlineCount > 0 && ( + {offlineCount} offline + )} + {mcps.length} total +
+
+
+ + {/* Cards da categoria */} +
+ {mcps.map(mcp => ( + + ))} +
+ + ) + })} + + +
+ ) +} diff --git a/src/pages/N8nMonitor.tsx b/src/pages/N8nMonitor.tsx new file mode 100644 index 0000000..d07ff24 --- /dev/null +++ b/src/pages/N8nMonitor.tsx @@ -0,0 +1,381 @@ +import { useState, useEffect, useCallback } from 'react' +import { motion } from 'framer-motion' +import { + GitBranch, + Play, + Pause, + CheckCircle2, + XCircle, + RefreshCw, + Clock, + AlertTriangle, +} from 'lucide-react' + +// --- Tipos --- + +interface N8nLastExecution { + status: 'success' | 'error' | 'running' | null + started_at: string | null + finished_at: string | null + duration_ms: number | null +} + +interface N8nWorkflow { + id: string + name: string + active: boolean + last_execution: N8nLastExecution | null + tags: string[] +} + +interface N8nDashboard { + total: number + active: number + inactive: number + failed_24h: number + workflows: N8nWorkflow[] + last_updated: string +} + +// --- Animações --- + +const containerVariants = { + hidden: { opacity: 0 }, + show: { opacity: 1, transition: { staggerChildren: 0.05 } }, +} + +const itemVariants = { + hidden: { opacity: 0, y: 20 }, + show: { opacity: 1, y: 0 }, +} + +// --- Utilitários --- + +function formatDuration(ms: number | null): string { + if (ms === null) return '—' + if (ms < 1000) return `${ms}ms` + if (ms < 60000) return `${(ms / 1000).toFixed(1)}s` + const min = Math.floor(ms / 60000) + const sec = Math.round((ms % 60000) / 1000) + return `${min}m ${sec}s` +} + +function formatDate(iso: string | null): string { + if (!iso) return '—' + const d = new Date(iso) + if (isNaN(d.getTime())) return '—' + return d.toLocaleString('pt-PT', { + day: '2-digit', + month: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) +} + +function formatLastUpdated(iso: string): string { + const d = new Date(iso) + if (isNaN(d.getTime())) return '' + return `Actualizado: ${d.toLocaleTimeString('pt-PT', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}` +} + +// --- Sub-componentes --- + +const StatCard = ({ + label, + value, + icon: Icon, + colorClass, + highlight, +}: { + label: string + value: number + icon: React.ElementType + colorClass: string + highlight?: boolean +}) => ( + 0 ? 'border border-red-500/40' : ''}`} + > +
+ {label} +
+ +
+
+
0 ? 'text-red-400' : 'text-white'}`}> + {value} +
+
+) + +function StatusBadge({ status }: { status: N8nLastExecution['status'] }) { + if (status === 'success') + return ( + + + Sucesso + + ) + if (status === 'error') + return ( + + + Erro + + ) + if (status === 'running') + return ( + + + A correr + + ) + return +} + +function ActiveBadge({ active }: { active: boolean }) { + return active ? ( + + + Activo + + ) : ( + + + Inactivo + + ) +} + +// --- Página principal --- + +export default function N8nMonitor() { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [refreshing, setRefreshing] = useState(false) + const [showOnlyActive, setShowOnlyActive] = useState(false) + + const fetchData = useCallback(async () => { + setRefreshing(true) + setError(null) + try { + const response = await fetch('/api/n8n') + if (!response.ok) { + const json = await response.json().catch(() => ({})) + throw new Error(json.error || `Erro ${response.status}`) + } + const json: N8nDashboard = await response.json() + setData(json) + } catch (err) { + setError(err instanceof Error ? err.message : 'Erro ao carregar dados do n8n') + } finally { + setLoading(false) + setRefreshing(false) + } + }, []) + + useEffect(() => { + fetchData() + }, [fetchData]) + + // --- Loading --- + if (loading) { + return ( +
+ + +

A carregar workflows n8n...

+
+
+ ) + } + + // --- Erro --- + if (error) { + return ( +
+ + +

Erro ao carregar dados

+

{error}

+ + Tentar novamente + +
+
+ ) + } + + if (!data) return null + + const visibleWorkflows = showOnlyActive + ? data.workflows.filter((w) => w.active) + : data.workflows + + return ( +
+ {/* Header */} +
+
+

Automações n8n

+

+ {data.last_updated ? formatLastUpdated(data.last_updated) : 'Workflows operacionais'} +

+
+
+ {/* Toggle activos/todos */} + + {/* Refresh */} + + + +
+
+ + + {/* Alerta falhas 24h */} + {data.failed_24h > 0 && ( + + +

+ {data.failed_24h} execução{data.failed_24h !== 1 ? 'ões' : ''} falharam{' '} + nas últimas 24 horas. Verifique os workflows em erro. +

+
+ )} + + {/* Stats Cards */} +
+ + + 0 ? 'bg-red-500/20' : 'bg-zinc-700/40'} + highlight + /> +
+ + {/* Tabela de Workflows */} + +
+

+ + Workflows +

+ {visibleWorkflows.length} workflow{visibleWorkflows.length !== 1 ? 's' : ''} +
+ + {visibleWorkflows.length === 0 ? ( +
+ +

Nenhum workflow encontrado

+
+ ) : ( +
+ + + + + + + + + + + + {visibleWorkflows.map((wf) => ( + + {/* Nome */} + + {/* Activo */} + + {/* Data do último run */} + + {/* Status */} + + {/* Duração */} + + + ))} + +
+ Nome + + Estado + + Último Run + + Resultado + + Duração +
+
+ + + {wf.name} + +
+
+ + + + {formatDate(wf.last_execution?.started_at ?? null)} + + + + + + {formatDuration(wf.last_execution?.duration_ms ?? null)} + +
+
+ )} +
+
+
+ ) +} diff --git a/src/pages/Operations.tsx b/src/pages/Operations.tsx new file mode 100644 index 0000000..5d04996 --- /dev/null +++ b/src/pages/Operations.tsx @@ -0,0 +1,322 @@ +/** + * Página Operações — Tickets e Cobertura de PROCs + * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 + */ +import { useState, useEffect, useCallback } from 'react' +import { motion } from 'framer-motion' +import { + BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, +} from 'recharts' +import { + Ticket, + AlertTriangle, + Clock, + FileText, + Building2, + RefreshCw, +} from 'lucide-react' + +// --------------------------------------------------------------------------- +// Tipos +// --------------------------------------------------------------------------- + +interface TicketsByDept { + dept: string + count: number +} + +interface CoverageItem { + dept: string + procs: number + total_expected: number + pct: number +} + +interface OperationsData { + tickets: { + open: number + high_priority: number + avg_response_hours: number + by_department: TicketsByDept[] + } + procedures: { + total: number + departments: number + coverage: CoverageItem[] + } +} + +// --------------------------------------------------------------------------- +// Animações +// --------------------------------------------------------------------------- + +const containerVariants = { + hidden: { opacity: 0 }, + show: { opacity: 1, transition: { staggerChildren: 0.05 } }, +} + +const itemVariants = { + hidden: { opacity: 0, y: 20 }, + show: { opacity: 1, y: 0 }, +} + +// --------------------------------------------------------------------------- +// Sub-componentes +// --------------------------------------------------------------------------- + +const StatCard = ({ + label, value, icon: Icon, color, sub, +}: { + label: string + value: string + icon: React.ElementType + color: string + sub?: string +}) => ( + +
+ {label} +
+ +
+
+
{value}
+ {sub &&
{sub}
} +
+) + +const CustomTooltip = ({ active, payload, label }: { + active?: boolean + payload?: { color: string; name: string; value: number }[] + label?: string +}) => { + if (!active || !payload?.length) return null + return ( +
+

{label}

+ {payload.map((p, i) => ( +

+ {p.name}: {p.value} +

+ ))} +
+ ) +} + +// Barra de progresso para cobertura de PROCs +const ProgressBar = ({ pct }: { pct: number }) => { + const color = + pct >= 80 ? 'bg-emerald-500' : + pct >= 50 ? 'bg-amber-500' : + 'bg-red-500' + + return ( +
+
+
+ ) +} + +// --------------------------------------------------------------------------- +// Página principal +// --------------------------------------------------------------------------- + +export default function Operations() { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [refreshing, setRefreshing] = useState(false) + + const fetchData = useCallback(async () => { + setRefreshing(true) + try { + const response = await fetch('/api/operations') + if (!response.ok) throw new Error('Resposta inválida') + const json: OperationsData = await response.json() + setData(json) + } catch (err) { + console.error('[Operations] Erro ao carregar dados:', err) + } finally { + setLoading(false) + setRefreshing(false) + } + }, []) + + useEffect(() => { fetchData() }, [fetchData]) + + // Estado de carregamento + if (loading) { + return ( +
+ + +

A carregar dados de operações...

+
+
+ ) + } + + if (!data) return null + + // Cor do card de alta prioridade: vermelho se existirem tickets críticos + const priorityColor = data.tickets.high_priority > 0 + ? 'bg-red-500/20' + : 'bg-zinc-700/40' + + return ( +
+ {/* Cabeçalho */} +
+
+

Operações

+

Tickets de Suporte e Cobertura de Procedimentos

+
+ + + +
+ + + {/* Cards de resumo */} +
+ + 0 ? 'Requerem atenção imediata' : 'Sem tickets críticos'} + /> + +
+ + {/* Gráfico de barras horizontal + Resumo de PROCs */} +
+ {/* Tickets por departamento (barra horizontal) */} + +

+ + Tickets por Departamento +

+
+ + + + + + } /> + + + +
+
+ + {/* Resumo de PROCs */} + +

+ + Procedimentos +

+
+
+ Total de PROCs + {data.procedures.total} +
+
+ Departamentos cobertos + {data.procedures.departments} +
+
+ Cobertura global + + {Math.round( + (data.procedures.coverage.reduce((s, d) => s + d.procs, 0) / + data.procedures.coverage.reduce((s, d) => s + d.total_expected, 0)) * 100 + )}% + +
+
+
+
+ + {/* Tabela de cobertura por departamento */} + +

+ + Cobertura de PROCs por Departamento +

+
+ + + + + + + + + + + + {data.procedures.coverage.map((item, i) => ( + + + + + + + + ))} + +
DepartamentoPROCsEsperadosCobertura%
{item.dept}{item.procs}{item.total_expected} + + = 80 ? '#34d399' : item.pct >= 50 ? '#fbbf24' : '#f87171', + }} + > + {item.pct}% +
+
+
+
+
+ ) +} diff --git a/src/pages/Paperclip.tsx b/src/pages/Paperclip.tsx new file mode 100644 index 0000000..9f641e8 --- /dev/null +++ b/src/pages/Paperclip.tsx @@ -0,0 +1,545 @@ +/** + * Página Paperclip — Painel de agentes, routines e issues + * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 + */ +import { useState, useEffect, useCallback } from 'react' +import { motion } from 'framer-motion' +import { + Bot, + Users, + Calendar, + CheckCircle2, + AlertTriangle, + Clock, + RefreshCw, + Activity, + Zap, +} from 'lucide-react' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface PaperclipAgent { + id: string + name: string + role: string + status: 'active' | 'idle' | 'error' | 'archived' + last_heartbeat: string | null + last_run: string | null + total_runs: number +} + +interface PaperclipRoutine { + id: string + name: string + cron: string + active: boolean + last_run: string | null + last_status: 'success' | 'error' | null + next_run: string | null +} + +interface PaperclipDashboard { + agents: { + total: number + active: number + idle: number + error: number + list: PaperclipAgent[] + } + routines: { + total: number + active: number + list: PaperclipRoutine[] + } + issues: { + open: number + in_progress: number + closed_7d: number + } +} + +// --------------------------------------------------------------------------- +// Animation variants (consistente com Monitor.tsx) +// --------------------------------------------------------------------------- + +const containerVariants = { + hidden: { opacity: 0 }, + show: { + opacity: 1, + transition: { staggerChildren: 0.06 }, + }, +} + +const itemVariants = { + hidden: { opacity: 0, y: 20 }, + show: { + opacity: 1, + y: 0, + transition: { type: 'spring' as const, stiffness: 300, damping: 30 }, + }, +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Ordem de prioridade de roles para agrupamento hierárquico. + * Roles desconhecidos ficam no fim. + */ +const ROLE_ORDER: Record = { + Board: 0, + CEO: 1, + 'C-Level': 2, + COO: 2, + CTO: 2, + CFO: 2, + CMO: 2, + Director: 3, + Specialist: 4, +} + +function roleOrder(role: string): number { + return ROLE_ORDER[role] ?? 99 +} + +/** Agrupa a lista de agentes por role, ordenando roles hierarchicamente. */ +function groupAgentsByRole(agents: PaperclipAgent[]): Record { + const groups: Record = {} + for (const agent of agents) { + if (!groups[agent.role]) groups[agent.role] = [] + groups[agent.role].push(agent) + } + return groups +} + +/** Ordena as keys de um objecto de grupos pelo ranking hierárquico. */ +function sortedRoleKeys(groups: Record): string[] { + return Object.keys(groups).sort((a, b) => roleOrder(a) - roleOrder(b)) +} + +/** Formata uma string ISO ou null para data/hora legível PT. */ +function formatDate(iso: string | null): string { + if (!iso) return '—' + try { + return new Date(iso).toLocaleString('pt-PT', { + day: '2-digit', + month: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) + } catch { + return iso + } +} + +// --------------------------------------------------------------------------- +// Sub-components +// --------------------------------------------------------------------------- + +/** Dot colorido de estado (verde/amarelo/vermelho). */ +const StatusDot = ({ status }: { status: string }) => { + const colour = + status === 'active' + ? 'bg-emerald-400 shadow-[0_0_10px_rgba(16,185,129,0.5)]' + : status === 'idle' + ? 'bg-amber-400 shadow-[0_0_10px_rgba(245,158,11,0.5)]' + : status === 'error' + ? 'bg-red-400 shadow-[0_0_10px_rgba(239,68,68,0.5)] animate-pulse' + : 'bg-zinc-600' + return +} + +/** Badge de role do agente. */ +const RoleBadge = ({ role }: { role: string }) => { + const styles: Record = { + Board: 'bg-violet-500/20 text-violet-300 border border-violet-500/30', + CEO: 'bg-cyan-500/20 text-cyan-300 border border-cyan-500/30', + 'C-Level': 'bg-blue-500/20 text-blue-300 border border-blue-500/30', + COO: 'bg-blue-500/20 text-blue-300 border border-blue-500/30', + CTO: 'bg-blue-500/20 text-blue-300 border border-blue-500/30', + CFO: 'bg-blue-500/20 text-blue-300 border border-blue-500/30', + CMO: 'bg-blue-500/20 text-blue-300 border border-blue-500/30', + Director: 'bg-sky-500/20 text-sky-300 border border-sky-500/30', + Specialist: 'bg-teal-500/20 text-teal-300 border border-teal-500/30', + } + const cls = styles[role] ?? 'bg-zinc-700/40 text-zinc-400 border border-zinc-600/30' + return ( + + {role} + + ) +} + +/** Card de estatística no topo. */ +const StatCard = ({ + label, + value, + icon: Icon, + accent, +}: { + label: string + value: number + icon: React.ElementType + accent: string +}) => ( + +
+ +
+
+

{value}

+

{label}

+
+
+) + +/** Card individual de agente. */ +const AgentCard = ({ agent }: { agent: PaperclipAgent }) => { + const statusLabel: Record = { + active: 'Activo', + idle: 'Inactivo', + error: 'Erro', + archived: 'Arquivado', + } + const statusColour: Record = { + active: 'text-emerald-400', + idle: 'text-amber-400', + error: 'text-red-400', + archived: 'text-zinc-500', + } + + return ( + + {/* Cabeçalho: ícone + nome + status dot */} +
+
+
+ +
+ {agent.name} +
+ +
+ + {/* Role badge + estado */} +
+ + + {statusLabel[agent.status] ?? agent.status} + +
+ + {/* Último heartbeat */} +
+ + + {agent.last_heartbeat ? formatDate(agent.last_heartbeat) : 'Sem heartbeat'} + +
+
+ ) +} + +/** Secção de agentes agrupados por role. */ +const AgentsSection = ({ agents }: { agents: PaperclipAgent[] }) => { + const groups = groupAgentsByRole(agents) + const roleKeys = sortedRoleKeys(groups) + + if (agents.length === 0) { + return ( + + +

Sem agentes disponíveis

+
+ ) + } + + return ( + <> + {roleKeys.map(role => ( + + {/* Separador de grupo */} +
+ +

{role}

+
+ {groups[role].length} +
+
+ {groups[role].map(agent => ( + + ))} +
+ + ))} + + ) +} + +/** Tabela de routines. */ +const RoutinesTable = ({ routines }: { routines: PaperclipRoutine[] }) => { + if (routines.length === 0) { + return ( + + +

Sem routines disponíveis

+
+ ) + } + + return ( + +
+ +

Routines

+ {routines.length} routines +
+
+ + + + + + + + + + + + + {routines.map((routine, idx) => ( + + + + + + + + + ))} + +
NomeCronActivaÚltimo runEstadoPróximo run
{routine.name} + + {routine.cron} + + + {routine.active ? ( + + Sim + + ) : ( + + Não + + )} + + {formatDate(routine.last_run)} + + {routine.last_status === 'success' ? ( + + ) : routine.last_status === 'error' ? ( + + ) : ( + + )} + + {formatDate(routine.next_run)} +
+
+
+ ) +} + +/** Contador de issues (open / in-progress / closed). */ +const IssuesBar = ({ issues }: { issues: PaperclipDashboard['issues'] }) => ( + +
+ +

Issues

+
+
+
+

{issues.open}

+

Abertas

+
+
+

{issues.in_progress}

+

Em progresso

+
+
+

{issues.closed_7d}

+

Fechadas (7d)

+
+
+
+) + +// --------------------------------------------------------------------------- +// Página principal +// --------------------------------------------------------------------------- + +export default function Paperclip() { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [lastUpdated, setLastUpdated] = useState(null) + + const fetchData = useCallback(async () => { + setLoading(true) + setError(null) + try { + const res = await fetch('/api/paperclip') + if (!res.ok) { + const body = await res.json().catch(() => ({})) + throw new Error(body?.message ?? `HTTP ${res.status}`) + } + const json: PaperclipDashboard = await res.json() + setData(json) + setLastUpdated(new Date()) + } catch (err) { + setError((err as Error).message) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchData() + }, [fetchData]) + + return ( +
+ {/* Cabeçalho */} + + +
+ +
+
+

Paperclip

+

+ Orquestrador autónomo — agentes, routines e issues +

+
+
+ + + {lastUpdated && ( + + Actualizado às {lastUpdated.toLocaleTimeString('pt-PT', { hour: '2-digit', minute: '2-digit' })} + + )} + + +
+ + {/* Estado de erro */} + {error && ( + + + Erro ao carregar dados: {error} + + )} + + {/* Estado de carregamento inicial */} + {loading && !data && ( + + {[...Array(4)].map((_, i) => ( +
+ ))} + + )} + + {/* Conteúdo principal */} + {data && ( + + {/* Stats cards */} +
+ + + + +
+ + {/* Issues */} + + + {/* Agentes agrupados por role */} +
+ + +

+ Agentes +

+ ({data.agents.total} total) +
+ +
+ + {/* Routines */} + +
+ )} +
+ ) +}