fix client UI and action
This commit is contained in:
Binary file not shown.
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 := `
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user