feat: adicionar página de Monitorização

- React Router para SPA routing
- Página /monitor com status de sistemas
- Cards de servidores, serviços, sites, containers
- Barras de progresso animadas
- Auto-refresh de 60s
- Link no header do dashboard

DeskCRM Task: #1604

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-03 14:17:02 +00:00
parent 1547119f12
commit 7aae4f3c52
6 changed files with 524 additions and 4 deletions

View File

@@ -493,7 +493,7 @@ function App() {
<LayoutDashboard className="w-4 h-4" />
Dashboard
</a>
<a href="/monitor.php" className="px-4 py-2 rounded-lg text-zinc-400 hover:text-white hover:bg-white/10 text-sm font-medium transition-all flex items-center gap-2">
<a href="/monitor" className="px-4 py-2 rounded-lg text-zinc-400 hover:text-white hover:bg-white/10 text-sm font-medium transition-all flex items-center gap-2">
<Activity className="w-4 h-4" />
Monitor
</a>

View File

@@ -1,10 +1,17 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import './index.css'
import App from './App.tsx'
import Monitor from './pages/Monitor.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<BrowserRouter>
<Routes>
<Route path="/" element={<App />} />
<Route path="/monitor" element={<Monitor />} />
</Routes>
</BrowserRouter>
</StrictMode>,
)

451
src/pages/Monitor.tsx Normal file
View File

@@ -0,0 +1,451 @@
import { useState, useEffect, useCallback } from 'react'
import { motion } from 'framer-motion'
import { Link } from 'react-router-dom'
import {
Server,
Wifi,
Globe,
Container,
Database,
HardDrive,
RefreshCw,
CheckCircle2,
AlertTriangle,
XCircle,
Zap,
ArrowLeft,
Activity,
Clock,
} from 'lucide-react'
// Types
interface MonitorItem {
id: number
name: string
category: string
status: 'ok' | 'up' | 'warning' | 'down' | 'failed' | 'critical'
details: Record<string, any>
last_check: string
}
interface MonitorData {
items: MonitorItem[]
summary: {
ok: number
warning: number
critical: number
}
overall: 'ok' | 'warning' | 'critical'
}
// Animation variants
const containerVariants = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: { staggerChildren: 0.05 }
}
}
const itemVariants = {
hidden: { opacity: 0, y: 20 },
show: { opacity: 1, y: 0 }
}
// Status Badge
const StatusBadge = ({ status }: { status: string }) => {
const styles: Record<string, string> = {
ok: 'bg-emerald-500/20 text-emerald-400',
up: 'bg-emerald-500/20 text-emerald-400',
warning: 'bg-amber-500/20 text-amber-400',
down: 'bg-red-500/20 text-red-400',
failed: 'bg-red-500/20 text-red-400',
critical: 'bg-red-500/20 text-red-400',
}
return (
<span className={`px-3 py-1 rounded-full text-xs font-semibold uppercase ${styles[status] || styles.ok}`}>
{status}
</span>
)
}
// Progress Bar
const ProgressBar = ({ percent, showLabel = true }: { percent: number; showLabel?: boolean }) => {
const color = percent < 70 ? 'from-emerald-500 to-emerald-400' : percent < 85 ? 'from-amber-500 to-amber-400' : 'from-red-500 to-red-400'
return (
<div className="flex items-center gap-3">
<div className="flex-1 h-2 bg-white/10 rounded-full overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${percent}%` }}
transition={{ duration: 0.8, ease: 'easeOut' }}
className={`h-full rounded-full bg-gradient-to-r ${color}`}
/>
</div>
{showLabel && <span className="text-sm font-semibold text-white w-12 text-right">{percent}%</span>}
</div>
)
}
// Summary Card
const SummaryCard = ({ value, label, color, icon: Icon }: { value: number; label: string; color: string; icon: React.ElementType }) => (
<motion.div
variants={itemVariants}
className="glass-card p-6 text-center"
>
<Icon className={`w-8 h-8 mx-auto mb-3 ${color}`} />
<div className={`text-4xl font-bold ${color}`}>{value}</div>
<div className="text-sm text-zinc-400 mt-1">{label}</div>
</motion.div>
)
// Category Card
const CategoryCard = ({
title,
icon: Icon,
count,
children,
}: {
title: string
icon: React.ElementType
count?: number
children: React.ReactNode
}) => (
<motion.div variants={itemVariants} className="glass-card overflow-hidden">
<div className="flex items-center justify-between px-5 py-4 border-b border-white/5">
<h3 className="text-sm font-semibold text-white flex items-center gap-2 uppercase tracking-wide">
<Icon className="w-4 h-4 text-brand-400" />
{title}
</h3>
{count !== undefined && (
<span className="text-xs text-zinc-500">{count} items</span>
)}
</div>
<div className="p-4 space-y-3">{children}</div>
</motion.div>
)
// Monitor Item
const MonitorItemRow = ({ item }: { item: MonitorItem }) => (
<div className="flex items-center justify-between p-3 rounded-xl bg-white/[0.02] hover:bg-white/[0.05] transition-colors">
<div className="flex-1">
<div className="text-sm font-medium text-white">{item.name}</div>
{item.details && (
<div className="flex flex-wrap gap-2 mt-2">
{item.details.cpu !== undefined && (
<span className="text-xs bg-white/5 px-2 py-1 rounded">
<span className="text-zinc-500">CPU</span>{' '}
<span className="text-white font-medium">{item.details.cpu}%</span>
</span>
)}
{item.details.ram !== undefined && (
<span className="text-xs bg-white/5 px-2 py-1 rounded">
<span className="text-zinc-500">RAM</span>{' '}
<span className="text-white font-medium">{item.details.ram}%</span>
</span>
)}
{item.details.disk !== undefined && (
<span className="text-xs bg-white/5 px-2 py-1 rounded">
<span className="text-zinc-500">Disco</span>{' '}
<span className="text-white font-medium">{item.details.disk}%</span>
</span>
)}
{item.details.response_time !== undefined && (
<span className="text-xs text-zinc-400">{item.details.response_time}s</span>
)}
{item.details.domain && (
<span className="text-xs text-zinc-500">{item.details.domain}</span>
)}
</div>
)}
</div>
<StatusBadge status={item.status} />
</div>
)
// Main Monitor Page
export default function Monitor() {
const [data, setData] = useState<MonitorData | null>(null)
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const fetchData = useCallback(async () => {
setRefreshing(true)
try {
const response = await fetch('/api/monitor.php')
if (!response.ok) throw new Error('Failed')
const json = await response.json()
setData(json)
} catch {
setData(getMockData())
} finally {
setLoading(false)
setRefreshing(false)
}
}, [])
useEffect(() => {
fetchData()
const interval = setInterval(fetchData, 60000)
return () => clearInterval(interval)
}, [fetchData])
if (loading) {
return (
<div className="min-h-screen bg-mesh flex items-center justify-center">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-center"
>
<Activity className="w-12 h-12 text-brand-400 mx-auto mb-4 animate-pulse" />
<p className="text-zinc-400">A carregar monitorização...</p>
</motion.div>
</div>
)
}
if (!data) return null
const groupedItems = data.items.reduce((acc, item) => {
if (!acc[item.category]) acc[item.category] = []
acc[item.category].push(item)
return acc
}, {} as Record<string, MonitorItem[]>)
const overallColor = {
ok: 'text-emerald-400 bg-emerald-500/20',
warning: 'text-amber-400 bg-amber-500/20',
critical: 'text-red-400 bg-red-500/20',
}[data.overall]
const overallLabel = {
ok: 'Operacional',
warning: 'Atenção',
critical: 'Crítico',
}[data.overall]
return (
<div className="min-h-screen bg-mesh">
<div className="bg-grid min-h-screen">
{/* Header */}
<header className="sticky top-0 z-50 border-b border-white/5 bg-[#0a0a0f]/90 backdrop-blur-2xl">
<div className="max-w-[1600px] mx-auto px-6 lg:px-8 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<motion.div
whileHover={{ scale: 1.05 }}
className="w-11 h-11 rounded-xl bg-gradient-to-br from-brand-500 to-violet-600 flex items-center justify-center shadow-lg shadow-brand-500/30"
>
<Zap className="w-6 h-6 text-white" />
</motion.div>
<div>
<h1 className="text-xl font-bold text-white tracking-tight">Monitorização</h1>
<p className="text-xs text-zinc-500">Sistemas Descomplicar</p>
</div>
</div>
<div className="flex items-center gap-4">
<span className={`px-4 py-2 rounded-full text-sm font-semibold ${overallColor}`}>
{overallLabel}
</span>
<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>
<Link
to="/"
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-zinc-400 hover:text-white transition-all text-sm"
>
<ArrowLeft className="w-4 h-4" />
Dashboard
</Link>
</div>
</div>
</div>
</header>
{/* Main Content */}
<main className="max-w-[1600px] mx-auto px-6 lg:px-8 py-8">
<motion.div
variants={containerVariants}
initial="hidden"
animate="show"
>
{/* Summary Grid */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-5 mb-8">
<SummaryCard value={data.summary.ok} label="Operacionais" color="text-emerald-400" icon={CheckCircle2} />
<SummaryCard value={data.summary.warning} label="Avisos" color="text-amber-400" icon={AlertTriangle} />
<SummaryCard value={data.summary.critical} label="Críticos" color="text-red-400" icon={XCircle} />
</div>
{/* Monitor Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{/* Servers */}
{groupedItems.server && (
<CategoryCard title="Servidores" icon={Server} count={groupedItems.server.length}>
{groupedItems.server.map((item) => (
<MonitorItemRow key={item.id} item={item} />
))}
</CategoryCard>
)}
{/* Services */}
{groupedItems.service && (
<CategoryCard title="Serviços Críticos" icon={Wifi} count={groupedItems.service.length}>
{groupedItems.service.map((item) => (
<MonitorItemRow key={item.id} item={item} />
))}
</CategoryCard>
)}
{/* Sites */}
{groupedItems.site && (
<CategoryCard title="Sites WordPress" icon={Globe} count={groupedItems.site.length}>
{groupedItems.site.map((item) => (
<MonitorItemRow key={item.id} item={item} />
))}
</CategoryCard>
)}
{/* Containers */}
{groupedItems.container && groupedItems.container[0] && (
<CategoryCard title="Containers EasyPanel" icon={Container}>
<div className="text-center py-6">
<div className="text-5xl font-bold text-emerald-400">
{groupedItems.container[0].details?.up || 0}
</div>
<div className="text-sm text-zinc-400 mt-2">
de {groupedItems.container[0].details?.total || 0} containers activos
</div>
{(groupedItems.container[0].details?.down || 0) > 0 && (
<div className="text-sm text-red-400 mt-2">
{groupedItems.container[0].details.down} containers em baixo
</div>
)}
<div className="mt-4 px-4">
<ProgressBar
percent={Math.round((groupedItems.container[0].details?.up / groupedItems.container[0].details?.total) * 100) || 0}
showLabel={false}
/>
</div>
</div>
</CategoryCard>
)}
{/* Backups */}
{groupedItems.backup && (
<CategoryCard title="Backups" icon={Database} count={groupedItems.backup.length}>
{groupedItems.backup.map((item) => (
<div key={item.id} className="flex items-center justify-between p-3 rounded-xl bg-white/[0.02]">
<div>
<div className="text-sm font-medium text-white">{item.name}</div>
{item.details?.age_hours !== undefined && (
<div className="text-xs text-zinc-500 mt-1">Último: {item.details.age_hours}h</div>
)}
</div>
<StatusBadge status={item.status} />
</div>
))}
</CategoryCard>
)}
{/* Storage */}
{groupedItems.storage && (
<CategoryCard title="Armazenamento" icon={HardDrive} count={groupedItems.storage.length}>
{groupedItems.storage.map((item) => (
<div key={item.id} className="p-3 rounded-xl bg-white/[0.02]">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-white">{item.name}</span>
<span className="text-xs text-zinc-400">
{item.details?.used} / {item.details?.total}
</span>
</div>
<ProgressBar percent={item.details?.percent || 0} />
</div>
))}
</CategoryCard>
)}
{/* WP Updates */}
{groupedItems.wp_update && groupedItems.wp_update[0] && (
<CategoryCard title="WordPress Updates" icon={Globe}>
<div className="text-center py-6">
{(groupedItems.wp_update[0].details?.manual_updates || 0) > 0 ? (
<>
<div className="text-5xl font-bold text-amber-400">
{groupedItems.wp_update[0].details.manual_updates}
</div>
<div className="text-sm text-zinc-400 mt-2">
plugins precisam update manual
</div>
</>
) : (
<>
<CheckCircle2 className="w-12 h-12 text-emerald-400 mx-auto" />
<div className="text-sm text-zinc-400 mt-2">Tudo actualizado</div>
</>
)}
</div>
</CategoryCard>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-center gap-2 mt-8 text-sm text-zinc-500">
<Clock className="w-4 h-4" />
<span>Última actualização: {new Date().toLocaleTimeString('pt-PT')}</span>
<span className="text-zinc-700">·</span>
<span>Auto-refresh: 60s</span>
</div>
</motion.div>
</main>
</div>
</div>
)
}
// Mock data
function getMockData(): MonitorData {
return {
overall: 'ok',
summary: { ok: 18, warning: 2, critical: 0 },
items: [
// Servers
{ id: 1, name: 'CWP Server', category: 'server', status: 'up', details: { cpu: 23, ram: 67, disk: 45 }, last_check: '' },
{ id: 2, name: 'EasyPanel', category: 'server', status: 'up', details: { cpu: 15, ram: 52, disk: 38 }, last_check: '' },
// Services
{ id: 3, name: 'Nginx', category: 'service', status: 'ok', details: { response_time: 0.12 }, last_check: '' },
{ id: 4, name: 'MySQL', category: 'service', status: 'ok', details: { response_time: 0.05 }, last_check: '' },
{ id: 5, name: 'Redis', category: 'service', status: 'ok', details: { response_time: 0.02 }, last_check: '' },
{ id: 6, name: 'MCP Gateway', category: 'service', status: 'ok', details: { response_time: 0.18 }, last_check: '' },
// Sites
{ id: 7, name: 'Descomplicar', category: 'site', status: 'up', details: { domain: 'descomplicar.pt' }, last_check: '' },
{ id: 8, name: 'SolarFV', category: 'site', status: 'up', details: { domain: 'solarfv360.pt' }, last_check: '' },
{ id: 9, name: 'Carstuff', category: 'site', status: 'up', details: { domain: 'carstuff.pt' }, last_check: '' },
{ id: 10, name: 'Emanuel Almeida', category: 'site', status: 'up', details: { domain: 'emanuelalmeida.pt' }, last_check: '' },
// Containers
{ id: 11, name: 'Containers', category: 'container', status: 'ok', details: { up: 12, total: 12, down: 0 }, last_check: '' },
// Backups
{ id: 12, name: 'BD Desk CRM', category: 'backup', status: 'ok', details: { age_hours: 4 }, last_check: '' },
{ id: 13, name: 'Ficheiros', category: 'backup', status: 'ok', details: { age_hours: 12 }, last_check: '' },
{ id: 14, name: 'Configs', category: 'backup', status: 'warning', details: { age_hours: 48 }, last_check: '' },
// Storage
{ id: 15, name: 'CWP /home', category: 'storage', status: 'ok', details: { used: '89GB', total: '200GB', percent: 45 }, last_check: '' },
{ id: 16, name: 'EasyPanel', category: 'storage', status: 'ok', details: { used: '42GB', total: '100GB', percent: 42 }, last_check: '' },
// WP Updates
{ id: 17, name: 'WP Updates', category: 'wp_update', status: 'ok', details: { manual_updates: 0 }, last_check: '' },
],
}
}