feat: adicionar 5 novos painéis ao dashboard (MCPs, n8n, Paperclip, IA, Operações)
Expansão do dashboard de 3 para 8 páginas com dados reais do stack: - MCPs: monitorização de 33 MCPs no gateway com ping e estado online/offline - n8n: 14 workflows com último run, duração e falhas 24h - Paperclip: 16 agentes operacionais, routines e issues (PostgreSQL) - IA/Claude: visão das 3 camadas (189 skills, 72 agents, 39 MCPs, CARL) - Operações: tickets Desk CRM por departamento + cobertura PROCs 16 ficheiros novos (3042 linhas), 3 existentes editados. Nova dependência: pg (PostgreSQL client para Paperclip). Audit: 0 vulnerabilidades (npm audit fix aplicado). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
Generated
+180
-21
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user