feat: implement uptime monitor synchronization for proxy host updates and enhance related tests
This commit is contained in:
@@ -297,6 +297,14 @@ func (h *ProxyHostHandler) Update(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Sync associated uptime monitor with updated proxy host values
|
||||
if h.uptimeService != nil {
|
||||
if err := h.uptimeService.SyncMonitorForHost(host.ID); err != nil {
|
||||
middleware.GetRequestLogger(c).WithError(err).WithField("host_id", host.ID).Warn("Failed to sync uptime monitor for host")
|
||||
// Don't fail the request if sync fails - the host update succeeded
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, host)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ package services
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/Wikid82/charon/backend/internal/logger"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/logger"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/util"
|
||||
"gorm.io/gorm"
|
||||
@@ -806,6 +807,47 @@ func (s *UptimeService) FlushPendingNotifications() {
|
||||
}
|
||||
}
|
||||
|
||||
// SyncMonitorForHost updates the uptime monitor linked to a specific proxy host.
|
||||
// This should be called when a proxy host is edited to keep the monitor in sync.
|
||||
// Returns nil if no monitor exists for the host (does not create one).
|
||||
func (s *UptimeService) SyncMonitorForHost(hostID uint) error {
|
||||
var host models.ProxyHost
|
||||
if err := s.DB.First(&host, hostID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var monitor models.UptimeMonitor
|
||||
if err := s.DB.Where("proxy_host_id = ?", hostID).First(&monitor).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil // No monitor to sync
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Update monitor fields based on current proxy host values
|
||||
domains := strings.Split(host.DomainNames, ",")
|
||||
firstDomain := ""
|
||||
if len(domains) > 0 {
|
||||
firstDomain = strings.TrimSpace(domains[0])
|
||||
}
|
||||
|
||||
scheme := "http"
|
||||
if host.SSLForced {
|
||||
scheme = "https"
|
||||
}
|
||||
|
||||
newName := host.Name
|
||||
if newName == "" {
|
||||
newName = firstDomain
|
||||
}
|
||||
|
||||
monitor.Name = newName
|
||||
monitor.URL = fmt.Sprintf("%s://%s", scheme, firstDomain)
|
||||
monitor.UpstreamHost = host.ForwardHost
|
||||
|
||||
return s.DB.Save(&monitor).Error
|
||||
}
|
||||
|
||||
// CRUD for Monitors
|
||||
|
||||
func (s *UptimeService) ListMonitors() ([]models.UptimeMonitor, error) {
|
||||
|
||||
@@ -1201,3 +1201,156 @@ func TestFormatDuration(t *testing.T) {
|
||||
assert.Equal(t, tc.expected, result, "formatDuration(%v)", tc.input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUptimeService_SyncMonitorForHost(t *testing.T) {
|
||||
t.Run("updates monitor when proxy host is edited", func(t *testing.T) {
|
||||
db := setupUptimeTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
us := NewUptimeService(db, ns)
|
||||
|
||||
// Create a proxy host
|
||||
host := models.ProxyHost{
|
||||
UUID: "sync-test-1",
|
||||
Name: "Original Name",
|
||||
DomainNames: "original.example.com",
|
||||
ForwardHost: "10.0.0.1",
|
||||
ForwardPort: 8080,
|
||||
SSLForced: false,
|
||||
Enabled: true,
|
||||
}
|
||||
db.Create(&host)
|
||||
|
||||
// Sync monitors to create the uptime monitor
|
||||
err := us.SyncMonitors()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify monitor was created with original values
|
||||
var monitor models.UptimeMonitor
|
||||
err = db.Where("proxy_host_id = ?", host.ID).First(&monitor).Error
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "Original Name", monitor.Name)
|
||||
assert.Equal(t, "http://original.example.com", monitor.URL)
|
||||
assert.Equal(t, "10.0.0.1", monitor.UpstreamHost)
|
||||
|
||||
// Update the proxy host
|
||||
host.Name = "Updated Name"
|
||||
host.DomainNames = "updated.example.com"
|
||||
host.ForwardHost = "10.0.0.2"
|
||||
host.SSLForced = true
|
||||
db.Save(&host)
|
||||
|
||||
// Call SyncMonitorForHost
|
||||
err = us.SyncMonitorForHost(host.ID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify monitor was updated
|
||||
err = db.Where("proxy_host_id = ?", host.ID).First(&monitor).Error
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "Updated Name", monitor.Name)
|
||||
assert.Equal(t, "https://updated.example.com", monitor.URL)
|
||||
assert.Equal(t, "10.0.0.2", monitor.UpstreamHost)
|
||||
})
|
||||
|
||||
t.Run("returns nil when no monitor exists", func(t *testing.T) {
|
||||
db := setupUptimeTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
us := NewUptimeService(db, ns)
|
||||
|
||||
// Create a proxy host without creating a monitor
|
||||
host := models.ProxyHost{
|
||||
UUID: "no-monitor-test",
|
||||
Name: "No Monitor Host",
|
||||
DomainNames: "nomonitor.example.com",
|
||||
ForwardHost: "10.0.0.3",
|
||||
ForwardPort: 8080,
|
||||
Enabled: true,
|
||||
}
|
||||
db.Create(&host)
|
||||
|
||||
// Call SyncMonitorForHost - should return nil without error
|
||||
err := us.SyncMonitorForHost(host.ID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify no monitor was created
|
||||
var count int64
|
||||
db.Model(&models.UptimeMonitor{}).Where("proxy_host_id = ?", host.ID).Count(&count)
|
||||
assert.Equal(t, int64(0), count)
|
||||
})
|
||||
|
||||
t.Run("returns error when host does not exist", func(t *testing.T) {
|
||||
db := setupUptimeTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
us := NewUptimeService(db, ns)
|
||||
|
||||
// Call SyncMonitorForHost with non-existent host ID
|
||||
err := us.SyncMonitorForHost(99999)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("uses domain name when proxy host name is empty", func(t *testing.T) {
|
||||
db := setupUptimeTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
us := NewUptimeService(db, ns)
|
||||
|
||||
// Create a proxy host with a name
|
||||
host := models.ProxyHost{
|
||||
UUID: "empty-name-test",
|
||||
Name: "Has Name",
|
||||
DomainNames: "domain.example.com",
|
||||
ForwardHost: "10.0.0.4",
|
||||
ForwardPort: 8080,
|
||||
Enabled: true,
|
||||
}
|
||||
db.Create(&host)
|
||||
|
||||
// Sync monitors
|
||||
err := us.SyncMonitors()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Clear the host name
|
||||
host.Name = ""
|
||||
db.Save(&host)
|
||||
|
||||
// Call SyncMonitorForHost
|
||||
err = us.SyncMonitorForHost(host.ID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify monitor uses domain name
|
||||
var monitor models.UptimeMonitor
|
||||
err = db.Where("proxy_host_id = ?", host.ID).First(&monitor).Error
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "domain.example.com", monitor.Name)
|
||||
})
|
||||
|
||||
t.Run("handles multiple domains correctly", func(t *testing.T) {
|
||||
db := setupUptimeTestDB(t)
|
||||
ns := NewNotificationService(db)
|
||||
us := NewUptimeService(db, ns)
|
||||
|
||||
// Create a proxy host with multiple domains
|
||||
host := models.ProxyHost{
|
||||
UUID: "multi-domain-test",
|
||||
Name: "Multi Domain",
|
||||
DomainNames: "first.example.com, second.example.com, third.example.com",
|
||||
ForwardHost: "10.0.0.5",
|
||||
ForwardPort: 8080,
|
||||
SSLForced: true,
|
||||
Enabled: true,
|
||||
}
|
||||
db.Create(&host)
|
||||
|
||||
// Sync monitors
|
||||
err := us.SyncMonitors()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Call SyncMonitorForHost
|
||||
err = us.SyncMonitorForHost(host.ID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify monitor uses first domain
|
||||
var monitor models.UptimeMonitor
|
||||
err = db.Where("proxy_host_id = ?", host.ID).First(&monitor).Error
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "https://first.example.com", monitor.URL)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -294,7 +294,7 @@ if (!status) return <div className="p-8 text-center text-gray-400">No security s
|
||||
children: [
|
||||
{ name: 'System', path: '/settings/system', icon: '⚙️' },
|
||||
{ name: 'Email (SMTP)', path: '/settings/smtp', icon: '📧' },
|
||||
{ name: 'Account', path: '/settings/account', icon: '🛡️' },
|
||||
{ name: 'Accounts', path: '/settings/accounts', icon: '🛡️' },
|
||||
{ name: 'Account Management', path: '/settings/account/management', icon: '👥' },
|
||||
]
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ export default function App() {
|
||||
<Route path="smtp" element={<SMTPSettings />} />
|
||||
<Route path="crowdsec" element={<Navigate to="/security/crowdsec" replace />} />
|
||||
<Route path="account" element={<Account />} />
|
||||
<Route path="account-management" element={<UsersPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Tasks Routes */}
|
||||
|
||||
@@ -155,13 +155,7 @@ export default function CertificateList() {
|
||||
return
|
||||
}
|
||||
|
||||
// Only allow deletion for non-active statuses
|
||||
const isDeletableStatus = cert.status !== 'valid' && cert.status !== 'expiring'
|
||||
if (!isDeletableStatus) {
|
||||
toast.error('Only expired or deactivated certificates can be deleted')
|
||||
return
|
||||
}
|
||||
|
||||
// Allow deletion for custom/staging certs not in use (status check removed)
|
||||
const message = cert.provider === 'custom'
|
||||
? 'Are you sure you want to delete this certificate? This will create a backup before deleting.'
|
||||
: 'Delete this staging certificate? It will be regenerated on next request.'
|
||||
|
||||
@@ -63,7 +63,6 @@ export default function Layout({ children }: LayoutProps) {
|
||||
{ name: 'WAF (Coraza)', path: '/security/waf', icon: '🛡️' },
|
||||
]},
|
||||
{ name: 'Notifications', path: '/notifications', icon: '🔔' },
|
||||
{ name: 'Users', path: '/users', icon: '👥' },
|
||||
// Import group moved under Tasks
|
||||
{
|
||||
name: 'Settings',
|
||||
@@ -72,7 +71,8 @@ export default function Layout({ children }: LayoutProps) {
|
||||
children: [
|
||||
{ name: 'System', path: '/settings/system', icon: '⚙️' },
|
||||
{ name: 'Email (SMTP)', path: '/settings/smtp', icon: '📧' },
|
||||
{ name: 'Account', path: '/settings/account', icon: '🛡️' },
|
||||
{ name: 'Admin Account', path: '/settings/account', icon: '🛡️' },
|
||||
{ name: 'Account Management', path: '/settings/account-management', icon: '👥' },
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -99,13 +99,15 @@ export default function Layout({ children }: LayoutProps) {
|
||||
<div className="min-h-screen bg-light-bg dark:bg-dark-bg flex transition-colors duration-200">
|
||||
{/* Mobile Header */}
|
||||
<div className="lg:hidden fixed top-0 left-0 right-0 h-16 bg-white dark:bg-dark-sidebar border-b border-gray-200 dark:border-gray-800 flex items-center justify-between px-4 z-40">
|
||||
<img src="/banner.png" alt="Charon" height={1280} width={640} />
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => setMobileSidebarOpen(!mobileSidebarOpen)} data-testid="mobile-menu-toggle">
|
||||
<Menu className="w-5 h-5" />
|
||||
</Button>
|
||||
<img src="/logo.png" alt="Charon" className="h-10 w-auto" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<NotificationCenter />
|
||||
<ThemeToggle />
|
||||
<Button variant="ghost" size="sm" onClick={() => setMobileSidebarOpen(!mobileSidebarOpen)}>
|
||||
{mobileSidebarOpen ? '✕' : '☰'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -118,11 +120,9 @@ export default function Layout({ children }: LayoutProps) {
|
||||
`}>
|
||||
<div className={`h-20 flex items-center justify-center border-b border-gray-200 dark:border-gray-800`}>
|
||||
{isCollapsed ? (
|
||||
<img src="/logo.png" alt="Charon" style={{ height: '150px', width: 'auto' }}/>
|
||||
|
||||
|
||||
<img src="/logo.png" alt="Charon" className="h-12 w-auto" />
|
||||
) : (
|
||||
<img src="/banner.png" alt="Charon" className="h-16 w-auto" />
|
||||
<img src="/banner.png" alt="Charon" className="h-14 w-auto max-w-[200px] object-contain" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -116,20 +116,18 @@ describe('Layout', () => {
|
||||
</Layout>
|
||||
)
|
||||
|
||||
// Initially sidebar is hidden on mobile (by CSS class, but we can check if the toggle button exists)
|
||||
// The toggle button has text '☰' when closed
|
||||
await userEvent.click(screen.getByText('☰'))
|
||||
// The mobile sidebar toggle is found by test-id
|
||||
const toggleButton = screen.getByTestId('mobile-menu-toggle')
|
||||
|
||||
// Now it should show '✕'
|
||||
expect(screen.getByText('✕')).toBeInTheDocument()
|
||||
// Click to open the sidebar
|
||||
await userEvent.click(toggleButton)
|
||||
|
||||
// And the overlay should be present
|
||||
// The overlay has class 'fixed inset-0 bg-black/50 z-20 lg:hidden'
|
||||
// We can find it by class or just assume if we click it it closes
|
||||
// Let's try to click the overlay. It doesn't have text.
|
||||
// We can query by selector if we add a test id or just rely on structure.
|
||||
// But let's just click the toggle button again to close.
|
||||
await userEvent.click(screen.getByText('✕'))
|
||||
expect(screen.getByText('☰')).toBeInTheDocument()
|
||||
// The overlay should be present when mobile sidebar is open
|
||||
// The overlay has class 'fixed inset-0 bg-gray-900/50 z-20 lg:hidden'
|
||||
// Click the toggle again to close
|
||||
await userEvent.click(toggleButton)
|
||||
|
||||
// Toggle button should still be in the document
|
||||
expect(toggleButton).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -91,7 +91,6 @@ body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
zoom: 0.75;
|
||||
}
|
||||
|
||||
#root {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { toast } from '../utils/toast'
|
||||
import { ConfigReloadOverlay } from '../components/LoadingStates'
|
||||
|
||||
export default function CrowdSecConfig() {
|
||||
const { data: status } = useQuery({ queryKey: ['security-status'], queryFn: getSecurityStatus })
|
||||
const { data: status, isLoading, error } = useQuery({ queryKey: ['security-status'], queryFn: getSecurityStatus })
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null)
|
||||
const [fileContent, setFileContent] = useState<string | null>(null)
|
||||
@@ -106,7 +106,9 @@ export default function CrowdSecConfig() {
|
||||
|
||||
const { message, submessage } = getMessage()
|
||||
|
||||
if (!status) return <div className="p-8 text-center">Loading...</div>
|
||||
if (isLoading) return <div className="p-8 text-center text-white">Loading CrowdSec configuration...</div>
|
||||
if (error) return <div className="p-8 text-center text-red-500">Failed to load security status: {(error as Error).message}</div>
|
||||
if (!status) return <div className="p-8 text-center text-gray-400">No security status available</div>
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -132,7 +132,7 @@ const ProviderForm: FC<{
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Template</label>
|
||||
<select {...register('template')} className="mt-1 block w-full rounded-md border-gray-300">
|
||||
<select {...register('template')} className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white sm:text-sm">
|
||||
{/* Built-in template options */}
|
||||
{builtins?.map((t: NotificationTemplate) => (
|
||||
<option key={t.id} value={t.id}>{t.name}</option>
|
||||
|
||||
@@ -242,19 +242,19 @@ export default function Security() {
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{crowdsecStatus.running ? `Running (pid ${crowdsecStatus.pid})` : 'Stopped'}</p>
|
||||
)}
|
||||
{status.crowdsec.enabled && (
|
||||
<div className="mt-4 flex gap-2">
|
||||
<div className="mt-4 grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
className="w-full text-xs"
|
||||
onClick={() => navigate('/tasks/logs?search=crowdsec')}
|
||||
>
|
||||
View Logs
|
||||
Logs
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
className="w-full text-xs"
|
||||
onClick={async () => {
|
||||
// download config
|
||||
try {
|
||||
@@ -275,35 +275,31 @@ export default function Security() {
|
||||
>
|
||||
Export
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" className="w-full" onClick={() => navigate('/security/crowdsec')}>
|
||||
Configure
|
||||
<Button variant="secondary" size="sm" className="w-full text-xs" onClick={() => navigate('/security/crowdsec')}>
|
||||
Config
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="w-full text-xs"
|
||||
onClick={() => startMutation.mutate()}
|
||||
data-testid="crowdsec-start"
|
||||
isLoading={startMutation.isPending}
|
||||
disabled={!!crowdsecStatus?.running}
|
||||
>
|
||||
Start
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="w-full text-xs"
|
||||
onClick={() => stopMutation.mutate()}
|
||||
data-testid="crowdsec-stop"
|
||||
isLoading={stopMutation.isPending}
|
||||
disabled={!crowdsecStatus?.running}
|
||||
>
|
||||
Stop
|
||||
</Button>
|
||||
<div className="flex gap-2 w-full">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => startMutation.mutate()}
|
||||
data-testid="crowdsec-start"
|
||||
isLoading={startMutation.isPending}
|
||||
disabled={!!crowdsecStatus?.running}
|
||||
>
|
||||
|
||||
Start
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => stopMutation.mutate()}
|
||||
data-testid="crowdsec-stop"
|
||||
isLoading={stopMutation.isPending}
|
||||
disabled={!crowdsecStatus?.running}
|
||||
>
|
||||
|
||||
Stop
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user