feat: WordPress Monitor API + Site Availability Checker
- Add POST /api/wp-monitor endpoint for WP plugin data - Add GET /api/wp-monitor for listing monitored sites - Add checkSiteAvailability() function for HTTP health checks - Add checkAllSitesAvailability() for batch checking - Add /api/scripts/check-sites.ts for cron execution - Add POST /api/monitor/check-sites for manual trigger DeskCRM Task: #1556 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,5 +8,8 @@ DB_NAME=ealmeida_desk24
|
|||||||
API_PORT=3001
|
API_PORT=3001
|
||||||
FRONTEND_URL=http://localhost:5173
|
FRONTEND_URL=http://localhost:5173
|
||||||
|
|
||||||
|
# Hetzner Cloud API
|
||||||
|
HETZNER_TOKEN=your_hetzner_api_token_here
|
||||||
|
|
||||||
# Production URLs
|
# Production URLs
|
||||||
# FRONTEND_URL=https://dash.descomplicar.pt
|
# FRONTEND_URL=https://dash.descomplicar.pt
|
||||||
|
|||||||
50
CHANGELOG.md
50
CHANGELOG.md
@@ -2,6 +2,56 @@
|
|||||||
|
|
||||||
Todas as alterações notáveis neste projecto serão documentadas neste ficheiro.
|
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
|
## [2.0.1] - 2026-02-04
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
141
api/routes/hetzner.ts
Normal file
141
api/routes/hetzner.ts
Normal file
@@ -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
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Monitor API Route
|
* 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
|
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||||
*/
|
*/
|
||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
@@ -9,6 +10,7 @@ import * as monitoringService from '../services/monitoring.js'
|
|||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
|
// Get monitoring data
|
||||||
router.get('/', async (req: Request, res: Response) => {
|
router.get('/', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const data = await monitoringService.getMonitoringData()
|
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
|
export default router
|
||||||
|
|||||||
144
api/routes/wp-monitor.ts
Normal file
144
api/routes/wp-monitor.ts
Normal file
@@ -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
|
||||||
50
api/scripts/check-sites.ts
Normal file
50
api/scripts/check-sites.ts
Normal file
@@ -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()
|
||||||
63
api/scripts/hetzner-collector.ts
Normal file
63
api/scripts/hetzner-collector.ts
Normal file
@@ -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()
|
||||||
@@ -8,6 +8,8 @@ import cors from 'cors'
|
|||||||
import dashboardRouter from './routes/dashboard.js'
|
import dashboardRouter from './routes/dashboard.js'
|
||||||
import monitorRouter from './routes/monitor.js'
|
import monitorRouter from './routes/monitor.js'
|
||||||
import diagnosticRouter from './routes/diagnostic.js'
|
import diagnosticRouter from './routes/diagnostic.js'
|
||||||
|
import hetznerRouter from './routes/hetzner.js'
|
||||||
|
import wpMonitorRouter from './routes/wp-monitor.js'
|
||||||
|
|
||||||
const app = express()
|
const app = express()
|
||||||
const PORT = process.env.API_PORT || 3001
|
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/dashboard', dashboardRouter)
|
||||||
app.use('/api/monitor', monitorRouter)
|
app.use('/api/monitor', monitorRouter)
|
||||||
app.use('/api/diagnostic', diagnosticRouter)
|
app.use('/api/diagnostic', diagnosticRouter)
|
||||||
|
app.use('/api/hetzner', hetznerRouter)
|
||||||
|
app.use('/api/wp-monitor', wpMonitorRouter)
|
||||||
|
|
||||||
// Error handling
|
// Error handling
|
||||||
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
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(`📊 Dashboard: http://localhost:${PORT}/api/dashboard`)
|
||||||
console.log(`🔍 Monitor: http://localhost:${PORT}/api/monitor`)
|
console.log(`🔍 Monitor: http://localhost:${PORT}/api/monitor`)
|
||||||
console.log(`🔧 Diagnostic: http://localhost:${PORT}/api/diagnostic`)
|
console.log(`🔧 Diagnostic: http://localhost:${PORT}/api/diagnostic`)
|
||||||
|
console.log(`☁️ Hetzner: http://localhost:${PORT}/api/hetzner`)
|
||||||
console.log('='.repeat(50))
|
console.log('='.repeat(50))
|
||||||
})
|
})
|
||||||
|
|||||||
265
api/services/hetzner.ts
Normal file
265
api/services/hetzner.ts
Normal file
@@ -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<string, string>
|
||||||
|
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<T>(endpoint: string): Promise<T> {
|
||||||
|
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<number> {
|
||||||
|
const data = await hetznerRequest<{ servers: HetznerServer[] }>('/servers')
|
||||||
|
let synced = 0
|
||||||
|
|
||||||
|
for (const server of data.servers) {
|
||||||
|
const [existing] = await db.query<RowDataPacket[]>(
|
||||||
|
'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<boolean> {
|
||||||
|
// Obter server_id local
|
||||||
|
const [servers] = await db.query<RowDataPacket[]>(
|
||||||
|
'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<HetznerMetrics>(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<RowDataPacket[]>(
|
||||||
|
'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<RowDataPacket[]>(`
|
||||||
|
SELECT * FROM v_eal_hetzner_latest
|
||||||
|
ORDER BY name
|
||||||
|
`)
|
||||||
|
|
||||||
|
// Calcular sumário
|
||||||
|
const [summary] = await db.query<RowDataPacket[]>(`
|
||||||
|
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<number> {
|
||||||
|
const [result] = await db.query<ResultSetHeader>(
|
||||||
|
`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<RowDataPacket[]> {
|
||||||
|
const [metrics] = await db.query<RowDataPacket[]>(`
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -25,6 +25,105 @@ interface CategorySummary {
|
|||||||
critical: number
|
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<RowDataPacket[]>(`
|
||||||
|
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() {
|
export async function getMonitoringData() {
|
||||||
// Get all items
|
// Get all items
|
||||||
const [items] = await db.query<RowDataPacket[]>(`
|
const [items] = await db.query<RowDataPacket[]>(`
|
||||||
|
|||||||
Reference in New Issue
Block a user