From 7aae4f3c5232e0b01ea4979139c0f694d497801d Mon Sep 17 00:00:00 2001 From: Emanuel Almeida Date: Tue, 3 Feb 2026 14:17:02 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20adicionar=20p=C3=A1gina=20de=20Monitori?= =?UTF-8?q?za=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- package-lock.json | 60 +++++- package.json | 1 + serve.json | 5 + src/App.tsx | 2 +- src/main.tsx | 9 +- src/pages/Monitor.tsx | 451 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 524 insertions(+), 4 deletions(-) create mode 100644 serve.json create mode 100644 src/pages/Monitor.tsx diff --git a/package-lock.json b/package-lock.json index ae354dd..c6bc021 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index fe91141..dde0dd2 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/serve.json b/serve.json new file mode 100644 index 0000000..bac26a5 --- /dev/null +++ b/serve.json @@ -0,0 +1,5 @@ +{ + "rewrites": [ + { "source": "**", "destination": "/index.html" } + ] +} diff --git a/src/App.tsx b/src/App.tsx index 7143bcd..43fbf61 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -493,7 +493,7 @@ function App() { Dashboard - + Monitor diff --git a/src/main.tsx b/src/main.tsx index bef5202..2cd0d56 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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( - + + + } /> + } /> + + , ) diff --git a/src/pages/Monitor.tsx b/src/pages/Monitor.tsx new file mode 100644 index 0000000..36cf3c0 --- /dev/null +++ b/src/pages/Monitor.tsx @@ -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 + 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 = { + 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 ( + + {status} + + ) +} + +// 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 ( +
+
+ +
+ {showLabel && {percent}%} +
+ ) +} + +// Summary Card +const SummaryCard = ({ value, label, color, icon: Icon }: { value: number; label: string; color: string; icon: React.ElementType }) => ( + + +
{value}
+
{label}
+
+) + +// Category Card +const CategoryCard = ({ + title, + icon: Icon, + count, + children, +}: { + title: string + icon: React.ElementType + count?: number + children: React.ReactNode +}) => ( + +
+

+ + {title} +

+ {count !== undefined && ( + {count} items + )} +
+
{children}
+
+) + +// Monitor Item +const MonitorItemRow = ({ item }: { item: MonitorItem }) => ( +
+
+
{item.name}
+ {item.details && ( +
+ {item.details.cpu !== undefined && ( + + CPU{' '} + {item.details.cpu}% + + )} + {item.details.ram !== undefined && ( + + RAM{' '} + {item.details.ram}% + + )} + {item.details.disk !== undefined && ( + + Disco{' '} + {item.details.disk}% + + )} + {item.details.response_time !== undefined && ( + {item.details.response_time}s + )} + {item.details.domain && ( + {item.details.domain} + )} +
+ )} +
+ +
+) + +// Main Monitor Page +export default function Monitor() { + const [data, setData] = useState(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 ( +
+ + +

A carregar monitorização...

+
+
+ ) + } + + 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) + + 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 ( +
+
+ {/* Header */} +
+
+
+
+ + + +
+

Monitorização

+

Sistemas Descomplicar

+
+
+ +
+ + {overallLabel} + + + + + + + Dashboard + +
+
+
+
+ + {/* Main Content */} +
+ + {/* Summary Grid */} +
+ + + +
+ + {/* Monitor Grid */} +
+ {/* Servers */} + {groupedItems.server && ( + + {groupedItems.server.map((item) => ( + + ))} + + )} + + {/* Services */} + {groupedItems.service && ( + + {groupedItems.service.map((item) => ( + + ))} + + )} + + {/* Sites */} + {groupedItems.site && ( + + {groupedItems.site.map((item) => ( + + ))} + + )} + + {/* Containers */} + {groupedItems.container && groupedItems.container[0] && ( + +
+
+ {groupedItems.container[0].details?.up || 0} +
+
+ de {groupedItems.container[0].details?.total || 0} containers activos +
+ {(groupedItems.container[0].details?.down || 0) > 0 && ( +
+ {groupedItems.container[0].details.down} containers em baixo +
+ )} +
+ +
+
+
+ )} + + {/* Backups */} + {groupedItems.backup && ( + + {groupedItems.backup.map((item) => ( +
+
+
{item.name}
+ {item.details?.age_hours !== undefined && ( +
Último: há {item.details.age_hours}h
+ )} +
+ +
+ ))} +
+ )} + + {/* Storage */} + {groupedItems.storage && ( + + {groupedItems.storage.map((item) => ( +
+
+ {item.name} + + {item.details?.used} / {item.details?.total} + +
+ +
+ ))} +
+ )} + + {/* WP Updates */} + {groupedItems.wp_update && groupedItems.wp_update[0] && ( + +
+ {(groupedItems.wp_update[0].details?.manual_updates || 0) > 0 ? ( + <> +
+ {groupedItems.wp_update[0].details.manual_updates} +
+
+ plugins precisam update manual +
+ + ) : ( + <> + +
Tudo actualizado
+ + )} +
+
+ )} +
+ + {/* Footer */} +
+ + Última actualização: {new Date().toLocaleTimeString('pt-PT')} + · + Auto-refresh: 60s +
+
+
+
+
+ ) +} + +// 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: '' }, + ], + } +}