2323 lines
67 KiB
Go
2323 lines
67 KiB
Go
package backup
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"os/exec"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/atlasos/calypso/internal/common/config"
|
|
"github.com/atlasos/calypso/internal/common/database"
|
|
"github.com/atlasos/calypso/internal/common/logger"
|
|
)
|
|
|
|
// Service handles backup job operations
|
|
type Service struct {
|
|
db *database.DB
|
|
baculaDB *database.DB // Direct connection to Bacula database
|
|
logger *logger.Logger
|
|
}
|
|
|
|
// NewService creates a new backup service
|
|
func NewService(db *database.DB, log *logger.Logger) *Service {
|
|
return &Service{
|
|
db: db,
|
|
logger: log,
|
|
}
|
|
}
|
|
|
|
// SetBaculaDatabase sets up direct connection to Bacula database
|
|
func (s *Service) SetBaculaDatabase(cfg config.DatabaseConfig, baculaDBName string) error {
|
|
// Create new database config for Bacula database
|
|
baculaCfg := cfg
|
|
baculaCfg.Database = baculaDBName // Override database name
|
|
|
|
// Create connection to Bacula database
|
|
baculaDB, err := database.NewConnection(baculaCfg)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to connect to Bacula database: %w", err)
|
|
}
|
|
|
|
s.baculaDB = baculaDB
|
|
s.logger.Info("Connected to Bacula database", "database", baculaDBName, "host", cfg.Host, "port", cfg.Port)
|
|
return nil
|
|
}
|
|
|
|
// Job represents a backup job
|
|
type Job struct {
|
|
ID string `json:"id"`
|
|
JobID int `json:"job_id"`
|
|
JobName string `json:"job_name"`
|
|
ClientName string `json:"client_name"`
|
|
JobType string `json:"job_type"`
|
|
JobLevel string `json:"job_level"`
|
|
Status string `json:"status"`
|
|
BytesWritten int64 `json:"bytes_written"`
|
|
FilesWritten int `json:"files_written"`
|
|
DurationSeconds *int `json:"duration_seconds,omitempty"`
|
|
StartedAt *time.Time `json:"started_at,omitempty"`
|
|
EndedAt *time.Time `json:"ended_at,omitempty"`
|
|
ErrorMessage *string `json:"error_message,omitempty"`
|
|
StorageName *string `json:"storage_name,omitempty"`
|
|
PoolName *string `json:"pool_name,omitempty"`
|
|
VolumeName *string `json:"volume_name,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// ListJobsOptions represents filtering and pagination options
|
|
type ListJobsOptions struct {
|
|
Status string // Filter by status: "Running", "Completed", "Failed", etc.
|
|
JobType string // Filter by job type: "Backup", "Restore", etc.
|
|
ClientName string // Filter by client name
|
|
JobName string // Filter by job name
|
|
Limit int // Number of results to return
|
|
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
|
|
}
|
|
|
|
// DashboardStats represents statistics for the backup dashboard
|
|
type DashboardStats struct {
|
|
DirectorStatus string `json:"director_status"` // "Active" or "Inactive"
|
|
DirectorUptime string `json:"director_uptime"` // e.g., "14d 2h 12m"
|
|
LastJob *Job `json:"last_job,omitempty"`
|
|
ActiveJobsCount int `json:"active_jobs_count"`
|
|
DefaultPool *PoolStats `json:"default_pool,omitempty"`
|
|
}
|
|
|
|
// PoolStats represents pool storage statistics
|
|
type PoolStats struct {
|
|
Name string `json:"name"`
|
|
UsedBytes int64 `json:"used_bytes"`
|
|
TotalBytes int64 `json:"total_bytes"`
|
|
UsagePercent float64 `json:"usage_percent"`
|
|
}
|
|
|
|
// StoragePool represents a Bacula storage pool
|
|
type StoragePool struct {
|
|
PoolID int `json:"pool_id"`
|
|
Name string `json:"name"`
|
|
PoolType string `json:"pool_type"`
|
|
LabelFormat *string `json:"label_format,omitempty"`
|
|
Recycle *bool `json:"recycle,omitempty"`
|
|
AutoPrune *bool `json:"auto_prune,omitempty"`
|
|
VolumeCount int `json:"volume_count"`
|
|
UsedBytes int64 `json:"used_bytes"`
|
|
TotalBytes int64 `json:"total_bytes"`
|
|
UsagePercent float64 `json:"usage_percent"`
|
|
}
|
|
|
|
// StorageVolume represents a Bacula storage volume
|
|
type StorageVolume struct {
|
|
VolumeID int `json:"volume_id"`
|
|
MediaID int `json:"media_id"`
|
|
VolumeName string `json:"volume_name"`
|
|
PoolName string `json:"pool_name"`
|
|
MediaType string `json:"media_type"`
|
|
VolStatus string `json:"vol_status"` // Full, Append, Used, Error, etc.
|
|
VolBytes int64 `json:"vol_bytes"`
|
|
MaxVolBytes int64 `json:"max_vol_bytes"`
|
|
VolFiles int `json:"vol_files"`
|
|
VolRetention *time.Time `json:"vol_retention,omitempty"`
|
|
LastWritten *time.Time `json:"last_written,omitempty"`
|
|
RecycleCount int `json:"recycle_count"`
|
|
}
|
|
|
|
// StorageDaemon represents a Bacula storage daemon
|
|
type StorageDaemon struct {
|
|
StorageID int `json:"storage_id"`
|
|
Name string `json:"name"`
|
|
Address string `json:"address"`
|
|
Port int `json:"port"`
|
|
DeviceName string `json:"device_name"`
|
|
MediaType string `json:"media_type"`
|
|
Status string `json:"status"` // Online, Offline
|
|
}
|
|
|
|
// 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 {
|
|
s.logger.Info("Starting sync from Bacula database", "bacula_db_configured", s.baculaDB != nil)
|
|
|
|
// Check if Bacula database connection is configured
|
|
if s.baculaDB == nil {
|
|
s.logger.Warn("Bacula database connection not configured, trying bconsole fallback")
|
|
return s.syncFromBconsole(ctx)
|
|
}
|
|
|
|
// Try to query Bacula database directly (if user has access)
|
|
jobs, err := s.queryBaculaDatabase(ctx)
|
|
if err != nil {
|
|
s.logger.Warn("Failed to query Bacula database directly, trying bconsole", "error", err)
|
|
// Fallback to bconsole
|
|
return s.syncFromBconsole(ctx)
|
|
}
|
|
|
|
s.logger.Info("Queried Bacula database", "jobs_found", len(jobs))
|
|
|
|
if len(jobs) == 0 {
|
|
s.logger.Debug("No jobs found in Bacula database")
|
|
return nil
|
|
}
|
|
|
|
// Upsert jobs to Calypso database
|
|
successCount := 0
|
|
errorCount := 0
|
|
for _, job := range jobs {
|
|
err := s.upsertJob(ctx, job)
|
|
if err != nil {
|
|
s.logger.Error("Failed to upsert job", "job_id", job.JobID, "job_name", job.JobName, "error", err)
|
|
errorCount++
|
|
continue
|
|
}
|
|
successCount++
|
|
s.logger.Debug("Upserted job", "job_id", job.JobID, "job_name", job.JobName)
|
|
}
|
|
|
|
s.logger.Info("Synced jobs from Bacula database", "total", len(jobs), "success", successCount, "errors", errorCount)
|
|
|
|
if errorCount > 0 {
|
|
return fmt.Errorf("failed to sync %d out of %d jobs", errorCount, len(jobs))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// queryBaculaDatabase queries Bacula database directly
|
|
// Uses direct connection to Bacula database (no dblink needed)
|
|
func (s *Service) queryBaculaDatabase(ctx context.Context) ([]Job, error) {
|
|
// Use direct connection to Bacula database
|
|
if s.baculaDB == nil {
|
|
return nil, fmt.Errorf("Bacula database connection not configured")
|
|
}
|
|
|
|
return s.queryBaculaDirect(ctx)
|
|
}
|
|
|
|
// queryBaculaDirect queries Job table directly (Bacularis approach)
|
|
// Assumes Bacula tables are in same database or accessible via search_path
|
|
func (s *Service) queryBaculaDirect(ctx context.Context) ([]Job, error) {
|
|
// Bacularis-style query: direct query to Job table with JOIN to Client
|
|
// This is the standard way Bacularis queries Bacula database
|
|
query := `
|
|
SELECT
|
|
j.JobId as job_id,
|
|
j.Name as job_name,
|
|
COALESCE(c.Name, 'unknown') as client_name,
|
|
CASE
|
|
WHEN j.Type = 'B' THEN 'Backup'
|
|
WHEN j.Type = 'R' THEN 'Restore'
|
|
WHEN j.Type = 'V' THEN 'Verify'
|
|
WHEN j.Type = 'C' THEN 'Copy'
|
|
WHEN j.Type = 'M' THEN 'Migrate'
|
|
ELSE 'Backup'
|
|
END as job_type,
|
|
CASE
|
|
WHEN j.Level = 'F' THEN 'Full'
|
|
WHEN j.Level = 'I' THEN 'Incremental'
|
|
WHEN j.Level = 'D' THEN 'Differential'
|
|
WHEN j.Level = 'S' THEN 'Since'
|
|
ELSE 'Full'
|
|
END as job_level,
|
|
CASE
|
|
WHEN j.JobStatus = 'T' THEN 'Running'
|
|
WHEN j.JobStatus = 'C' THEN 'Completed'
|
|
WHEN j.JobStatus = 'f' OR j.JobStatus = 'F' THEN 'Failed'
|
|
WHEN j.JobStatus = 'A' THEN 'Canceled'
|
|
WHEN j.JobStatus = 'W' THEN 'Waiting'
|
|
ELSE 'Waiting'
|
|
END as status,
|
|
COALESCE(j.JobBytes, 0) as bytes_written,
|
|
COALESCE(j.JobFiles, 0) as files_written,
|
|
j.StartTime as started_at,
|
|
j.EndTime as ended_at
|
|
FROM Job j
|
|
LEFT JOIN Client c ON j.ClientId = c.ClientId
|
|
ORDER BY j.StartTime DESC
|
|
LIMIT 1000
|
|
`
|
|
|
|
// Use direct connection to Bacula database
|
|
rows, err := s.baculaDB.QueryContext(ctx, query)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to query Bacula Job table: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var jobs []Job
|
|
for rows.Next() {
|
|
var job Job
|
|
var startedAt, endedAt sql.NullTime
|
|
|
|
err := rows.Scan(
|
|
&job.JobID, &job.JobName, &job.ClientName,
|
|
&job.JobType, &job.JobLevel, &job.Status,
|
|
&job.BytesWritten, &job.FilesWritten, &startedAt, &endedAt,
|
|
)
|
|
if err != nil {
|
|
s.logger.Error("Failed to scan Bacula job", "error", err)
|
|
continue
|
|
}
|
|
|
|
if startedAt.Valid {
|
|
job.StartedAt = &startedAt.Time
|
|
}
|
|
if endedAt.Valid {
|
|
job.EndedAt = &endedAt.Time
|
|
// Calculate duration if both start and end times are available
|
|
if job.StartedAt != nil {
|
|
duration := int(endedAt.Time.Sub(*job.StartedAt).Seconds())
|
|
job.DurationSeconds = &duration
|
|
}
|
|
}
|
|
|
|
jobs = append(jobs, job)
|
|
}
|
|
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(jobs) > 0 {
|
|
s.logger.Info("Successfully queried Bacula database (direct)", "count", len(jobs))
|
|
return jobs, nil
|
|
}
|
|
|
|
return jobs, nil // Return empty list, not an error
|
|
}
|
|
|
|
// syncFromBconsole syncs jobs using bconsole command (fallback method)
|
|
func (s *Service) syncFromBconsole(ctx context.Context) error {
|
|
// Execute bconsole command to list jobs
|
|
cmd := exec.CommandContext(ctx, "sh", "-c", "echo -e 'list jobs\nquit' | bconsole")
|
|
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
s.logger.Debug("Failed to execute bconsole", "error", err, "output", string(output))
|
|
return nil // Don't fail, just return empty
|
|
}
|
|
|
|
if len(output) == 0 {
|
|
s.logger.Debug("bconsole returned empty output")
|
|
return nil
|
|
}
|
|
|
|
// Parse bconsole output
|
|
jobs := s.parseBconsoleOutput(ctx, string(output))
|
|
|
|
if len(jobs) == 0 {
|
|
s.logger.Debug("No jobs found in bconsole output")
|
|
return nil
|
|
}
|
|
|
|
// Upsert jobs to database
|
|
successCount := 0
|
|
for _, job := range jobs {
|
|
err := s.upsertJob(ctx, job)
|
|
if err != nil {
|
|
s.logger.Error("Failed to upsert job", "job_id", job.JobID, "error", err)
|
|
continue
|
|
}
|
|
successCount++
|
|
}
|
|
|
|
s.logger.Info("Synced jobs from bconsole", "total", len(jobs), "success", successCount)
|
|
return nil
|
|
}
|
|
|
|
// parseBconsoleOutput parses bconsole "list jobs" output
|
|
func (s *Service) parseBconsoleOutput(ctx context.Context, output string) []Job {
|
|
var jobs []Job
|
|
lines := strings.Split(output, "\n")
|
|
|
|
// Skip header lines until we find the data rows
|
|
inDataSection := false
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
|
|
// Skip empty lines and separators
|
|
if line == "" || strings.HasPrefix(line, "+") {
|
|
continue
|
|
}
|
|
|
|
// Start data section when we see header
|
|
if strings.HasPrefix(line, "| jobid") {
|
|
inDataSection = true
|
|
continue
|
|
}
|
|
|
|
// Stop at footer separator
|
|
if strings.HasPrefix(line, "*") {
|
|
break
|
|
}
|
|
|
|
if !inDataSection {
|
|
continue
|
|
}
|
|
|
|
// Parse data row: | jobid | name | starttime | type | level | jobfiles | jobbytes | jobstatus |
|
|
if strings.HasPrefix(line, "|") {
|
|
parts := strings.Split(line, "|")
|
|
if len(parts) < 9 {
|
|
continue
|
|
}
|
|
|
|
// Extract fields (skip first empty part)
|
|
jobIDStr := strings.TrimSpace(parts[1])
|
|
jobName := strings.TrimSpace(parts[2])
|
|
startTimeStr := strings.TrimSpace(parts[3])
|
|
jobTypeChar := strings.TrimSpace(parts[4])
|
|
jobLevelChar := strings.TrimSpace(parts[5])
|
|
jobFilesStr := strings.TrimSpace(parts[6])
|
|
jobBytesStr := strings.TrimSpace(parts[7])
|
|
jobStatusChar := strings.TrimSpace(parts[8])
|
|
|
|
// Parse job ID
|
|
jobID, err := strconv.Atoi(jobIDStr)
|
|
if err != nil {
|
|
s.logger.Warn("Failed to parse job ID", "value", jobIDStr, "error", err)
|
|
continue
|
|
}
|
|
|
|
// Parse start time
|
|
var startedAt *time.Time
|
|
if startTimeStr != "" && startTimeStr != "-" {
|
|
// Format: 2025-12-27 23:05:02
|
|
parsedTime, err := time.Parse("2006-01-02 15:04:05", startTimeStr)
|
|
if err == nil {
|
|
startedAt = &parsedTime
|
|
}
|
|
}
|
|
|
|
// Map job type
|
|
jobType := "Backup"
|
|
switch jobTypeChar {
|
|
case "B":
|
|
jobType = "Backup"
|
|
case "R":
|
|
jobType = "Restore"
|
|
case "V":
|
|
jobType = "Verify"
|
|
case "C":
|
|
jobType = "Copy"
|
|
case "M":
|
|
jobType = "Migrate"
|
|
}
|
|
|
|
// Map job level
|
|
jobLevel := "Full"
|
|
switch jobLevelChar {
|
|
case "F":
|
|
jobLevel = "Full"
|
|
case "I":
|
|
jobLevel = "Incremental"
|
|
case "D":
|
|
jobLevel = "Differential"
|
|
case "S":
|
|
jobLevel = "Since"
|
|
}
|
|
|
|
// Parse files and bytes
|
|
filesWritten := 0
|
|
if jobFilesStr != "" && jobFilesStr != "-" {
|
|
if f, err := strconv.Atoi(jobFilesStr); err == nil {
|
|
filesWritten = f
|
|
}
|
|
}
|
|
|
|
bytesWritten := int64(0)
|
|
if jobBytesStr != "" && jobBytesStr != "-" {
|
|
if b, err := strconv.ParseInt(jobBytesStr, 10, 64); err == nil {
|
|
bytesWritten = b
|
|
}
|
|
}
|
|
|
|
// Map job status
|
|
status := "Waiting"
|
|
switch strings.ToLower(jobStatusChar) {
|
|
case "t", "T":
|
|
status = "Running"
|
|
case "c", "C":
|
|
status = "Completed"
|
|
case "f", "F":
|
|
status = "Failed"
|
|
case "A":
|
|
status = "Canceled"
|
|
case "W":
|
|
status = "Waiting"
|
|
}
|
|
|
|
// Try to extract client name from job name (common pattern: JobName-ClientName)
|
|
clientName := "unknown"
|
|
// For now, use job name as client name if it looks like a client name
|
|
// In real implementation, we'd query job details from Bacula
|
|
if jobName != "" {
|
|
// Try to get client name from job details
|
|
clientNameFromJob := s.getClientNameFromJob(ctx, jobID)
|
|
if clientNameFromJob != "" {
|
|
clientName = clientNameFromJob
|
|
} else {
|
|
// Fallback: use job name as client name
|
|
clientName = jobName
|
|
}
|
|
}
|
|
|
|
job := Job{
|
|
JobID: jobID,
|
|
JobName: jobName,
|
|
ClientName: clientName,
|
|
JobType: jobType,
|
|
JobLevel: jobLevel,
|
|
Status: status,
|
|
BytesWritten: bytesWritten,
|
|
FilesWritten: filesWritten,
|
|
StartedAt: startedAt,
|
|
}
|
|
|
|
jobs = append(jobs, job)
|
|
}
|
|
}
|
|
|
|
return jobs
|
|
}
|
|
|
|
// getClientNameFromJob gets client name from job details using bconsole
|
|
func (s *Service) getClientNameFromJob(ctx context.Context, jobID int) string {
|
|
// Execute bconsole to get job details
|
|
cmd := exec.CommandContext(ctx, "sh", "-c", fmt.Sprintf("echo -e 'list job jobid=%d\nquit' | bconsole", jobID))
|
|
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
s.logger.Debug("Failed to get job details", "job_id", jobID, "error", err)
|
|
return ""
|
|
}
|
|
|
|
// Parse output to find Client line
|
|
lines := strings.Split(string(output), "\n")
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if strings.HasPrefix(line, "Client:") {
|
|
parts := strings.Split(line, ":")
|
|
if len(parts) >= 2 {
|
|
return strings.TrimSpace(parts[1])
|
|
}
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// ExecuteBconsoleCommand executes a bconsole command and returns the output
|
|
func (s *Service) ExecuteBconsoleCommand(ctx context.Context, command string) (string, error) {
|
|
// Sanitize command
|
|
command = strings.TrimSpace(command)
|
|
if command == "" {
|
|
return "", fmt.Errorf("command cannot be empty")
|
|
}
|
|
|
|
// Remove any existing quit commands from user input
|
|
command = strings.TrimSuffix(strings.ToLower(command), "quit")
|
|
command = strings.TrimSpace(command)
|
|
|
|
// Ensure command ends with quit
|
|
commandWithQuit := command + "\nquit"
|
|
|
|
// Use printf instead of echo -e for better compatibility
|
|
// Escape single quotes in command
|
|
escapedCommand := strings.ReplaceAll(commandWithQuit, "'", "'\"'\"'")
|
|
|
|
// Execute bconsole command using printf to avoid echo -e issues
|
|
cmd := exec.CommandContext(ctx, "sh", "-c", fmt.Sprintf("printf '%%s\\n' '%s' | bconsole", escapedCommand))
|
|
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
// bconsole may return non-zero exit code even on success, so check output
|
|
outputStr := string(output)
|
|
if len(outputStr) > 0 {
|
|
// If there's output, return it even if there's an error
|
|
return outputStr, nil
|
|
}
|
|
return outputStr, fmt.Errorf("bconsole error: %w", err)
|
|
}
|
|
|
|
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 := `
|
|
INSERT INTO backup_jobs (
|
|
job_id, job_name, client_name, job_type, job_level, status,
|
|
bytes_written, files_written, started_at, ended_at, duration_seconds, updated_at
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW())
|
|
ON CONFLICT (job_id) DO UPDATE SET
|
|
job_name = EXCLUDED.job_name,
|
|
client_name = EXCLUDED.client_name,
|
|
job_type = EXCLUDED.job_type,
|
|
job_level = EXCLUDED.job_level,
|
|
status = EXCLUDED.status,
|
|
bytes_written = EXCLUDED.bytes_written,
|
|
files_written = EXCLUDED.files_written,
|
|
started_at = EXCLUDED.started_at,
|
|
ended_at = EXCLUDED.ended_at,
|
|
duration_seconds = EXCLUDED.duration_seconds,
|
|
updated_at = NOW()
|
|
`
|
|
|
|
// Use job name as client name if client_name is empty (we'll improve this later)
|
|
clientName := job.ClientName
|
|
if clientName == "" {
|
|
clientName = "unknown"
|
|
}
|
|
|
|
result, err := s.db.ExecContext(ctx, query,
|
|
job.JobID, job.JobName, clientName, job.JobType, job.JobLevel, job.Status,
|
|
job.BytesWritten, job.FilesWritten, job.StartedAt, job.EndedAt, job.DurationSeconds,
|
|
)
|
|
|
|
if err != nil {
|
|
s.logger.Error("Database error in upsertJob", "job_id", job.JobID, "error", err)
|
|
return err
|
|
}
|
|
|
|
rowsAffected, _ := result.RowsAffected()
|
|
s.logger.Debug("Upserted job to database", "job_id", job.JobID, "rows_affected", rowsAffected)
|
|
|
|
return nil
|
|
}
|
|
|
|
// ListJobs lists backup jobs with optional filters
|
|
func (s *Service) ListJobs(ctx context.Context, opts ListJobsOptions) ([]Job, int, error) {
|
|
// Try to sync jobs from Bacula first (non-blocking - if it fails, continue with database)
|
|
// Don't return error if sync fails, just log it and continue
|
|
// This allows the API to work even if bconsole is not available
|
|
s.logger.Info("ListJobs called, syncing from Bacula first")
|
|
syncErr := s.SyncJobsFromBacula(ctx)
|
|
if syncErr != nil {
|
|
s.logger.Warn("Failed to sync jobs from Bacula, using database only", "error", syncErr)
|
|
// Continue anyway - we'll use whatever is in the database
|
|
} else {
|
|
s.logger.Info("Successfully synced jobs from Bacula")
|
|
}
|
|
|
|
// Build WHERE clause
|
|
whereClauses := []string{"1=1"}
|
|
args := []interface{}{}
|
|
argIndex := 1
|
|
|
|
if opts.Status != "" {
|
|
whereClauses = append(whereClauses, fmt.Sprintf("status = $%d", argIndex))
|
|
args = append(args, opts.Status)
|
|
argIndex++
|
|
}
|
|
|
|
if opts.JobType != "" {
|
|
whereClauses = append(whereClauses, fmt.Sprintf("job_type = $%d", argIndex))
|
|
args = append(args, opts.JobType)
|
|
argIndex++
|
|
}
|
|
|
|
if opts.ClientName != "" {
|
|
whereClauses = append(whereClauses, fmt.Sprintf("client_name ILIKE $%d", argIndex))
|
|
args = append(args, "%"+opts.ClientName+"%")
|
|
argIndex++
|
|
}
|
|
|
|
if opts.JobName != "" {
|
|
whereClauses = append(whereClauses, fmt.Sprintf("job_name ILIKE $%d", argIndex))
|
|
args = append(args, "%"+opts.JobName+"%")
|
|
argIndex++
|
|
}
|
|
|
|
whereClause := ""
|
|
if len(whereClauses) > 0 {
|
|
whereClause = "WHERE " + whereClauses[0]
|
|
for i := 1; i < len(whereClauses); i++ {
|
|
whereClause += " AND " + whereClauses[i]
|
|
}
|
|
}
|
|
|
|
// Get total count
|
|
countQuery := fmt.Sprintf("SELECT COUNT(*) FROM backup_jobs %s", whereClause)
|
|
var totalCount int
|
|
err := s.db.QueryRowContext(ctx, countQuery, args...).Scan(&totalCount)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("failed to count jobs: %w", err)
|
|
}
|
|
|
|
// Set default limit
|
|
limit := opts.Limit
|
|
if limit <= 0 {
|
|
limit = 50
|
|
}
|
|
if limit > 100 {
|
|
limit = 100
|
|
}
|
|
|
|
// Build query with pagination
|
|
query := fmt.Sprintf(`
|
|
SELECT id, job_id, job_name, client_name, job_type, job_level, status,
|
|
bytes_written, files_written, duration_seconds,
|
|
started_at, ended_at, error_message,
|
|
storage_name, pool_name, volume_name,
|
|
created_at, updated_at
|
|
FROM backup_jobs
|
|
%s
|
|
ORDER BY started_at DESC NULLS LAST, created_at DESC
|
|
LIMIT $%d OFFSET $%d
|
|
`, whereClause, argIndex, argIndex+1)
|
|
|
|
args = append(args, limit, opts.Offset)
|
|
|
|
rows, err := s.db.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("failed to query jobs: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var jobs []Job
|
|
for rows.Next() {
|
|
var job Job
|
|
var durationSeconds sql.NullInt64
|
|
var startedAt, endedAt sql.NullTime
|
|
var errorMessage, storageName, poolName, volumeName sql.NullString
|
|
|
|
err := rows.Scan(
|
|
&job.ID, &job.JobID, &job.JobName, &job.ClientName,
|
|
&job.JobType, &job.JobLevel, &job.Status,
|
|
&job.BytesWritten, &job.FilesWritten, &durationSeconds,
|
|
&startedAt, &endedAt, &errorMessage,
|
|
&storageName, &poolName, &volumeName,
|
|
&job.CreatedAt, &job.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
s.logger.Error("Failed to scan job", "error", err)
|
|
continue
|
|
}
|
|
|
|
if durationSeconds.Valid {
|
|
dur := int(durationSeconds.Int64)
|
|
job.DurationSeconds = &dur
|
|
}
|
|
if startedAt.Valid {
|
|
job.StartedAt = &startedAt.Time
|
|
}
|
|
if endedAt.Valid {
|
|
job.EndedAt = &endedAt.Time
|
|
}
|
|
if errorMessage.Valid {
|
|
job.ErrorMessage = &errorMessage.String
|
|
}
|
|
if storageName.Valid {
|
|
job.StorageName = &storageName.String
|
|
}
|
|
if poolName.Valid {
|
|
job.PoolName = &poolName.String
|
|
}
|
|
if volumeName.Valid {
|
|
job.VolumeName = &volumeName.String
|
|
}
|
|
|
|
jobs = append(jobs, job)
|
|
}
|
|
|
|
return jobs, totalCount, rows.Err()
|
|
}
|
|
|
|
// GetJob retrieves a job by ID
|
|
func (s *Service) GetJob(ctx context.Context, id string) (*Job, error) {
|
|
query := `
|
|
SELECT id, job_id, job_name, client_name, job_type, job_level, status,
|
|
bytes_written, files_written, duration_seconds,
|
|
started_at, ended_at, error_message,
|
|
storage_name, pool_name, volume_name,
|
|
created_at, updated_at
|
|
FROM backup_jobs
|
|
WHERE id = $1
|
|
`
|
|
|
|
var job Job
|
|
var durationSeconds sql.NullInt64
|
|
var startedAt, endedAt sql.NullTime
|
|
var errorMessage, storageName, poolName, volumeName sql.NullString
|
|
|
|
err := s.db.QueryRowContext(ctx, query, id).Scan(
|
|
&job.ID, &job.JobID, &job.JobName, &job.ClientName,
|
|
&job.JobType, &job.JobLevel, &job.Status,
|
|
&job.BytesWritten, &job.FilesWritten, &durationSeconds,
|
|
&startedAt, &endedAt, &errorMessage,
|
|
&storageName, &poolName, &volumeName,
|
|
&job.CreatedAt, &job.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, fmt.Errorf("job not found")
|
|
}
|
|
return nil, fmt.Errorf("failed to get job: %w", err)
|
|
}
|
|
|
|
if durationSeconds.Valid {
|
|
dur := int(durationSeconds.Int64)
|
|
job.DurationSeconds = &dur
|
|
}
|
|
if startedAt.Valid {
|
|
job.StartedAt = &startedAt.Time
|
|
}
|
|
if endedAt.Valid {
|
|
job.EndedAt = &endedAt.Time
|
|
}
|
|
if errorMessage.Valid {
|
|
job.ErrorMessage = &errorMessage.String
|
|
}
|
|
if storageName.Valid {
|
|
job.StorageName = &storageName.String
|
|
}
|
|
if poolName.Valid {
|
|
job.PoolName = &poolName.String
|
|
}
|
|
if volumeName.Valid {
|
|
job.VolumeName = &volumeName.String
|
|
}
|
|
|
|
return &job, nil
|
|
}
|
|
|
|
// CreateJobRequest represents a request to create a new backup job
|
|
type CreateJobRequest struct {
|
|
JobName string `json:"job_name" binding:"required"`
|
|
ClientName string `json:"client_name" binding:"required"`
|
|
JobType string `json:"job_type" binding:"required"` // 'Backup', 'Restore', 'Verify', 'Copy', 'Migrate'
|
|
JobLevel string `json:"job_level" binding:"required"` // 'Full', 'Incremental', 'Differential', 'Since'
|
|
StorageName *string `json:"storage_name,omitempty"`
|
|
PoolName *string `json:"pool_name,omitempty"`
|
|
}
|
|
|
|
// CreateJob creates a new backup job
|
|
func (s *Service) CreateJob(ctx context.Context, req CreateJobRequest) (*Job, error) {
|
|
// Generate a unique job ID (in real implementation, this would come from Bareos)
|
|
// For now, we'll use a simple incrementing approach or timestamp-based ID
|
|
var jobID int
|
|
err := s.db.QueryRowContext(ctx, `
|
|
SELECT COALESCE(MAX(job_id), 0) + 1 FROM backup_jobs
|
|
`).Scan(&jobID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate job ID: %w", err)
|
|
}
|
|
|
|
// Insert the job into database
|
|
query := `
|
|
INSERT INTO backup_jobs (
|
|
job_id, job_name, client_name, job_type, job_level,
|
|
status, bytes_written, files_written,
|
|
storage_name, pool_name, started_at
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW())
|
|
RETURNING id, job_id, job_name, client_name, job_type, job_level, status,
|
|
bytes_written, files_written, duration_seconds,
|
|
started_at, ended_at, error_message,
|
|
storage_name, pool_name, volume_name,
|
|
created_at, updated_at
|
|
`
|
|
|
|
var job Job
|
|
var durationSeconds sql.NullInt64
|
|
var startedAt, endedAt sql.NullTime
|
|
var errorMessage, storageName, poolName, volumeName sql.NullString
|
|
|
|
err = s.db.QueryRowContext(ctx, query,
|
|
jobID, req.JobName, req.ClientName, req.JobType, req.JobLevel,
|
|
"Waiting", 0, 0,
|
|
req.StorageName, req.PoolName,
|
|
).Scan(
|
|
&job.ID, &job.JobID, &job.JobName, &job.ClientName,
|
|
&job.JobType, &job.JobLevel, &job.Status,
|
|
&job.BytesWritten, &job.FilesWritten, &durationSeconds,
|
|
&startedAt, &endedAt, &errorMessage,
|
|
&storageName, &poolName, &volumeName,
|
|
&job.CreatedAt, &job.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create job: %w", err)
|
|
}
|
|
|
|
if durationSeconds.Valid {
|
|
dur := int(durationSeconds.Int64)
|
|
job.DurationSeconds = &dur
|
|
}
|
|
if startedAt.Valid {
|
|
job.StartedAt = &startedAt.Time
|
|
}
|
|
if endedAt.Valid {
|
|
job.EndedAt = &endedAt.Time
|
|
}
|
|
if errorMessage.Valid {
|
|
job.ErrorMessage = &errorMessage.String
|
|
}
|
|
if storageName.Valid {
|
|
job.StorageName = &storageName.String
|
|
}
|
|
if poolName.Valid {
|
|
job.PoolName = &poolName.String
|
|
}
|
|
if volumeName.Valid {
|
|
job.VolumeName = &volumeName.String
|
|
}
|
|
|
|
s.logger.Info("Backup job created",
|
|
"job_id", job.JobID,
|
|
"job_name", job.JobName,
|
|
"client_name", job.ClientName,
|
|
"job_type", job.JobType,
|
|
)
|
|
|
|
return &job, nil
|
|
}
|
|
|
|
// GetDashboardStats returns statistics for the backup dashboard
|
|
func (s *Service) GetDashboardStats(ctx context.Context) (*DashboardStats, error) {
|
|
stats := &DashboardStats{
|
|
DirectorStatus: "Active", // Default to active
|
|
ActiveJobsCount: 0,
|
|
}
|
|
|
|
// Get director status and uptime from bconsole
|
|
output, err := s.ExecuteBconsoleCommand(ctx, "status director")
|
|
if err == nil && len(output) > 0 {
|
|
// If bconsole returns output, director is active
|
|
// Parse output to extract uptime
|
|
lines := strings.Split(output, "\n")
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
// Look for "Daemon started" line which contains uptime info
|
|
if strings.Contains(line, "Daemon started") {
|
|
stats.DirectorStatus = "Active"
|
|
stats.DirectorUptime = s.parseUptimeFromStatus(line)
|
|
break
|
|
}
|
|
// Also check for version line as indicator of active director
|
|
if strings.Contains(line, "Version:") {
|
|
stats.DirectorStatus = "Active"
|
|
}
|
|
}
|
|
|
|
// If we didn't find uptime yet, try to parse from any date in the output
|
|
if stats.DirectorUptime == "" {
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if strings.Contains(line, "started") || strings.Contains(line, "since") {
|
|
uptime := s.parseUptimeFromStatus(line)
|
|
if uptime != "Unknown" {
|
|
stats.DirectorUptime = uptime
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// If still no uptime, set default
|
|
if stats.DirectorUptime == "" {
|
|
stats.DirectorUptime = "Active"
|
|
}
|
|
} else {
|
|
s.logger.Warn("Failed to get director status from bconsole", "error", err)
|
|
stats.DirectorStatus = "Inactive"
|
|
stats.DirectorUptime = "Unknown"
|
|
}
|
|
|
|
// Get last completed job
|
|
lastJobQuery := `
|
|
SELECT id, job_id, job_name, client_name, job_type, job_level, status,
|
|
bytes_written, files_written, started_at, ended_at, created_at, updated_at
|
|
FROM backup_jobs
|
|
WHERE status = 'Completed'
|
|
ORDER BY ended_at DESC NULLS LAST, started_at DESC
|
|
LIMIT 1
|
|
`
|
|
var lastJob Job
|
|
var startedAt, endedAt sql.NullTime
|
|
err = s.db.QueryRowContext(ctx, lastJobQuery).Scan(
|
|
&lastJob.ID, &lastJob.JobID, &lastJob.JobName, &lastJob.ClientName,
|
|
&lastJob.JobType, &lastJob.JobLevel, &lastJob.Status,
|
|
&lastJob.BytesWritten, &lastJob.FilesWritten,
|
|
&startedAt, &endedAt, &lastJob.CreatedAt, &lastJob.UpdatedAt,
|
|
)
|
|
if err == nil {
|
|
if startedAt.Valid {
|
|
lastJob.StartedAt = &startedAt.Time
|
|
}
|
|
if endedAt.Valid {
|
|
lastJob.EndedAt = &endedAt.Time
|
|
}
|
|
// Calculate duration
|
|
if lastJob.StartedAt != nil && lastJob.EndedAt != nil {
|
|
duration := int(lastJob.EndedAt.Sub(*lastJob.StartedAt).Seconds())
|
|
lastJob.DurationSeconds = &duration
|
|
}
|
|
stats.LastJob = &lastJob
|
|
} else if err != sql.ErrNoRows {
|
|
s.logger.Warn("Failed to get last job", "error", err)
|
|
}
|
|
|
|
// Count active (running) jobs
|
|
activeJobsQuery := `SELECT COUNT(*) FROM backup_jobs WHERE status = 'Running'`
|
|
err = s.db.QueryRowContext(ctx, activeJobsQuery).Scan(&stats.ActiveJobsCount)
|
|
if err != nil {
|
|
s.logger.Warn("Failed to count active jobs", "error", err)
|
|
}
|
|
|
|
// Get default pool stats from Bacula database
|
|
if s.baculaDB != nil {
|
|
poolStats, err := s.getDefaultPoolStats(ctx)
|
|
if err == nil {
|
|
stats.DefaultPool = poolStats
|
|
} else {
|
|
s.logger.Warn("Failed to get pool stats", "error", err)
|
|
}
|
|
}
|
|
|
|
return stats, nil
|
|
}
|
|
|
|
// parseUptimeFromStatus parses uptime from bconsole status output
|
|
func (s *Service) parseUptimeFromStatus(line string) string {
|
|
// Look for "Daemon started" pattern: "Daemon started 28-Dec-25 01:45"
|
|
// Bacula format: "28-Dec-25 01:45" or "30-Dec-2025 02:24:58"
|
|
|
|
// Try to find date pattern in the line
|
|
// Format: "DD-MMM-YY HH:MM" or "DD-MMM-YYYY HH:MM:SS"
|
|
words := strings.Fields(line)
|
|
for i := 0; i < len(words); i++ {
|
|
word := words[i]
|
|
// Check if word looks like a date (contains "-" and month abbreviation)
|
|
if strings.Contains(word, "-") && (strings.Contains(word, "Jan") ||
|
|
strings.Contains(word, "Feb") || strings.Contains(word, "Mar") ||
|
|
strings.Contains(word, "Apr") || strings.Contains(word, "May") ||
|
|
strings.Contains(word, "Jun") || strings.Contains(word, "Jul") ||
|
|
strings.Contains(word, "Aug") || strings.Contains(word, "Sep") ||
|
|
strings.Contains(word, "Oct") || strings.Contains(word, "Nov") ||
|
|
strings.Contains(word, "Dec")) {
|
|
|
|
// Try to parse date + time
|
|
var dateTimeStr string
|
|
if i+1 < len(words) {
|
|
// Date + time (2 words)
|
|
dateTimeStr = word + " " + words[i+1]
|
|
} else {
|
|
dateTimeStr = word
|
|
}
|
|
|
|
// Try different date formats
|
|
formats := []string{
|
|
"02-Jan-06 15:04", // "28-Dec-25 01:45"
|
|
"02-Jan-2006 15:04:05", // "30-Dec-2025 02:24:58"
|
|
"02-Jan-2006 15:04", // "30-Dec-2025 02:24"
|
|
"2006-01-02 15:04:05", // ISO format
|
|
"2006-01-02 15:04", // ISO format without seconds
|
|
}
|
|
|
|
for _, format := range formats {
|
|
if t, err := time.Parse(format, dateTimeStr); err == nil {
|
|
duration := time.Since(t)
|
|
return s.formatUptime(duration)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return "Unknown"
|
|
}
|
|
|
|
// formatUptime formats a duration as "Xd Xh Xm"
|
|
func (s *Service) formatUptime(duration time.Duration) string {
|
|
days := int(duration.Hours() / 24)
|
|
hours := int(duration.Hours()) % 24
|
|
minutes := int(duration.Minutes()) % 60
|
|
|
|
if days > 0 {
|
|
return fmt.Sprintf("%dd %dh %dm", days, hours, minutes)
|
|
}
|
|
if hours > 0 {
|
|
return fmt.Sprintf("%dh %dm", hours, minutes)
|
|
}
|
|
return fmt.Sprintf("%dm", minutes)
|
|
}
|
|
|
|
// getDefaultPoolStats gets statistics for the default pool from Bacula database
|
|
func (s *Service) getDefaultPoolStats(ctx context.Context) (*PoolStats, error) {
|
|
// Query Pool table for default pool (usually named "Default" or "Full")
|
|
query := `
|
|
SELECT
|
|
p.Name,
|
|
COALESCE(SUM(v.VolBytes), 0) as used_bytes,
|
|
COALESCE(SUM(v.MaxVolBytes), 0) as total_bytes
|
|
FROM Pool p
|
|
LEFT JOIN Media m ON p.PoolId = m.PoolId
|
|
LEFT JOIN Volumes v ON m.MediaId = v.MediaId
|
|
WHERE p.Name = 'Default' OR p.Name = 'Full'
|
|
GROUP BY p.Name
|
|
ORDER BY p.Name
|
|
LIMIT 1
|
|
`
|
|
|
|
var pool PoolStats
|
|
var name sql.NullString
|
|
var usedBytes, totalBytes sql.NullInt64
|
|
|
|
err := s.baculaDB.QueryRowContext(ctx, query).Scan(&name, &usedBytes, &totalBytes)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
// Try alternative query - get pool with most volumes
|
|
altQuery := `
|
|
SELECT
|
|
p.Name,
|
|
COALESCE(SUM(v.VolBytes), 0) as used_bytes,
|
|
COALESCE(SUM(v.MaxVolBytes), 0) as total_bytes
|
|
FROM Pool p
|
|
LEFT JOIN Media m ON p.PoolId = m.PoolId
|
|
LEFT JOIN Volumes v ON m.MediaId = v.MediaId
|
|
GROUP BY p.Name
|
|
ORDER BY COUNT(m.MediaId) DESC
|
|
LIMIT 1
|
|
`
|
|
err = s.baculaDB.QueryRowContext(ctx, altQuery).Scan(&name, &usedBytes, &totalBytes)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to query pool stats: %w", err)
|
|
}
|
|
} else {
|
|
return nil, fmt.Errorf("failed to query pool stats: %w", err)
|
|
}
|
|
}
|
|
|
|
if name.Valid {
|
|
pool.Name = name.String
|
|
}
|
|
if usedBytes.Valid {
|
|
pool.UsedBytes = usedBytes.Int64
|
|
}
|
|
if totalBytes.Valid {
|
|
pool.TotalBytes = totalBytes.Int64
|
|
}
|
|
|
|
// Calculate usage percent
|
|
if pool.TotalBytes > 0 {
|
|
pool.UsagePercent = float64(pool.UsedBytes) / float64(pool.TotalBytes) * 100
|
|
} else {
|
|
// If total is 0, set total to used to show 100% if there's data
|
|
if pool.UsedBytes > 0 {
|
|
pool.TotalBytes = pool.UsedBytes
|
|
pool.UsagePercent = 100.0
|
|
}
|
|
}
|
|
|
|
return &pool, nil
|
|
}
|
|
|
|
// ListStoragePools lists all storage pools from Bacula database
|
|
func (s *Service) ListStoragePools(ctx context.Context) ([]StoragePool, error) {
|
|
if s.baculaDB == nil {
|
|
return nil, fmt.Errorf("Bacula database connection not configured")
|
|
}
|
|
|
|
query := `
|
|
SELECT
|
|
p.PoolId,
|
|
p.Name,
|
|
COALESCE(p.PoolType, 'Backup') as PoolType,
|
|
p.LabelFormat,
|
|
p.Recycle,
|
|
p.AutoPrune,
|
|
COALESCE(COUNT(DISTINCT m.MediaId), 0) as volume_count,
|
|
COALESCE(SUM(m.VolBytes), 0) as used_bytes,
|
|
COALESCE(SUM(m.VolBytes), 0) as total_bytes
|
|
FROM Pool p
|
|
LEFT JOIN Media m ON p.PoolId = m.PoolId
|
|
GROUP BY p.PoolId, p.Name, p.PoolType, p.LabelFormat, p.Recycle, p.AutoPrune
|
|
ORDER BY p.Name
|
|
`
|
|
|
|
rows, err := s.baculaDB.QueryContext(ctx, query)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to query storage pools: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var pools []StoragePool
|
|
for rows.Next() {
|
|
var pool StoragePool
|
|
var labelFormat, poolType sql.NullString
|
|
var recycle, autoPrune sql.NullBool
|
|
var usedBytes, totalBytes sql.NullInt64
|
|
|
|
err := rows.Scan(
|
|
&pool.PoolID, &pool.Name, &poolType, &labelFormat,
|
|
&recycle, &autoPrune, &pool.VolumeCount,
|
|
&usedBytes, &totalBytes,
|
|
)
|
|
if err != nil {
|
|
s.logger.Error("Failed to scan pool row", "error", err)
|
|
continue
|
|
}
|
|
|
|
// Set default pool type if null
|
|
if poolType.Valid && poolType.String != "" {
|
|
pool.PoolType = poolType.String
|
|
} else {
|
|
pool.PoolType = "Backup" // Default
|
|
}
|
|
|
|
if labelFormat.Valid && labelFormat.String != "" {
|
|
pool.LabelFormat = &labelFormat.String
|
|
}
|
|
if recycle.Valid {
|
|
pool.Recycle = &recycle.Bool
|
|
}
|
|
if autoPrune.Valid {
|
|
pool.AutoPrune = &autoPrune.Bool
|
|
}
|
|
if usedBytes.Valid {
|
|
pool.UsedBytes = usedBytes.Int64
|
|
} else {
|
|
pool.UsedBytes = 0
|
|
}
|
|
if totalBytes.Valid {
|
|
pool.TotalBytes = totalBytes.Int64
|
|
} else {
|
|
pool.TotalBytes = 0
|
|
}
|
|
|
|
// Calculate usage percent
|
|
if pool.TotalBytes > 0 {
|
|
pool.UsagePercent = float64(pool.UsedBytes) / float64(pool.TotalBytes) * 100
|
|
} else if pool.UsedBytes > 0 {
|
|
pool.TotalBytes = pool.UsedBytes
|
|
pool.UsagePercent = 100.0
|
|
} else {
|
|
pool.UsagePercent = 0.0
|
|
}
|
|
|
|
s.logger.Debug("Loaded pool", "pool_id", pool.PoolID, "name", pool.Name, "type", pool.PoolType, "volumes", pool.VolumeCount)
|
|
pools = append(pools, pool)
|
|
}
|
|
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("error iterating pool rows: %w", err)
|
|
}
|
|
|
|
return pools, nil
|
|
}
|
|
|
|
// ListStorageVolumes lists all storage volumes from Bacula database
|
|
func (s *Service) ListStorageVolumes(ctx context.Context, poolName string) ([]StorageVolume, error) {
|
|
if s.baculaDB == nil {
|
|
return nil, fmt.Errorf("Bacula database connection not configured")
|
|
}
|
|
|
|
query := `
|
|
SELECT
|
|
v.VolumeId,
|
|
v.MediaId,
|
|
v.VolumeName,
|
|
COALESCE(p.Name, 'Unknown') as pool_name,
|
|
m.MediaType,
|
|
v.VolStatus,
|
|
COALESCE(v.VolBytes, 0) as vol_bytes,
|
|
COALESCE(v.MaxVolBytes, 0) as max_vol_bytes,
|
|
COALESCE(v.VolFiles, 0) as vol_files,
|
|
v.VolRetention,
|
|
v.LastWritten,
|
|
COALESCE(v.RecycleCount, 0) as recycle_count
|
|
FROM Volumes v
|
|
LEFT JOIN Media m ON v.MediaId = m.MediaId
|
|
LEFT JOIN Pool p ON m.PoolId = p.PoolId
|
|
WHERE 1=1
|
|
`
|
|
args := []interface{}{}
|
|
argIndex := 1
|
|
|
|
if poolName != "" {
|
|
query += fmt.Sprintf(" AND p.Name = $%d", argIndex)
|
|
args = append(args, poolName)
|
|
argIndex++
|
|
}
|
|
|
|
query += " ORDER BY v.LastWritten DESC NULLS LAST, v.VolumeName"
|
|
|
|
rows, err := s.baculaDB.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to query storage volumes: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var volumes []StorageVolume
|
|
for rows.Next() {
|
|
var vol StorageVolume
|
|
var volRetention, lastWritten sql.NullTime
|
|
|
|
err := rows.Scan(
|
|
&vol.VolumeID, &vol.MediaID, &vol.VolumeName, &vol.PoolName,
|
|
&vol.MediaType, &vol.VolStatus, &vol.VolBytes, &vol.MaxVolBytes,
|
|
&vol.VolFiles, &volRetention, &lastWritten, &vol.RecycleCount,
|
|
)
|
|
if err != nil {
|
|
s.logger.Error("Failed to scan volume row", "error", err)
|
|
continue
|
|
}
|
|
|
|
if volRetention.Valid {
|
|
vol.VolRetention = &volRetention.Time
|
|
}
|
|
if lastWritten.Valid {
|
|
vol.LastWritten = &lastWritten.Time
|
|
}
|
|
|
|
volumes = append(volumes, vol)
|
|
}
|
|
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("error iterating volume rows: %w", err)
|
|
}
|
|
|
|
return volumes, nil
|
|
}
|
|
|
|
// ListStorageDaemons lists all storage daemons from Bacula database
|
|
func (s *Service) ListStorageDaemons(ctx context.Context) ([]StorageDaemon, error) {
|
|
if s.baculaDB == nil {
|
|
return nil, fmt.Errorf("Bacula database connection not configured")
|
|
}
|
|
|
|
query := `
|
|
SELECT
|
|
s.StorageId,
|
|
s.Name,
|
|
s.Address,
|
|
s.Port,
|
|
s.DeviceName,
|
|
s.MediaType
|
|
FROM Storage s
|
|
ORDER BY s.Name
|
|
`
|
|
|
|
rows, err := s.baculaDB.QueryContext(ctx, query)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to query storage daemons: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var daemons []StorageDaemon
|
|
for rows.Next() {
|
|
var daemon StorageDaemon
|
|
var address, deviceName, mediaType sql.NullString
|
|
var port sql.NullInt64
|
|
|
|
err := rows.Scan(
|
|
&daemon.StorageID, &daemon.Name, &address, &port,
|
|
&deviceName, &mediaType,
|
|
)
|
|
if err != nil {
|
|
s.logger.Error("Failed to scan storage daemon row", "error", err)
|
|
continue
|
|
}
|
|
|
|
if address.Valid {
|
|
daemon.Address = address.String
|
|
}
|
|
if port.Valid {
|
|
daemon.Port = int(port.Int64)
|
|
}
|
|
if deviceName.Valid {
|
|
daemon.DeviceName = deviceName.String
|
|
}
|
|
if mediaType.Valid {
|
|
daemon.MediaType = mediaType.String
|
|
}
|
|
|
|
// Default status to Online (could be enhanced with actual connection check)
|
|
daemon.Status = "Online"
|
|
|
|
daemons = append(daemons, daemon)
|
|
}
|
|
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("error iterating storage daemon rows: %w", err)
|
|
}
|
|
|
|
return daemons, nil
|
|
}
|
|
|
|
// CreatePoolRequest represents a request to create a new storage pool
|
|
type CreatePoolRequest struct {
|
|
Name string `json:"name" binding:"required"`
|
|
PoolType string `json:"pool_type"` // Backup, Scratch, Recycle
|
|
LabelFormat *string `json:"label_format,omitempty"`
|
|
Recycle *bool `json:"recycle,omitempty"`
|
|
AutoPrune *bool `json:"auto_prune,omitempty"`
|
|
}
|
|
|
|
// CreateStoragePool creates a new storage pool in Bacula database
|
|
func (s *Service) CreateStoragePool(ctx context.Context, req CreatePoolRequest) (*StoragePool, error) {
|
|
if s.baculaDB == nil {
|
|
return nil, fmt.Errorf("Bacula database connection not configured")
|
|
}
|
|
|
|
// Validate pool name
|
|
if req.Name == "" {
|
|
return nil, fmt.Errorf("pool name is required")
|
|
}
|
|
|
|
// Check if pool already exists
|
|
var existingID int
|
|
err := s.baculaDB.QueryRowContext(ctx, "SELECT PoolId FROM Pool WHERE Name = $1", req.Name).Scan(&existingID)
|
|
if err == nil {
|
|
return nil, fmt.Errorf("pool with name %s already exists", req.Name)
|
|
} else if err != sql.ErrNoRows {
|
|
return nil, fmt.Errorf("failed to check existing pool: %w", err)
|
|
}
|
|
|
|
// Set defaults
|
|
poolType := req.PoolType
|
|
if poolType == "" {
|
|
poolType = "Backup" // Default to Backup pool
|
|
}
|
|
|
|
// Insert new pool
|
|
query := `
|
|
INSERT INTO Pool (Name, PoolType, LabelFormat, Recycle, AutoPrune)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
RETURNING PoolId, Name, PoolType, LabelFormat, Recycle, AutoPrune
|
|
`
|
|
|
|
var pool StoragePool
|
|
var labelFormat sql.NullString
|
|
var recycle, autoPrune sql.NullBool
|
|
|
|
err = s.baculaDB.QueryRowContext(ctx, query,
|
|
req.Name, poolType, req.LabelFormat, req.Recycle, req.AutoPrune,
|
|
).Scan(
|
|
&pool.PoolID, &pool.Name, &pool.PoolType, &labelFormat, &recycle, &autoPrune,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create pool: %w", err)
|
|
}
|
|
|
|
if labelFormat.Valid {
|
|
pool.LabelFormat = &labelFormat.String
|
|
}
|
|
if recycle.Valid {
|
|
pool.Recycle = &recycle.Bool
|
|
}
|
|
if autoPrune.Valid {
|
|
pool.AutoPrune = &autoPrune.Bool
|
|
}
|
|
|
|
pool.VolumeCount = 0
|
|
pool.UsedBytes = 0
|
|
pool.TotalBytes = 0
|
|
pool.UsagePercent = 0
|
|
|
|
s.logger.Info("Storage pool created", "pool_id", pool.PoolID, "name", pool.Name, "type", pool.PoolType)
|
|
return &pool, nil
|
|
}
|
|
|
|
// DeleteStoragePool deletes a storage pool from Bacula database
|
|
func (s *Service) DeleteStoragePool(ctx context.Context, poolID int) error {
|
|
if s.baculaDB == nil {
|
|
return fmt.Errorf("Bacula database connection not configured")
|
|
}
|
|
|
|
// Check if pool exists and get name
|
|
var poolName string
|
|
err := s.baculaDB.QueryRowContext(ctx, "SELECT Name FROM Pool WHERE PoolId = $1", poolID).Scan(&poolName)
|
|
if err == sql.ErrNoRows {
|
|
return fmt.Errorf("pool not found")
|
|
} else if err != nil {
|
|
return fmt.Errorf("failed to check pool: %w", err)
|
|
}
|
|
|
|
// Check if pool has volumes
|
|
var volumeCount int
|
|
err = s.baculaDB.QueryRowContext(ctx, `
|
|
SELECT COUNT(*) FROM Media m
|
|
INNER JOIN Pool p ON m.PoolId = p.PoolId
|
|
WHERE p.PoolId = $1
|
|
`, poolID).Scan(&volumeCount)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to check pool volumes: %w", err)
|
|
}
|
|
|
|
if volumeCount > 0 {
|
|
return fmt.Errorf("cannot delete pool %s: pool contains %d volumes. Please remove or move volumes first", poolName, volumeCount)
|
|
}
|
|
|
|
// Delete pool
|
|
_, err = s.baculaDB.ExecContext(ctx, "DELETE FROM Pool WHERE PoolId = $1", poolID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to delete pool: %w", err)
|
|
}
|
|
|
|
s.logger.Info("Storage pool deleted", "pool_id", poolID, "name", poolName)
|
|
return nil
|
|
}
|
|
|
|
// CreateVolumeRequest represents a request to create a new storage volume
|
|
type CreateVolumeRequest struct {
|
|
VolumeName string `json:"volume_name" binding:"required"`
|
|
PoolName string `json:"pool_name" binding:"required"`
|
|
MediaType string `json:"media_type"` // File, Tape, etc.
|
|
MaxVolBytes *int64 `json:"max_vol_bytes,omitempty"`
|
|
VolRetention *int `json:"vol_retention,omitempty"` // Retention period in days
|
|
}
|
|
|
|
// CreateStorageVolume creates a new storage volume in Bacula database
|
|
func (s *Service) CreateStorageVolume(ctx context.Context, req CreateVolumeRequest) (*StorageVolume, error) {
|
|
if s.baculaDB == nil {
|
|
return nil, fmt.Errorf("Bacula database connection not configured")
|
|
}
|
|
|
|
// Validate volume name
|
|
if req.VolumeName == "" {
|
|
return nil, fmt.Errorf("volume name is required")
|
|
}
|
|
|
|
// Get pool ID
|
|
var poolID int
|
|
err := s.baculaDB.QueryRowContext(ctx, "SELECT PoolId FROM Pool WHERE Name = $1", req.PoolName).Scan(&poolID)
|
|
if err == sql.ErrNoRows {
|
|
return nil, fmt.Errorf("pool %s not found", req.PoolName)
|
|
} else if err != nil {
|
|
return nil, fmt.Errorf("failed to get pool: %w", err)
|
|
}
|
|
|
|
// Set defaults
|
|
mediaType := req.MediaType
|
|
if mediaType == "" {
|
|
mediaType = "File" // Default to File for disk volumes
|
|
}
|
|
|
|
// Create Media entry first (Volumes table references Media)
|
|
var mediaID int
|
|
mediaQuery := `
|
|
INSERT INTO Media (PoolId, MediaType, VolumeName, VolBytes, VolFiles, VolStatus, LastWritten)
|
|
VALUES ($1, $2, $3, 0, 0, 'Append', NOW())
|
|
RETURNING MediaId
|
|
`
|
|
err = s.baculaDB.QueryRowContext(ctx, mediaQuery, poolID, mediaType, req.VolumeName).Scan(&mediaID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create media entry: %w", err)
|
|
}
|
|
|
|
// Create Volume entry
|
|
var maxVolBytes sql.NullInt64
|
|
if req.MaxVolBytes != nil {
|
|
maxVolBytes = sql.NullInt64{Int64: *req.MaxVolBytes, Valid: true}
|
|
}
|
|
|
|
var volRetention sql.NullTime
|
|
if req.VolRetention != nil {
|
|
retentionTime := time.Now().AddDate(0, 0, *req.VolRetention)
|
|
volRetention = sql.NullTime{Time: retentionTime, Valid: true}
|
|
}
|
|
|
|
volumeQuery := `
|
|
INSERT INTO Volumes (MediaId, VolumeName, VolBytes, MaxVolBytes, VolFiles, VolStatus, VolRetention, LastWritten)
|
|
VALUES ($1, $2, 0, $3, 0, 'Append', $4, NOW())
|
|
RETURNING VolumeId, MediaId, VolumeName, VolBytes, MaxVolBytes, VolFiles, VolRetention, LastWritten
|
|
`
|
|
|
|
var vol StorageVolume
|
|
var lastWritten sql.NullTime
|
|
|
|
err = s.baculaDB.QueryRowContext(ctx, volumeQuery,
|
|
mediaID, req.VolumeName, maxVolBytes, volRetention,
|
|
).Scan(
|
|
&vol.VolumeID, &vol.MediaID, &vol.VolumeName, &vol.VolBytes, &vol.MaxVolBytes,
|
|
&vol.VolFiles, &volRetention, &lastWritten,
|
|
)
|
|
if err != nil {
|
|
// Cleanup: delete media if volume creation fails
|
|
s.baculaDB.ExecContext(ctx, "DELETE FROM Media WHERE MediaId = $1", mediaID)
|
|
return nil, fmt.Errorf("failed to create volume: %w", err)
|
|
}
|
|
|
|
vol.PoolName = req.PoolName
|
|
vol.MediaType = mediaType
|
|
vol.VolStatus = "Append"
|
|
vol.RecycleCount = 0
|
|
|
|
if volRetention.Valid {
|
|
vol.VolRetention = &volRetention.Time
|
|
}
|
|
if lastWritten.Valid {
|
|
vol.LastWritten = &lastWritten.Time
|
|
}
|
|
|
|
s.logger.Info("Storage volume created", "volume_id", vol.VolumeID, "name", vol.VolumeName, "pool", req.PoolName)
|
|
return &vol, nil
|
|
}
|
|
|
|
// UpdateVolumeRequest represents a request to update a storage volume
|
|
type UpdateVolumeRequest struct {
|
|
MaxVolBytes *int64 `json:"max_vol_bytes,omitempty"`
|
|
VolRetention *int `json:"vol_retention,omitempty"` // Retention period in days
|
|
}
|
|
|
|
// UpdateStorageVolume updates a storage volume's meta-data in Bacula database
|
|
func (s *Service) UpdateStorageVolume(ctx context.Context, volumeID int, req UpdateVolumeRequest) (*StorageVolume, error) {
|
|
if s.baculaDB == nil {
|
|
return nil, fmt.Errorf("Bacula database connection not configured")
|
|
}
|
|
|
|
// Check if volume exists
|
|
var volumeName string
|
|
err := s.baculaDB.QueryRowContext(ctx, "SELECT VolumeName FROM Volumes WHERE VolumeId = $1", volumeID).Scan(&volumeName)
|
|
if err == sql.ErrNoRows {
|
|
return nil, fmt.Errorf("volume not found")
|
|
} else if err != nil {
|
|
return nil, fmt.Errorf("failed to check volume: %w", err)
|
|
}
|
|
|
|
// Build update query dynamically
|
|
updates := []string{}
|
|
args := []interface{}{}
|
|
argIndex := 1
|
|
|
|
if req.MaxVolBytes != nil {
|
|
updates = append(updates, fmt.Sprintf("MaxVolBytes = $%d", argIndex))
|
|
args = append(args, *req.MaxVolBytes)
|
|
argIndex++
|
|
}
|
|
|
|
if req.VolRetention != nil {
|
|
retentionTime := time.Now().AddDate(0, 0, *req.VolRetention)
|
|
updates = append(updates, fmt.Sprintf("VolRetention = $%d", argIndex))
|
|
args = append(args, retentionTime)
|
|
argIndex++
|
|
}
|
|
|
|
if len(updates) == 0 {
|
|
return nil, fmt.Errorf("no fields to update")
|
|
}
|
|
|
|
args = append(args, volumeID)
|
|
query := fmt.Sprintf("UPDATE Volumes SET %s WHERE VolumeId = $%d", strings.Join(updates, ", "), argIndex)
|
|
|
|
_, err = s.baculaDB.ExecContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to update volume: %w", err)
|
|
}
|
|
|
|
// Get updated volume
|
|
volumes, err := s.ListStorageVolumes(ctx, "")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get updated volume: %w", err)
|
|
}
|
|
|
|
for _, vol := range volumes {
|
|
if vol.VolumeID == volumeID {
|
|
s.logger.Info("Storage volume updated", "volume_id", volumeID, "name", volumeName)
|
|
return &vol, nil
|
|
}
|
|
}
|
|
|
|
return nil, fmt.Errorf("updated volume not found")
|
|
}
|
|
|
|
// DeleteStorageVolume deletes a storage volume from Bacula database
|
|
func (s *Service) DeleteStorageVolume(ctx context.Context, volumeID int) error {
|
|
if s.baculaDB == nil {
|
|
return fmt.Errorf("Bacula database connection not configured")
|
|
}
|
|
|
|
// Check if volume exists and get name
|
|
var volumeName string
|
|
var mediaID int
|
|
err := s.baculaDB.QueryRowContext(ctx, "SELECT VolumeName, MediaId FROM Volumes WHERE VolumeId = $1", volumeID).Scan(&volumeName, &mediaID)
|
|
if err == sql.ErrNoRows {
|
|
return fmt.Errorf("volume not found")
|
|
} else if err != nil {
|
|
return fmt.Errorf("failed to check volume: %w", err)
|
|
}
|
|
|
|
// Check if volume has data
|
|
var volBytes int64
|
|
err = s.baculaDB.QueryRowContext(ctx, "SELECT VolBytes FROM Volumes WHERE VolumeId = $1", volumeID).Scan(&volBytes)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to check volume data: %w", err)
|
|
}
|
|
|
|
if volBytes > 0 {
|
|
return fmt.Errorf("cannot delete volume %s: volume contains data. Please purge or truncate first", volumeName)
|
|
}
|
|
|
|
// Delete volume
|
|
_, err = s.baculaDB.ExecContext(ctx, "DELETE FROM Volumes WHERE VolumeId = $1", volumeID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to delete volume: %w", err)
|
|
}
|
|
|
|
// Delete associated media entry
|
|
_, err = s.baculaDB.ExecContext(ctx, "DELETE FROM Media WHERE MediaId = $1", mediaID)
|
|
if err != nil {
|
|
s.logger.Warn("Failed to delete media entry", "media_id", mediaID, "error", err)
|
|
// Continue anyway, volume is deleted
|
|
}
|
|
|
|
s.logger.Info("Storage volume deleted", "volume_id", volumeID, "name", volumeName)
|
|
return nil
|
|
}
|
|
|
|
// Media represents a media entry from bconsole "list media"
|
|
type Media struct {
|
|
MediaID int `json:"media_id"`
|
|
VolumeName string `json:"volume_name"`
|
|
PoolName string `json:"pool_name"`
|
|
MediaType string `json:"media_type"`
|
|
Status string `json:"status"`
|
|
VolBytes int64 `json:"vol_bytes"`
|
|
MaxVolBytes int64 `json:"max_vol_bytes"`
|
|
VolFiles int `json:"vol_files"`
|
|
LastWritten string `json:"last_written,omitempty"`
|
|
RecycleCount int `json:"recycle_count"`
|
|
Slot int `json:"slot,omitempty"` // Slot number in library
|
|
InChanger int `json:"in_changer,omitempty"` // 1 if in changer, 0 if not
|
|
LibraryName string `json:"library_name,omitempty"` // Library name (for tape media)
|
|
}
|
|
|
|
// ListMedia lists all media from bconsole "list media" command
|
|
func (s *Service) ListMedia(ctx context.Context) ([]Media, error) {
|
|
// Execute bconsole command to list media
|
|
s.logger.Debug("Executing bconsole list media command")
|
|
output, err := s.ExecuteBconsoleCommand(ctx, "list media")
|
|
if err != nil {
|
|
s.logger.Error("Failed to execute bconsole list media", "error", err)
|
|
return nil, fmt.Errorf("failed to execute bconsole list media: %w", err)
|
|
}
|
|
|
|
previewLen := 500
|
|
if len(output) < previewLen {
|
|
previewLen = len(output)
|
|
}
|
|
s.logger.Debug("bconsole list media output", "output_length", len(output), "output_preview", output[:previewLen])
|
|
|
|
// Parse bconsole output
|
|
media := s.parseBconsoleMediaOutput(output)
|
|
s.logger.Debug("Parsed media from bconsole", "count", len(media))
|
|
|
|
// Enrich with pool names from database
|
|
if s.baculaDB != nil && len(media) > 0 {
|
|
media = s.enrichMediaWithPoolNames(ctx, media)
|
|
}
|
|
|
|
return media, nil
|
|
}
|
|
|
|
// enrichMediaWithPoolNames enriches media list with pool names from database
|
|
func (s *Service) enrichMediaWithPoolNames(ctx context.Context, media []Media) []Media {
|
|
// Create maps of media_id to pool_name and library_name
|
|
poolMap := make(map[int]string)
|
|
libraryMap := make(map[int]string)
|
|
|
|
if len(media) == 0 {
|
|
return media
|
|
}
|
|
|
|
// Query database to get pool names for all media
|
|
mediaIDs := make([]interface{}, len(media))
|
|
for i, m := range media {
|
|
mediaIDs[i] = m.MediaID
|
|
}
|
|
|
|
// Build query with placeholders
|
|
placeholders := make([]string, len(mediaIDs))
|
|
args := make([]interface{}, len(mediaIDs))
|
|
for i := range mediaIDs {
|
|
placeholders[i] = fmt.Sprintf("$%d", i+1)
|
|
args[i] = mediaIDs[i]
|
|
}
|
|
|
|
// First, get pool names
|
|
query := fmt.Sprintf(`
|
|
SELECT m.MediaId, COALESCE(p.Name, 'Unknown') as pool_name
|
|
FROM Media m
|
|
LEFT JOIN Pool p ON m.PoolId = p.PoolId
|
|
WHERE m.MediaId IN (%s)
|
|
`, strings.Join(placeholders, ","))
|
|
|
|
rows, err := s.baculaDB.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
s.logger.Warn("Failed to query pool names for media", "error", err)
|
|
} else {
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var mediaID int
|
|
var poolName string
|
|
if err := rows.Scan(&mediaID, &poolName); err == nil {
|
|
poolMap[mediaID] = poolName
|
|
}
|
|
}
|
|
}
|
|
|
|
// Get storage names for tape media
|
|
// Since Storage table doesn't have MediaType column, we'll use bconsole to match
|
|
// For each storage, check which media belong to it using "list volumes storage=<name>"
|
|
storageQuery := `
|
|
SELECT Name
|
|
FROM Storage
|
|
ORDER BY Name
|
|
`
|
|
storageRows, err := s.baculaDB.QueryContext(ctx, storageQuery)
|
|
if err == nil {
|
|
defer storageRows.Close()
|
|
var storageNames []string
|
|
for storageRows.Next() {
|
|
var storageName string
|
|
if err := storageRows.Scan(&storageName); err == nil {
|
|
storageNames = append(storageNames, storageName)
|
|
}
|
|
}
|
|
|
|
// For each storage, use bconsole to get list of media volumes
|
|
// This will tell us which media belong to which storage
|
|
for _, storageName := range storageNames {
|
|
// Skip file storages (File1, File2)
|
|
if strings.Contains(strings.ToLower(storageName), "file") {
|
|
continue
|
|
}
|
|
|
|
// Use bconsole to list volumes for this storage
|
|
cmd := fmt.Sprintf("list volumes storage=%s", storageName)
|
|
output, err := s.ExecuteBconsoleCommand(ctx, cmd)
|
|
if err != nil {
|
|
s.logger.Debug("Failed to get volumes for storage", "storage", storageName, "error", err)
|
|
continue
|
|
}
|
|
|
|
// Parse output to get media IDs
|
|
mediaIDs := s.parseMediaIDsFromBconsoleOutput(output)
|
|
for _, mediaID := range mediaIDs {
|
|
if mediaID > 0 {
|
|
libraryMap[mediaID] = storageName
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update media with pool names and library names
|
|
for i := range media {
|
|
if poolName, ok := poolMap[media[i].MediaID]; ok {
|
|
media[i].PoolName = poolName
|
|
}
|
|
// Set library name for tape media that are in changer
|
|
if media[i].MediaType != "" && (strings.Contains(strings.ToLower(media[i].MediaType), "lto") || strings.Contains(strings.ToLower(media[i].MediaType), "tape")) {
|
|
if libraryName, ok := libraryMap[media[i].MediaID]; ok && libraryName != "" {
|
|
media[i].LibraryName = libraryName
|
|
} else if media[i].InChanger > 0 {
|
|
// If in changer but no storage name, use generic name
|
|
media[i].LibraryName = "Unknown Library"
|
|
}
|
|
}
|
|
}
|
|
|
|
return media
|
|
}
|
|
|
|
// parseMediaIDsFromBconsoleOutput parses media IDs from bconsole "list volumes storage=..." output
|
|
func (s *Service) parseMediaIDsFromBconsoleOutput(output string) []int {
|
|
var mediaIDs []int
|
|
lines := strings.Split(output, "\n")
|
|
|
|
inTable := false
|
|
headerFound := false
|
|
mediaIDColIndex := -1
|
|
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
|
|
// Skip connection messages
|
|
if strings.Contains(line, "Connecting to Director") ||
|
|
strings.Contains(line, "Enter a period") ||
|
|
strings.Contains(line, "list volumes") ||
|
|
strings.Contains(line, "quit") ||
|
|
strings.Contains(line, "You have messages") ||
|
|
strings.Contains(line, "Automatically selected") ||
|
|
strings.Contains(line, "Using Catalog") ||
|
|
strings.Contains(line, "Pool:") {
|
|
continue
|
|
}
|
|
|
|
// Detect table header
|
|
if !headerFound && strings.Contains(line, "|") && strings.Contains(strings.ToLower(line), "mediaid") {
|
|
parts := strings.Split(line, "|")
|
|
for i, part := range parts {
|
|
if strings.Contains(strings.ToLower(strings.TrimSpace(part)), "mediaid") {
|
|
mediaIDColIndex = i
|
|
break
|
|
}
|
|
}
|
|
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
|
|
if inTable && strings.Contains(line, "|") && mediaIDColIndex >= 0 {
|
|
parts := strings.Split(line, "|")
|
|
if mediaIDColIndex < len(parts) {
|
|
mediaIDStr := strings.TrimSpace(parts[mediaIDColIndex])
|
|
// Remove commas
|
|
mediaIDStr = strings.ReplaceAll(mediaIDStr, ",", "")
|
|
if mediaID, err := strconv.Atoi(mediaIDStr); err == nil && mediaID > 0 {
|
|
// Skip header row
|
|
if mediaIDStr != "mediaid" && mediaIDStr != "MediaId" {
|
|
mediaIDs = append(mediaIDs, mediaID)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return mediaIDs
|
|
}
|
|
|
|
// parseBconsoleMediaOutput parses bconsole "list media" output
|
|
func (s *Service) parseBconsoleMediaOutput(output string) []Media {
|
|
var mediaList []Media
|
|
lines := strings.Split(output, "\n")
|
|
|
|
inTable := false
|
|
headerFound := false
|
|
headerMap := make(map[string]int) // Map header name to column index
|
|
|
|
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 media") ||
|
|
strings.Contains(line, "quit") ||
|
|
strings.Contains(line, "You have messages") ||
|
|
strings.Contains(line, "Automatically selected") ||
|
|
strings.Contains(line, "Using Catalog") ||
|
|
strings.Contains(line, "Pool:") {
|
|
continue
|
|
}
|
|
|
|
// Detect table header - format: | mediaid | volumename | volstatus | ...
|
|
if !headerFound && strings.Contains(line, "|") && (strings.Contains(strings.ToLower(line), "mediaid") || strings.Contains(strings.ToLower(line), "volumename")) {
|
|
// Parse header to get column positions
|
|
parts := strings.Split(line, "|")
|
|
for i, part := range parts {
|
|
headerName := strings.ToLower(strings.TrimSpace(part))
|
|
if headerName != "" {
|
|
headerMap[headerName] = i
|
|
}
|
|
}
|
|
headerFound = true
|
|
inTable = true
|
|
s.logger.Debug("Found media table header", "headers", headerMap)
|
|
continue
|
|
}
|
|
|
|
// Detect table separator
|
|
if strings.HasPrefix(line, "+") && strings.Contains(line, "-") {
|
|
continue
|
|
}
|
|
|
|
// Skip empty lines
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
// Parse table rows
|
|
if inTable && strings.Contains(line, "|") {
|
|
parts := strings.Split(line, "|")
|
|
if len(parts) > 1 {
|
|
// Helper to get string value safely
|
|
getString := func(colName string) string {
|
|
if idx, ok := headerMap[colName]; ok && idx < len(parts) {
|
|
return strings.TrimSpace(parts[idx])
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// Helper to get int value safely
|
|
getInt := func(colName string) int {
|
|
valStr := getString(colName)
|
|
// Remove commas from numbers
|
|
valStr = strings.ReplaceAll(valStr, ",", "")
|
|
if val, e := strconv.Atoi(valStr); e == nil {
|
|
return val
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// Helper to get int64 value safely
|
|
getInt64 := func(colName string) int64 {
|
|
valStr := getString(colName)
|
|
// Remove commas from numbers
|
|
valStr = strings.ReplaceAll(valStr, ",", "")
|
|
if val, e := strconv.ParseInt(valStr, 10, 64); e == nil {
|
|
return val
|
|
}
|
|
return 0
|
|
}
|
|
|
|
mediaID := getInt("mediaid")
|
|
volumeName := getString("volumename")
|
|
volStatus := getString("volstatus")
|
|
volBytes := getInt64("volbytes")
|
|
volFiles := getInt("volfiles")
|
|
mediaType := getString("mediatype")
|
|
lastWritten := getString("lastwritten")
|
|
recycleCount := getInt("recycle")
|
|
slot := getInt("slot")
|
|
inChanger := getInt("inchanger")
|
|
|
|
// Skip header row or invalid rows
|
|
if mediaID == 0 || volumeName == "" || volumeName == "volumename" {
|
|
continue
|
|
}
|
|
|
|
// Get pool name - it's not in the table, we'll need to get it from database or set default
|
|
// For now, we'll use "Default" as pool name since bconsole list media doesn't show pool
|
|
poolName := "Default"
|
|
|
|
// MaxVolBytes is not in the output, we'll set to 0 for now
|
|
maxVolBytes := int64(0)
|
|
|
|
media := Media{
|
|
MediaID: mediaID,
|
|
VolumeName: volumeName,
|
|
PoolName: poolName,
|
|
MediaType: mediaType,
|
|
Status: volStatus,
|
|
VolBytes: volBytes,
|
|
MaxVolBytes: maxVolBytes,
|
|
VolFiles: volFiles,
|
|
LastWritten: lastWritten,
|
|
RecycleCount: recycleCount,
|
|
Slot: slot,
|
|
InChanger: inChanger,
|
|
}
|
|
|
|
mediaList = append(mediaList, media)
|
|
}
|
|
}
|
|
}
|
|
|
|
s.logger.Debug("Parsed media from bconsole", "count", len(mediaList))
|
|
return mediaList
|
|
}
|