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,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