diff --git a/backend/bin/calypso-api b/backend/bin/calypso-api index 088e8c6..9d3e623 100755 Binary files a/backend/bin/calypso-api and b/backend/bin/calypso-api differ diff --git a/backend/internal/backup/handler.go b/backend/internal/backup/handler.go index c3e7709..f7f89dd 100644 --- a/backend/internal/backup/handler.go +++ b/backend/internal/backup/handler.go @@ -116,3 +116,29 @@ func (h *Handler) CreateJob(c *gin.Context) { c.JSON(http.StatusCreated, job) } + +// ExecuteBconsoleCommand executes a bconsole command +func (h *Handler) ExecuteBconsoleCommand(c *gin.Context) { + var req struct { + Command string `json:"command" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "command is required"}) + return + } + + output, err := h.service.ExecuteBconsoleCommand(c.Request.Context(), req.Command) + if err != nil { + h.logger.Error("Failed to execute bconsole command", "error", err, "command", req.Command) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "failed to execute command", + "output": output, + "details": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "output": output, + }) +} diff --git a/backend/internal/backup/service.go b/backend/internal/backup/service.go index 7a2a9fd..ecd9bd5 100644 --- a/backend/internal/backup/service.go +++ b/backend/internal/backup/service.go @@ -451,6 +451,42 @@ func (s *Service) getClientNameFromJob(ctx context.Context, jobID int) string { 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 +} + // upsertJob inserts or updates a job in the database func (s *Service) upsertJob(ctx context.Context, job Job) error { query := ` diff --git a/backend/internal/common/router/router.go b/backend/internal/common/router/router.go index 9a80c0f..5f8dd98 100644 --- a/backend/internal/common/router/router.go +++ b/backend/internal/common/router/router.go @@ -349,6 +349,7 @@ func NewRouter(cfg *config.Config, db *database.DB, log *logger.Logger) *gin.Eng backupGroup.GET("/jobs", backupHandler.ListJobs) backupGroup.GET("/jobs/:id", backupHandler.GetJob) backupGroup.POST("/jobs", requirePermission("backup", "write"), backupHandler.CreateJob) + backupGroup.POST("/console/execute", requirePermission("backup", "write"), backupHandler.ExecuteBconsoleCommand) } // Monitoring diff --git a/frontend/src/api/backup.ts b/frontend/src/api/backup.ts index c59313b..b44556c 100644 --- a/frontend/src/api/backup.ts +++ b/frontend/src/api/backup.ts @@ -71,5 +71,10 @@ export const backupAPI = { const response = await apiClient.post('/backup/jobs', data) return response.data }, + + executeBconsoleCommand: async (command: string): Promise<{ output: string }> => { + const response = await apiClient.post<{ output: string }>('/backup/console/execute', { command }) + return response.data + }, } diff --git a/frontend/src/pages/BackupManagement.tsx b/frontend/src/pages/BackupManagement.tsx index b813d21..15b5c18 100644 --- a/frontend/src/pages/BackupManagement.tsx +++ b/frontend/src/pages/BackupManagement.tsx @@ -1,10 +1,10 @@ -import { useState } from 'react' +import { useState, useRef, useEffect } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { backupAPI } from '@/api/backup' import { Search, X } from 'lucide-react' export default function BackupManagement() { - const [activeTab, setActiveTab] = useState<'dashboard' | 'jobs' | 'clients' | 'storage' | 'restore'>('dashboard') + const [activeTab, setActiveTab] = useState<'dashboard' | 'jobs' | 'clients' | 'storage' | 'restore' | 'console'>('dashboard') return (
@@ -96,6 +96,17 @@ export default function BackupManagement() { history

Restore

+
@@ -337,6 +348,10 @@ export default function BackupManagement() { Restore tab coming soon )} + + {activeTab === 'console' && ( + + )} @@ -809,3 +824,170 @@ function CreateJobForm({ onClose, onSuccess }: CreateJobFormProps) { ) } +// Backup Console Tab Component +function BackupConsoleTab() { + const [commandHistory, setCommandHistory] = useState>([]) + const [currentCommand, setCurrentCommand] = useState('') + const [isExecuting, setIsExecuting] = useState(false) + const terminalRef = useRef(null) + const inputRef = useRef(null) + + // Auto-scroll to bottom when new output is added + useEffect(() => { + if (terminalRef.current) { + terminalRef.current.scrollTop = terminalRef.current.scrollHeight + } + }, [commandHistory]) + + // Focus input on mount + useEffect(() => { + if (inputRef.current) { + inputRef.current.focus() + } + }, []) + + const executeCommand = useMutation({ + mutationFn: (cmd: string) => backupAPI.executeBconsoleCommand(cmd), + onSuccess: (data, command) => { + setCommandHistory((prev) => [ + ...prev, + { + command, + output: data.output, + timestamp: new Date(), + }, + ]) + setCurrentCommand('') + setIsExecuting(false) + // Focus input after command execution + setTimeout(() => { + if (inputRef.current) { + inputRef.current.focus() + } + }, 100) + }, + onError: (error: any) => { + setCommandHistory((prev) => [ + ...prev, + { + command: currentCommand, + output: error?.response?.data?.output || error?.response?.data?.details || error.message || 'Error executing command', + timestamp: new Date(), + }, + ]) + setCurrentCommand('') + setIsExecuting(false) + setTimeout(() => { + if (inputRef.current) { + inputRef.current.focus() + } + }, 100) + }, + }) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + const cmd = currentCommand.trim() + if (!cmd || isExecuting) return + + setIsExecuting(true) + executeCommand.mutate(cmd) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + // Handle Ctrl+L to clear (common terminal shortcut) + if (e.ctrlKey && e.key === 'l') { + e.preventDefault() + setCommandHistory([]) + } + } + + return ( +
+ {/* Terminal Header */} +
+
+ terminal +

Console View

+
+ +
+ + {/* Terminal Output */} +
+ {commandHistory.length === 0 ? ( +
+
Console View - Type commands below
+
+
Common commands:
+
+
• list jobs
+
• list clients
+
• list pools
+
• status director
+
• help
+
+
+
+ ) : ( + commandHistory.map((item, idx) => ( +
+
+ $ {item.command} +
+
+ {item.output} +
+
+ )) + )} + {isExecuting && ( +
+ Executing... +
+ )} +
+ + {/* Terminal Input */} +
+
+ $ + setCurrentCommand(e.target.value)} + onKeyDown={handleKeyDown} + disabled={isExecuting} + placeholder="Enter bconsole command..." + className="flex-1 bg-transparent text-white font-mono text-sm py-3 focus:outline-none disabled:opacity-50" + /> + +
+
+
+ ) +} +