feat: Add CheckMonitor functionality to trigger immediate health checks for uptime monitors
This commit is contained in:
@@ -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"})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user