diff --git a/backend/internal/api/handlers/proxy_host_handler.go b/backend/internal/api/handlers/proxy_host_handler.go index 2f70fa49..9807c820 100644 --- a/backend/internal/api/handlers/proxy_host_handler.go +++ b/backend/internal/api/handlers/proxy_host_handler.go @@ -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) } diff --git a/backend/internal/services/uptime_service.go b/backend/internal/services/uptime_service.go index e9a222b5..e39d013d 100644 --- a/backend/internal/services/uptime_service.go +++ b/backend/internal/services/uptime_service.go @@ -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) { diff --git a/backend/internal/services/uptime_service_test.go b/backend/internal/services/uptime_service_test.go index 0ff0d262..5fa85341 100644 --- a/backend/internal/services/uptime_service_test.go +++ b/backend/internal/services/uptime_service_test.go @@ -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) + }) +} diff --git a/docs/plans/ui_ux_bugfixes_spec.md b/docs/plans/ui_ux_bugfixes_spec.md index 331c9c23..6b9e9910 100644 --- a/docs/plans/ui_ux_bugfixes_spec.md +++ b/docs/plans/ui_ux_bugfixes_spec.md @@ -294,7 +294,7 @@ if (!status) return
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: '👥' }, ] } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6659402e..f889ad7e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -76,6 +76,7 @@ export default function App() { } /> } /> } /> + } /> {/* Tasks Routes */} diff --git a/frontend/src/components/CertificateList.tsx b/frontend/src/components/CertificateList.tsx index 02110dd3..4673a71a 100644 --- a/frontend/src/components/CertificateList.tsx +++ b/frontend/src/components/CertificateList.tsx @@ -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.' diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 097b5f60..0898d97a 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -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) {
{/* Mobile Header */}
- Charon +
+ + Charon +
-
@@ -118,11 +120,9 @@ export default function Layout({ children }: LayoutProps) { `}>
{isCollapsed ? ( - Charon - - + Charon ) : ( - Charon + Charon )}
diff --git a/frontend/src/components/__tests__/Layout.test.tsx b/frontend/src/components/__tests__/Layout.test.tsx index a06a15bd..22c6248d 100644 --- a/frontend/src/components/__tests__/Layout.test.tsx +++ b/frontend/src/components/__tests__/Layout.test.tsx @@ -116,20 +116,18 @@ describe('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() }) }) diff --git a/frontend/src/index.css b/frontend/src/index.css index 4769a102..81f4bbba 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -91,7 +91,6 @@ body { margin: 0; min-width: 320px; min-height: 100vh; - zoom: 0.75; } #root { diff --git a/frontend/src/pages/CrowdSecConfig.tsx b/frontend/src/pages/CrowdSecConfig.tsx index 7efd587a..c81e70d8 100644 --- a/frontend/src/pages/CrowdSecConfig.tsx +++ b/frontend/src/pages/CrowdSecConfig.tsx @@ -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(null) const [selectedPath, setSelectedPath] = useState(null) const [fileContent, setFileContent] = useState(null) @@ -106,7 +106,9 @@ export default function CrowdSecConfig() { const { message, submessage } = getMessage() - if (!status) return
Loading...
+ if (isLoading) return
Loading CrowdSec configuration...
+ if (error) return
Failed to load security status: {(error as Error).message}
+ if (!status) return
No security status available
return ( <> diff --git a/frontend/src/pages/Notifications.tsx b/frontend/src/pages/Notifications.tsx index eac5a4c9..a44c712f 100644 --- a/frontend/src/pages/Notifications.tsx +++ b/frontend/src/pages/Notifications.tsx @@ -132,7 +132,7 @@ const ProviderForm: FC<{
- {/* Built-in template options */} {builtins?.map((t: NotificationTemplate) => ( diff --git a/frontend/src/pages/Security.tsx b/frontend/src/pages/Security.tsx index 8b63080a..8c26d964 100644 --- a/frontend/src/pages/Security.tsx +++ b/frontend/src/pages/Security.tsx @@ -242,19 +242,19 @@ export default function Security() {

{crowdsecStatus.running ? `Running (pid ${crowdsecStatus.pid})` : 'Stopped'}

)} {status.crowdsec.enabled && ( -
+
- + + -
- - -
)}