diff --git a/backend/internal/api/handlers/uptime_handler.go b/backend/internal/api/handlers/uptime_handler.go index 9c0a3198..6e34893c 100644 --- a/backend/internal/api/handlers/uptime_handler.go +++ b/backend/internal/api/handlers/uptime_handler.go @@ -71,3 +71,18 @@ func (h *UptimeHandler) Delete(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{"message": "Monitor deleted"}) } + +// CheckMonitor triggers an immediate check for a specific monitor +func (h *UptimeHandler) CheckMonitor(c *gin.Context) { + id := c.Param("id") + monitor, err := h.service.GetMonitorByID(id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Monitor not found"}) + return + } + + // Trigger immediate check in background + go h.service.CheckMonitor(*monitor) + + c.JSON(http.StatusOK, gin.H{"message": "Check triggered"}) +} diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index ec186abb..8c6d11ee 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -180,6 +180,7 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { protected.GET("/uptime/monitors/:id/history", uptimeHandler.GetHistory) protected.PUT("/uptime/monitors/:id", uptimeHandler.Update) protected.DELETE("/uptime/monitors/:id", uptimeHandler.Delete) + protected.POST("/uptime/monitors/:id/check", uptimeHandler.CheckMonitor) protected.POST("/uptime/sync", uptimeHandler.Sync) // Notification Providers diff --git a/backend/internal/services/uptime_service.go b/backend/internal/services/uptime_service.go index 6000b430..e9a222b5 100644 --- a/backend/internal/services/uptime_service.go +++ b/backend/internal/services/uptime_service.go @@ -528,6 +528,11 @@ func (s *UptimeService) sendHostDownNotification(host *models.UptimeHost, downMo logger.Log().WithField("host_name", host.Name).WithField("service_count", len(downMonitors)).Info("Sent consolidated DOWN notification") } +// CheckMonitor is the exported version for on-demand checks +func (s *UptimeService) CheckMonitor(monitor models.UptimeMonitor) { + s.checkMonitor(monitor) +} + func (s *UptimeService) checkMonitor(monitor models.UptimeMonitor) { start := time.Now() success := false @@ -809,6 +814,14 @@ func (s *UptimeService) ListMonitors() ([]models.UptimeMonitor, error) { return monitors, result.Error } +func (s *UptimeService) GetMonitorByID(id string) (*models.UptimeMonitor, error) { + var monitor models.UptimeMonitor + if err := s.DB.First(&monitor, "id = ?", id).Error; err != nil { + return nil, err + } + return &monitor, nil +} + func (s *UptimeService) GetMonitorHistory(id string, limit int) ([]models.UptimeHeartbeat, error) { var heartbeats []models.UptimeHeartbeat result := s.DB.Where("monitor_id = ?", id).Order("created_at desc").Limit(limit).Find(&heartbeats) diff --git a/backend/internal/services/uptime_service_test.go b/backend/internal/services/uptime_service_test.go index 26d74eff..0ff0d262 100644 --- a/backend/internal/services/uptime_service_test.go +++ b/backend/internal/services/uptime_service_test.go @@ -169,6 +169,39 @@ func TestUptimeService_ListMonitors(t *testing.T) { assert.Equal(t, "Test Monitor", monitors[0].Name) } +func TestUptimeService_GetMonitorByID(t *testing.T) { + db := setupUptimeTestDB(t) + ns := NewNotificationService(db) + us := NewUptimeService(db, ns) + + monitor := models.UptimeMonitor{ + ID: "monitor-1", + Name: "Test Monitor", + Type: "http", + URL: "https://example.com", + Interval: 60, + Enabled: true, + Status: "up", + } + db.Create(&monitor) + + t.Run("get existing monitor", func(t *testing.T) { + result, err := us.GetMonitorByID(monitor.ID) + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, monitor.ID, result.ID) + assert.Equal(t, monitor.Name, result.Name) + assert.Equal(t, monitor.Type, result.Type) + assert.Equal(t, monitor.URL, result.URL) + }) + + t.Run("get non-existent monitor", func(t *testing.T) { + result, err := us.GetMonitorByID("non-existent") + assert.Error(t, err) + assert.Nil(t, result) + }) +} + func TestUptimeService_GetMonitorHistory(t *testing.T) { db := setupUptimeTestDB(t) ns := NewNotificationService(db) diff --git a/frontend/src/api/uptime.ts b/frontend/src/api/uptime.ts index ad182261..862de6af 100644 --- a/frontend/src/api/uptime.ts +++ b/frontend/src/api/uptime.ts @@ -49,3 +49,8 @@ export async function syncMonitors(body?: { interval?: number; max_retries?: num const res = await client.post('/uptime/sync', body || {}); return res.data; } + +export const checkMonitor = async (id: string) => { + const response = await client.post<{ message: string }>(`/uptime/monitors/${id}/check`); + return response.data; +}; diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 024da4fc..ad6fc3a6 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -54,6 +54,7 @@ export default function Layout({ children }: LayoutProps) { { name: 'Remote Servers', path: '/remote-servers', icon: '🖥️' }, { name: 'Domains', path: '/domains', icon: '🌍' }, { name: 'Certificates', path: '/certificates', icon: '🔒' }, + { name: 'Uptime', path: '/uptime', icon: '📈' }, { name: 'Security', path: '/security', icon: '🛡️', children: [ { name: 'Overview', path: '/security', icon: '🛡️' }, { name: 'CrowdSec', path: '/security/crowdsec', icon: '🛡️' }, @@ -61,7 +62,6 @@ export default function Layout({ children }: LayoutProps) { { name: 'Rate Limiting', path: '/security/rate-limiting', icon: '⚡' }, { name: 'WAF (Coraza)', path: '/security/waf', icon: '🛡️' }, ]}, - { name: 'Uptime', path: '/uptime', icon: '📈' }, { name: 'Notifications', path: '/notifications', icon: '🔔' }, // Import group moved under Tasks { diff --git a/frontend/src/pages/Uptime.tsx b/frontend/src/pages/Uptime.tsx index 77eedf28..229753d3 100644 --- a/frontend/src/pages/Uptime.tsx +++ b/frontend/src/pages/Uptime.tsx @@ -1,7 +1,7 @@ import { useMemo, useState, type FC, type FormEvent } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { getMonitors, getMonitorHistory, updateMonitor, deleteMonitor, UptimeMonitor } from '../api/uptime'; -import { Activity, ArrowUp, ArrowDown, Settings, X, Pause } from 'lucide-react'; +import { getMonitors, getMonitorHistory, updateMonitor, deleteMonitor, checkMonitor, UptimeMonitor } from '../api/uptime'; +import { Activity, ArrowUp, ArrowDown, Settings, X, Pause, RefreshCw } from 'lucide-react'; import { toast } from 'react-hot-toast' import { formatDistanceToNow } from 'date-fns'; @@ -39,6 +39,23 @@ const MonitorCard: FC<{ monitor: UptimeMonitor; onEdit: (monitor: UptimeMonitor) } }) + const checkMutation = useMutation({ + mutationFn: async (id: string) => { + return await checkMonitor(id) + }, + onSuccess: () => { + toast.success('Health check triggered') + // Refetch monitor and history after a short delay to get updated results + setTimeout(() => { + queryClient.invalidateQueries({ queryKey: ['monitors'] }) + queryClient.invalidateQueries({ queryKey: ['uptimeHistory', monitor.id] }) + }, 2000) + }, + onError: (err: unknown) => { + toast.error(err instanceof Error ? err.message : 'Failed to trigger check') + } + }) + const [showMenu, setShowMenu] = useState(false) // Determine current status from most recent heartbeat when available @@ -65,6 +82,20 @@ const MonitorCard: FC<{ monitor: UptimeMonitor; onEdit: (monitor: UptimeMonitor) {isPaused ? : isUp ? : } {isPaused ? 'PAUSED' : monitor.status.toUpperCase()} +