diff --git a/.env.example b/.env.example index 4b7972d..df66640 100644 --- a/.env.example +++ b/.env.example @@ -8,5 +8,8 @@ DB_NAME=ealmeida_desk24 API_PORT=3001 FRONTEND_URL=http://localhost:5173 +# Hetzner Cloud API +HETZNER_TOKEN=your_hetzner_api_token_here + # Production URLs # FRONTEND_URL=https://dash.descomplicar.pt diff --git a/CHANGELOG.md b/CHANGELOG.md index 3535d7d..3c8cb84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,56 @@ Todas as alterações notáveis neste projecto serão documentadas neste ficheiro. +## [2.2.0] - 2026-02-04 + +### Added +- ✅ **WordPress Monitor API** - Endpoint para receber dados do plugin WP + - Rota `POST /api/wp-monitor` - Recebe dados de sites WordPress + - Rota `GET /api/wp-monitor` - Lista sites monitorizados + - Rota `GET /api/wp-monitor?test` - Testar conexão + - Autenticação via header `X-API-Key` + +- ✅ **Site Availability Checker** - Verificação de disponibilidade HTTP + - Função `checkSiteAvailability()` em `/api/services/monitoring.ts` + - Função `checkAllSitesAvailability()` para verificar todos os sites + - Script cron `/api/scripts/check-sites.ts` para verificação periódica + - Rota `POST /api/monitor/check-sites` para trigger manual + +### Changed +- ✅ `/api/routes/monitor.ts` - Adicionada rota POST para check manual + +### Technical Notes +- Script de cron: `npx tsx api/scripts/check-sites.ts` +- Recomendado: executar a cada 10 minutos via cron +- Sites down são marcados com status `down` na BD +- Plugin WP actualizado para usar `dash.descomplicar.pt/api/wp-monitor` + +--- + +## [2.1.0] - 2026-02-04 + +### Added +- ✅ **Hetzner Cloud Monitoring** - Monitorização de VPS Hetzner + - Tabela `tbl_eal_hetzner_servers` - Inventário de servidores + - Tabela `tbl_eal_hetzner_metrics` - Métricas time-series + - View `v_eal_hetzner_latest` - Últimas métricas por servidor + - Serviço `/api/services/hetzner.ts` - Integração API Hetzner Cloud + - Rotas `/api/hetzner/*` para dashboard e collectors + +### Endpoints Hetzner +- `GET /api/hetzner` - Dashboard (servidores + últimas métricas) +- `POST /api/hetzner/sync` - Sincronizar lista de servidores +- `POST /api/hetzner/collect` - Recolher métricas de todos os servidores +- `POST /api/hetzner/collect/:id` - Recolher métricas de um servidor +- `GET /api/hetzner/history/:id` - Histórico de métricas (24h default) +- `POST /api/hetzner/cleanup` - Limpar métricas antigas (7 dias default) + +### Technical Notes +- Token Hetzner armazenado em `.env` (HETZNER_TOKEN) +- Métricas: CPU%, disk IOPS/bandwidth, network bandwidth/pps +- Retenção de dados: 7 dias (configurável) +- Collector pode ser executado via cron: `curl -X POST localhost:3001/api/hetzner/collect` + ## [2.0.1] - 2026-02-04 ### Added diff --git a/api/routes/hetzner.ts b/api/routes/hetzner.ts new file mode 100644 index 0000000..a27c622 --- /dev/null +++ b/api/routes/hetzner.ts @@ -0,0 +1,141 @@ +/** + * Hetzner Cloud API Routes + * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 + */ +import { Router, Request, Response } from 'express' +import { + syncServers, + collectAllMetrics, + collectMetrics, + getHetznerDashboard, + getMetricsHistory, + cleanupOldMetrics +} from '../services/hetzner.js' + +const router = Router() + +// GET /api/hetzner - Dashboard data (servidores + últimas métricas) +router.get('/', async (_req: Request, res: Response) => { + try { + const data = await getHetznerDashboard() + res.json({ + success: true, + data + }) + } catch (error) { + console.error('Error fetching Hetzner dashboard:', error) + res.status(500).json({ + success: false, + error: 'Failed to fetch Hetzner data' + }) + } +}) + +// POST /api/hetzner/sync - Sincronizar lista de servidores +router.post('/sync', async (_req: Request, res: Response) => { + try { + const synced = await syncServers() + res.json({ + success: true, + message: `Sincronizados ${synced} servidores`, + synced + }) + } catch (error) { + console.error('Error syncing servers:', error) + res.status(500).json({ + success: false, + error: 'Failed to sync servers' + }) + } +}) + +// POST /api/hetzner/collect - Recolher métricas de todos os servidores +router.post('/collect', async (_req: Request, res: Response) => { + try { + const result = await collectAllMetrics() + res.json({ + success: true, + message: `Recolhidas métricas: ${result.success} OK, ${result.failed} falharam`, + ...result + }) + } catch (error) { + console.error('Error collecting metrics:', error) + res.status(500).json({ + success: false, + error: 'Failed to collect metrics' + }) + } +}) + +// POST /api/hetzner/collect/:hetzner_id - Recolher métricas de um servidor específico +router.post('/collect/:hetzner_id', async (req: Request, res: Response) => { + try { + const hetzner_id = parseInt(req.params.hetzner_id) + if (isNaN(hetzner_id)) { + return res.status(400).json({ + success: false, + error: 'Invalid server ID' + }) + } + + const success = await collectMetrics(hetzner_id) + res.json({ + success, + message: success ? 'Métricas recolhidas' : 'Falha ao recolher métricas' + }) + } catch (error) { + console.error('Error collecting metrics:', error) + res.status(500).json({ + success: false, + error: 'Failed to collect metrics' + }) + } +}) + +// GET /api/hetzner/history/:server_id - Histórico de métricas para gráficos +router.get('/history/:server_id', async (req: Request, res: Response) => { + try { + const server_id = parseInt(req.params.server_id) + const hours = parseInt(req.query.hours as string) || 24 + + if (isNaN(server_id)) { + return res.status(400).json({ + success: false, + error: 'Invalid server ID' + }) + } + + const metrics = await getMetricsHistory(server_id, hours) + res.json({ + success: true, + data: metrics + }) + } catch (error) { + console.error('Error fetching metrics history:', error) + res.status(500).json({ + success: false, + error: 'Failed to fetch metrics history' + }) + } +}) + +// POST /api/hetzner/cleanup - Limpar métricas antigas +router.post('/cleanup', async (req: Request, res: Response) => { + try { + const days = parseInt(req.query.days as string) || 7 + const deleted = await cleanupOldMetrics(days) + res.json({ + success: true, + message: `Eliminadas ${deleted} entradas com mais de ${days} dias`, + deleted + }) + } catch (error) { + console.error('Error cleaning up metrics:', error) + res.status(500).json({ + success: false, + error: 'Failed to cleanup metrics' + }) + } +}) + +export default router diff --git a/api/routes/monitor.ts b/api/routes/monitor.ts index 4453c76..024c1e3 100644 --- a/api/routes/monitor.ts +++ b/api/routes/monitor.ts @@ -1,6 +1,7 @@ /** * Monitor API Route - * GET /api/monitor + * GET /api/monitor - Get all monitoring data + * POST /api/monitor/check-sites - Trigger site availability check * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 */ import { Router } from 'express' @@ -9,6 +10,7 @@ import * as monitoringService from '../services/monitoring.js' const router = Router() +// Get monitoring data router.get('/', async (req: Request, res: Response) => { try { const data = await monitoringService.getMonitoringData() @@ -19,4 +21,21 @@ router.get('/', async (req: Request, res: Response) => { } }) +// Trigger site availability check +router.post('/check-sites', async (req: Request, res: Response) => { + try { + console.log('[Monitor] Manual site check triggered') + const result = await monitoringService.checkAllSitesAvailability() + res.json({ + success: true, + message: 'Site check completed', + ...result, + timestamp: new Date().toISOString() + }) + } catch (error) { + console.error('Site check error:', error) + res.status(500).json({ error: 'Internal server error', message: (error as Error).message }) + } +}) + export default router diff --git a/api/routes/wp-monitor.ts b/api/routes/wp-monitor.ts new file mode 100644 index 0000000..b697c0e --- /dev/null +++ b/api/routes/wp-monitor.ts @@ -0,0 +1,144 @@ +/** + * WordPress Monitor API Route + * Receives data from Descomplicar Monitor WordPress plugin + * POST /api/wp-monitor - Receive site data + * GET /api/wp-monitor - List monitored sites + * GET /api/wp-monitor?test - Connection test + * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 + */ +import { Router } from 'express' +import type { Request, Response } from 'express' +import db from '../db.js' + +const router = Router() +const API_KEY = process.env.WP_MONITOR_API_KEY || 'descomplicar-monitor-2026' + +// Middleware to validate API key +function validateApiKey(req: Request, res: Response, next: Function) { + const apiKey = req.headers['x-api-key'] || req.query.key + + if (apiKey !== API_KEY) { + return res.status(401).json({ error: 'Unauthorized', message: 'Invalid API Key' }) + } + next() +} + +// Test endpoint +router.get('/', validateApiKey, async (req: Request, res: Response) => { + // Test connection + if (req.query.test !== undefined) { + return res.json({ success: true, message: 'Connection OK', timestamp: new Date().toISOString() }) + } + + // List all WordPress sites + try { + const [sites] = await db.query(` + SELECT name, status, details, last_check + FROM tbl_eal_monitoring + WHERE category = 'site' + ORDER BY name ASC + `) + + const result = (sites as any[]).map(site => ({ + name: site.name, + status: site.status, + last_check: site.last_check, + data: typeof site.details === 'string' ? JSON.parse(site.details) : site.details + })) + + res.json({ + success: true, + sites: result, + total: result.length, + timestamp: new Date().toISOString() + }) + } catch (error) { + console.error('WP Monitor GET error:', error) + res.status(500).json({ error: 'Database error', message: (error as Error).message }) + } +}) + +// Receive data from WordPress plugin +router.post('/', validateApiKey, async (req: Request, res: Response) => { + const data = req.body + + if (!data || !data.site_url) { + return res.status(400).json({ error: 'Bad Request', message: 'Invalid JSON or missing site_url' }) + } + + try { + const siteUrl = data.site_url.replace(/\/$/, '') + const siteName = data.site_name || new URL(siteUrl).hostname + + // Determine status based on data + const status = determineStatus(data) + const jsonData = JSON.stringify(data) + + // Check if site exists + const [existing] = await db.query( + 'SELECT id FROM tbl_eal_monitoring WHERE category = ? AND name = ?', + ['site', siteName] + ) + + if ((existing as any[]).length > 0) { + // Update existing + await db.query( + 'UPDATE tbl_eal_monitoring SET details = ?, status = ?, last_check = NOW() WHERE category = ? AND name = ?', + [jsonData, status, 'site', siteName] + ) + } else { + // Insert new + await db.query( + 'INSERT INTO tbl_eal_monitoring (name, category, status, details, last_check) VALUES (?, ?, ?, ?, NOW())', + [siteName, 'site', status, jsonData] + ) + } + + console.log(`[WP-Monitor] Received data from: ${siteUrl} - Status: ${status}`) + + res.json({ + success: true, + message: 'Data received', + site: siteName, + status, + timestamp: new Date().toISOString() + }) + } catch (error) { + console.error('WP Monitor POST error:', error) + res.status(500).json({ error: 'Database error', message: (error as Error).message }) + } +}) + +/** + * Determine status based on WordPress data + */ +function determineStatus(data: any): string { + // Critical conditions + if (data.health?.status === 'critical') { + return 'failed' + } + + let warnings = 0 + + // Many pending updates + if (data.updates?.counts?.total > 5) warnings++ + + // Core update pending + if (data.updates?.core?.length > 0) warnings++ + + // Debug mode in production + if (data.system?.debug_mode === true) warnings++ + + // Health issues + if (data.health?.issues?.length > 0) { + warnings += data.health.issues.length + } + + // Large database (>500MB) + if (data.database?.size_mb > 500) warnings++ + + if (warnings >= 3) return 'warning' + return 'ok' +} + +export default router diff --git a/api/scripts/check-sites.ts b/api/scripts/check-sites.ts new file mode 100644 index 0000000..2116b99 --- /dev/null +++ b/api/scripts/check-sites.ts @@ -0,0 +1,50 @@ +#!/usr/bin/env npx tsx +/** + * Site Availability Checker Script + * Run via cron every 5-15 minutes to check if sites are online + * + * Usage: + * npx tsx api/scripts/check-sites.ts + * + * Cron example (every 10 minutes): + * */10 * * * * cd /path/to/DashDescomplicar && npx tsx api/scripts/check-sites.ts >> /var/log/check-sites.log 2>&1 + * + * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 + */ +import 'dotenv/config' +import { checkAllSitesAvailability } from '../services/monitoring.js' + +async function main() { + const startTime = Date.now() + console.log(`[${new Date().toISOString()}] Starting site availability check...`) + + try { + const result = await checkAllSitesAvailability() + + console.log(`[${new Date().toISOString()}] Check completed:`) + console.log(` - Sites checked: ${result.checked}`) + console.log(` - Up: ${result.up}`) + console.log(` - Down: ${result.down}`) + + // Log any down sites + const downSites = result.results.filter(r => !r.available) + if (downSites.length > 0) { + console.log(` - Down sites:`) + for (const site of downSites) { + console.log(` - ${site.name}: ${site.error || 'No response'}`) + } + } + + const elapsed = Date.now() - startTime + console.log(`[${new Date().toISOString()}] Done in ${elapsed}ms`) + + // Exit with code 1 if any sites are down (useful for alerting) + process.exit(downSites.length > 0 ? 1 : 0) + + } catch (error) { + console.error(`[${new Date().toISOString()}] Error:`, error) + process.exit(2) + } +} + +main() diff --git a/api/scripts/hetzner-collector.ts b/api/scripts/hetzner-collector.ts new file mode 100644 index 0000000..25223a3 --- /dev/null +++ b/api/scripts/hetzner-collector.ts @@ -0,0 +1,63 @@ +#!/usr/bin/env npx tsx +/** + * Hetzner Metrics Collector - Standalone Script + * Pode ser executado via cron para recolha periódica de métricas + * + * Uso: + * npx tsx api/scripts/hetzner-collector.ts + * npx tsx api/scripts/hetzner-collector.ts --sync # Sync + Collect + * npx tsx api/scripts/hetzner-collector.ts --cleanup # Collect + Cleanup + * npx tsx api/scripts/hetzner-collector.ts --all # Sync + Collect + Cleanup + * + * Cron example (a cada 5 minutos): + * */5 * * * * cd /path/to/DashDescomplicar && npx tsx api/scripts/hetzner-collector.ts >> /var/log/hetzner-collector.log 2>&1 + * + * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 + */ +import 'dotenv/config' +import { + syncServers, + collectAllMetrics, + cleanupOldMetrics +} from '../services/hetzner.js' + +const args = process.argv.slice(2) +const doSync = args.includes('--sync') || args.includes('--all') +const doCleanup = args.includes('--cleanup') || args.includes('--all') + +async function main() { + const timestamp = new Date().toISOString() + console.log(`[${timestamp}] Hetzner Collector Started`) + console.log('='.repeat(50)) + + try { + // 1. Sync servers (optional) + if (doSync) { + console.log('[SYNC] Sincronizando lista de servidores...') + const synced = await syncServers() + console.log(`[SYNC] ✅ ${synced} servidores sincronizados`) + } + + // 2. Collect metrics (always) + console.log('[COLLECT] Recolhendo métricas...') + const result = await collectAllMetrics() + console.log(`[COLLECT] ✅ ${result.success} OK, ${result.failed} falharam`) + + // 3. Cleanup old data (optional) + if (doCleanup) { + console.log('[CLEANUP] Limpando métricas antigas (>7 dias)...') + const deleted = await cleanupOldMetrics(7) + console.log(`[CLEANUP] ✅ ${deleted} entradas eliminadas`) + } + + console.log('='.repeat(50)) + console.log(`[${new Date().toISOString()}] Collector Finished Successfully`) + process.exit(0) + + } catch (error) { + console.error('[ERROR] Collector failed:', error) + process.exit(1) + } +} + +main() diff --git a/api/server.ts b/api/server.ts index 93d308b..bb13831 100644 --- a/api/server.ts +++ b/api/server.ts @@ -8,6 +8,8 @@ import cors from 'cors' import dashboardRouter from './routes/dashboard.js' import monitorRouter from './routes/monitor.js' import diagnosticRouter from './routes/diagnostic.js' +import hetznerRouter from './routes/hetzner.js' +import wpMonitorRouter from './routes/wp-monitor.js' const app = express() const PORT = process.env.API_PORT || 3001 @@ -28,6 +30,8 @@ app.get('/api/health', (req, res) => { app.use('/api/dashboard', dashboardRouter) app.use('/api/monitor', monitorRouter) app.use('/api/diagnostic', diagnosticRouter) +app.use('/api/hetzner', hetznerRouter) +app.use('/api/wp-monitor', wpMonitorRouter) // Error handling app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => { @@ -42,5 +46,6 @@ app.listen(PORT, () => { console.log(`📊 Dashboard: http://localhost:${PORT}/api/dashboard`) console.log(`🔍 Monitor: http://localhost:${PORT}/api/monitor`) console.log(`🔧 Diagnostic: http://localhost:${PORT}/api/diagnostic`) + console.log(`☁️ Hetzner: http://localhost:${PORT}/api/hetzner`) console.log('='.repeat(50)) }) diff --git a/api/services/hetzner.ts b/api/services/hetzner.ts new file mode 100644 index 0000000..81e428c --- /dev/null +++ b/api/services/hetzner.ts @@ -0,0 +1,265 @@ +/** + * Hetzner Cloud API Service + * Recolhe métricas dos VPS via API Hetzner Cloud + * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 + */ +import db from '../db.js' +import type { RowDataPacket, ResultSetHeader } from 'mysql2' + +// Hetzner API Configuration +const HETZNER_API_URL = 'https://api.hetzner.cloud/v1' +const HETZNER_TOKEN = process.env.HETZNER_TOKEN || '' + +interface HetznerServer { + id: number + name: string + status: string + server_type: { name: string } + datacenter: { name: string } + public_net: { + ipv4: { ip: string } + ipv6: { ip: string } + } + private_net: Array<{ ip: string }> + labels: Record + created: string +} + +interface HetznerMetrics { + metrics: { + time_series: { + [key: string]: { + values: Array<[number, string]> + } + } + } +} + +interface ServerWithMetrics { + id: number + hetzner_id: number + name: string + status: string + server_type: string + datacenter: string + public_ipv4: string + collected_at: string | null + cpu_percent: number | null + disk_read_iops: number | null + disk_write_iops: number | null + network_in_bps: number | null + network_out_bps: number | null +} + +// Helper para requests à API Hetzner +async function hetznerRequest(endpoint: string): Promise { + const response = await fetch(`${HETZNER_API_URL}${endpoint}`, { + headers: { + 'Authorization': `Bearer ${HETZNER_TOKEN}`, + 'Content-Type': 'application/json' + } + }) + + if (!response.ok) { + throw new Error(`Hetzner API error: ${response.status} ${response.statusText}`) + } + + return response.json() +} + +// Sincronizar lista de servidores +export async function syncServers(): Promise { + const data = await hetznerRequest<{ servers: HetznerServer[] }>('/servers') + let synced = 0 + + for (const server of data.servers) { + const [existing] = await db.query( + 'SELECT id FROM tbl_eal_hetzner_servers WHERE hetzner_id = ?', + [server.id] + ) + + const serverData = { + hetzner_id: server.id, + name: server.name, + status: server.status, + server_type: server.server_type.name, + datacenter: server.datacenter.name, + public_ipv4: server.public_net.ipv4?.ip || null, + public_ipv6: server.public_net.ipv6?.ip || null, + private_ip: server.private_net?.[0]?.ip || null, + labels: JSON.stringify(server.labels), + created_hetzner: new Date(server.created) + } + + if (existing.length > 0) { + // Update existing + await db.query( + `UPDATE tbl_eal_hetzner_servers SET + name = ?, status = ?, server_type = ?, datacenter = ?, + public_ipv4 = ?, public_ipv6 = ?, private_ip = ?, labels = ? + WHERE hetzner_id = ?`, + [ + serverData.name, serverData.status, serverData.server_type, + serverData.datacenter, serverData.public_ipv4, serverData.public_ipv6, + serverData.private_ip, serverData.labels, server.id + ] + ) + } else { + // Insert new + await db.query( + `INSERT INTO tbl_eal_hetzner_servers + (hetzner_id, name, status, server_type, datacenter, public_ipv4, public_ipv6, private_ip, labels, created_hetzner) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + serverData.hetzner_id, serverData.name, serverData.status, + serverData.server_type, serverData.datacenter, serverData.public_ipv4, + serverData.public_ipv6, serverData.private_ip, serverData.labels, + serverData.created_hetzner + ] + ) + } + synced++ + } + + return synced +} + +// Recolher métricas de um servidor +export async function collectMetrics(hetzner_id: number): Promise { + // Obter server_id local + const [servers] = await db.query( + 'SELECT id FROM tbl_eal_hetzner_servers WHERE hetzner_id = ?', + [hetzner_id] + ) + + if (servers.length === 0) { + console.error(`Server ${hetzner_id} not found in database`) + return false + } + + const server_id = servers[0].id + const now = new Date() + const start = new Date(now.getTime() - 5 * 60 * 1000) // 5 minutos atrás + + try { + // Obter métricas da API + const metricsUrl = `/servers/${hetzner_id}/metrics?type=cpu,disk,network&start=${start.toISOString()}&end=${now.toISOString()}` + const data = await hetznerRequest(metricsUrl) + + // Extrair valores mais recentes + const getLatestValue = (series: string): number | null => { + const values = data.metrics.time_series[series]?.values + if (!values || values.length === 0) return null + return parseFloat(values[values.length - 1][1]) + } + + const metrics = { + cpu_percent: getLatestValue('cpu'), + disk_read_iops: getLatestValue('disk.0.iops.read'), + disk_write_iops: getLatestValue('disk.0.iops.write'), + disk_read_bps: getLatestValue('disk.0.bandwidth.read'), + disk_write_bps: getLatestValue('disk.0.bandwidth.write'), + network_in_bps: getLatestValue('network.0.bandwidth.in'), + network_out_bps: getLatestValue('network.0.bandwidth.out'), + network_in_pps: getLatestValue('network.0.pps.in'), + network_out_pps: getLatestValue('network.0.pps.out') + } + + // Inserir métricas + await db.query( + `INSERT INTO tbl_eal_hetzner_metrics + (server_id, collected_at, cpu_percent, disk_read_iops, disk_write_iops, + disk_read_bps, disk_write_bps, network_in_bps, network_out_bps, + network_in_pps, network_out_pps) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + server_id, now, metrics.cpu_percent, + metrics.disk_read_iops, metrics.disk_write_iops, + metrics.disk_read_bps, metrics.disk_write_bps, + metrics.network_in_bps, metrics.network_out_bps, + metrics.network_in_pps, metrics.network_out_pps + ] + ) + + return true + } catch (error) { + console.error(`Error collecting metrics for server ${hetzner_id}:`, error) + return false + } +} + +// Recolher métricas de todos os servidores +export async function collectAllMetrics(): Promise<{ success: number; failed: number }> { + const [servers] = await db.query( + 'SELECT hetzner_id FROM tbl_eal_hetzner_servers WHERE status = "running"' + ) + + let success = 0 + let failed = 0 + + for (const server of servers) { + const result = await collectMetrics(server.hetzner_id) + if (result) success++ + else failed++ + } + + return { success, failed } +} + +// Obter dados para o dashboard +export async function getHetznerDashboard(): Promise<{ + servers: ServerWithMetrics[] + summary: { total: number; running: number; off: number } +}> { + // Usar a view para obter últimas métricas + const [servers] = await db.query(` + SELECT * FROM v_eal_hetzner_latest + ORDER BY name + `) + + // Calcular sumário + const [summary] = await db.query(` + SELECT + COUNT(*) as total, + SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END) as running, + SUM(CASE WHEN status != 'running' THEN 1 ELSE 0 END) as off + FROM tbl_eal_hetzner_servers + `) + + return { + servers: servers as ServerWithMetrics[], + summary: summary[0] as { total: number; running: number; off: number } + } +} + +// Limpar métricas antigas (manter últimos 7 dias) +export async function cleanupOldMetrics(days: number = 7): Promise { + const [result] = await db.query( + `DELETE FROM tbl_eal_hetzner_metrics + WHERE collected_at < DATE_SUB(NOW(), INTERVAL ? DAY)`, + [days] + ) + return result.affectedRows +} + +// Obter histórico de métricas para gráficos +export async function getMetricsHistory( + server_id: number, + hours: number = 24 +): Promise { + const [metrics] = await db.query(` + SELECT + collected_at, + cpu_percent, + network_in_bps, + network_out_bps, + disk_read_iops, + disk_write_iops + FROM tbl_eal_hetzner_metrics + WHERE server_id = ? + AND collected_at > DATE_SUB(NOW(), INTERVAL ? HOUR) + ORDER BY collected_at ASC + `, [server_id, hours]) + + return metrics +} diff --git a/api/services/monitoring.ts b/api/services/monitoring.ts index 64a6947..e6fc2ea 100644 --- a/api/services/monitoring.ts +++ b/api/services/monitoring.ts @@ -25,6 +25,105 @@ interface CategorySummary { critical: number } +/** + * Check if a URL is accessible (HTTP HEAD request) + */ +export async function checkSiteAvailability(url: string, timeout = 10000): Promise<{ + available: boolean + statusCode?: number + responseTime?: number + error?: string +}> { + const startTime = Date.now() + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), timeout) + + try { + const response = await fetch(url, { + method: 'HEAD', + signal: controller.signal, + headers: { + 'User-Agent': 'Descomplicar-Monitor/1.0' + } + }) + + clearTimeout(timeoutId) + const responseTime = Date.now() - startTime + + return { + available: response.ok || response.status < 500, + statusCode: response.status, + responseTime + } + } catch (error) { + clearTimeout(timeoutId) + return { + available: false, + error: (error as Error).message, + responseTime: Date.now() - startTime + } + } +} + +/** + * Check all sites and update their availability status + */ +export async function checkAllSitesAvailability(): Promise<{ + checked: number + up: number + down: number + results: any[] +}> { + // Get all sites from monitoring table + const [sites] = await db.query(` + SELECT id, name, details FROM tbl_eal_monitoring + WHERE category = 'site' + `) + + const results: any[] = [] + let up = 0 + let down = 0 + + for (const site of sites) { + const details = typeof site.details === 'string' ? JSON.parse(site.details) : site.details + const siteUrl = details?.site_url || `https://${site.name}` + + const check = await checkSiteAvailability(siteUrl) + + // Update status if site is down + if (!check.available) { + await db.query( + 'UPDATE tbl_eal_monitoring SET status = ?, last_check = NOW() WHERE id = ?', + ['down', site.id] + ) + down++ + } else { + // If was down and now is up, set to 'up' (will be replaced by plugin data later) + const currentStatus = details?.health?.status || 'ok' + if (currentStatus === 'down') { + await db.query( + 'UPDATE tbl_eal_monitoring SET status = ?, last_check = NOW() WHERE id = ?', + ['up', site.id] + ) + } + up++ + } + + results.push({ + name: site.name, + url: siteUrl, + ...check + }) + } + + return { + checked: sites.length, + up, + down, + results + } +} + export async function getMonitoringData() { // Get all items const [items] = await db.query(`