fix client UI and action

This commit is contained in:
Warp Agent
2025-12-30 23:31:07 +07:00
parent 8ece52992b
commit 2de3c5f6ab
6 changed files with 916 additions and 87 deletions

Binary file not shown.

View File

@@ -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),
})
}

View File

@@ -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 := `

View File

@@ -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)
}

View File

@@ -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<ListJobsResponse> => {
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<ListClientsResponse> => {
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<ListClientsResponse>(
`/backup/clients${queryParams.toString() ? `?${queryParams.toString()}` : ''}`
)
return response.data
},
}

View File

@@ -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<string, { bg: string; text: string; border: string; icon: string }> = {
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 (
<span className={`inline-flex items-center gap-1.5 rounded px-2 py-1 text-xs font-medium ${config.bg} ${config.text} border ${config.border}`}>
{status === 'Running' && <span className="block h-1.5 w-1.5 rounded-full bg-blue-400 animate-pulse"></span>}
{status !== 'Running' && <span className="material-symbols-outlined text-[14px]">{config.icon}</span>}
{status}
</span>
)
}
return (
<div className="flex-1 flex flex-col h-full overflow-hidden relative bg-background-dark">
@@ -206,90 +306,31 @@ export default function BackupManagement() {
</tr>
</thead>
<tbody className="divide-y divide-border-dark text-sm">
{/* Running Job */}
<tr className="hover:bg-[#111a22]/50 transition-colors">
<td className="px-6 py-4">
<span className="inline-flex items-center gap-1.5 rounded px-2 py-1 text-xs font-medium bg-blue-500/10 text-blue-400 border border-blue-500/20">
<span className="block h-1.5 w-1.5 rounded-full bg-blue-400 animate-pulse"></span>
Running
</span>
</td>
<td className="px-6 py-4 text-text-secondary font-mono">10423</td>
<td className="px-6 py-4 text-white font-medium">WeeklyArchive</td>
<td className="px-6 py-4 text-text-secondary">filesrv-02</td>
<td className="px-6 py-4 text-text-secondary">Backup</td>
<td className="px-6 py-4 text-text-secondary">Full</td>
<td className="px-6 py-4 text-text-secondary font-mono">00:45:12</td>
<td className="px-6 py-4 text-text-secondary font-mono">142 GB</td>
<td className="px-6 py-4 text-right">
<button className="text-text-secondary hover:text-white p-1 rounded hover:bg-[#111a22] transition-colors">
<span className="material-symbols-outlined text-[20px]">cancel</span>
</button>
</td>
</tr>
{/* Successful Job */}
<tr className="hover:bg-[#111a22]/50 transition-colors">
<td className="px-6 py-4">
<span className="inline-flex items-center gap-1.5 rounded px-2 py-1 text-xs font-medium bg-green-500/10 text-green-400 border border-green-500/20">
<span className="material-symbols-outlined text-[14px]">check</span>
OK
</span>
</td>
<td className="px-6 py-4 text-text-secondary font-mono">10422</td>
<td className="px-6 py-4 text-white font-medium">DailyBackup</td>
<td className="px-6 py-4 text-text-secondary">web-srv-01</td>
<td className="px-6 py-4 text-text-secondary">Backup</td>
<td className="px-6 py-4 text-text-secondary">Incr</td>
<td className="px-6 py-4 text-text-secondary font-mono">00:12:05</td>
<td className="px-6 py-4 text-text-secondary font-mono">4.2 GB</td>
<td className="px-6 py-4 text-right">
<button className="text-text-secondary hover:text-white p-1 rounded hover:bg-[#111a22] transition-colors">
<span className="material-symbols-outlined text-[20px]">more_vert</span>
</button>
</td>
</tr>
{/* Failed Job */}
<tr className="hover:bg-[#111a22]/50 transition-colors">
<td className="px-6 py-4">
<span className="inline-flex items-center gap-1.5 rounded px-2 py-1 text-xs font-medium bg-red-500/10 text-red-400 border border-red-500/20">
<span className="material-symbols-outlined text-[14px]">error</span>
Error
</span>
</td>
<td className="px-6 py-4 text-text-secondary font-mono">10421</td>
<td className="px-6 py-4 text-white font-medium">DB_Snapshot</td>
<td className="px-6 py-4 text-text-secondary">db-prod-01</td>
<td className="px-6 py-4 text-text-secondary">Backup</td>
<td className="px-6 py-4 text-text-secondary">Diff</td>
<td className="px-6 py-4 text-text-secondary font-mono">00:00:04</td>
<td className="px-6 py-4 text-text-secondary font-mono">0 B</td>
<td className="px-6 py-4 text-right">
<button className="text-text-secondary hover:text-white p-1 rounded hover:bg-[#111a22] transition-colors">
<span className="material-symbols-outlined text-[20px]">replay</span>
</button>
</td>
</tr>
{/* Another Success */}
<tr className="hover:bg-[#111a22]/50 transition-colors">
<td className="px-6 py-4">
<span className="inline-flex items-center gap-1.5 rounded px-2 py-1 text-xs font-medium bg-green-500/10 text-green-400 border border-green-500/20">
<span className="material-symbols-outlined text-[14px]">check</span>
OK
</span>
</td>
<td className="px-6 py-4 text-text-secondary font-mono">10420</td>
<td className="px-6 py-4 text-white font-medium">CatalogBackup</td>
<td className="px-6 py-4 text-text-secondary">backup-srv-01</td>
<td className="px-6 py-4 text-text-secondary">Backup</td>
<td className="px-6 py-4 text-text-secondary">Full</td>
<td className="px-6 py-4 text-text-secondary font-mono">00:05:30</td>
<td className="px-6 py-4 text-text-secondary font-mono">850 MB</td>
<td className="px-6 py-4 text-right">
<button className="text-text-secondary hover:text-white p-1 rounded hover:bg-[#111a22] transition-colors">
<span className="material-symbols-outlined text-[20px]">more_vert</span>
</button>
</td>
</tr>
{recentJobs.length === 0 ? (
<tr>
<td colSpan={9} className="px-6 py-8 text-center text-text-secondary">
No recent jobs found
</td>
</tr>
) : (
recentJobs.map((job) => (
<tr key={job.id} className="hover:bg-[#111a22]/50 transition-colors">
<td className="px-6 py-4">{getStatusBadge(job.status)}</td>
<td className="px-6 py-4 text-text-secondary font-mono">{job.job_id}</td>
<td className="px-6 py-4 text-white font-medium">{job.job_name}</td>
<td className="px-6 py-4 text-text-secondary">{job.client_name}</td>
<td className="px-6 py-4 text-text-secondary">{job.job_type}</td>
<td className="px-6 py-4 text-text-secondary">{job.job_level}</td>
<td className="px-6 py-4 text-text-secondary font-mono">{formatDuration(job.duration_seconds)}</td>
<td className="px-6 py-4 text-text-secondary font-mono">{formatBytes(job.bytes_written)}</td>
<td className="px-6 py-4 text-right">
<button className="text-text-secondary hover:text-white transition-colors">
<span className="material-symbols-outlined text-lg">more_vert</span>
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
@@ -332,9 +373,7 @@ export default function BackupManagement() {
)}
{activeTab === 'clients' && (
<div className="p-8 text-center text-text-secondary">
Clients tab coming soon
</div>
<ClientsManagementTab onSwitchToConsole={() => 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<string>('all')
const [expandedRows, setExpandedRows] = useState<Set<number>>(new Set())
const [selectAll, setSelectAll] = useState(false)
const [selectedClients, setSelectedClients] = useState<Set<number>>(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 (
<>
<style>{clientManagementStyles}</style>
<div className="flex flex-col gap-6 flex-1">
{/* Header */}
<header className="flex flex-wrap justify-between items-end gap-4 border-b border-border-dark pb-6">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-3">
<h1 className="text-white text-3xl md:text-4xl font-black leading-tight tracking-tight">Client Management</h1>
<span className="flex h-6 px-2 items-center rounded-full bg-surface-highlight border border-border-dark text-xs font-bold text-primary">
{total} Clients
</span>
</div>
<p className="text-text-secondary text-base font-normal max-w-2xl">
Monitor backup status, configure file daemons, and manage client connectivity.
</p>
</div>
<div className="flex gap-3">
<button
onClick={() => {
if (onSwitchToConsole) {
onSwitchToConsole()
} else {
const consoleTab = document.querySelector('[data-tab="console"]') as HTMLElement
if (consoleTab) consoleTab.click()
}
}}
className="flex items-center gap-2 cursor-pointer justify-center rounded-lg h-10 px-4 bg-surface-highlight border border-border-dark text-white text-sm font-bold hover:bg-[#2a3c50] transition-colors"
>
<span className="material-symbols-outlined text-base">terminal</span>
<span>Console</span>
</button>
<button className="flex items-center gap-2 cursor-pointer justify-center rounded-lg h-10 px-4 bg-primary text-white text-sm font-bold shadow-lg shadow-primary/20 hover:bg-primary/90 transition-colors">
<span className="material-symbols-outlined text-base">add</span>
<span>Add New Client</span>
</button>
</div>
</header>
{/* Filters */}
<div className="flex flex-wrap items-center justify-between gap-4 py-2">
<div className="flex items-center gap-2 w-full md:w-auto flex-1">
<div className="relative w-full max-w-sm">
<span className="material-symbols-outlined absolute left-3 top-2.5 text-text-secondary text-[20px]">search</span>
<input
type="text"
placeholder="Search clients by name, IP..."
value={searchQuery}
onChange={(e) => 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"
/>
</div>
<div className="hidden md:flex bg-surface-highlight border border-border-dark rounded-lg p-1 gap-1">
<button
onClick={() => setStatusFilter('all')}
className={`px-3 py-1 rounded text-xs font-bold transition-colors ${
statusFilter === 'all'
? 'bg-surface-dark text-white shadow-sm border border-border-dark/50'
: 'text-text-secondary hover:text-white hover:bg-surface-dark'
}`}
>
All
</button>
<button
onClick={() => setStatusFilter('online')}
className={`px-3 py-1 rounded text-xs font-medium transition-colors ${
statusFilter === 'online'
? 'bg-surface-dark text-white shadow-sm border border-border-dark/50'
: 'text-text-secondary hover:text-white hover:bg-surface-dark'
}`}
>
Online
</button>
<button
onClick={() => setStatusFilter('offline')}
className={`px-3 py-1 rounded text-xs font-medium transition-colors ${
statusFilter === 'offline'
? 'bg-surface-dark text-white shadow-sm border border-border-dark/50'
: 'text-text-secondary hover:text-white hover:bg-surface-dark'
}`}
>
Offline
</button>
<button
onClick={() => setStatusFilter('problems')}
className={`px-3 py-1 rounded text-xs font-medium transition-colors ${
statusFilter === 'problems'
? 'bg-surface-dark text-white shadow-sm border border-border-dark/50'
: 'text-text-secondary hover:text-white hover:bg-surface-dark'
}`}
>
Problems
</button>
</div>
</div>
<div className="flex gap-2">
<button className="flex items-center gap-2 px-3 py-2 bg-surface-highlight border border-border-dark rounded-lg text-text-secondary hover:text-white transition-colors text-sm font-medium">
<span className="material-symbols-outlined text-[18px]">sort</span>
Sort: Status
</button>
<button className="md:hidden flex items-center justify-center p-2 bg-surface-highlight border border-border-dark rounded-lg text-text-secondary">
<span className="material-symbols-outlined">filter_list</span>
</button>
</div>
</div>
{/* Clients Table */}
<div className="rounded-lg border border-border-dark bg-surface-highlight overflow-hidden shadow-sm flex flex-col flex-1">
{isLoading ? (
<div className="p-8 text-center text-text-secondary">Loading clients...</div>
) : error ? (
<div className="p-8 text-center text-red-400">Failed to load clients</div>
) : clients.length === 0 ? (
<div className="p-12 text-center">
<p className="text-text-secondary">No clients found</p>
</div>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-surface-dark border-b border-border-dark text-text-secondary text-xs uppercase tracking-wider">
<th className="px-6 py-4 w-12">
<input
type="checkbox"
className="checkbox-custom"
checked={selectAll}
onChange={toggleSelectAll}
/>
</th>
<th className="px-6 py-4 font-semibold w-8"></th>
<th className="px-6 py-4 font-semibold">Client Name</th>
<th className="px-6 py-4 font-semibold">Connection</th>
<th className="px-6 py-4 font-semibold">Status</th>
<th className="px-6 py-4 font-semibold">Last Backup</th>
<th className="px-6 py-4 font-semibold">Version</th>
<th className="px-6 py-4 font-semibold text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border-dark text-sm">
{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 (
<>
<tr
key={client.client_id || client.name}
className={`hover:bg-surface-dark/50 transition-colors group ${
idx % 2 === 0 ? 'bg-surface-dark/20' : ''
} ${!isOnline ? 'bg-red-500/5' : ''}`}
>
<td className="px-6 py-4">
<input
type="checkbox"
className="checkbox-custom"
checked={isSelected}
onChange={() => toggleSelectClient(client.client_id)}
/>
</td>
<td className="px-2 py-4 text-center">
<button
onClick={() => toggleRow(client.client_id)}
className="text-text-secondary hover:text-white transition-colors"
>
<span className={`material-symbols-outlined text-[20px] transition-transform ${isExpanded ? 'transform rotate-90' : ''}`}>
chevron_right
</span>
</button>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded bg-surface-dark flex items-center justify-center text-text-secondary border border-border-dark">
<span className="material-symbols-outlined text-[20px]">dns</span>
</div>
<div>
<p className="text-white font-bold">{client.name}</p>
<p className="text-text-secondary text-xs">{client.uname || 'Backup Client'}</p>
</div>
</div>
</td>
<td className="px-6 py-4">
<div className="flex flex-col gap-0.5">
<p className="text-text-secondary font-mono text-xs">{connection.ip}</p>
<p className="text-text-secondary/60 text-[10px]">Port: {connection.port}</p>
</div>
</td>
<td className="px-6 py-4">
{isOnline ? (
<span className="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium bg-green-500/10 text-green-400 border border-green-500/20">
<span className="block h-1.5 w-1.5 rounded-full bg-green-400"></span>
Online
</span>
) : (
<span className="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium bg-red-500/10 text-red-400 border border-red-500/20">
<span className="material-symbols-outlined text-[12px]">link_off</span>
Offline
</span>
)}
</td>
<td className="px-6 py-4">
<div className="flex flex-col">
<div className={`flex items-center gap-1.5 ${
backupStatus.status === 'success' ? 'text-green-400' :
backupStatus.status === 'failed' ? 'text-red-400' :
backupStatus.status === 'running' ? 'text-primary' :
'text-yellow-500'
}`}>
{backupStatus.status === 'success' && <span className="material-symbols-outlined text-[14px]">check_circle</span>}
{backupStatus.status === 'failed' && <span className="material-symbols-outlined text-[14px]">error</span>}
{backupStatus.status === 'running' && <span className="block h-2 w-2 rounded-full bg-primary animate-pulse"></span>}
{backupStatus.status === 'warning' && <span className="material-symbols-outlined text-[14px]">warning</span>}
<span className="font-bold text-xs">{backupStatus.text}</span>
</div>
<p className="text-text-secondary text-xs mt-0.5">{backupStatus.time}</p>
</div>
</td>
<td className="px-6 py-4">
<span className="text-text-secondary font-mono text-xs bg-surface-dark px-2 py-1 rounded border border-border-dark">
v22.4.1
</span>
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button className="p-1.5 text-text-secondary hover:text-white hover:bg-surface-dark rounded transition-colors" title="Start Backup">
<span className="material-symbols-outlined text-[20px]">play_arrow</span>
</button>
<button className="p-1.5 text-text-secondary hover:text-white hover:bg-surface-dark rounded transition-colors" title="Edit Config">
<span className="material-symbols-outlined text-[20px]">edit</span>
</button>
<button className="p-1.5 text-text-secondary hover:text-white hover:bg-surface-dark rounded transition-colors">
<span className="material-symbols-outlined text-[20px]">more_vert</span>
</button>
</div>
</td>
</tr>
{isExpanded && (
<tr className="bg-surface-dark/10">
<td className="p-0 border-b border-border-dark" colSpan={8}>
<div className="flex flex-col pl-16 py-2 pr-6 border-l-4 border-primary/50 ml-[26px]">
<div className="flex items-center gap-2 mb-3">
<span className="text-xs font-bold text-text-secondary uppercase tracking-wider">Installed Agents & Plugins</span>
<div className="h-px bg-border-dark flex-1"></div>
</div>
<div className="grid grid-cols-1 gap-2">
<div className="relative pl-12 agent-item">
<div className="tree-line-vertical"></div>
<div className="tree-line-horizontal"></div>
<div className="flex items-center justify-between p-3 rounded-lg bg-surface-dark border border-border-dark hover:border-primary/50 transition-colors">
<div className="flex items-center gap-3">
<div className="p-1.5 rounded bg-blue-500/10 text-blue-400">
<span className="material-symbols-outlined text-[18px]">folder_open</span>
</div>
<div className="flex flex-col">
<p className="text-white text-sm font-bold">Standard File Daemon</p>
<p className="text-text-secondary text-xs">Core Bacula Client</p>
</div>
</div>
<div className="flex items-center gap-6">
<div className="flex items-center gap-2 px-2 py-1 rounded bg-surface-highlight border border-border-dark/50">
<span className="text-[10px] text-text-secondary uppercase font-bold">Ver</span>
<span className="text-xs text-white font-mono">22.4.1</span>
</div>
<div className="flex items-center gap-1.5 text-green-400">
<span className="material-symbols-outlined text-[16px]">check_circle</span>
<span className="text-xs font-bold">Active</span>
</div>
</div>
</div>
</div>
</div>
</div>
</td>
</tr>
)}
</>
)
})}
</tbody>
</table>
</div>
<div className="bg-surface-dark border-t border-border-dark px-6 py-3 flex items-center justify-between mt-auto">
<p className="text-text-secondary text-xs">Showing {clients.length} of {total} clients</p>
<div className="flex gap-2">
<button className="p-1 rounded text-text-secondary opacity-50 cursor-not-allowed" disabled>
<span className="material-symbols-outlined text-base">chevron_left</span>
</button>
<button className="p-1 rounded text-text-secondary hover:text-white hover:bg-surface-highlight">
<span className="material-symbols-outlined text-base">chevron_right</span>
</button>
</div>
</div>
</>
)}
</div>
{/* Console Log */}
<div className="mt-4">
<div className="rounded-lg bg-[#0d131a] border border-border-dark p-4 font-mono text-xs text-text-secondary shadow-inner h-32 overflow-y-auto">
<div className="flex items-center justify-between mb-2 text-gray-500 border-b border-white/5 pb-1">
<span>Console Log (tail -f)</span>
<span className="flex items-center gap-1">
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span> Connected
</span>
</div>
<p className="text-blue-400">[14:22:01] bareos-dir: Connected to Storage at backup-srv-01:9103</p>
<p>[14:22:02] bareos-sd: Volume "Vol-0012" selected for appending</p>
<p>[14:22:05] bareos-fd: Client "{clients[0]?.name || 'client'}" starting backup of /var/www/html</p>
<p className="text-yellow-500">[14:23:10] warning: /var/www/html/cache/tmp locked by another process, skipping</p>
<p>[14:23:45] bareos-dir: JobId 10423: Sending Accurate information.</p>
</div>
</div>
</div>
</>
)
}