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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-06 20:58:48 +01:00
parent a4271fd06a
commit 12f688ff7c
23 changed files with 4123 additions and 21 deletions
+545
View File
@@ -0,0 +1,545 @@
/**
* Página Paperclip — Painel de agentes, routines e issues
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
*/
import { useState, useEffect, useCallback } from 'react'
import { motion } from 'framer-motion'
import {
Bot,
Users,
Calendar,
CheckCircle2,
AlertTriangle,
Clock,
RefreshCw,
Activity,
Zap,
} from 'lucide-react'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface PaperclipAgent {
id: string
name: string
role: string
status: 'active' | 'idle' | 'error' | 'archived'
last_heartbeat: string | null
last_run: string | null
total_runs: number
}
interface PaperclipRoutine {
id: string
name: string
cron: string
active: boolean
last_run: string | null
last_status: 'success' | 'error' | null
next_run: string | null
}
interface PaperclipDashboard {
agents: {
total: number
active: number
idle: number
error: number
list: PaperclipAgent[]
}
routines: {
total: number
active: number
list: PaperclipRoutine[]
}
issues: {
open: number
in_progress: number
closed_7d: number
}
}
// ---------------------------------------------------------------------------
// Animation variants (consistente com Monitor.tsx)
// ---------------------------------------------------------------------------
const containerVariants = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: { staggerChildren: 0.06 },
},
}
const itemVariants = {
hidden: { opacity: 0, y: 20 },
show: {
opacity: 1,
y: 0,
transition: { type: 'spring' as const, stiffness: 300, damping: 30 },
},
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Ordem de prioridade de roles para agrupamento hierárquico.
* Roles desconhecidos ficam no fim.
*/
const ROLE_ORDER: Record<string, number> = {
Board: 0,
CEO: 1,
'C-Level': 2,
COO: 2,
CTO: 2,
CFO: 2,
CMO: 2,
Director: 3,
Specialist: 4,
}
function roleOrder(role: string): number {
return ROLE_ORDER[role] ?? 99
}
/** Agrupa a lista de agentes por role, ordenando roles hierarchicamente. */
function groupAgentsByRole(agents: PaperclipAgent[]): Record<string, PaperclipAgent[]> {
const groups: Record<string, PaperclipAgent[]> = {}
for (const agent of agents) {
if (!groups[agent.role]) groups[agent.role] = []
groups[agent.role].push(agent)
}
return groups
}
/** Ordena as keys de um objecto de grupos pelo ranking hierárquico. */
function sortedRoleKeys(groups: Record<string, PaperclipAgent[]>): string[] {
return Object.keys(groups).sort((a, b) => roleOrder(a) - roleOrder(b))
}
/** Formata uma string ISO ou null para data/hora legível PT. */
function formatDate(iso: string | null): string {
if (!iso) return '—'
try {
return new Date(iso).toLocaleString('pt-PT', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
} catch {
return iso
}
}
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
/** Dot colorido de estado (verde/amarelo/vermelho). */
const StatusDot = ({ status }: { status: string }) => {
const colour =
status === 'active'
? 'bg-emerald-400 shadow-[0_0_10px_rgba(16,185,129,0.5)]'
: status === 'idle'
? 'bg-amber-400 shadow-[0_0_10px_rgba(245,158,11,0.5)]'
: status === 'error'
? 'bg-red-400 shadow-[0_0_10px_rgba(239,68,68,0.5)] animate-pulse'
: 'bg-zinc-600'
return <span className={`inline-block w-2 h-2 rounded-full ${colour}`} />
}
/** Badge de role do agente. */
const RoleBadge = ({ role }: { role: string }) => {
const styles: Record<string, string> = {
Board: 'bg-violet-500/20 text-violet-300 border border-violet-500/30',
CEO: 'bg-cyan-500/20 text-cyan-300 border border-cyan-500/30',
'C-Level': 'bg-blue-500/20 text-blue-300 border border-blue-500/30',
COO: 'bg-blue-500/20 text-blue-300 border border-blue-500/30',
CTO: 'bg-blue-500/20 text-blue-300 border border-blue-500/30',
CFO: 'bg-blue-500/20 text-blue-300 border border-blue-500/30',
CMO: 'bg-blue-500/20 text-blue-300 border border-blue-500/30',
Director: 'bg-sky-500/20 text-sky-300 border border-sky-500/30',
Specialist: 'bg-teal-500/20 text-teal-300 border border-teal-500/30',
}
const cls = styles[role] ?? 'bg-zinc-700/40 text-zinc-400 border border-zinc-600/30'
return (
<span className={`px-2 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wide ${cls}`}>
{role}
</span>
)
}
/** Card de estatística no topo. */
const StatCard = ({
label,
value,
icon: Icon,
accent,
}: {
label: string
value: number
icon: React.ElementType
accent: string
}) => (
<motion.div
variants={itemVariants}
className="glass-card p-5 flex items-center gap-4"
>
<div className={`w-10 h-10 rounded-xl flex items-center justify-center ${accent}`}>
<Icon className="w-5 h-5" />
</div>
<div>
<p className="text-2xl font-bold text-white">{value}</p>
<p className="text-xs text-zinc-500 uppercase tracking-wide">{label}</p>
</div>
</motion.div>
)
/** Card individual de agente. */
const AgentCard = ({ agent }: { agent: PaperclipAgent }) => {
const statusLabel: Record<string, string> = {
active: 'Activo',
idle: 'Inactivo',
error: 'Erro',
archived: 'Arquivado',
}
const statusColour: Record<string, string> = {
active: 'text-emerald-400',
idle: 'text-amber-400',
error: 'text-red-400',
archived: 'text-zinc-500',
}
return (
<motion.div
variants={itemVariants}
className="glass-card p-4 flex flex-col gap-3 hover:border-cyan-500/20 transition-colors"
>
{/* Cabeçalho: ícone + nome + status dot */}
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-cyan-500/20 to-blue-600/20 border border-cyan-500/20 flex items-center justify-center flex-shrink-0">
<Bot className="w-4 h-4 text-cyan-400" />
</div>
<span className="text-sm font-semibold text-white leading-tight">{agent.name}</span>
</div>
<StatusDot status={agent.status} />
</div>
{/* Role badge + estado */}
<div className="flex items-center gap-2 flex-wrap">
<RoleBadge role={agent.role} />
<span className={`text-xs font-medium ${statusColour[agent.status] ?? 'text-zinc-400'}`}>
{statusLabel[agent.status] ?? agent.status}
</span>
</div>
{/* Último heartbeat */}
<div className="flex items-center gap-1.5 text-xs text-zinc-500">
<Clock className="w-3 h-3 flex-shrink-0" />
<span className="truncate">
{agent.last_heartbeat ? formatDate(agent.last_heartbeat) : 'Sem heartbeat'}
</span>
</div>
</motion.div>
)
}
/** Secção de agentes agrupados por role. */
const AgentsSection = ({ agents }: { agents: PaperclipAgent[] }) => {
const groups = groupAgentsByRole(agents)
const roleKeys = sortedRoleKeys(groups)
if (agents.length === 0) {
return (
<motion.div variants={itemVariants} className="glass-card p-8 text-center">
<Bot className="w-8 h-8 text-zinc-600 mx-auto mb-2" />
<p className="text-zinc-500 text-sm">Sem agentes disponíveis</p>
</motion.div>
)
}
return (
<>
{roleKeys.map(role => (
<motion.div key={role} variants={itemVariants}>
{/* Separador de grupo */}
<div className="flex items-center gap-3 mb-3">
<Users className="w-4 h-4 text-cyan-500" />
<h3 className="text-xs font-semibold uppercase tracking-widest text-cyan-400">{role}</h3>
<div className="flex-1 h-px bg-white/5" />
<span className="text-xs text-zinc-600">{groups[role].length}</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 mb-6">
{groups[role].map(agent => (
<AgentCard key={agent.id || agent.name} agent={agent} />
))}
</div>
</motion.div>
))}
</>
)
}
/** Tabela de routines. */
const RoutinesTable = ({ routines }: { routines: PaperclipRoutine[] }) => {
if (routines.length === 0) {
return (
<motion.div variants={itemVariants} className="glass-card p-8 text-center">
<Calendar className="w-8 h-8 text-zinc-600 mx-auto mb-2" />
<p className="text-zinc-500 text-sm">Sem routines disponíveis</p>
</motion.div>
)
}
return (
<motion.div variants={itemVariants} className="glass-card overflow-hidden">
<div className="px-5 py-4 border-b border-white/5 flex items-center gap-2">
<Calendar className="w-4 h-4 text-cyan-400" />
<h3 className="text-sm font-semibold text-white uppercase tracking-wide">Routines</h3>
<span className="ml-auto text-xs text-zinc-500">{routines.length} routines</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-zinc-500 uppercase tracking-wide border-b border-white/5">
<th className="px-5 py-3 text-left">Nome</th>
<th className="px-5 py-3 text-left">Cron</th>
<th className="px-5 py-3 text-center">Activa</th>
<th className="px-5 py-3 text-left">Último run</th>
<th className="px-5 py-3 text-center">Estado</th>
<th className="px-5 py-3 text-left">Próximo run</th>
</tr>
</thead>
<tbody>
{routines.map((routine, idx) => (
<tr
key={routine.id || routine.name}
className={`border-b border-white/5 hover:bg-white/[0.02] transition-colors ${
idx % 2 === 0 ? '' : 'bg-white/[0.01]'
}`}
>
<td className="px-5 py-3 text-white font-medium">{routine.name}</td>
<td className="px-5 py-3">
<code className="text-xs bg-white/5 px-2 py-0.5 rounded text-cyan-300">
{routine.cron}
</code>
</td>
<td className="px-5 py-3 text-center">
{routine.active ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-emerald-500/20 text-emerald-400 text-xs font-medium">
<CheckCircle2 className="w-3 h-3" /> Sim
</span>
) : (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-zinc-700/40 text-zinc-500 text-xs font-medium">
Não
</span>
)}
</td>
<td className="px-5 py-3 text-zinc-400 text-xs whitespace-nowrap">
{formatDate(routine.last_run)}
</td>
<td className="px-5 py-3 text-center">
{routine.last_status === 'success' ? (
<CheckCircle2 className="w-4 h-4 text-emerald-400 mx-auto" />
) : routine.last_status === 'error' ? (
<AlertTriangle className="w-4 h-4 text-red-400 mx-auto" />
) : (
<span className="text-zinc-600 text-xs"></span>
)}
</td>
<td className="px-5 py-3 text-zinc-400 text-xs whitespace-nowrap">
{formatDate(routine.next_run)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</motion.div>
)
}
/** Contador de issues (open / in-progress / closed). */
const IssuesBar = ({ issues }: { issues: PaperclipDashboard['issues'] }) => (
<motion.div variants={itemVariants} className="glass-card p-5">
<div className="flex items-center gap-2 mb-4">
<Activity className="w-4 h-4 text-cyan-400" />
<h3 className="text-sm font-semibold text-white uppercase tracking-wide">Issues</h3>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="text-center">
<p className="text-2xl font-bold text-red-400">{issues.open}</p>
<p className="text-xs text-zinc-500 mt-1">Abertas</p>
</div>
<div className="text-center border-x border-white/5">
<p className="text-2xl font-bold text-amber-400">{issues.in_progress}</p>
<p className="text-xs text-zinc-500 mt-1">Em progresso</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold text-emerald-400">{issues.closed_7d}</p>
<p className="text-xs text-zinc-500 mt-1">Fechadas (7d)</p>
</div>
</div>
</motion.div>
)
// ---------------------------------------------------------------------------
// Página principal
// ---------------------------------------------------------------------------
export default function Paperclip() {
const [data, setData] = useState<PaperclipDashboard | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
const fetchData = useCallback(async () => {
setLoading(true)
setError(null)
try {
const res = await fetch('/api/paperclip')
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body?.message ?? `HTTP ${res.status}`)
}
const json: PaperclipDashboard = await res.json()
setData(json)
setLastUpdated(new Date())
} catch (err) {
setError((err as Error).message)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData()
}, [fetchData])
return (
<div className="min-h-screen bg-zinc-950 p-6 space-y-6">
{/* Cabeçalho */}
<motion.div
variants={containerVariants}
initial="hidden"
animate="show"
className="flex items-center justify-between"
>
<motion.div variants={itemVariants} className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-cyan-500 to-blue-600 flex items-center justify-center shadow-lg shadow-cyan-500/30">
<Bot className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-xl font-bold text-white">Paperclip</h1>
<p className="text-xs text-zinc-500">
Orquestrador autónomo agentes, routines e issues
</p>
</div>
</motion.div>
<motion.div variants={itemVariants} className="flex items-center gap-3">
{lastUpdated && (
<span className="text-xs text-zinc-600">
Actualizado às {lastUpdated.toLocaleTimeString('pt-PT', { hour: '2-digit', minute: '2-digit' })}
</span>
)}
<button
onClick={fetchData}
disabled={loading}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-white/5 hover:bg-white/10 border border-white/10 text-sm text-zinc-300 transition-colors disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
Actualizar
</button>
</motion.div>
</motion.div>
{/* Estado de erro */}
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-center gap-3 px-5 py-4 rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 text-sm"
>
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
<span>Erro ao carregar dados: {error}</span>
</motion.div>
)}
{/* Estado de carregamento inicial */}
{loading && !data && (
<motion.div
variants={containerVariants}
initial="hidden"
animate="show"
className="grid grid-cols-2 sm:grid-cols-4 gap-4"
>
{[...Array(4)].map((_, i) => (
<div key={i} className="glass-card p-5 h-20 animate-pulse bg-white/[0.03]" />
))}
</motion.div>
)}
{/* Conteúdo principal */}
{data && (
<motion.div
variants={containerVariants}
initial="hidden"
animate="show"
className="space-y-6"
>
{/* Stats cards */}
<div className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
label="Agentes activos"
value={data.agents.active}
icon={Zap}
accent="bg-emerald-500/20 text-emerald-400"
/>
<StatCard
label="Agentes idle"
value={data.agents.idle}
icon={Bot}
accent="bg-amber-500/20 text-amber-400"
/>
<StatCard
label="Agentes com erro"
value={data.agents.error}
icon={AlertTriangle}
accent="bg-red-500/20 text-red-400"
/>
<StatCard
label="Routines activas"
value={data.routines.active}
icon={Calendar}
accent="bg-cyan-500/20 text-cyan-400"
/>
</div>
{/* Issues */}
<IssuesBar issues={data.issues} />
{/* Agentes agrupados por role */}
<div>
<motion.div variants={itemVariants} className="flex items-center gap-2 mb-4">
<Users className="w-4 h-4 text-cyan-400" />
<h2 className="text-sm font-semibold text-white uppercase tracking-wide">
Agentes
</h2>
<span className="text-xs text-zinc-500 ml-1">({data.agents.total} total)</span>
</motion.div>
<AgentsSection agents={data.agents.list} />
</div>
{/* Routines */}
<RoutinesTable routines={data.routines.list} />
</motion.div>
)}
</div>
)
}