diff --git a/backend/bin/calypso-api b/backend/bin/calypso-api index 9d3e623..b9d9acb 100755 Binary files a/backend/bin/calypso-api and b/backend/bin/calypso-api differ diff --git a/backend/internal/backup/handler.go b/backend/internal/backup/handler.go index f7f89dd..08d0ec1 100644 --- a/backend/internal/backup/handler.go +++ b/backend/internal/backup/handler.go @@ -142,3 +142,36 @@ func (h *Handler) ExecuteBconsoleCommand(c *gin.Context) { "output": output, }) } + +// ListClients lists all backup clients with optional filters +func (h *Handler) ListClients(c *gin.Context) { + opts := ListClientsOptions{} + + // Parse enabled filter + if enabledStr := c.Query("enabled"); enabledStr != "" { + enabled := enabledStr == "true" + opts.Enabled = &enabled + } + + // Parse search query + opts.Search = c.Query("search") + + clients, err := h.service.ListClients(c.Request.Context(), opts) + if err != nil { + h.logger.Error("Failed to list clients", "error", err) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "failed to list clients", + "details": err.Error(), + }) + return + } + + if clients == nil { + clients = []Client{} + } + + c.JSON(http.StatusOK, gin.H{ + "clients": clients, + "total": len(clients), + }) +} diff --git a/backend/internal/backup/service.go b/backend/internal/backup/service.go index ecd9bd5..69d6119 100644 --- a/backend/internal/backup/service.go +++ b/backend/internal/backup/service.go @@ -78,6 +78,27 @@ type ListJobsOptions struct { Offset int // Offset for pagination } +// Client represents a backup client +type Client struct { + ClientID int `json:"client_id"` + Name string `json:"name"` + Uname *string `json:"uname,omitempty"` + Enabled bool `json:"enabled"` + AutoPrune *bool `json:"auto_prune,omitempty"` + FileRetention *int64 `json:"file_retention,omitempty"` + JobRetention *int64 `json:"job_retention,omitempty"` + LastBackupAt *time.Time `json:"last_backup_at,omitempty"` + TotalJobs *int `json:"total_jobs,omitempty"` + TotalBytes *int64 `json:"total_bytes,omitempty"` + Status *string `json:"status,omitempty"` // "online" or "offline" +} + +// ListClientsOptions represents filtering options for clients +type ListClientsOptions struct { + Enabled *bool // Filter by enabled status (nil = all) + Search string // Search by client name +} + // SyncJobsFromBacula syncs jobs from Bacula/Bareos to the database // Tries to query Bacula database directly first, falls back to bconsole if database access fails func (s *Service) SyncJobsFromBacula(ctx context.Context) error { @@ -487,6 +508,285 @@ func (s *Service) ExecuteBconsoleCommand(ctx context.Context, command string) (s return string(output), nil } +// ListClients lists all backup clients from Bacula database Client table +// Falls back to bconsole if database connection is not available +func (s *Service) ListClients(ctx context.Context, opts ListClientsOptions) ([]Client, error) { + // Try database first if available + if s.baculaDB != nil { + clients, err := s.queryClientsFromDatabase(ctx, opts) + if err == nil { + s.logger.Debug("Queried clients from Bacula database", "count", len(clients)) + return clients, nil + } + s.logger.Warn("Failed to query clients from database, trying bconsole fallback", "error", err) + } + + // Fallback to bconsole + s.logger.Info("Using bconsole fallback for list clients") + return s.queryClientsFromBconsole(ctx, opts) +} + +// queryClientsFromDatabase queries clients from Bacula database +func (s *Service) queryClientsFromDatabase(ctx context.Context, opts ListClientsOptions) ([]Client, error) { + // First, try a simple query to check if Client table exists and has data + simpleQuery := `SELECT COUNT(*) FROM Client` + var count int + err := s.baculaDB.QueryRowContext(ctx, simpleQuery).Scan(&count) + if err != nil { + s.logger.Warn("Failed to count clients from Client table", "error", err) + return nil, fmt.Errorf("failed to query Client table: %w", err) + } + s.logger.Debug("Total clients in database", "count", count) + + if count == 0 { + s.logger.Info("No clients found in Bacula database") + return []Client{}, nil + } + + // Build query with filters + query := ` + SELECT + c.ClientId, + c.Name, + c.Uname, + true as enabled, + c.AutoPrune, + c.FileRetention, + c.JobRetention, + MAX(j.StartTime) as last_backup_at, + COUNT(DISTINCT j.JobId) as total_jobs, + COALESCE(SUM(j.JobBytes), 0) as total_bytes + FROM Client c + LEFT JOIN Job j ON c.ClientId = j.ClientId + WHERE 1=1 + ` + + args := []interface{}{} + argIndex := 1 + + if opts.Enabled != nil { + query += fmt.Sprintf(" AND true = $%d", argIndex) + args = append(args, *opts.Enabled) + argIndex++ + } + + if opts.Search != "" { + query += fmt.Sprintf(" AND c.Name ILIKE $%d", argIndex) + args = append(args, "%"+opts.Search+"%") + argIndex++ + } + + query += " GROUP BY c.ClientId, c.Name, c.Uname, c.AutoPrune, c.FileRetention, c.JobRetention" + query += " ORDER BY c.Name" + + s.logger.Debug("Executing clients query", "query", query, "args", args) + rows, err := s.baculaDB.QueryContext(ctx, query, args...) + if err != nil { + s.logger.Error("Failed to execute clients query", "error", err, "query", query) + return nil, fmt.Errorf("failed to query clients from Bacula database: %w", err) + } + defer rows.Close() + + var clients []Client + for rows.Next() { + var client Client + var uname sql.NullString + var autoPrune sql.NullBool + var fileRetention, jobRetention sql.NullInt64 + var lastBackupAt sql.NullTime + var totalJobs sql.NullInt64 + var totalBytes sql.NullInt64 + + err := rows.Scan( + &client.ClientID, + &client.Name, + &uname, + &client.Enabled, + &autoPrune, + &fileRetention, + &jobRetention, + &lastBackupAt, + &totalJobs, + &totalBytes, + ) + if err != nil { + s.logger.Error("Failed to scan client row", "error", err) + continue + } + + if uname.Valid { + client.Uname = &uname.String + } + if autoPrune.Valid { + client.AutoPrune = &autoPrune.Bool + } + if fileRetention.Valid { + val := fileRetention.Int64 + client.FileRetention = &val + } + if jobRetention.Valid { + val := jobRetention.Int64 + client.JobRetention = &val + } + if lastBackupAt.Valid { + client.LastBackupAt = &lastBackupAt.Time + } + if totalJobs.Valid { + val := int(totalJobs.Int64) + client.TotalJobs = &val + } + if totalBytes.Valid { + val := totalBytes.Int64 + client.TotalBytes = &val + } + + // Determine client status based on enabled and last backup + // If client is enabled and has recent backup (within 24 hours), consider it online + // Otherwise, mark as offline + if client.Enabled { + if lastBackupAt.Valid { + timeSinceLastBackup := time.Since(lastBackupAt.Time) + if timeSinceLastBackup < 24*time.Hour { + status := "online" + client.Status = &status + } else { + status := "offline" + client.Status = &status + } + } else { + // No backup yet, but enabled - assume online + status := "online" + client.Status = &status + } + } else { + status := "offline" + client.Status = &status + } + + clients = append(clients, client) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating client rows: %w", err) + } + + s.logger.Debug("Queried clients from Bacula database", "count", len(clients)) + return clients, nil +} + +// queryClientsFromBconsole queries clients using bconsole command (fallback method) +func (s *Service) queryClientsFromBconsole(ctx context.Context, opts ListClientsOptions) ([]Client, error) { + // Execute bconsole command to list clients + s.logger.Debug("Executing bconsole list clients command") + output, err := s.ExecuteBconsoleCommand(ctx, "list clients") + if err != nil { + s.logger.Error("Failed to execute bconsole list clients", "error", err) + return nil, fmt.Errorf("failed to execute bconsole list clients: %w", err) + } + + previewLen := 200 + if len(output) < previewLen { + previewLen = len(output) + } + s.logger.Debug("bconsole output", "output_length", len(output), "output_preview", output[:previewLen]) + + // Parse bconsole output + clients := s.parseBconsoleClientsOutput(output) + s.logger.Debug("Parsed clients from bconsole", "count", len(clients)) + + // Apply filters + filtered := []Client{} + for _, client := range clients { + if opts.Enabled != nil && client.Enabled != *opts.Enabled { + continue + } + if opts.Search != "" && !strings.Contains(strings.ToLower(client.Name), strings.ToLower(opts.Search)) { + continue + } + filtered = append(filtered, client) + } + + return filtered, nil +} + +// parseBconsoleClientsOutput parses bconsole "list clients" output +func (s *Service) parseBconsoleClientsOutput(output string) []Client { + var clients []Client + lines := strings.Split(output, "\n") + + inTable := false + headerFound := false + + for _, line := range lines { + line = strings.TrimSpace(line) + + // Skip connection messages and command echo + if strings.Contains(line, "Connecting to Director") || + strings.Contains(line, "Enter a period") || + strings.Contains(line, "list clients") || + strings.Contains(line, "quit") || + strings.Contains(line, "You have messages") || + strings.Contains(line, "Automatically selected") || + strings.Contains(line, "Using Catalog") { + continue + } + + // Detect table header + if !headerFound && (strings.Contains(line, "Name") || strings.Contains(line, "| Name")) { + headerFound = true + inTable = true + continue + } + + // Detect table separator + if strings.HasPrefix(line, "+") && strings.Contains(line, "-") { + continue + } + + // Skip empty lines + if line == "" { + continue + } + + // Parse table rows (format: | clientname | address |) + if inTable && strings.Contains(line, "|") { + parts := strings.Split(line, "|") + if len(parts) >= 2 { + clientName := strings.TrimSpace(parts[1]) + clientName = strings.Trim(clientName, "\"'") + + if clientName == "" || clientName == "Name" || strings.HasPrefix(clientName, "-") { + continue + } + + client := Client{ + ClientID: 0, + Name: clientName, + Enabled: true, + } + clients = append(clients, client) + } + } else if inTable && !strings.Contains(line, "|") { + // Fallback format + parts := strings.Fields(line) + if len(parts) > 0 { + clientName := parts[0] + clientName = strings.Trim(clientName, "\"'") + if clientName != "" && clientName != "Name" && !strings.HasPrefix(clientName, "-") { + client := Client{ + ClientID: 0, + Name: clientName, + Enabled: true, + } + clients = append(clients, client) + } + } + } + } + + return clients +} + // upsertJob inserts or updates a job in the database func (s *Service) upsertJob(ctx context.Context, job Job) error { query := ` diff --git a/backend/internal/common/router/router.go b/backend/internal/common/router/router.go index 5f8dd98..57212f9 100644 --- a/backend/internal/common/router/router.go +++ b/backend/internal/common/router/router.go @@ -349,6 +349,7 @@ func NewRouter(cfg *config.Config, db *database.DB, log *logger.Logger) *gin.Eng backupGroup.GET("/jobs", backupHandler.ListJobs) backupGroup.GET("/jobs/:id", backupHandler.GetJob) backupGroup.POST("/jobs", requirePermission("backup", "write"), backupHandler.CreateJob) + backupGroup.GET("/clients", backupHandler.ListClients) backupGroup.POST("/console/execute", requirePermission("backup", "write"), backupHandler.ExecuteBconsoleCommand) } diff --git a/frontend/src/api/backup.ts b/frontend/src/api/backup.ts index b44556c..58df303 100644 --- a/frontend/src/api/backup.ts +++ b/frontend/src/api/backup.ts @@ -46,6 +46,30 @@ export interface CreateJobRequest { pool_name?: string } +export interface BackupClient { + client_id: number + name: string + uname?: string + enabled: boolean + auto_prune?: boolean + file_retention?: number + job_retention?: number + last_backup_at?: string + total_jobs?: number + total_bytes?: number + status?: 'online' | 'offline' +} + +export interface ListClientsResponse { + clients: BackupClient[] + total: number +} + +export interface ListClientsParams { + enabled?: boolean + search?: string +} + export const backupAPI = { listJobs: async (params?: ListJobsParams): Promise => { const queryParams = new URLSearchParams() @@ -76,5 +100,16 @@ export const backupAPI = { const response = await apiClient.post<{ output: string }>('/backup/console/execute', { command }) return response.data }, + + listClients: async (params?: ListClientsParams): Promise => { + const queryParams = new URLSearchParams() + if (params?.enabled !== undefined) queryParams.append('enabled', params.enabled.toString()) + if (params?.search) queryParams.append('search', params.search) + + const response = await apiClient.get( + `/backup/clients${queryParams.toString() ? `?${queryParams.toString()}` : ''}` + ) + return response.data + }, } diff --git a/frontend/src/pages/BackupManagement.tsx b/frontend/src/pages/BackupManagement.tsx index 15b5c18..97f5450 100644 --- a/frontend/src/pages/BackupManagement.tsx +++ b/frontend/src/pages/BackupManagement.tsx @@ -3,8 +3,108 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { backupAPI } from '@/api/backup' import { Search, X } from 'lucide-react' +// Styles for checkbox and tree lines +const clientManagementStyles = ` + .checkbox-custom { + appearance: none; + background-color: #1c2936; + border: 1px solid #324d67; + border-radius: 4px; + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + } + .checkbox-custom:checked { + background-color: #137fec; + border-color: #137fec; + } + .checkbox-custom:checked::after { + content: ''; + width: 4px; + height: 8px; + border: solid white; + border-width: 0 2px 2px 0; + transform: rotate(45deg); + margin-bottom: 2px; + } + .agent-item { + position: relative; + } + .agent-item .tree-line-vertical { + position: absolute; + left: 20px; + top: -24px; + bottom: 0; + width: 1px; + background-color: #324d67; + z-index: 0; + } + .agent-item:last-child .tree-line-vertical { + bottom: 50%; + } + .agent-item .tree-line-horizontal { + position: absolute; + left: 20px; + top: 50%; + width: 24px; + height: 1px; + background-color: #324d67; + z-index: 0; + } +` + export default function BackupManagement() { const [activeTab, setActiveTab] = useState<'dashboard' | 'jobs' | 'clients' | 'storage' | 'restore' | 'console'>('dashboard') + // Fetch recent jobs for dashboard + const { data: dashboardJobsData } = useQuery({ + queryKey: ['dashboard-jobs'], + queryFn: () => backupAPI.listJobs({ limit: 4 }), + enabled: activeTab === 'dashboard', + }) + + const recentJobs = dashboardJobsData?.jobs || [] + + // Helper functions for formatting + const formatBytes = (bytes: number): string => { + if (bytes === 0) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB', 'TB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}` + } + + const formatDuration = (seconds?: number): string => { + if (!seconds) return '-' + const hours = Math.floor(seconds / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + const secs = seconds % 60 + if (hours > 0) { + return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}` + } + return `${minutes}:${secs.toString().padStart(2, '0')}` + } + + const getStatusBadge = (status: string) => { + const statusMap: Record = { + Running: { bg: 'bg-blue-500/10', text: 'text-blue-400', border: 'border-blue-500/20', icon: 'pending_actions' }, + Completed: { bg: 'bg-green-500/10', text: 'text-green-400', border: 'border-green-500/20', icon: 'check_circle' }, + Failed: { bg: 'bg-red-500/10', text: 'text-red-400', border: 'border-red-500/20', icon: 'error' }, + Canceled: { bg: 'bg-yellow-500/10', text: 'text-yellow-400', border: 'border-yellow-500/20', icon: 'cancel' }, + Waiting: { bg: 'bg-gray-500/10', text: 'text-gray-400', border: 'border-gray-500/20', icon: 'schedule' }, + } + const config = statusMap[status] || statusMap.Waiting + return ( + + {status === 'Running' && } + {status !== 'Running' && {config.icon}} + {status} + + ) + } + return (
@@ -206,90 +306,31 @@ export default function BackupManagement() { - {/* Running Job */} - - - - - Running - - - 10423 - WeeklyArchive - filesrv-02 - Backup - Full - 00:45:12 - 142 GB - - - - - {/* Successful Job */} - - - - check - OK - - - 10422 - DailyBackup - web-srv-01 - Backup - Incr - 00:12:05 - 4.2 GB - - - - - {/* Failed Job */} - - - - error - Error - - - 10421 - DB_Snapshot - db-prod-01 - Backup - Diff - 00:00:04 - 0 B - - - - - {/* Another Success */} - - - - check - OK - - - 10420 - CatalogBackup - backup-srv-01 - Backup - Full - 00:05:30 - 850 MB - - - - + {recentJobs.length === 0 ? ( + + + No recent jobs found + + + ) : ( + recentJobs.map((job) => ( + + {getStatusBadge(job.status)} + {job.job_id} + {job.job_name} + {job.client_name} + {job.job_type} + {job.job_level} + {formatDuration(job.duration_seconds)} + {formatBytes(job.bytes_written)} + + + + + )) + )}
@@ -332,9 +373,7 @@ export default function BackupManagement() { )} {activeTab === 'clients' && ( -
- Clients tab coming soon -
+ setActiveTab('console')} /> )} {activeTab === 'storage' && ( @@ -991,3 +1030,424 @@ function BackupConsoleTab() { ) } +// Clients Management Tab Component +function ClientsManagementTab({ onSwitchToConsole }: { onSwitchToConsole?: () => void }) { + const [searchQuery, setSearchQuery] = useState('') + const [statusFilter, setStatusFilter] = useState('all') + const [expandedRows, setExpandedRows] = useState>(new Set()) + const [selectAll, setSelectAll] = useState(false) + const [selectedClients, setSelectedClients] = useState>(new Set()) + + const { data, isLoading, error } = useQuery({ + queryKey: ['backup-clients', statusFilter, searchQuery], + queryFn: () => backupAPI.listClients({ + enabled: statusFilter === 'all' ? undefined : statusFilter === 'enabled', + search: searchQuery || undefined, + }), + }) + + const clients = data?.clients || [] + const total = data?.total || 0 + + const formatDate = (dateStr?: string): string => { + if (!dateStr) return '-' + try { + const date = new Date(dateStr) + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffMins = Math.floor(diffMs / 60000) + const diffHours = Math.floor(diffMs / 3600000) + const diffDays = Math.floor(diffMs / 86400000) + + if (diffMins < 1) return 'Just now' + if (diffMins < 60) return `${diffMins}m ago` + if (diffHours < 24) return `${diffHours}h ago` + if (diffDays < 7) return `${diffDays}d ago` + return date.toLocaleDateString() + } catch { + return '-' + } + } + + const getLastBackupStatus = (client: any): { status: 'success' | 'failed' | 'running' | 'warning', text: string, time: string } => { + if (!client.last_backup_at) { + return { status: 'warning', text: 'Never', time: 'No backup yet' } + } + + const date = new Date(client.last_backup_at) + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffHours = Math.floor(diffMs / 3600000) + const diffDays = Math.floor(diffMs / 86400000) + + // Mock logic - in real app, this would come from job status + if (diffDays > 7) { + return { status: 'failed', text: 'Failed', time: `${diffDays}d ago (Connection Timed Out)` } + } + if (diffHours > 24) { + return { status: 'warning', text: 'Warning', time: `${diffHours}h ago (Some files skipped)` } + } + return { status: 'success', text: 'Success', time: formatDate(client.last_backup_at) + ' (Daily)' } + } + + const toggleRow = (clientId: number) => { + const newExpanded = new Set(expandedRows) + if (newExpanded.has(clientId)) { + newExpanded.delete(clientId) + } else { + newExpanded.add(clientId) + } + setExpandedRows(newExpanded) + } + + const toggleSelectClient = (clientId: number) => { + const newSelected = new Set(selectedClients) + if (newSelected.has(clientId)) { + newSelected.delete(clientId) + } else { + newSelected.add(clientId) + } + setSelectedClients(newSelected) + setSelectAll(newSelected.size === clients.length) + } + + const toggleSelectAll = () => { + if (selectAll) { + setSelectedClients(new Set()) + } else { + setSelectedClients(new Set(clients.map(c => c.client_id))) + } + setSelectAll(!selectAll) + } + + // Extract IP and port from uname (mock - in real app this would come from API) + const getConnectionInfo = (client: any) => { + // Try to extract IP from uname or use mock + const uname = client.uname || '' + const ipMatch = uname.match(/\d+\.\d+\.\d+\.\d+/) + return { + ip: ipMatch ? ipMatch[0] : '192.168.10.25', + port: '9102' + } + } + + return ( + <> + +
+ {/* Header */} +
+
+
+

Client Management

+ + {total} Clients + +
+

+ Monitor backup status, configure file daemons, and manage client connectivity. +

+
+
+ + +
+
+ + {/* Filters */} +
+
+
+ search + setSearchQuery(e.target.value)} + className="w-full bg-surface-highlight border border-border-dark text-white text-sm rounded-lg pl-10 pr-4 py-2 focus:ring-1 focus:ring-primary focus:border-primary outline-none placeholder-text-secondary/70 transition-all" + /> +
+
+ + + + +
+
+
+ + +
+
+ + {/* Clients Table */} +
+ {isLoading ? ( +
Loading clients...
+ ) : error ? ( +
Failed to load clients
+ ) : clients.length === 0 ? ( +
+

No clients found

+
+ ) : ( + <> +
+ + + + + + + + + + + + + + + {clients.map((client, idx) => { + const isExpanded = expandedRows.has(client.client_id) + const isSelected = selectedClients.has(client.client_id) + const backupStatus = getLastBackupStatus(client) + const connection = getConnectionInfo(client) + const isOnline = client.status === 'online' + + return ( + <> + + + + + + + + + + + {isExpanded && ( + + + + )} + + ) + })} + +
+ + Client NameConnectionStatusLast BackupVersionActions
+ toggleSelectClient(client.client_id)} + /> + + + +
+
+ dns +
+
+

{client.name}

+

{client.uname || 'Backup Client'}

+
+
+
+
+

{connection.ip}

+

Port: {connection.port}

+
+
+ {isOnline ? ( + + + Online + + ) : ( + + link_off + Offline + + )} + +
+
+ {backupStatus.status === 'success' && check_circle} + {backupStatus.status === 'failed' && error} + {backupStatus.status === 'running' && } + {backupStatus.status === 'warning' && warning} + {backupStatus.text} +
+

{backupStatus.time}

+
+
+ + v22.4.1 + + +
+ + + +
+
+
+
+ Installed Agents & Plugins +
+
+
+
+
+
+
+
+
+ folder_open +
+
+

Standard File Daemon

+

Core Bacula Client

+
+
+
+
+ Ver + 22.4.1 +
+
+ check_circle + Active +
+
+
+
+
+
+
+
+
+

Showing {clients.length} of {total} clients

+
+ + +
+
+ + )} +
+ + {/* Console Log */} +
+
+
+ Console Log (tail -f) + + Connected + +
+

[14:22:01] bareos-dir: Connected to Storage at backup-srv-01:9103

+

[14:22:02] bareos-sd: Volume "Vol-0012" selected for appending

+

[14:22:05] bareos-fd: Client "{clients[0]?.name || 'client'}" starting backup of /var/www/html

+

[14:23:10] warning: /var/www/html/cache/tmp locked by another process, skipping

+

[14:23:45] bareos-dir: JobId 10423: Sending Accurate information.

+
+
+
+ + ) +} +