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