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,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 }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user