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>
546 lines
18 KiB
TypeScript
546 lines
18 KiB
TypeScript
/**
|
|
* 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>
|
|
)
|
|
}
|