add bconsole on backup management dashboard with limited commands
This commit is contained in:
Binary file not shown.
@@ -116,3 +116,29 @@ func (h *Handler) CreateJob(c *gin.Context) {
|
|||||||
|
|
||||||
c.JSON(http.StatusCreated, job)
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -451,6 +451,42 @@ func (s *Service) getClientNameFromJob(ctx context.Context, jobID int) string {
|
|||||||
return ""
|
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
|
// upsertJob inserts or updates a job in the database
|
||||||
func (s *Service) upsertJob(ctx context.Context, job Job) error {
|
func (s *Service) upsertJob(ctx context.Context, job Job) error {
|
||||||
query := `
|
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", backupHandler.ListJobs)
|
||||||
backupGroup.GET("/jobs/:id", backupHandler.GetJob)
|
backupGroup.GET("/jobs/:id", backupHandler.GetJob)
|
||||||
backupGroup.POST("/jobs", requirePermission("backup", "write"), backupHandler.CreateJob)
|
backupGroup.POST("/jobs", requirePermission("backup", "write"), backupHandler.CreateJob)
|
||||||
|
backupGroup.POST("/console/execute", requirePermission("backup", "write"), backupHandler.ExecuteBconsoleCommand)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Monitoring
|
// Monitoring
|
||||||
|
|||||||
@@ -71,5 +71,10 @@ export const backupAPI = {
|
|||||||
const response = await apiClient.post<BackupJob>('/backup/jobs', data)
|
const response = await apiClient.post<BackupJob>('/backup/jobs', data)
|
||||||
return response.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
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useRef, useEffect } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { backupAPI } from '@/api/backup'
|
import { backupAPI } from '@/api/backup'
|
||||||
import { Search, X } from 'lucide-react'
|
import { Search, X } from 'lucide-react'
|
||||||
|
|
||||||
export default function BackupManagement() {
|
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 (
|
return (
|
||||||
<div className="flex-1 flex flex-col h-full overflow-hidden relative bg-background-dark">
|
<div className="flex-1 flex flex-col h-full overflow-hidden relative bg-background-dark">
|
||||||
@@ -96,6 +96,17 @@ export default function BackupManagement() {
|
|||||||
<span className="material-symbols-outlined text-base">history</span>
|
<span className="material-symbols-outlined text-base">history</span>
|
||||||
<p className="text-sm font-bold tracking-wide">Restore</p>
|
<p className="text-sm font-bold tracking-wide">Restore</p>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('console')}
|
||||||
|
className={`flex items-center gap-2 border-b-[3px] pb-3 pt-2 transition-colors ${
|
||||||
|
activeTab === 'console'
|
||||||
|
? 'border-primary text-white'
|
||||||
|
: 'border-transparent text-text-secondary hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-base">terminal</span>
|
||||||
|
<p className="text-sm font-bold tracking-wide">Backup Console</p>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -337,6 +348,10 @@ export default function BackupManagement() {
|
|||||||
Restore tab coming soon
|
Restore tab coming soon
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'console' && (
|
||||||
|
<BackupConsoleTab />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -809,3 +824,170 @@ function CreateJobForm({ onClose, onSuccess }: CreateJobFormProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Backup Console Tab Component
|
||||||
|
function BackupConsoleTab() {
|
||||||
|
const [commandHistory, setCommandHistory] = useState<Array<{ command: string; output: string; timestamp: Date }>>([])
|
||||||
|
const [currentCommand, setCurrentCommand] = useState('')
|
||||||
|
const [isExecuting, setIsExecuting] = useState(false)
|
||||||
|
const terminalRef = useRef<HTMLDivElement>(null)
|
||||||
|
const inputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
|
||||||
|
// Handle Ctrl+L to clear (common terminal shortcut)
|
||||||
|
if (e.ctrlKey && e.key === 'l') {
|
||||||
|
e.preventDefault()
|
||||||
|
setCommandHistory([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full bg-[#0a0f14] border border-border-dark rounded-xl overflow-hidden">
|
||||||
|
{/* Terminal Header */}
|
||||||
|
<div className="flex-none px-4 py-3 bg-[#161f29] border-b border-border-dark flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="material-symbols-outlined text-base text-primary">terminal</span>
|
||||||
|
<h3 className="text-white text-sm font-bold">Console View</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setCommandHistory([])}
|
||||||
|
className="text-xs text-text-secondary hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Terminal Output */}
|
||||||
|
<div
|
||||||
|
ref={terminalRef}
|
||||||
|
className="flex-1 overflow-y-auto p-4 bg-[#0a0f14] custom-scrollbar"
|
||||||
|
style={{ minHeight: '400px' }}
|
||||||
|
>
|
||||||
|
{commandHistory.length === 0 ? (
|
||||||
|
<div className="text-text-secondary">
|
||||||
|
<div className="mb-2">Console View - Type commands below</div>
|
||||||
|
<div className="text-xs opacity-70">
|
||||||
|
<div>Common commands:</div>
|
||||||
|
<div className="ml-4 mt-1">
|
||||||
|
<div>• list jobs</div>
|
||||||
|
<div>• list clients</div>
|
||||||
|
<div>• list pools</div>
|
||||||
|
<div>• status director</div>
|
||||||
|
<div>• help</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
commandHistory.map((item, idx) => (
|
||||||
|
<div key={idx} className="mb-6">
|
||||||
|
<div className="text-primary mb-2 font-mono text-sm">
|
||||||
|
<span className="text-text-secondary">$</span> <span className="text-white">{item.command}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="text-green-400 font-mono text-xs leading-relaxed whitespace-pre overflow-x-auto"
|
||||||
|
style={{
|
||||||
|
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
|
||||||
|
lineHeight: '1.6',
|
||||||
|
tabSize: 2
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.output}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
{isExecuting && (
|
||||||
|
<div className="text-text-secondary">
|
||||||
|
<span className="animate-pulse">Executing...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Terminal Input */}
|
||||||
|
<div className="flex-none border-t border-border-dark bg-[#161f29]">
|
||||||
|
<form onSubmit={handleSubmit} className="flex items-center">
|
||||||
|
<span className="px-4 text-primary font-mono text-sm">$</span>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={currentCommand}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!currentCommand.trim() || isExecuting}
|
||||||
|
className="px-4 py-3 text-primary hover:text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-base">send</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user