12f688ff7c
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>
183 lines
5.0 KiB
TypeScript
183 lines
5.0 KiB
TypeScript
/**
|
|
* 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
|
|
}
|