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:
60
package-lock.json
generated
60
package-lock.json
generated
@@ -13,6 +13,7 @@
|
||||
"lucide-react": "^0.563.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"recharts": "^3.7.0",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
@@ -2414,6 +2415,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -3193,7 +3207,6 @@
|
||||
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
@@ -3295,7 +3308,6 @@
|
||||
"integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3"
|
||||
},
|
||||
@@ -3896,6 +3908,44 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.13.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz",
|
||||
"integrity": "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
"set-cookie-parser": "^2.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.13.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.0.tgz",
|
||||
"integrity": "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.13.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz",
|
||||
@@ -4019,6 +4069,12 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"lucide-react": "^0.563.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"recharts": "^3.7.0",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
|
||||
5
serve.json
Normal file
5
serve.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"rewrites": [
|
||||
{ "source": "**", "destination": "/index.html" }
|
||||
]
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
451
src/pages/Monitor.tsx
Normal 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: há {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: '' },
|
||||
],
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user