feat: Add CheckMonitor functionality to trigger immediate health checks for uptime monitors

This commit is contained in:
GitHub Actions
2025-12-02 22:08:58 +00:00
parent 355992e665
commit 078b5803e6
7 changed files with 101 additions and 3 deletions

View File

@@ -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"})
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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;
};

View File

@@ -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
{

View File

@@ -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 ? <Pause className="w-4 h-4 mr-1" /> : isUp ? <ArrowUp className="w-4 h-4 mr-1" /> : <ArrowDown className="w-4 h-4 mr-1" />}
{isPaused ? 'PAUSED' : monitor.status.toUpperCase()}
</div>
<button
onClick={async () => {
try {
await checkMutation.mutateAsync(monitor.id)
} catch {
// handled in onError
}
}}
disabled={checkMutation.isPending}
className="p-1 text-gray-400 hover:text-blue-400 transition-colors disabled:opacity-50"
title="Trigger immediate health check"
>
<RefreshCw size={16} className={checkMutation.isPending ? 'animate-spin' : ''} />
</button>
<div className="relative">
<button
onClick={() => setShowMenu(prev => !prev)}