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 ?