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:
@@ -5,6 +5,11 @@ import {
|
||||
LayoutDashboard,
|
||||
Activity,
|
||||
CreditCard,
|
||||
Network,
|
||||
GitBranch,
|
||||
Bot,
|
||||
Brain,
|
||||
ClipboardList,
|
||||
Zap,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
@@ -25,6 +30,11 @@ const NAV_ITEMS: NavItem[] = [
|
||||
{ to: '/', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ to: '/monitor', label: 'Monitor', icon: Activity },
|
||||
{ to: '/financial', label: 'Financeiro', icon: CreditCard },
|
||||
{ to: '/mcps', label: 'MCPs', icon: Network },
|
||||
{ to: '/n8n', label: 'Automações', icon: GitBranch },
|
||||
{ to: '/paperclip', label: 'Paperclip', icon: Bot },
|
||||
{ to: '/ai', label: 'IA / Claude', icon: Brain },
|
||||
{ to: '/operations', label: 'Operações', icon: ClipboardList },
|
||||
]
|
||||
|
||||
function useIsMobile() {
|
||||
|
||||
@@ -6,6 +6,11 @@ import './index.css'
|
||||
import App from './App.tsx'
|
||||
import Monitor from './pages/Monitor.tsx'
|
||||
import Financial from './pages/Financial.tsx'
|
||||
import McpMonitor from './pages/McpMonitor.tsx'
|
||||
import N8nMonitor from './pages/N8nMonitor.tsx'
|
||||
import Paperclip from './pages/Paperclip.tsx'
|
||||
import AiOverview from './pages/AiOverview.tsx'
|
||||
import Operations from './pages/Operations.tsx'
|
||||
import Layout from './components/Layout.tsx'
|
||||
import { oidcConfig } from './auth/config.ts'
|
||||
import { AuthWrapper } from './auth/AuthWrapper.tsx'
|
||||
@@ -20,6 +25,11 @@ createRoot(document.getElementById('root')!).render(
|
||||
<Route path="/" element={<App />} />
|
||||
<Route path="/monitor" element={<Monitor />} />
|
||||
<Route path="/financial" element={<Financial />} />
|
||||
<Route path="/mcps" element={<McpMonitor />} />
|
||||
<Route path="/n8n" element={<N8nMonitor />} />
|
||||
<Route path="/paperclip" element={<Paperclip />} />
|
||||
<Route path="/ai" element={<AiOverview />} />
|
||||
<Route path="/operations" element={<Operations />} />
|
||||
<Route path="/callback" element={<App />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
/**
|
||||
* AiOverview — Painel Stack IA (3 Camadas de Execução)
|
||||
* Rota: /ai
|
||||
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||
*/
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import {
|
||||
Brain,
|
||||
Cpu,
|
||||
Bot,
|
||||
Zap,
|
||||
Shield,
|
||||
Database,
|
||||
BookOpen,
|
||||
Layers,
|
||||
RefreshCw,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
Circle,
|
||||
} from 'lucide-react'
|
||||
|
||||
// --- Types ---
|
||||
|
||||
interface AiLayerItem {
|
||||
metric: string
|
||||
value: number
|
||||
detail: string
|
||||
}
|
||||
|
||||
interface AiLayer {
|
||||
name: string
|
||||
label: string
|
||||
items: AiLayerItem[]
|
||||
}
|
||||
|
||||
interface TransversalSystem {
|
||||
name: string
|
||||
status: 'active' | 'warning' | 'inactive'
|
||||
detail: string
|
||||
}
|
||||
|
||||
interface CarlConfig {
|
||||
domains: string[]
|
||||
total_rules: number
|
||||
}
|
||||
|
||||
interface AiDashboard {
|
||||
layers: AiLayer[]
|
||||
transversal: TransversalSystem[]
|
||||
carl: CarlConfig
|
||||
notebooks: number
|
||||
last_updated: string
|
||||
}
|
||||
|
||||
// --- Animation variants ---
|
||||
|
||||
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 },
|
||||
},
|
||||
}
|
||||
|
||||
// --- Configuração visual por camada ---
|
||||
|
||||
interface LayerConfig {
|
||||
gradient: string
|
||||
border: string
|
||||
badge: string
|
||||
valueCls: string
|
||||
glow: string
|
||||
icon: React.ElementType
|
||||
}
|
||||
|
||||
const LAYER_CONFIG: Record<string, LayerConfig> = {
|
||||
'Claude Code': {
|
||||
gradient: 'from-violet-500/15 to-purple-500/10',
|
||||
border: 'border-violet-500/25',
|
||||
badge: 'bg-violet-500/20 text-violet-300 border border-violet-500/30',
|
||||
valueCls: 'text-violet-300',
|
||||
glow: 'shadow-[0_0_20px_rgba(139,92,246,0.15)]',
|
||||
icon: Brain,
|
||||
},
|
||||
'n8n': {
|
||||
gradient: 'from-amber-500/15 to-orange-500/10',
|
||||
border: 'border-amber-500/25',
|
||||
badge: 'bg-amber-500/20 text-amber-300 border border-amber-500/30',
|
||||
valueCls: 'text-amber-300',
|
||||
glow: 'shadow-[0_0_20px_rgba(245,158,11,0.15)]',
|
||||
icon: Zap,
|
||||
},
|
||||
'Paperclip': {
|
||||
gradient: 'from-cyan-500/15 to-teal-500/10',
|
||||
border: 'border-cyan-500/25',
|
||||
badge: 'bg-cyan-500/20 text-cyan-300 border border-cyan-500/30',
|
||||
valueCls: 'text-cyan-300',
|
||||
glow: 'shadow-[0_0_20px_rgba(6,182,212,0.15)]',
|
||||
icon: Bot,
|
||||
},
|
||||
}
|
||||
|
||||
// --- Sub-components ---
|
||||
|
||||
const MetricCard = ({
|
||||
item,
|
||||
valueCls,
|
||||
}: {
|
||||
item: AiLayerItem
|
||||
valueCls: string
|
||||
}) => (
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className="bg-white/5 border border-white/8 rounded-xl px-5 py-4 flex flex-col gap-1"
|
||||
>
|
||||
<span className="text-xs text-zinc-500 uppercase tracking-wide font-medium">
|
||||
{item.metric}
|
||||
</span>
|
||||
<span className={`text-3xl font-bold ${valueCls}`}>{item.value}</span>
|
||||
<span className="text-xs text-zinc-400">{item.detail}</span>
|
||||
</motion.div>
|
||||
)
|
||||
|
||||
const LayerSection = ({ layer }: { layer: AiLayer }) => {
|
||||
const cfg = LAYER_CONFIG[layer.name] ?? LAYER_CONFIG['Claude Code']
|
||||
const Icon = cfg.icon
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className={`glass-card overflow-hidden bg-gradient-to-br ${cfg.gradient} border ${cfg.border} ${cfg.glow}`}
|
||||
>
|
||||
{/* Cabeçalho da camada */}
|
||||
<div className={`px-6 py-4 border-b ${cfg.border} flex items-center justify-between`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-9 h-9 rounded-xl flex items-center justify-center ${cfg.badge}`}>
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-white uppercase tracking-wider">
|
||||
{layer.name}
|
||||
</h3>
|
||||
<p className="text-xs text-zinc-500 mt-0.5">{layer.label}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cards de métricas */}
|
||||
<div className="p-6">
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3"
|
||||
>
|
||||
{layer.items.map((item) => (
|
||||
<MetricCard key={item.metric} item={item} valueCls={cfg.valueCls} />
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
const TransversalStatusIcon = ({ status }: { status: string }) => {
|
||||
if (status === 'active') {
|
||||
return <CheckCircle2 className="w-4 h-4 text-emerald-400 shrink-0" />
|
||||
}
|
||||
if (status === 'warning') {
|
||||
return <AlertTriangle className="w-4 h-4 text-amber-400 shrink-0" />
|
||||
}
|
||||
return <Circle className="w-4 h-4 text-zinc-600 shrink-0" />
|
||||
}
|
||||
|
||||
const TransversalCard = ({ sys }: { sys: TransversalSystem }) => {
|
||||
const borderCls =
|
||||
sys.status === 'active'
|
||||
? 'border-emerald-500/20'
|
||||
: sys.status === 'warning'
|
||||
? 'border-amber-500/20'
|
||||
: 'border-white/8'
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className={`glass-card px-5 py-4 flex items-start gap-3 border ${borderCls}`}
|
||||
>
|
||||
<TransversalStatusIcon status={sys.status} />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-white truncate">{sys.name}</p>
|
||||
<p className="text-xs text-zinc-400 mt-0.5 leading-relaxed">{sys.detail}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
const DomainPill = ({ name }: { name: string }) => (
|
||||
<motion.span
|
||||
variants={itemVariants}
|
||||
className="px-3 py-1.5 rounded-full text-xs font-semibold uppercase tracking-wide
|
||||
bg-violet-500/15 text-violet-300 border border-violet-500/25"
|
||||
>
|
||||
{name}
|
||||
</motion.span>
|
||||
)
|
||||
|
||||
// --- Página principal ---
|
||||
|
||||
export default function AiOverview() {
|
||||
const [data, setData] = useState<AiDashboard | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
try {
|
||||
const response = await fetch('/api/ai')
|
||||
if (!response.ok) throw new Error('Failed to fetch /api/ai')
|
||||
const json = await response.json()
|
||||
setData(json)
|
||||
} catch {
|
||||
console.error('Falha ao carregar dados do stack IA')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchData() }, [fetchData])
|
||||
|
||||
const formatDate = (iso: string) => {
|
||||
// Converter YYYY-MM-DD para DD-MM-YYYY
|
||||
const [y, m, d] = iso.split('-')
|
||||
return `${d}-${m}-${y}`
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-10 h-10 border-2 border-violet-500/50 border-t-violet-400 rounded-full animate-spin" />
|
||||
<p className="text-sm text-zinc-500">A carregar stack IA...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<p className="text-sm text-zinc-500">Não foi possível carregar os dados.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8 pb-10">
|
||||
|
||||
{/* Cabeçalho */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-2xl bg-violet-500/20 border border-violet-500/30
|
||||
flex items-center justify-center shadow-[0_0_24px_rgba(139,92,246,0.2)]">
|
||||
<Layers className="w-6 h-6 text-violet-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Stack IA — 3 Camadas de Execução</h1>
|
||||
<p className="text-sm text-zinc-500 mt-0.5">
|
||||
Inventário completo do sistema IA Descomplicar®
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<span className="text-xs px-3 py-1.5 rounded-full bg-violet-500/10 border border-violet-500/20 text-violet-400">
|
||||
Última actualização: {formatDate(data.last_updated)}
|
||||
</span>
|
||||
<button
|
||||
onClick={fetchData}
|
||||
disabled={refreshing}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-xl 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 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
Actualizar
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Sumário global — pill badges */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="flex flex-wrap gap-3"
|
||||
>
|
||||
<span className="px-3 py-1.5 rounded-full text-xs font-medium bg-white/5 border border-white/10 text-zinc-300">
|
||||
<span className="text-violet-400 font-bold mr-1">{data.notebooks}</span>
|
||||
Notebooks NotebookLM
|
||||
</span>
|
||||
<span className="px-3 py-1.5 rounded-full text-xs font-medium bg-white/5 border border-white/10 text-zinc-300">
|
||||
<span className="text-violet-400 font-bold mr-1">{data.carl.total_rules}</span>
|
||||
Regras CARL
|
||||
</span>
|
||||
<span className="px-3 py-1.5 rounded-full text-xs font-medium bg-white/5 border border-white/10 text-zinc-300">
|
||||
<span className="text-violet-400 font-bold mr-1">{data.carl.domains.length}</span>
|
||||
Domínios contextuais
|
||||
</span>
|
||||
</motion.div>
|
||||
|
||||
{/* 3 Camadas */}
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="space-y-4"
|
||||
>
|
||||
{data.layers.map((layer) => (
|
||||
<LayerSection key={layer.name} layer={layer} />
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* Sistemas Transversais */}
|
||||
<div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="flex items-center gap-3 mb-4"
|
||||
>
|
||||
<Shield className="w-5 h-5 text-violet-400" />
|
||||
<h2 className="text-sm font-bold text-white uppercase tracking-wider">
|
||||
Sistemas Transversais
|
||||
</h2>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-white/5 border border-white/10 text-zinc-400">
|
||||
{data.transversal.length} sistemas
|
||||
</span>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-3"
|
||||
>
|
||||
{data.transversal.map((sys) => (
|
||||
<TransversalCard key={sys.name} sys={sys} />
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* CARL — domínios contextuais */}
|
||||
<div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="flex items-center gap-3 mb-4"
|
||||
>
|
||||
<Database className="w-5 h-5 text-violet-400" />
|
||||
<h2 className="text-sm font-bold text-white uppercase tracking-wider">
|
||||
CARL — Contexto Adaptativo
|
||||
</h2>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.35 }}
|
||||
className="glass-card p-6 border border-violet-500/20
|
||||
bg-gradient-to-br from-violet-500/10 to-purple-500/5"
|
||||
>
|
||||
<div className="flex items-start gap-6 flex-col sm:flex-row">
|
||||
{/* Métricas CARL */}
|
||||
<div className="flex gap-6 shrink-0">
|
||||
<div className="text-center">
|
||||
<p className="text-3xl font-bold text-violet-300">{data.carl.domains.length}</p>
|
||||
<p className="text-xs text-zinc-500 mt-1 uppercase tracking-wide">Domínios</p>
|
||||
</div>
|
||||
<div className="w-px bg-white/10 self-stretch" />
|
||||
<div className="text-center">
|
||||
<p className="text-3xl font-bold text-violet-300">{data.carl.total_rules}</p>
|
||||
<p className="text-xs text-zinc-500 mt-1 uppercase tracking-wide">Regras</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divisor */}
|
||||
<div className="hidden sm:block w-px bg-white/10 self-stretch" />
|
||||
|
||||
{/* Domínios como pills */}
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="flex flex-wrap gap-2"
|
||||
>
|
||||
{data.carl.domains.map((domain) => (
|
||||
<DomainPill key={domain} name={domain} />
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Knowledge Notebooks */}
|
||||
<div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
className="flex items-center gap-3 mb-4"
|
||||
>
|
||||
<BookOpen className="w-5 h-5 text-violet-400" />
|
||||
<h2 className="text-sm font-bold text-white uppercase tracking-wider">
|
||||
Knowledge Base
|
||||
</h2>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.97 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.45 }}
|
||||
className="glass-card p-6 border border-violet-500/15
|
||||
bg-gradient-to-br from-violet-500/8 to-transparent
|
||||
flex items-center gap-6"
|
||||
>
|
||||
<Cpu className="w-10 h-10 text-violet-400/60 shrink-0" />
|
||||
<div>
|
||||
<p className="text-4xl font-bold text-violet-300">{data.notebooks}</p>
|
||||
<p className="text-sm text-zinc-400 mt-1">
|
||||
Notebooks NotebookLM activos — fonte de conhecimento para todos os agentes
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* McpMonitor — Painel de estado dos MCPs
|
||||
* Visualização em tempo real de todos os MCPs via gateway
|
||||
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||
*/
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import {
|
||||
Network,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
RefreshCw,
|
||||
Server,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Clock,
|
||||
} from 'lucide-react'
|
||||
|
||||
// --- Types ---
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
interface McpDashboard {
|
||||
gateway_status: 'online' | 'offline'
|
||||
total: number
|
||||
online: number
|
||||
offline: number
|
||||
disabled: number
|
||||
mcps: McpStatus[]
|
||||
auth: {
|
||||
method: string
|
||||
token_expires: string | null
|
||||
}
|
||||
}
|
||||
|
||||
// Mapeamento de categorias para etiquetas legíveis
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
ai: 'Inteligência Artificial',
|
||||
crm: 'CRM',
|
||||
external: 'Externos',
|
||||
gateway: 'Gateway',
|
||||
infra: 'Infraestrutura',
|
||||
project: 'Projecto',
|
||||
tools: 'Ferramentas',
|
||||
}
|
||||
|
||||
// Ordem de apresentação das categorias
|
||||
const CATEGORY_ORDER = ['crm', 'infra', 'ai', 'tools', 'external', 'gateway', 'project']
|
||||
|
||||
// --- Animation variants ---
|
||||
|
||||
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 },
|
||||
},
|
||||
}
|
||||
|
||||
// --- Sub-components ---
|
||||
|
||||
const StatusIndicator = ({ status }: { status: McpStatus['status'] }) => {
|
||||
if (status === 'online') {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-400 shadow-[0_0_8px_rgba(52,211,153,0.7)]" />
|
||||
<span className="text-xs font-medium text-emerald-400">online</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (status === 'offline') {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-2 h-2 rounded-full bg-red-400 shadow-[0_0_8px_rgba(248,113,113,0.7)] animate-pulse" />
|
||||
<span className="text-xs font-medium text-red-400">offline</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-2 h-2 rounded-full bg-zinc-600" />
|
||||
<span className="text-xs font-medium text-zinc-500">disabled</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const McpCard = ({ mcp }: { mcp: McpStatus }) => {
|
||||
const isDisabled = mcp.status === 'disabled'
|
||||
const isOffline = mcp.status === 'offline'
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className={`
|
||||
rounded-xl border p-4 transition-colors
|
||||
${isDisabled
|
||||
? 'bg-white/[0.02] border-white/5'
|
||||
: isOffline
|
||||
? 'bg-red-500/[0.04] border-red-500/15'
|
||||
: 'bg-white/[0.04] border-white/10 hover:bg-white/[0.06]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Server className={`w-3.5 h-3.5 flex-shrink-0 ${isDisabled ? 'text-zinc-600' : 'text-brand-400'}`} />
|
||||
<span className={`text-sm font-medium truncate font-mono ${isDisabled ? 'text-zinc-600' : 'text-white'}`}>
|
||||
{mcp.name}
|
||||
</span>
|
||||
</div>
|
||||
<StatusIndicator status={mcp.status} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-3">
|
||||
{mcp.port > 0 ? (
|
||||
<span className="text-[11px] text-zinc-600 font-mono">:{mcp.port}</span>
|
||||
) : (
|
||||
<span className="text-[11px] text-zinc-700">local</span>
|
||||
)}
|
||||
|
||||
{mcp.response_time_ms !== null && mcp.status === 'online' ? (
|
||||
<div className="flex items-center gap-1 text-[11px] text-zinc-500">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>{mcp.response_time_ms}ms</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
const StatCard = ({
|
||||
label,
|
||||
value,
|
||||
icon: Icon,
|
||||
gradient,
|
||||
textColor,
|
||||
}: {
|
||||
label: string
|
||||
value: number | string
|
||||
icon: React.ElementType
|
||||
gradient: string
|
||||
textColor: string
|
||||
}) => (
|
||||
<motion.div variants={itemVariants} className="glass-card p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-xs text-zinc-500 uppercase tracking-wide">{label}</span>
|
||||
<div className={`w-8 h-8 rounded-lg bg-gradient-to-br ${gradient} flex items-center justify-center`}>
|
||||
<Icon className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div className={`text-3xl font-bold ${textColor}`}>{value}</div>
|
||||
</motion.div>
|
||||
)
|
||||
|
||||
// --- Main Component ---
|
||||
|
||||
export default function McpMonitor() {
|
||||
const [data, setData] = useState<McpDashboard | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch('/api/mcps')
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const json: McpDashboard = await res.json()
|
||||
setData(json)
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Erro desconhecido'
|
||||
setError(`Não foi possível carregar os dados: ${msg}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
const interval = setInterval(fetchData, 60_000)
|
||||
return () => clearInterval(interval)
|
||||
}, [fetchData])
|
||||
|
||||
// --- Loading state ---
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="text-center"
|
||||
>
|
||||
<Network className="w-12 h-12 text-brand-400 mx-auto mb-4 animate-pulse" />
|
||||
<p className="text-zinc-400">A verificar MCPs...</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Error state ---
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-center max-w-sm"
|
||||
>
|
||||
<XCircle className="w-12 h-12 text-red-400 mx-auto mb-4" />
|
||||
<p className="text-white font-medium mb-2">Erro ao carregar</p>
|
||||
<p className="text-zinc-500 text-sm mb-6">{error}</p>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={fetchData}
|
||||
className="px-4 py-2 rounded-xl bg-brand-500/20 hover:bg-brand-500/30 border border-brand-500/30 text-brand-400 text-sm font-medium transition-all"
|
||||
>
|
||||
Tentar novamente
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) return null
|
||||
|
||||
// Agrupar MCPs por categoria na ordem definida
|
||||
const grouped: Record<string, McpStatus[]> = {}
|
||||
for (const cat of CATEGORY_ORDER) {
|
||||
const items = data.mcps.filter(m => m.category === cat)
|
||||
if (items.length > 0) grouped[cat] = items
|
||||
}
|
||||
// Categorias não previstas na ordem
|
||||
for (const mcp of data.mcps) {
|
||||
if (!CATEGORY_ORDER.includes(mcp.category)) {
|
||||
if (!grouped[mcp.category]) grouped[mcp.category] = []
|
||||
grouped[mcp.category].push(mcp)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-[1600px] mx-auto px-6 lg:px-8 py-8">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white tracking-tight">MCPs</h2>
|
||||
<p className="text-xs text-zinc-500">
|
||||
Gateway:{' '}
|
||||
<span className={data.gateway_status === 'online' ? 'text-emerald-400' : 'text-red-400'}>
|
||||
{data.gateway_status}
|
||||
</span>
|
||||
{' '}— gateway.descomplicar.pt
|
||||
</p>
|
||||
</div>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={fetchData}
|
||||
disabled={refreshing}
|
||||
className="p-2.5 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 transition-all"
|
||||
title="Actualizar"
|
||||
>
|
||||
<RefreshCw className={`w-5 h-5 text-zinc-400 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
<motion.div variants={containerVariants} initial="hidden" animate="show" className="space-y-8">
|
||||
|
||||
{/* Stat cards */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
label="Total"
|
||||
value={data.total}
|
||||
icon={Network}
|
||||
gradient="from-brand-500 to-violet-600"
|
||||
textColor="text-white"
|
||||
/>
|
||||
<StatCard
|
||||
label="Online"
|
||||
value={data.online}
|
||||
icon={CheckCircle2}
|
||||
gradient="from-emerald-500 to-emerald-400"
|
||||
textColor="text-emerald-400"
|
||||
/>
|
||||
<StatCard
|
||||
label="Offline"
|
||||
value={data.offline}
|
||||
icon={WifiOff}
|
||||
gradient="from-red-500 to-red-400"
|
||||
textColor={data.offline > 0 ? 'text-red-400' : 'text-zinc-500'}
|
||||
/>
|
||||
<StatCard
|
||||
label="Disabled"
|
||||
value={data.disabled}
|
||||
icon={Wifi}
|
||||
gradient="from-zinc-700 to-zinc-600"
|
||||
textColor="text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Grid por categoria */}
|
||||
{Object.entries(grouped).map(([category, mcps]) => {
|
||||
const onlineCount = mcps.filter(m => m.status === 'online').length
|
||||
const offlineCount = mcps.filter(m => m.status === 'offline').length
|
||||
const label = CATEGORY_LABELS[category] ?? category
|
||||
|
||||
return (
|
||||
<motion.div key={category} variants={itemVariants}>
|
||||
{/* Cabeçalho de categoria */}
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<h3 className="text-sm font-semibold text-white uppercase tracking-wide">
|
||||
{label}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 text-xs text-zinc-600">
|
||||
{onlineCount > 0 && (
|
||||
<span className="text-emerald-500">{onlineCount} online</span>
|
||||
)}
|
||||
{offlineCount > 0 && (
|
||||
<span className="text-red-500">{offlineCount} offline</span>
|
||||
)}
|
||||
<span>{mcps.length} total</span>
|
||||
</div>
|
||||
<div className="flex-1 h-px bg-white/5" />
|
||||
</div>
|
||||
|
||||
{/* Cards da categoria */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
|
||||
{mcps.map(mcp => (
|
||||
<McpCard key={mcp.name} mcp={mcp} />
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import {
|
||||
GitBranch,
|
||||
Play,
|
||||
Pause,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
RefreshCw,
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react'
|
||||
|
||||
// --- Tipos ---
|
||||
|
||||
interface N8nLastExecution {
|
||||
status: 'success' | 'error' | 'running' | null
|
||||
started_at: string | null
|
||||
finished_at: string | null
|
||||
duration_ms: number | null
|
||||
}
|
||||
|
||||
interface N8nWorkflow {
|
||||
id: string
|
||||
name: string
|
||||
active: boolean
|
||||
last_execution: N8nLastExecution | null
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
interface N8nDashboard {
|
||||
total: number
|
||||
active: number
|
||||
inactive: number
|
||||
failed_24h: number
|
||||
workflows: N8nWorkflow[]
|
||||
last_updated: string
|
||||
}
|
||||
|
||||
// --- Animações ---
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
show: { opacity: 1, transition: { staggerChildren: 0.05 } },
|
||||
}
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
show: { opacity: 1, y: 0 },
|
||||
}
|
||||
|
||||
// --- Utilitários ---
|
||||
|
||||
function formatDuration(ms: number | null): string {
|
||||
if (ms === null) return '—'
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
|
||||
const min = Math.floor(ms / 60000)
|
||||
const sec = Math.round((ms % 60000) / 1000)
|
||||
return `${min}m ${sec}s`
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null): string {
|
||||
if (!iso) return '—'
|
||||
const d = new Date(iso)
|
||||
if (isNaN(d.getTime())) return '—'
|
||||
return d.toLocaleString('pt-PT', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function formatLastUpdated(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
if (isNaN(d.getTime())) return ''
|
||||
return `Actualizado: ${d.toLocaleTimeString('pt-PT', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}`
|
||||
}
|
||||
|
||||
// --- Sub-componentes ---
|
||||
|
||||
const StatCard = ({
|
||||
label,
|
||||
value,
|
||||
icon: Icon,
|
||||
colorClass,
|
||||
highlight,
|
||||
}: {
|
||||
label: string
|
||||
value: number
|
||||
icon: React.ElementType
|
||||
colorClass: string
|
||||
highlight?: boolean
|
||||
}) => (
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className={`glass-card p-6 ${highlight && value > 0 ? 'border border-red-500/40' : ''}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm text-zinc-400">{label}</span>
|
||||
<div className={`w-10 h-10 rounded-xl ${colorClass} flex items-center justify-center`}>
|
||||
<Icon className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div className={`text-3xl font-bold ${highlight && value > 0 ? 'text-red-400' : 'text-white'}`}>
|
||||
{value}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
|
||||
function StatusBadge({ status }: { status: N8nLastExecution['status'] }) {
|
||||
if (status === 'success')
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-emerald-500/15 text-emerald-400 text-xs font-medium">
|
||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||
Sucesso
|
||||
</span>
|
||||
)
|
||||
if (status === 'error')
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-red-500/15 text-red-400 text-xs font-medium">
|
||||
<XCircle className="w-3.5 h-3.5" />
|
||||
Erro
|
||||
</span>
|
||||
)
|
||||
if (status === 'running')
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-blue-500/15 text-blue-400 text-xs font-medium">
|
||||
<Clock className="w-3.5 h-3.5 animate-spin" />
|
||||
A correr
|
||||
</span>
|
||||
)
|
||||
return <span className="text-zinc-600 text-xs">—</span>
|
||||
}
|
||||
|
||||
function ActiveBadge({ active }: { active: boolean }) {
|
||||
return active ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-emerald-500/15 text-emerald-400 text-xs font-medium">
|
||||
<Play className="w-3 h-3" />
|
||||
Activo
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-zinc-700/50 text-zinc-500 text-xs font-medium">
|
||||
<Pause className="w-3 h-3" />
|
||||
Inactivo
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Página principal ---
|
||||
|
||||
export default function N8nMonitor() {
|
||||
const [data, setData] = useState<N8nDashboard | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [showOnlyActive, setShowOnlyActive] = useState(false)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
setError(null)
|
||||
try {
|
||||
const response = await fetch('/api/n8n')
|
||||
if (!response.ok) {
|
||||
const json = await response.json().catch(() => ({}))
|
||||
throw new Error(json.error || `Erro ${response.status}`)
|
||||
}
|
||||
const json: N8nDashboard = await response.json()
|
||||
setData(json)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Erro ao carregar dados do n8n')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [fetchData])
|
||||
|
||||
// --- Loading ---
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
|
||||
<GitBranch className="w-12 h-12 text-brand-400 mx-auto mb-4 animate-pulse" />
|
||||
<p className="text-zinc-400">A carregar workflows n8n...</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Erro ---
|
||||
if (error) {
|
||||
return (
|
||||
<div className="max-w-[1600px] mx-auto px-6 lg:px-8 py-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="glass-card p-8 text-center"
|
||||
>
|
||||
<XCircle className="w-12 h-12 text-red-400 mx-auto mb-4" />
|
||||
<p className="text-white font-semibold mb-2">Erro ao carregar dados</p>
|
||||
<p className="text-zinc-400 text-sm mb-6">{error}</p>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={fetchData}
|
||||
className="px-5 py-2.5 rounded-xl bg-brand-500/20 hover:bg-brand-500/30 border border-brand-500/30 text-brand-300 text-sm font-medium transition-all"
|
||||
>
|
||||
Tentar novamente
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) return null
|
||||
|
||||
const visibleWorkflows = showOnlyActive
|
||||
? data.workflows.filter((w) => w.active)
|
||||
: data.workflows
|
||||
|
||||
return (
|
||||
<div className="max-w-[1600px] mx-auto px-6 lg:px-8 py-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white tracking-tight">Automações n8n</h2>
|
||||
<p className="text-xs text-zinc-500">
|
||||
{data.last_updated ? formatLastUpdated(data.last_updated) : 'Workflows operacionais'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Toggle activos/todos */}
|
||||
<button
|
||||
onClick={() => setShowOnlyActive((v) => !v)}
|
||||
className={`px-4 py-2 rounded-xl text-sm font-medium border transition-all ${
|
||||
showOnlyActive
|
||||
? 'bg-emerald-500/20 border-emerald-500/30 text-emerald-300'
|
||||
: 'bg-white/5 border-white/10 text-zinc-400 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
{showOnlyActive ? 'Apenas activos' : 'Todos'}
|
||||
</button>
|
||||
{/* Refresh */}
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={fetchData}
|
||||
disabled={refreshing}
|
||||
className="p-2.5 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 transition-all"
|
||||
>
|
||||
<RefreshCw className={`w-5 h-5 text-zinc-400 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.div variants={containerVariants} initial="hidden" animate="show">
|
||||
{/* Alerta falhas 24h */}
|
||||
{data.failed_24h > 0 && (
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className="mb-6 flex items-center gap-3 px-5 py-4 rounded-2xl bg-red-500/10 border border-red-500/30"
|
||||
>
|
||||
<AlertTriangle className="w-5 h-5 text-red-400 flex-shrink-0" />
|
||||
<p className="text-sm text-red-300">
|
||||
<span className="font-semibold">{data.failed_24h} execução{data.failed_24h !== 1 ? 'ões' : ''} falharam</span>{' '}
|
||||
nas últimas 24 horas. Verifique os workflows em erro.
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 gap-5 mb-8">
|
||||
<StatCard
|
||||
label="Total de Workflows"
|
||||
value={data.total}
|
||||
icon={GitBranch}
|
||||
colorClass="bg-violet-500/20"
|
||||
/>
|
||||
<StatCard
|
||||
label="Activos"
|
||||
value={data.active}
|
||||
icon={Play}
|
||||
colorClass="bg-emerald-500/20"
|
||||
/>
|
||||
<StatCard
|
||||
label="Falhas (24h)"
|
||||
value={data.failed_24h}
|
||||
icon={AlertTriangle}
|
||||
colorClass={data.failed_24h > 0 ? 'bg-red-500/20' : 'bg-zinc-700/40'}
|
||||
highlight
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tabela de Workflows */}
|
||||
<motion.div variants={itemVariants} className="glass-card overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-white uppercase tracking-wide flex items-center gap-2">
|
||||
<GitBranch className="w-4 h-4 text-brand-400" />
|
||||
Workflows
|
||||
</h3>
|
||||
<span className="text-xs text-zinc-500">{visibleWorkflows.length} workflow{visibleWorkflows.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
|
||||
{visibleWorkflows.length === 0 ? (
|
||||
<div className="px-6 py-12 text-center">
|
||||
<Pause className="w-8 h-8 text-zinc-600 mx-auto mb-3" />
|
||||
<p className="text-zinc-500 text-sm">Nenhum workflow encontrado</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-white/5">
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wide">
|
||||
Nome
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wide">
|
||||
Estado
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wide">
|
||||
Último Run
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wide">
|
||||
Resultado
|
||||
</th>
|
||||
<th className="text-right px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wide">
|
||||
Duração
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{visibleWorkflows.map((wf) => (
|
||||
<tr
|
||||
key={wf.id}
|
||||
className="hover:bg-white/[0.02] transition-colors"
|
||||
>
|
||||
{/* Nome */}
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<GitBranch className="w-4 h-4 text-zinc-600 flex-shrink-0" />
|
||||
<span className="text-sm text-white font-medium truncate max-w-[220px]">
|
||||
{wf.name}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
{/* Activo */}
|
||||
<td className="px-4 py-4">
|
||||
<ActiveBadge active={wf.active} />
|
||||
</td>
|
||||
{/* Data do último run */}
|
||||
<td className="px-4 py-4">
|
||||
<span className="text-sm text-zinc-400 tabular-nums">
|
||||
{formatDate(wf.last_execution?.started_at ?? null)}
|
||||
</span>
|
||||
</td>
|
||||
{/* Status */}
|
||||
<td className="px-4 py-4">
|
||||
<StatusBadge status={wf.last_execution?.status ?? null} />
|
||||
</td>
|
||||
{/* Duração */}
|
||||
<td className="px-6 py-4 text-right">
|
||||
<span className="text-sm text-zinc-500 tabular-nums font-mono">
|
||||
{formatDuration(wf.last_execution?.duration_ms ?? null)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* Página Operações — Tickets e Cobertura de PROCs
|
||||
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||
*/
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
import {
|
||||
Ticket,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
FileText,
|
||||
Building2,
|
||||
RefreshCw,
|
||||
} from 'lucide-react'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tipos
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TicketsByDept {
|
||||
dept: string
|
||||
count: number
|
||||
}
|
||||
|
||||
interface CoverageItem {
|
||||
dept: string
|
||||
procs: number
|
||||
total_expected: number
|
||||
pct: number
|
||||
}
|
||||
|
||||
interface OperationsData {
|
||||
tickets: {
|
||||
open: number
|
||||
high_priority: number
|
||||
avg_response_hours: number
|
||||
by_department: TicketsByDept[]
|
||||
}
|
||||
procedures: {
|
||||
total: number
|
||||
departments: number
|
||||
coverage: CoverageItem[]
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Animações
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
show: { opacity: 1, transition: { staggerChildren: 0.05 } },
|
||||
}
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
show: { opacity: 1, y: 0 },
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-componentes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const StatCard = ({
|
||||
label, value, icon: Icon, color, sub,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
icon: React.ElementType
|
||||
color: string
|
||||
sub?: string
|
||||
}) => (
|
||||
<motion.div variants={itemVariants} className="glass-card p-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm text-zinc-400">{label}</span>
|
||||
<div className={`w-10 h-10 rounded-xl ${color} flex items-center justify-center`}>
|
||||
<Icon className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-white">{value}</div>
|
||||
{sub && <div className="text-xs text-zinc-500 mt-1">{sub}</div>}
|
||||
</motion.div>
|
||||
)
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }: {
|
||||
active?: boolean
|
||||
payload?: { color: string; name: string; value: number }[]
|
||||
label?: string
|
||||
}) => {
|
||||
if (!active || !payload?.length) return null
|
||||
return (
|
||||
<div className="bg-zinc-900 border border-white/10 rounded-xl px-4 py-3 shadow-xl">
|
||||
<p className="text-sm font-medium text-white mb-1">{label}</p>
|
||||
{payload.map((p, i) => (
|
||||
<p key={i} className="text-xs" style={{ color: p.color }}>
|
||||
{p.name}: {p.value}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Barra de progresso para cobertura de PROCs
|
||||
const ProgressBar = ({ pct }: { pct: number }) => {
|
||||
const color =
|
||||
pct >= 80 ? 'bg-emerald-500' :
|
||||
pct >= 50 ? 'bg-amber-500' :
|
||||
'bg-red-500'
|
||||
|
||||
return (
|
||||
<div className="w-full bg-white/10 rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all duration-500 ${color}`}
|
||||
style={{ width: `${Math.min(pct, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Página principal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function Operations() {
|
||||
const [data, setData] = useState<OperationsData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
try {
|
||||
const response = await fetch('/api/operations')
|
||||
if (!response.ok) throw new Error('Resposta inválida')
|
||||
const json: OperationsData = await response.json()
|
||||
setData(json)
|
||||
} catch (err) {
|
||||
console.error('[Operations] Erro ao carregar dados:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchData() }, [fetchData])
|
||||
|
||||
// Estado de carregamento
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
|
||||
<Ticket className="w-12 h-12 text-brand-400 mx-auto mb-4 animate-pulse" />
|
||||
<p className="text-zinc-400">A carregar dados de operações...</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) return null
|
||||
|
||||
// Cor do card de alta prioridade: vermelho se existirem tickets críticos
|
||||
const priorityColor = data.tickets.high_priority > 0
|
||||
? 'bg-red-500/20'
|
||||
: 'bg-zinc-700/40'
|
||||
|
||||
return (
|
||||
<div className="max-w-[1600px] mx-auto px-6 lg:px-8 py-8">
|
||||
{/* Cabeçalho */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white tracking-tight">Operações</h2>
|
||||
<p className="text-xs text-zinc-500">Tickets de Suporte e Cobertura de Procedimentos</p>
|
||||
</div>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={fetchData}
|
||||
disabled={refreshing}
|
||||
className="p-2.5 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 transition-all"
|
||||
>
|
||||
<RefreshCw className={`w-5 h-5 text-zinc-400 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
<motion.div variants={containerVariants} initial="hidden" animate="show">
|
||||
{/* Cards de resumo */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5 mb-8">
|
||||
<StatCard
|
||||
label="Tickets Abertos"
|
||||
value={String(data.tickets.open)}
|
||||
icon={Ticket}
|
||||
color="bg-blue-500/20"
|
||||
sub="Status: Open, In Progress, Answered"
|
||||
/>
|
||||
<StatCard
|
||||
label="Alta Prioridade"
|
||||
value={String(data.tickets.high_priority)}
|
||||
icon={AlertTriangle}
|
||||
color={priorityColor}
|
||||
sub={data.tickets.high_priority > 0 ? 'Requerem atenção imediata' : 'Sem tickets críticos'}
|
||||
/>
|
||||
<StatCard
|
||||
label="Tempo Médio Resposta"
|
||||
value={`${data.tickets.avg_response_hours}h`}
|
||||
icon={Clock}
|
||||
color="bg-amber-500/20"
|
||||
sub="Média últimos 90 dias"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Gráfico de barras horizontal + Resumo de PROCs */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5 mb-8">
|
||||
{/* Tickets por departamento (barra horizontal) */}
|
||||
<motion.div variants={itemVariants} className="glass-card p-6 lg:col-span-2">
|
||||
<h3 className="text-sm font-semibold text-white mb-6 uppercase tracking-wide flex items-center gap-2">
|
||||
<Building2 className="w-4 h-4 text-brand-400" />
|
||||
Tickets por Departamento
|
||||
</h3>
|
||||
<div className="h-[280px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={data.tickets.by_department}
|
||||
layout="vertical"
|
||||
margin={{ top: 5, right: 20, left: 0, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" horizontal={false} />
|
||||
<XAxis
|
||||
type="number"
|
||||
tick={{ fill: '#71717a', fontSize: 12 }}
|
||||
axisLine={{ stroke: 'rgba(255,255,255,0.1)' }}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="dept"
|
||||
tick={{ fill: '#a1a1aa', fontSize: 11 }}
|
||||
axisLine={{ stroke: 'rgba(255,255,255,0.1)' }}
|
||||
width={140}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Bar dataKey="count" name="Tickets" fill="#10b981" radius={[0, 4, 4, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Resumo de PROCs */}
|
||||
<motion.div variants={itemVariants} className="glass-card p-6">
|
||||
<h3 className="text-sm font-semibold text-white mb-5 uppercase tracking-wide flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-brand-400" />
|
||||
Procedimentos
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between py-2 border-b border-white/10">
|
||||
<span className="text-sm text-zinc-400">Total de PROCs</span>
|
||||
<span className="text-xl font-bold text-white">{data.procedures.total}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2 border-b border-white/10">
|
||||
<span className="text-sm text-zinc-400">Departamentos cobertos</span>
|
||||
<span className="text-xl font-bold text-white">{data.procedures.departments}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="text-sm text-zinc-400">Cobertura global</span>
|
||||
<span className="text-xl font-bold text-emerald-400">
|
||||
{Math.round(
|
||||
(data.procedures.coverage.reduce((s, d) => s + d.procs, 0) /
|
||||
data.procedures.coverage.reduce((s, d) => s + d.total_expected, 0)) * 100
|
||||
)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Tabela de cobertura por departamento */}
|
||||
<motion.div variants={itemVariants} className="glass-card p-6">
|
||||
<h3 className="text-sm font-semibold text-white mb-6 uppercase tracking-wide flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-brand-400" />
|
||||
Cobertura de PROCs por Departamento
|
||||
</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-white/10">
|
||||
<th className="text-left py-2 pr-4 text-zinc-400 font-medium">Departamento</th>
|
||||
<th className="text-center py-2 px-4 text-zinc-400 font-medium">PROCs</th>
|
||||
<th className="text-center py-2 px-4 text-zinc-400 font-medium">Esperados</th>
|
||||
<th className="text-left py-2 pl-4 text-zinc-400 font-medium w-48">Cobertura</th>
|
||||
<th className="text-right py-2 pl-4 text-zinc-400 font-medium">%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.procedures.coverage.map((item, i) => (
|
||||
<tr
|
||||
key={i}
|
||||
className="border-b border-white/5 hover:bg-white/[0.02] transition-colors"
|
||||
>
|
||||
<td className="py-3 pr-4 text-white font-medium">{item.dept}</td>
|
||||
<td className="py-3 px-4 text-center text-zinc-300 tabular-nums">{item.procs}</td>
|
||||
<td className="py-3 px-4 text-center text-zinc-500 tabular-nums">{item.total_expected}</td>
|
||||
<td className="py-3 pl-4 w-48">
|
||||
<ProgressBar pct={item.pct} />
|
||||
</td>
|
||||
<td className="py-3 pl-4 text-right tabular-nums font-semibold"
|
||||
style={{
|
||||
color: item.pct >= 80 ? '#34d399' : item.pct >= 50 ? '#fbbf24' : '#f87171',
|
||||
}}
|
||||
>
|
||||
{item.pct}%
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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