feat: adicionar 5 novos painéis ao dashboard (MCPs, n8n, Paperclip, IA, Operações)

Expansão do dashboard de 3 para 8 páginas com dados reais do stack:
- MCPs: monitorização de 33 MCPs no gateway com ping e estado online/offline
- n8n: 14 workflows com último run, duração e falhas 24h
- Paperclip: 16 agentes operacionais, routines e issues (PostgreSQL)
- IA/Claude: visão das 3 camadas (189 skills, 72 agents, 39 MCPs, CARL)
- Operações: tickets Desk CRM por departamento + cobertura PROCs

16 ficheiros novos (3042 linhas), 3 existentes editados.
Nova dependência: pg (PostgreSQL client para Paperclip).
Audit: 0 vulnerabilidades (npm audit fix aplicado).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-06 20:58:48 +01:00
parent a4271fd06a
commit 12f688ff7c
23 changed files with 4123 additions and 21 deletions
+22
View File
@@ -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
+23
View File
@@ -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
+23
View File
@@ -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
+25
View File
@@ -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
+28
View File
@@ -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
+10
View File
@@ -16,6 +16,11 @@ import hetznerRouter from './routes/hetzner.js'
import wpMonitorRouter from './routes/wp-monitor.js' import wpMonitorRouter from './routes/wp-monitor.js'
import serverMetricsRouter from './routes/server-metrics.js' import serverMetricsRouter from './routes/server-metrics.js'
import financialRouter from './routes/financial.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 { collectAllServerMetrics } from './services/server-metrics.js'
import { collectMonitoringData } from './services/monitoring-collector.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/wp-monitor', wpMonitorRouter)
app.use('/api/server-metrics', serverMetricsRouter) app.use('/api/server-metrics', serverMetricsRouter)
app.use('/api/financial', financialRouter) 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 // Serve static files in production
if (isProduction) { if (isProduction) {
+83
View File
@@ -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<AiDashboard> {
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',
}
}
+218
View File
@@ -0,0 +1,218 @@
/**
* MCPs Service
* Ping paralelo aos MCPs via gateway com cache de 60 segundos
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
*/
export interface McpStatus {
name: string
port: number
category: string
enabled: boolean
status: 'online' | 'offline' | 'disabled' | 'unknown'
response_time_ms: number | null
last_check: string
tools_count?: number
}
export interface McpDashboard {
gateway_status: 'online' | 'offline'
total: number
online: number
offline: number
disabled: number
mcps: McpStatus[]
auth: {
method: string
token_expires: string | null
}
}
// Lista de MCPs com metadados (extraída de port-map.json e claude.json)
const MCP_LIST: Array<{ name: string; port: number; category: string; enabled: boolean }> = [
// CRM
{ name: 'desk-crm-v3', port: 3150, category: 'crm', enabled: true },
{ name: 'desk-project-minimal', port: 3153, category: 'crm', enabled: false },
// Infra
{ name: 'ssh-unified', port: 3192, category: 'infra', enabled: true },
{ name: 'filesystem', port: 0, category: 'infra', enabled: true },
{ name: 'chrome-devtools', port: 0, category: 'infra', enabled: true },
// AI
{ name: 'lightrag', port: 3160, category: 'ai', enabled: true },
{ name: 'notebooklm', port: 3190, category: 'ai', enabled: false },
{ name: 'context7', port: 3169, category: 'ai', enabled: false },
{ name: 'replicate', port: 3176, category: 'ai', enabled: false },
{ name: 'memory-supabase', port: 3151, category: 'ai', enabled: true },
// Tools
{ name: 'mcp-time', port: 3155, category: 'tools', enabled: true },
{ name: 'deepl', port: 3188, category: 'tools', enabled: true },
{ name: 'pexels', port: 3175, category: 'tools', enabled: false },
{ name: 'vimeo', port: 3177, category: 'tools', enabled: false },
{ name: 'drawio', port: 3184, category: 'tools', enabled: false },
// External
{ name: 'google-workspace', port: 3156, category: 'external', enabled: false },
{ name: 'google-analytics', port: 3164, category: 'external', enabled: false },
{ name: 'gsc', port: 3165, category: 'external', enabled: false },
{ name: 'youtube', port: 3166, category: 'external', enabled: false },
{ name: 'youtube-research', port: 3167, category: 'external', enabled: false },
{ name: 'moloni', port: 3158, category: 'external', enabled: false },
{ name: 'n8n', port: 3171, category: 'external', enabled: false },
{ name: 'gitea', port: 3162, category: 'external', enabled: true },
{ name: 'stitch', port: 0, category: 'external', enabled: false },
{ name: 'design-systems', port: 0, category: 'external', enabled: false },
// Gateway
{ name: 'authentik', port: 3191, category: 'gateway', enabled: false },
{ name: 'spaceship', port: 3189, category: 'gateway', enabled: false },
{ name: 'puppeteer', port: 3193, category: 'gateway', enabled: false },
{ name: 'lighthouse', port: 3194, category: 'gateway', enabled: false },
// Project
{ name: 'carl-mcp', port: 0, category: 'project', enabled: true },
{ name: 'reonic', port: 3187, category: 'project', enabled: false },
]
const GATEWAY_URL = process.env.MCP_GATEWAY_URL ?? 'https://gateway.descomplicar.pt'
const GATEWAY_TOKEN = process.env.MCP_GATEWAY_TOKEN ?? ''
const PING_TIMEOUT_MS = 5000
const CACHE_TTL_MS = 60_000
// Cache em memória
let cache: { data: McpDashboard; timestamp: number } | null = null
function buildHeaders(): Record<string, string> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (GATEWAY_TOKEN) {
headers['Authorization'] = `Bearer ${GATEWAY_TOKEN}`
}
return headers
}
async function pingGatewayHealth(): Promise<boolean> {
try {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), PING_TIMEOUT_MS)
const res = await fetch(`${GATEWAY_URL}/health`, { signal: controller.signal })
clearTimeout(timer)
return res.ok
} catch {
return false
}
}
async function pingMcp(name: string): Promise<{ ok: boolean; response_time_ms: number | null }> {
const start = Date.now()
try {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), PING_TIMEOUT_MS)
const res = await fetch(`${GATEWAY_URL}/v1/${name}/mcp`, {
method: 'GET',
headers: buildHeaders(),
signal: controller.signal,
})
clearTimeout(timer)
const elapsed = Date.now() - start
// Qualquer resposta HTTP não-5xx conta como online
return { ok: res.status < 500, response_time_ms: elapsed }
} catch {
return { ok: false, response_time_ms: null }
}
}
export async function getMcpDashboard(): Promise<McpDashboard> {
// Servir cache se ainda válido
if (cache !== null && Date.now() - cache.timestamp < CACHE_TTL_MS) {
return cache.data
}
const now = new Date().toISOString()
// Separar MCPs por tipo para determinar estratégia de ping
const enabledWithPort = MCP_LIST.filter(m => m.enabled && m.port > 0)
const enabledLocal = MCP_LIST.filter(m => m.enabled && m.port === 0)
const disabledMcps = MCP_LIST.filter(m => !m.enabled)
// Ping gateway health + todos os MCPs com porta em paralelo
const [gatewayResult, ...pingResults] = await Promise.allSettled([
pingGatewayHealth(),
...enabledWithPort.map(m => pingMcp(m.name)),
])
const gatewayStatus: 'online' | 'offline' =
gatewayResult.status === 'fulfilled' && gatewayResult.value ? 'online' : 'offline'
// MCPs enabled com porta gateway
const enabledWithPortStatuses: McpStatus[] = enabledWithPort.map((m, idx) => {
const result = pingResults[idx]
const pong = result.status === 'fulfilled'
? result.value
: { ok: false, response_time_ms: null }
return {
name: m.name,
port: m.port,
category: m.category,
enabled: true,
status: pong.ok ? 'online' : 'offline',
response_time_ms: pong.response_time_ms,
last_check: now,
} satisfies McpStatus
})
// MCPs enabled locais (sem porta no gateway) — assumir online se enabled
const enabledLocalStatuses: McpStatus[] = enabledLocal.map(m => ({
name: m.name,
port: m.port,
category: m.category,
enabled: true,
status: 'online' as const,
response_time_ms: null,
last_check: now,
}))
// MCPs disabled — sem ping, estado disabled
const disabledStatuses: McpStatus[] = disabledMcps.map(m => ({
name: m.name,
port: m.port,
category: m.category,
enabled: false,
status: 'disabled' as const,
response_time_ms: null,
last_check: now,
}))
const allMcps: McpStatus[] = [
...enabledWithPortStatuses,
...enabledLocalStatuses,
...disabledStatuses,
]
// Ordenar por categoria e depois por nome
allMcps.sort((a, b) => {
if (a.category !== b.category) return a.category.localeCompare(b.category)
return a.name.localeCompare(b.name)
})
const online = allMcps.filter(m => m.status === 'online').length
const offline = allMcps.filter(m => m.status === 'offline').length
const disabledCount = allMcps.filter(m => m.status === 'disabled').length
const data: McpDashboard = {
gateway_status: gatewayStatus,
total: allMcps.length,
online,
offline,
disabled: disabledCount,
mcps: allMcps,
auth: {
method: 'dual-layer: IP whitelist + Bearer token',
token_expires: null,
},
}
cache = { data, timestamp: Date.now() }
return data
}
+182
View File
@@ -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<string, string> {
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<T>(url: string, headers: Record<string, string>): Promise<T> {
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<T>
}
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<N8nDashboard> {
// 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<string, N8nExecutionRaw>()
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
}
+122
View File
@@ -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<OperationsDashboard> {
// 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<RowDataPacket[]>(
`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<RowDataPacket[]>(
`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<RowDataPacket[]>(
`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<RowDataPacket[]>(
`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,
},
}
}
+47
View File
@@ -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
+209
View File
@@ -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 <tabela> 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<PaperclipDashboard> {
// 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 }
}
}
+396
View File
@@ -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 <Route> dentro do <Route element={<Layout />}>
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 <branch-agent-a> --no-edit
# git merge <branch-agent-b> --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
+473
View File
@@ -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/<nome>/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=<authentik-bearer-token>
# n8n
N8N_API_URL=https://automator.descomplicar.pt/api/v1
N8N_API_KEY=<n8n-api-key>
# Paperclip PostgreSQL
PAPERCLIP_DB_HOST=clip.descomplicar.pt
PAPERCLIP_DB_PORT=54329
PAPERCLIP_DB_NAME=paperclip
PAPERCLIP_DB_USER=<user>
PAPERCLIP_DB_PASS=<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)
+180 -21
View File
@@ -8,6 +8,7 @@
"name": "dash-descomplicar", "name": "dash-descomplicar",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@types/pg": "^8.20.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.6.1", "dotenv": "^16.6.1",
@@ -19,6 +20,7 @@
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"mysql2": "^3.11.5", "mysql2": "^3.11.5",
"oidc-client-ts": "^3.0.1", "oidc-client-ts": "^3.0.1",
"pg": "^8.20.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-oidc-context": "^3.1.1", "react-oidc-context": "^3.1.1",
@@ -2372,6 +2374,17 @@
"undici-types": "~7.16.0" "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": { "node_modules/@types/qs": {
"version": "6.14.0", "version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", "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": { "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "2.0.2", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -3278,9 +3291,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.12", "version": "1.1.13",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -4964,9 +4977,9 @@
} }
}, },
"node_modules/flatted": { "node_modules/flatted": {
"version": "3.3.3", "version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
@@ -7288,9 +7301,9 @@
} }
}, },
"node_modules/path-to-regexp": { "node_modules/path-to-regexp": {
"version": "0.1.12", "version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/pathe": { "node_modules/pathe": {
@@ -7300,6 +7313,95 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -7308,9 +7410,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "4.0.3", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -7365,6 +7467,45 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/prelude-ls": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -8193,6 +8334,15 @@
"node": ">=0.10.0" "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": { "node_modules/sqlstring": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
@@ -8736,9 +8886,9 @@
} }
}, },
"node_modules/undici": { "node_modules/undici": {
"version": "7.22.0", "version": "7.24.7",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz",
"integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", "integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -8885,9 +9035,9 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "7.3.1", "version": "7.3.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -9245,6 +9395,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/y18n": {
"version": "5.0.8", "version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+2
View File
@@ -16,6 +16,7 @@
"test:coverage": "vitest --coverage" "test:coverage": "vitest --coverage"
}, },
"dependencies": { "dependencies": {
"@types/pg": "^8.20.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.6.1", "dotenv": "^16.6.1",
@@ -27,6 +28,7 @@
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"mysql2": "^3.11.5", "mysql2": "^3.11.5",
"oidc-client-ts": "^3.0.1", "oidc-client-ts": "^3.0.1",
"pg": "^8.20.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-oidc-context": "^3.1.1", "react-oidc-context": "^3.1.1",
+10
View File
@@ -5,6 +5,11 @@ import {
LayoutDashboard, LayoutDashboard,
Activity, Activity,
CreditCard, CreditCard,
Network,
GitBranch,
Bot,
Brain,
ClipboardList,
Zap, Zap,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
@@ -25,6 +30,11 @@ const NAV_ITEMS: NavItem[] = [
{ to: '/', label: 'Dashboard', icon: LayoutDashboard }, { to: '/', label: 'Dashboard', icon: LayoutDashboard },
{ to: '/monitor', label: 'Monitor', icon: Activity }, { to: '/monitor', label: 'Monitor', icon: Activity },
{ to: '/financial', label: 'Financeiro', icon: CreditCard }, { 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() { function useIsMobile() {
+10
View File
@@ -6,6 +6,11 @@ import './index.css'
import App from './App.tsx' import App from './App.tsx'
import Monitor from './pages/Monitor.tsx' import Monitor from './pages/Monitor.tsx'
import Financial from './pages/Financial.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 Layout from './components/Layout.tsx'
import { oidcConfig } from './auth/config.ts' import { oidcConfig } from './auth/config.ts'
import { AuthWrapper } from './auth/AuthWrapper.tsx' import { AuthWrapper } from './auth/AuthWrapper.tsx'
@@ -20,6 +25,11 @@ createRoot(document.getElementById('root')!).render(
<Route path="/" element={<App />} /> <Route path="/" element={<App />} />
<Route path="/monitor" element={<Monitor />} /> <Route path="/monitor" element={<Monitor />} />
<Route path="/financial" element={<Financial />} /> <Route path="/financial" element={<Financial />} />
<Route path="/mcps" element={<McpMonitor />} />
<Route path="/n8n" element={<N8nMonitor />} />
<Route path="/paperclip" element={<Paperclip />} />
<Route path="/ai" element={<AiOverview />} />
<Route path="/operations" element={<Operations />} />
<Route path="/callback" element={<App />} /> <Route path="/callback" element={<App />} />
</Route> </Route>
</Routes> </Routes>
+451
View File
@@ -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<string, LayerConfig> = {
'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
}) => (
<motion.div
variants={itemVariants}
className="bg-white/5 border border-white/8 rounded-xl px-5 py-4 flex flex-col gap-1"
>
<span className="text-xs text-zinc-500 uppercase tracking-wide font-medium">
{item.metric}
</span>
<span className={`text-3xl font-bold ${valueCls}`}>{item.value}</span>
<span className="text-xs text-zinc-400">{item.detail}</span>
</motion.div>
)
const LayerSection = ({ layer }: { layer: AiLayer }) => {
const cfg = LAYER_CONFIG[layer.name] ?? LAYER_CONFIG['Claude Code']
const Icon = cfg.icon
return (
<motion.div
variants={itemVariants}
className={`glass-card overflow-hidden bg-gradient-to-br ${cfg.gradient} border ${cfg.border} ${cfg.glow}`}
>
{/* Cabeçalho da camada */}
<div className={`px-6 py-4 border-b ${cfg.border} flex items-center justify-between`}>
<div className="flex items-center gap-3">
<div className={`w-9 h-9 rounded-xl flex items-center justify-center ${cfg.badge}`}>
<Icon className="w-5 h-5" />
</div>
<div>
<h3 className="text-sm font-bold text-white uppercase tracking-wider">
{layer.name}
</h3>
<p className="text-xs text-zinc-500 mt-0.5">{layer.label}</p>
</div>
</div>
</div>
{/* Cards de métricas */}
<div className="p-6">
<motion.div
variants={containerVariants}
initial="hidden"
animate="show"
className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3"
>
{layer.items.map((item) => (
<MetricCard key={item.metric} item={item} valueCls={cfg.valueCls} />
))}
</motion.div>
</div>
</motion.div>
)
}
const TransversalStatusIcon = ({ status }: { status: string }) => {
if (status === 'active') {
return <CheckCircle2 className="w-4 h-4 text-emerald-400 shrink-0" />
}
if (status === 'warning') {
return <AlertTriangle className="w-4 h-4 text-amber-400 shrink-0" />
}
return <Circle className="w-4 h-4 text-zinc-600 shrink-0" />
}
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 (
<motion.div
variants={itemVariants}
className={`glass-card px-5 py-4 flex items-start gap-3 border ${borderCls}`}
>
<TransversalStatusIcon status={sys.status} />
<div className="min-w-0">
<p className="text-sm font-semibold text-white truncate">{sys.name}</p>
<p className="text-xs text-zinc-400 mt-0.5 leading-relaxed">{sys.detail}</p>
</div>
</motion.div>
)
}
const DomainPill = ({ name }: { name: string }) => (
<motion.span
variants={itemVariants}
className="px-3 py-1.5 rounded-full text-xs font-semibold uppercase tracking-wide
bg-violet-500/15 text-violet-300 border border-violet-500/25"
>
{name}
</motion.span>
)
// --- Página principal ---
export default function AiOverview() {
const [data, setData] = useState<AiDashboard | null>(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 (
<div className="flex items-center justify-center h-96">
<div className="flex flex-col items-center gap-4">
<div className="w-10 h-10 border-2 border-violet-500/50 border-t-violet-400 rounded-full animate-spin" />
<p className="text-sm text-zinc-500">A carregar stack IA...</p>
</div>
</div>
)
}
if (!data) {
return (
<div className="flex items-center justify-center h-96">
<p className="text-sm text-zinc-500">Não foi possível carregar os dados.</p>
</div>
)
}
return (
<div className="space-y-8 pb-10">
{/* Cabeçalho */}
<motion.div
initial={{ opacity: 0, y: -16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"
>
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-2xl bg-violet-500/20 border border-violet-500/30
flex items-center justify-center shadow-[0_0_24px_rgba(139,92,246,0.2)]">
<Layers className="w-6 h-6 text-violet-400" />
</div>
<div>
<h1 className="text-2xl font-bold text-white">Stack IA 3 Camadas de Execução</h1>
<p className="text-sm text-zinc-500 mt-0.5">
Inventário completo do sistema IA Descomplicar®
</p>
</div>
</div>
<div className="flex items-center gap-3 shrink-0">
<span className="text-xs px-3 py-1.5 rounded-full bg-violet-500/10 border border-violet-500/20 text-violet-400">
Última actualização: {formatDate(data.last_updated)}
</span>
<button
onClick={fetchData}
disabled={refreshing}
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-white/5 hover:bg-white/10
border border-white/10 text-sm text-zinc-300 transition-colors disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin' : ''}`} />
Actualizar
</button>
</div>
</motion.div>
{/* Sumário global — pill badges */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.1 }}
className="flex flex-wrap gap-3"
>
<span className="px-3 py-1.5 rounded-full text-xs font-medium bg-white/5 border border-white/10 text-zinc-300">
<span className="text-violet-400 font-bold mr-1">{data.notebooks}</span>
Notebooks NotebookLM
</span>
<span className="px-3 py-1.5 rounded-full text-xs font-medium bg-white/5 border border-white/10 text-zinc-300">
<span className="text-violet-400 font-bold mr-1">{data.carl.total_rules}</span>
Regras CARL
</span>
<span className="px-3 py-1.5 rounded-full text-xs font-medium bg-white/5 border border-white/10 text-zinc-300">
<span className="text-violet-400 font-bold mr-1">{data.carl.domains.length}</span>
Domínios contextuais
</span>
</motion.div>
{/* 3 Camadas */}
<motion.div
variants={containerVariants}
initial="hidden"
animate="show"
className="space-y-4"
>
{data.layers.map((layer) => (
<LayerSection key={layer.name} layer={layer} />
))}
</motion.div>
{/* Sistemas Transversais */}
<div>
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="flex items-center gap-3 mb-4"
>
<Shield className="w-5 h-5 text-violet-400" />
<h2 className="text-sm font-bold text-white uppercase tracking-wider">
Sistemas Transversais
</h2>
<span className="text-xs px-2 py-0.5 rounded-full bg-white/5 border border-white/10 text-zinc-400">
{data.transversal.length} sistemas
</span>
</motion.div>
<motion.div
variants={containerVariants}
initial="hidden"
animate="show"
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-3"
>
{data.transversal.map((sys) => (
<TransversalCard key={sys.name} sys={sys} />
))}
</motion.div>
</div>
{/* CARL — domínios contextuais */}
<div>
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="flex items-center gap-3 mb-4"
>
<Database className="w-5 h-5 text-violet-400" />
<h2 className="text-sm font-bold text-white uppercase tracking-wider">
CARL Contexto Adaptativo
</h2>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.35 }}
className="glass-card p-6 border border-violet-500/20
bg-gradient-to-br from-violet-500/10 to-purple-500/5"
>
<div className="flex items-start gap-6 flex-col sm:flex-row">
{/* Métricas CARL */}
<div className="flex gap-6 shrink-0">
<div className="text-center">
<p className="text-3xl font-bold text-violet-300">{data.carl.domains.length}</p>
<p className="text-xs text-zinc-500 mt-1 uppercase tracking-wide">Domínios</p>
</div>
<div className="w-px bg-white/10 self-stretch" />
<div className="text-center">
<p className="text-3xl font-bold text-violet-300">{data.carl.total_rules}</p>
<p className="text-xs text-zinc-500 mt-1 uppercase tracking-wide">Regras</p>
</div>
</div>
{/* Divisor */}
<div className="hidden sm:block w-px bg-white/10 self-stretch" />
{/* Domínios como pills */}
<motion.div
variants={containerVariants}
initial="hidden"
animate="show"
className="flex flex-wrap gap-2"
>
{data.carl.domains.map((domain) => (
<DomainPill key={domain} name={domain} />
))}
</motion.div>
</div>
</motion.div>
</div>
{/* Knowledge Notebooks */}
<div>
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="flex items-center gap-3 mb-4"
>
<BookOpen className="w-5 h-5 text-violet-400" />
<h2 className="text-sm font-bold text-white uppercase tracking-wider">
Knowledge Base
</h2>
</motion.div>
<motion.div
initial={{ opacity: 0, scale: 0.97 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.45 }}
className="glass-card p-6 border border-violet-500/15
bg-gradient-to-br from-violet-500/8 to-transparent
flex items-center gap-6"
>
<Cpu className="w-10 h-10 text-violet-400/60 shrink-0" />
<div>
<p className="text-4xl font-bold text-violet-300">{data.notebooks}</p>
<p className="text-sm text-zinc-400 mt-1">
Notebooks NotebookLM activos fonte de conhecimento para todos os agentes
</p>
</div>
</motion.div>
</div>
</div>
)
}
+361
View File
@@ -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<string, string> = {
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 (
<div className="flex items-center gap-1.5">
<div className="w-2 h-2 rounded-full bg-emerald-400 shadow-[0_0_8px_rgba(52,211,153,0.7)]" />
<span className="text-xs font-medium text-emerald-400">online</span>
</div>
)
}
if (status === 'offline') {
return (
<div className="flex items-center gap-1.5">
<div className="w-2 h-2 rounded-full bg-red-400 shadow-[0_0_8px_rgba(248,113,113,0.7)] animate-pulse" />
<span className="text-xs font-medium text-red-400">offline</span>
</div>
)
}
return (
<div className="flex items-center gap-1.5">
<div className="w-2 h-2 rounded-full bg-zinc-600" />
<span className="text-xs font-medium text-zinc-500">disabled</span>
</div>
)
}
const McpCard = ({ mcp }: { mcp: McpStatus }) => {
const isDisabled = mcp.status === 'disabled'
const isOffline = mcp.status === 'offline'
return (
<motion.div
variants={itemVariants}
className={`
rounded-xl border p-4 transition-colors
${isDisabled
? 'bg-white/[0.02] border-white/5'
: isOffline
? 'bg-red-500/[0.04] border-red-500/15'
: 'bg-white/[0.04] border-white/10 hover:bg-white/[0.06]'
}
`}
>
<div className="flex items-start justify-between gap-2 mb-2">
<div className="flex items-center gap-2 min-w-0">
<Server className={`w-3.5 h-3.5 flex-shrink-0 ${isDisabled ? 'text-zinc-600' : 'text-brand-400'}`} />
<span className={`text-sm font-medium truncate font-mono ${isDisabled ? 'text-zinc-600' : 'text-white'}`}>
{mcp.name}
</span>
</div>
<StatusIndicator status={mcp.status} />
</div>
<div className="flex items-center justify-between mt-3">
{mcp.port > 0 ? (
<span className="text-[11px] text-zinc-600 font-mono">:{mcp.port}</span>
) : (
<span className="text-[11px] text-zinc-700">local</span>
)}
{mcp.response_time_ms !== null && mcp.status === 'online' ? (
<div className="flex items-center gap-1 text-[11px] text-zinc-500">
<Clock className="w-3 h-3" />
<span>{mcp.response_time_ms}ms</span>
</div>
) : null}
</div>
</motion.div>
)
}
const StatCard = ({
label,
value,
icon: Icon,
gradient,
textColor,
}: {
label: string
value: number | string
icon: React.ElementType
gradient: string
textColor: string
}) => (
<motion.div variants={itemVariants} className="glass-card p-5">
<div className="flex items-center justify-between mb-3">
<span className="text-xs text-zinc-500 uppercase tracking-wide">{label}</span>
<div className={`w-8 h-8 rounded-lg bg-gradient-to-br ${gradient} flex items-center justify-center`}>
<Icon className="w-4 h-4 text-white" />
</div>
</div>
<div className={`text-3xl font-bold ${textColor}`}>{value}</div>
</motion.div>
)
// --- Main Component ---
export default function McpMonitor() {
const [data, setData] = useState<McpDashboard | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(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 (
<div className="flex items-center justify-center py-24">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-center"
>
<Network className="w-12 h-12 text-brand-400 mx-auto mb-4 animate-pulse" />
<p className="text-zinc-400">A verificar MCPs...</p>
</motion.div>
</div>
)
}
// --- Error state ---
if (error) {
return (
<div className="flex items-center justify-center py-24">
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="text-center max-w-sm"
>
<XCircle className="w-12 h-12 text-red-400 mx-auto mb-4" />
<p className="text-white font-medium mb-2">Erro ao carregar</p>
<p className="text-zinc-500 text-sm mb-6">{error}</p>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={fetchData}
className="px-4 py-2 rounded-xl bg-brand-500/20 hover:bg-brand-500/30 border border-brand-500/30 text-brand-400 text-sm font-medium transition-all"
>
Tentar novamente
</motion.button>
</motion.div>
</div>
)
}
if (!data) return null
// Agrupar MCPs por categoria na ordem definida
const grouped: Record<string, McpStatus[]> = {}
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 (
<div className="max-w-[1600px] mx-auto px-6 lg:px-8 py-8">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-bold text-white tracking-tight">MCPs</h2>
<p className="text-xs text-zinc-500">
Gateway:{' '}
<span className={data.gateway_status === 'online' ? 'text-emerald-400' : 'text-red-400'}>
{data.gateway_status}
</span>
{' '} gateway.descomplicar.pt
</p>
</div>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={fetchData}
disabled={refreshing}
className="p-2.5 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 transition-all"
title="Actualizar"
>
<RefreshCw className={`w-5 h-5 text-zinc-400 ${refreshing ? 'animate-spin' : ''}`} />
</motion.button>
</div>
<motion.div variants={containerVariants} initial="hidden" animate="show" className="space-y-8">
{/* Stat cards */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<StatCard
label="Total"
value={data.total}
icon={Network}
gradient="from-brand-500 to-violet-600"
textColor="text-white"
/>
<StatCard
label="Online"
value={data.online}
icon={CheckCircle2}
gradient="from-emerald-500 to-emerald-400"
textColor="text-emerald-400"
/>
<StatCard
label="Offline"
value={data.offline}
icon={WifiOff}
gradient="from-red-500 to-red-400"
textColor={data.offline > 0 ? 'text-red-400' : 'text-zinc-500'}
/>
<StatCard
label="Disabled"
value={data.disabled}
icon={Wifi}
gradient="from-zinc-700 to-zinc-600"
textColor="text-zinc-500"
/>
</div>
{/* 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 (
<motion.div key={category} variants={itemVariants}>
{/* Cabeçalho de categoria */}
<div className="flex items-center gap-3 mb-3">
<h3 className="text-sm font-semibold text-white uppercase tracking-wide">
{label}
</h3>
<div className="flex items-center gap-2 text-xs text-zinc-600">
{onlineCount > 0 && (
<span className="text-emerald-500">{onlineCount} online</span>
)}
{offlineCount > 0 && (
<span className="text-red-500">{offlineCount} offline</span>
)}
<span>{mcps.length} total</span>
</div>
<div className="flex-1 h-px bg-white/5" />
</div>
{/* Cards da categoria */}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
{mcps.map(mcp => (
<McpCard key={mcp.name} mcp={mcp} />
))}
</div>
</motion.div>
)
})}
</motion.div>
</div>
)
}
+381
View File
@@ -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
}) => (
<motion.div
variants={itemVariants}
className={`glass-card p-6 ${highlight && value > 0 ? 'border border-red-500/40' : ''}`}
>
<div className="flex items-center justify-between mb-3">
<span className="text-sm text-zinc-400">{label}</span>
<div className={`w-10 h-10 rounded-xl ${colorClass} flex items-center justify-center`}>
<Icon className="w-5 h-5 text-white" />
</div>
</div>
<div className={`text-3xl font-bold ${highlight && value > 0 ? 'text-red-400' : 'text-white'}`}>
{value}
</div>
</motion.div>
)
function StatusBadge({ status }: { status: N8nLastExecution['status'] }) {
if (status === 'success')
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-emerald-500/15 text-emerald-400 text-xs font-medium">
<CheckCircle2 className="w-3.5 h-3.5" />
Sucesso
</span>
)
if (status === 'error')
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-red-500/15 text-red-400 text-xs font-medium">
<XCircle className="w-3.5 h-3.5" />
Erro
</span>
)
if (status === 'running')
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-blue-500/15 text-blue-400 text-xs font-medium">
<Clock className="w-3.5 h-3.5 animate-spin" />
A correr
</span>
)
return <span className="text-zinc-600 text-xs"></span>
}
function ActiveBadge({ active }: { active: boolean }) {
return active ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-emerald-500/15 text-emerald-400 text-xs font-medium">
<Play className="w-3 h-3" />
Activo
</span>
) : (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-zinc-700/50 text-zinc-500 text-xs font-medium">
<Pause className="w-3 h-3" />
Inactivo
</span>
)
}
// --- Página principal ---
export default function N8nMonitor() {
const [data, setData] = useState<N8nDashboard | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(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 (
<div className="flex items-center justify-center py-20">
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
<GitBranch className="w-12 h-12 text-brand-400 mx-auto mb-4 animate-pulse" />
<p className="text-zinc-400">A carregar workflows n8n...</p>
</motion.div>
</div>
)
}
// --- Erro ---
if (error) {
return (
<div className="max-w-[1600px] mx-auto px-6 lg:px-8 py-8">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="glass-card p-8 text-center"
>
<XCircle className="w-12 h-12 text-red-400 mx-auto mb-4" />
<p className="text-white font-semibold mb-2">Erro ao carregar dados</p>
<p className="text-zinc-400 text-sm mb-6">{error}</p>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={fetchData}
className="px-5 py-2.5 rounded-xl bg-brand-500/20 hover:bg-brand-500/30 border border-brand-500/30 text-brand-300 text-sm font-medium transition-all"
>
Tentar novamente
</motion.button>
</motion.div>
</div>
)
}
if (!data) return null
const visibleWorkflows = showOnlyActive
? data.workflows.filter((w) => w.active)
: data.workflows
return (
<div className="max-w-[1600px] mx-auto px-6 lg:px-8 py-8">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-bold text-white tracking-tight">Automações n8n</h2>
<p className="text-xs text-zinc-500">
{data.last_updated ? formatLastUpdated(data.last_updated) : 'Workflows operacionais'}
</p>
</div>
<div className="flex items-center gap-3">
{/* Toggle activos/todos */}
<button
onClick={() => setShowOnlyActive((v) => !v)}
className={`px-4 py-2 rounded-xl text-sm font-medium border transition-all ${
showOnlyActive
? 'bg-emerald-500/20 border-emerald-500/30 text-emerald-300'
: 'bg-white/5 border-white/10 text-zinc-400 hover:bg-white/10'
}`}
>
{showOnlyActive ? 'Apenas activos' : 'Todos'}
</button>
{/* Refresh */}
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={fetchData}
disabled={refreshing}
className="p-2.5 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 transition-all"
>
<RefreshCw className={`w-5 h-5 text-zinc-400 ${refreshing ? 'animate-spin' : ''}`} />
</motion.button>
</div>
</div>
<motion.div variants={containerVariants} initial="hidden" animate="show">
{/* Alerta falhas 24h */}
{data.failed_24h > 0 && (
<motion.div
variants={itemVariants}
className="mb-6 flex items-center gap-3 px-5 py-4 rounded-2xl bg-red-500/10 border border-red-500/30"
>
<AlertTriangle className="w-5 h-5 text-red-400 flex-shrink-0" />
<p className="text-sm text-red-300">
<span className="font-semibold">{data.failed_24h} execução{data.failed_24h !== 1 ? 'ões' : ''} falharam</span>{' '}
nas últimas 24 horas. Verifique os workflows em erro.
</p>
</motion.div>
)}
{/* Stats Cards */}
<div className="grid grid-cols-2 lg:grid-cols-3 gap-5 mb-8">
<StatCard
label="Total de Workflows"
value={data.total}
icon={GitBranch}
colorClass="bg-violet-500/20"
/>
<StatCard
label="Activos"
value={data.active}
icon={Play}
colorClass="bg-emerald-500/20"
/>
<StatCard
label="Falhas (24h)"
value={data.failed_24h}
icon={AlertTriangle}
colorClass={data.failed_24h > 0 ? 'bg-red-500/20' : 'bg-zinc-700/40'}
highlight
/>
</div>
{/* Tabela de Workflows */}
<motion.div variants={itemVariants} className="glass-card overflow-hidden">
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between">
<h3 className="text-sm font-semibold text-white uppercase tracking-wide flex items-center gap-2">
<GitBranch className="w-4 h-4 text-brand-400" />
Workflows
</h3>
<span className="text-xs text-zinc-500">{visibleWorkflows.length} workflow{visibleWorkflows.length !== 1 ? 's' : ''}</span>
</div>
{visibleWorkflows.length === 0 ? (
<div className="px-6 py-12 text-center">
<Pause className="w-8 h-8 text-zinc-600 mx-auto mb-3" />
<p className="text-zinc-500 text-sm">Nenhum workflow encontrado</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-white/5">
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wide">
Nome
</th>
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wide">
Estado
</th>
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wide">
Último Run
</th>
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wide">
Resultado
</th>
<th className="text-right px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wide">
Duração
</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{visibleWorkflows.map((wf) => (
<tr
key={wf.id}
className="hover:bg-white/[0.02] transition-colors"
>
{/* Nome */}
<td className="px-6 py-4">
<div className="flex items-center gap-2.5">
<GitBranch className="w-4 h-4 text-zinc-600 flex-shrink-0" />
<span className="text-sm text-white font-medium truncate max-w-[220px]">
{wf.name}
</span>
</div>
</td>
{/* Activo */}
<td className="px-4 py-4">
<ActiveBadge active={wf.active} />
</td>
{/* Data do último run */}
<td className="px-4 py-4">
<span className="text-sm text-zinc-400 tabular-nums">
{formatDate(wf.last_execution?.started_at ?? null)}
</span>
</td>
{/* Status */}
<td className="px-4 py-4">
<StatusBadge status={wf.last_execution?.status ?? null} />
</td>
{/* Duração */}
<td className="px-6 py-4 text-right">
<span className="text-sm text-zinc-500 tabular-nums font-mono">
{formatDuration(wf.last_execution?.duration_ms ?? null)}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</motion.div>
</motion.div>
</div>
)
}
+322
View File
@@ -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
}) => (
<motion.div variants={itemVariants} className="glass-card p-6">
<div className="flex items-center justify-between mb-3">
<span className="text-sm text-zinc-400">{label}</span>
<div className={`w-10 h-10 rounded-xl ${color} flex items-center justify-center`}>
<Icon className="w-5 h-5 text-white" />
</div>
</div>
<div className="text-3xl font-bold text-white">{value}</div>
{sub && <div className="text-xs text-zinc-500 mt-1">{sub}</div>}
</motion.div>
)
const CustomTooltip = ({ active, payload, label }: {
active?: boolean
payload?: { color: string; name: string; value: number }[]
label?: string
}) => {
if (!active || !payload?.length) return null
return (
<div className="bg-zinc-900 border border-white/10 rounded-xl px-4 py-3 shadow-xl">
<p className="text-sm font-medium text-white mb-1">{label}</p>
{payload.map((p, i) => (
<p key={i} className="text-xs" style={{ color: p.color }}>
{p.name}: {p.value}
</p>
))}
</div>
)
}
// 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 (
<div className="w-full bg-white/10 rounded-full h-2 overflow-hidden">
<div
className={`h-2 rounded-full transition-all duration-500 ${color}`}
style={{ width: `${Math.min(pct, 100)}%` }}
/>
</div>
)
}
// ---------------------------------------------------------------------------
// Página principal
// ---------------------------------------------------------------------------
export default function Operations() {
const [data, setData] = useState<OperationsData | null>(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 (
<div className="flex items-center justify-center py-20">
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
<Ticket className="w-12 h-12 text-brand-400 mx-auto mb-4 animate-pulse" />
<p className="text-zinc-400">A carregar dados de operações...</p>
</motion.div>
</div>
)
}
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 (
<div className="max-w-[1600px] mx-auto px-6 lg:px-8 py-8">
{/* Cabeçalho */}
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-bold text-white tracking-tight">Operações</h2>
<p className="text-xs text-zinc-500">Tickets de Suporte e Cobertura de Procedimentos</p>
</div>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={fetchData}
disabled={refreshing}
className="p-2.5 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 transition-all"
>
<RefreshCw className={`w-5 h-5 text-zinc-400 ${refreshing ? 'animate-spin' : ''}`} />
</motion.button>
</div>
<motion.div variants={containerVariants} initial="hidden" animate="show">
{/* Cards de resumo */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5 mb-8">
<StatCard
label="Tickets Abertos"
value={String(data.tickets.open)}
icon={Ticket}
color="bg-blue-500/20"
sub="Status: Open, In Progress, Answered"
/>
<StatCard
label="Alta Prioridade"
value={String(data.tickets.high_priority)}
icon={AlertTriangle}
color={priorityColor}
sub={data.tickets.high_priority > 0 ? 'Requerem atenção imediata' : 'Sem tickets críticos'}
/>
<StatCard
label="Tempo Médio Resposta"
value={`${data.tickets.avg_response_hours}h`}
icon={Clock}
color="bg-amber-500/20"
sub="Média últimos 90 dias"
/>
</div>
{/* Gráfico de barras horizontal + Resumo de PROCs */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5 mb-8">
{/* Tickets por departamento (barra horizontal) */}
<motion.div variants={itemVariants} className="glass-card p-6 lg:col-span-2">
<h3 className="text-sm font-semibold text-white mb-6 uppercase tracking-wide flex items-center gap-2">
<Building2 className="w-4 h-4 text-brand-400" />
Tickets por Departamento
</h3>
<div className="h-[280px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={data.tickets.by_department}
layout="vertical"
margin={{ top: 5, right: 20, left: 0, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" horizontal={false} />
<XAxis
type="number"
tick={{ fill: '#71717a', fontSize: 12 }}
axisLine={{ stroke: 'rgba(255,255,255,0.1)' }}
allowDecimals={false}
/>
<YAxis
type="category"
dataKey="dept"
tick={{ fill: '#a1a1aa', fontSize: 11 }}
axisLine={{ stroke: 'rgba(255,255,255,0.1)' }}
width={140}
/>
<Tooltip content={<CustomTooltip />} />
<Bar dataKey="count" name="Tickets" fill="#10b981" radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</motion.div>
{/* Resumo de PROCs */}
<motion.div variants={itemVariants} className="glass-card p-6">
<h3 className="text-sm font-semibold text-white mb-5 uppercase tracking-wide flex items-center gap-2">
<FileText className="w-4 h-4 text-brand-400" />
Procedimentos
</h3>
<div className="space-y-4">
<div className="flex items-center justify-between py-2 border-b border-white/10">
<span className="text-sm text-zinc-400">Total de PROCs</span>
<span className="text-xl font-bold text-white">{data.procedures.total}</span>
</div>
<div className="flex items-center justify-between py-2 border-b border-white/10">
<span className="text-sm text-zinc-400">Departamentos cobertos</span>
<span className="text-xl font-bold text-white">{data.procedures.departments}</span>
</div>
<div className="flex items-center justify-between py-2">
<span className="text-sm text-zinc-400">Cobertura global</span>
<span className="text-xl font-bold text-emerald-400">
{Math.round(
(data.procedures.coverage.reduce((s, d) => s + d.procs, 0) /
data.procedures.coverage.reduce((s, d) => s + d.total_expected, 0)) * 100
)}%
</span>
</div>
</div>
</motion.div>
</div>
{/* Tabela de cobertura por departamento */}
<motion.div variants={itemVariants} className="glass-card p-6">
<h3 className="text-sm font-semibold text-white mb-6 uppercase tracking-wide flex items-center gap-2">
<FileText className="w-4 h-4 text-brand-400" />
Cobertura de PROCs por Departamento
</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/10">
<th className="text-left py-2 pr-4 text-zinc-400 font-medium">Departamento</th>
<th className="text-center py-2 px-4 text-zinc-400 font-medium">PROCs</th>
<th className="text-center py-2 px-4 text-zinc-400 font-medium">Esperados</th>
<th className="text-left py-2 pl-4 text-zinc-400 font-medium w-48">Cobertura</th>
<th className="text-right py-2 pl-4 text-zinc-400 font-medium">%</th>
</tr>
</thead>
<tbody>
{data.procedures.coverage.map((item, i) => (
<tr
key={i}
className="border-b border-white/5 hover:bg-white/[0.02] transition-colors"
>
<td className="py-3 pr-4 text-white font-medium">{item.dept}</td>
<td className="py-3 px-4 text-center text-zinc-300 tabular-nums">{item.procs}</td>
<td className="py-3 px-4 text-center text-zinc-500 tabular-nums">{item.total_expected}</td>
<td className="py-3 pl-4 w-48">
<ProgressBar pct={item.pct} />
</td>
<td className="py-3 pl-4 text-right tabular-nums font-semibold"
style={{
color: item.pct >= 80 ? '#34d399' : item.pct >= 50 ? '#fbbf24' : '#f87171',
}}
>
{item.pct}%
</td>
</tr>
))}
</tbody>
</table>
</div>
</motion.div>
</motion.div>
</div>
)
}
+545
View File
@@ -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<string, number> = {
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<string, PaperclipAgent[]> {
const groups: Record<string, PaperclipAgent[]> = {}
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, PaperclipAgent[]>): 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 <span className={`inline-block w-2 h-2 rounded-full ${colour}`} />
}
/** Badge de role do agente. */
const RoleBadge = ({ role }: { role: string }) => {
const styles: Record<string, string> = {
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 (
<span className={`px-2 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wide ${cls}`}>
{role}
</span>
)
}
/** Card de estatística no topo. */
const StatCard = ({
label,
value,
icon: Icon,
accent,
}: {
label: string
value: number
icon: React.ElementType
accent: string
}) => (
<motion.div
variants={itemVariants}
className="glass-card p-5 flex items-center gap-4"
>
<div className={`w-10 h-10 rounded-xl flex items-center justify-center ${accent}`}>
<Icon className="w-5 h-5" />
</div>
<div>
<p className="text-2xl font-bold text-white">{value}</p>
<p className="text-xs text-zinc-500 uppercase tracking-wide">{label}</p>
</div>
</motion.div>
)
/** Card individual de agente. */
const AgentCard = ({ agent }: { agent: PaperclipAgent }) => {
const statusLabel: Record<string, string> = {
active: 'Activo',
idle: 'Inactivo',
error: 'Erro',
archived: 'Arquivado',
}
const statusColour: Record<string, string> = {
active: 'text-emerald-400',
idle: 'text-amber-400',
error: 'text-red-400',
archived: 'text-zinc-500',
}
return (
<motion.div
variants={itemVariants}
className="glass-card p-4 flex flex-col gap-3 hover:border-cyan-500/20 transition-colors"
>
{/* Cabeçalho: ícone + nome + status dot */}
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-cyan-500/20 to-blue-600/20 border border-cyan-500/20 flex items-center justify-center flex-shrink-0">
<Bot className="w-4 h-4 text-cyan-400" />
</div>
<span className="text-sm font-semibold text-white leading-tight">{agent.name}</span>
</div>
<StatusDot status={agent.status} />
</div>
{/* Role badge + estado */}
<div className="flex items-center gap-2 flex-wrap">
<RoleBadge role={agent.role} />
<span className={`text-xs font-medium ${statusColour[agent.status] ?? 'text-zinc-400'}`}>
{statusLabel[agent.status] ?? agent.status}
</span>
</div>
{/* Último heartbeat */}
<div className="flex items-center gap-1.5 text-xs text-zinc-500">
<Clock className="w-3 h-3 flex-shrink-0" />
<span className="truncate">
{agent.last_heartbeat ? formatDate(agent.last_heartbeat) : 'Sem heartbeat'}
</span>
</div>
</motion.div>
)
}
/** 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 (
<motion.div variants={itemVariants} className="glass-card p-8 text-center">
<Bot className="w-8 h-8 text-zinc-600 mx-auto mb-2" />
<p className="text-zinc-500 text-sm">Sem agentes disponíveis</p>
</motion.div>
)
}
return (
<>
{roleKeys.map(role => (
<motion.div key={role} variants={itemVariants}>
{/* Separador de grupo */}
<div className="flex items-center gap-3 mb-3">
<Users className="w-4 h-4 text-cyan-500" />
<h3 className="text-xs font-semibold uppercase tracking-widest text-cyan-400">{role}</h3>
<div className="flex-1 h-px bg-white/5" />
<span className="text-xs text-zinc-600">{groups[role].length}</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 mb-6">
{groups[role].map(agent => (
<AgentCard key={agent.id || agent.name} agent={agent} />
))}
</div>
</motion.div>
))}
</>
)
}
/** Tabela de routines. */
const RoutinesTable = ({ routines }: { routines: PaperclipRoutine[] }) => {
if (routines.length === 0) {
return (
<motion.div variants={itemVariants} className="glass-card p-8 text-center">
<Calendar className="w-8 h-8 text-zinc-600 mx-auto mb-2" />
<p className="text-zinc-500 text-sm">Sem routines disponíveis</p>
</motion.div>
)
}
return (
<motion.div variants={itemVariants} className="glass-card overflow-hidden">
<div className="px-5 py-4 border-b border-white/5 flex items-center gap-2">
<Calendar className="w-4 h-4 text-cyan-400" />
<h3 className="text-sm font-semibold text-white uppercase tracking-wide">Routines</h3>
<span className="ml-auto text-xs text-zinc-500">{routines.length} routines</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-zinc-500 uppercase tracking-wide border-b border-white/5">
<th className="px-5 py-3 text-left">Nome</th>
<th className="px-5 py-3 text-left">Cron</th>
<th className="px-5 py-3 text-center">Activa</th>
<th className="px-5 py-3 text-left">Último run</th>
<th className="px-5 py-3 text-center">Estado</th>
<th className="px-5 py-3 text-left">Próximo run</th>
</tr>
</thead>
<tbody>
{routines.map((routine, idx) => (
<tr
key={routine.id || routine.name}
className={`border-b border-white/5 hover:bg-white/[0.02] transition-colors ${
idx % 2 === 0 ? '' : 'bg-white/[0.01]'
}`}
>
<td className="px-5 py-3 text-white font-medium">{routine.name}</td>
<td className="px-5 py-3">
<code className="text-xs bg-white/5 px-2 py-0.5 rounded text-cyan-300">
{routine.cron}
</code>
</td>
<td className="px-5 py-3 text-center">
{routine.active ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-emerald-500/20 text-emerald-400 text-xs font-medium">
<CheckCircle2 className="w-3 h-3" /> Sim
</span>
) : (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-zinc-700/40 text-zinc-500 text-xs font-medium">
Não
</span>
)}
</td>
<td className="px-5 py-3 text-zinc-400 text-xs whitespace-nowrap">
{formatDate(routine.last_run)}
</td>
<td className="px-5 py-3 text-center">
{routine.last_status === 'success' ? (
<CheckCircle2 className="w-4 h-4 text-emerald-400 mx-auto" />
) : routine.last_status === 'error' ? (
<AlertTriangle className="w-4 h-4 text-red-400 mx-auto" />
) : (
<span className="text-zinc-600 text-xs"></span>
)}
</td>
<td className="px-5 py-3 text-zinc-400 text-xs whitespace-nowrap">
{formatDate(routine.next_run)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</motion.div>
)
}
/** Contador de issues (open / in-progress / closed). */
const IssuesBar = ({ issues }: { issues: PaperclipDashboard['issues'] }) => (
<motion.div variants={itemVariants} className="glass-card p-5">
<div className="flex items-center gap-2 mb-4">
<Activity className="w-4 h-4 text-cyan-400" />
<h3 className="text-sm font-semibold text-white uppercase tracking-wide">Issues</h3>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="text-center">
<p className="text-2xl font-bold text-red-400">{issues.open}</p>
<p className="text-xs text-zinc-500 mt-1">Abertas</p>
</div>
<div className="text-center border-x border-white/5">
<p className="text-2xl font-bold text-amber-400">{issues.in_progress}</p>
<p className="text-xs text-zinc-500 mt-1">Em progresso</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold text-emerald-400">{issues.closed_7d}</p>
<p className="text-xs text-zinc-500 mt-1">Fechadas (7d)</p>
</div>
</div>
</motion.div>
)
// ---------------------------------------------------------------------------
// Página principal
// ---------------------------------------------------------------------------
export default function Paperclip() {
const [data, setData] = useState<PaperclipDashboard | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [lastUpdated, setLastUpdated] = useState<Date | null>(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 (
<div className="min-h-screen bg-zinc-950 p-6 space-y-6">
{/* Cabeçalho */}
<motion.div
variants={containerVariants}
initial="hidden"
animate="show"
className="flex items-center justify-between"
>
<motion.div variants={itemVariants} className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-cyan-500 to-blue-600 flex items-center justify-center shadow-lg shadow-cyan-500/30">
<Bot className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-xl font-bold text-white">Paperclip</h1>
<p className="text-xs text-zinc-500">
Orquestrador autónomo agentes, routines e issues
</p>
</div>
</motion.div>
<motion.div variants={itemVariants} className="flex items-center gap-3">
{lastUpdated && (
<span className="text-xs text-zinc-600">
Actualizado às {lastUpdated.toLocaleTimeString('pt-PT', { hour: '2-digit', minute: '2-digit' })}
</span>
)}
<button
onClick={fetchData}
disabled={loading}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-white/5 hover:bg-white/10 border border-white/10 text-sm text-zinc-300 transition-colors disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
Actualizar
</button>
</motion.div>
</motion.div>
{/* Estado de erro */}
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-center gap-3 px-5 py-4 rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 text-sm"
>
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
<span>Erro ao carregar dados: {error}</span>
</motion.div>
)}
{/* Estado de carregamento inicial */}
{loading && !data && (
<motion.div
variants={containerVariants}
initial="hidden"
animate="show"
className="grid grid-cols-2 sm:grid-cols-4 gap-4"
>
{[...Array(4)].map((_, i) => (
<div key={i} className="glass-card p-5 h-20 animate-pulse bg-white/[0.03]" />
))}
</motion.div>
)}
{/* Conteúdo principal */}
{data && (
<motion.div
variants={containerVariants}
initial="hidden"
animate="show"
className="space-y-6"
>
{/* Stats cards */}
<div className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
label="Agentes activos"
value={data.agents.active}
icon={Zap}
accent="bg-emerald-500/20 text-emerald-400"
/>
<StatCard
label="Agentes idle"
value={data.agents.idle}
icon={Bot}
accent="bg-amber-500/20 text-amber-400"
/>
<StatCard
label="Agentes com erro"
value={data.agents.error}
icon={AlertTriangle}
accent="bg-red-500/20 text-red-400"
/>
<StatCard
label="Routines activas"
value={data.routines.active}
icon={Calendar}
accent="bg-cyan-500/20 text-cyan-400"
/>
</div>
{/* Issues */}
<IssuesBar issues={data.issues} />
{/* Agentes agrupados por role */}
<div>
<motion.div variants={itemVariants} className="flex items-center gap-2 mb-4">
<Users className="w-4 h-4 text-cyan-400" />
<h2 className="text-sm font-semibold text-white uppercase tracking-wide">
Agentes
</h2>
<span className="text-xs text-zinc-500 ml-1">({data.agents.total} total)</span>
</motion.div>
<AgentsSection agents={data.agents.list} />
</div>
{/* Routines */}
<RoutinesTable routines={data.routines.list} />
</motion.div>
)}
</div>
)
}