working on bacula engine

This commit is contained in:
2026-01-15 17:16:04 +00:00
parent 70b7841d1a
commit 7343ea65d5
7 changed files with 1143 additions and 555 deletions

View File

@@ -1694,14 +1694,12 @@ func (s *Service) ListStorageDaemons(ctx context.Context) ([]StorageDaemon, erro
return nil, fmt.Errorf("Bacula database connection not configured")
}
// Query only columns that exist in Storage table
query := `
SELECT
s.StorageId,
s.Name,
s.Address,
s.Port,
s.DeviceName,
s.MediaType
s.Autochanger
FROM Storage s
ORDER BY s.Name
`
@@ -1715,33 +1713,29 @@ func (s *Service) ListStorageDaemons(ctx context.Context) ([]StorageDaemon, erro
var daemons []StorageDaemon
for rows.Next() {
var daemon StorageDaemon
var address, deviceName, mediaType sql.NullString
var port sql.NullInt64
var autochanger sql.NullInt64
err := rows.Scan(
&daemon.StorageID, &daemon.Name, &address, &port,
&deviceName, &mediaType,
&daemon.StorageID, &daemon.Name, &autochanger,
)
if err != nil {
s.logger.Error("Failed to scan storage daemon row", "error", err)
continue
}
if address.Valid {
daemon.Address = address.String
// Get detailed information from bconsole
details, err := s.getStorageDaemonDetails(ctx, daemon.Name)
if err != nil {
s.logger.Warn("Failed to get storage daemon details from bconsole", "name", daemon.Name, "error", err)
// Continue with defaults
daemon.Status = "Unknown"
} else {
daemon.Address = details.Address
daemon.Port = details.Port
daemon.DeviceName = details.DeviceName
daemon.MediaType = details.MediaType
daemon.Status = details.Status
}
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)
}
@@ -1753,6 +1747,77 @@ func (s *Service) ListStorageDaemons(ctx context.Context) ([]StorageDaemon, erro
return daemons, nil
}
// getStorageDaemonDetails retrieves detailed information about a storage daemon using bconsole
func (s *Service) getStorageDaemonDetails(ctx context.Context, storageName string) (*StorageDaemon, error) {
bconsoleConfig := "/opt/calypso/conf/bacula/bconsole.conf"
cmd := exec.CommandContext(ctx, "sh", "-c", fmt.Sprintf("echo -e 'show storage=%s\nquit' | bconsole -c %s", storageName, bconsoleConfig))
output, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("failed to execute bconsole: %w", err)
}
// Parse bconsole output
// Example output:
// Autochanger: name=File1 address=localhost SDport=9103 MaxJobs=10 NumJobs=0
// DeviceName=FileChgr1 MediaType=File1 StorageId=1 Autochanger=1
outputStr := string(output)
daemon := &StorageDaemon{
Name: storageName,
Status: "Online", // Default, could check connection status
}
// Parse address
if idx := strings.Index(outputStr, "address="); idx != -1 {
addrStart := idx + len("address=")
addrEnd := strings.IndexAny(outputStr[addrStart:], " \n")
if addrEnd == -1 {
addrEnd = len(outputStr) - addrStart
}
daemon.Address = strings.TrimSpace(outputStr[addrStart : addrStart+addrEnd])
}
// Parse port (SDport)
if idx := strings.Index(outputStr, "SDport="); idx != -1 {
portStart := idx + len("SDport=")
portEnd := strings.IndexAny(outputStr[portStart:], " \n")
if portEnd == -1 {
portEnd = len(outputStr) - portStart
}
if port, err := strconv.Atoi(strings.TrimSpace(outputStr[portStart : portStart+portEnd])); err == nil {
daemon.Port = port
}
}
// Parse DeviceName
if idx := strings.Index(outputStr, "DeviceName="); idx != -1 {
devStart := idx + len("DeviceName=")
devEnd := strings.IndexAny(outputStr[devStart:], " \n")
if devEnd == -1 {
devEnd = len(outputStr) - devStart
}
daemon.DeviceName = strings.TrimSpace(outputStr[devStart : devStart+devEnd])
}
// Parse MediaType
if idx := strings.Index(outputStr, "MediaType="); idx != -1 {
mediaStart := idx + len("MediaType=")
mediaEnd := strings.IndexAny(outputStr[mediaStart:], " \n")
if mediaEnd == -1 {
mediaEnd = len(outputStr) - mediaStart
}
daemon.MediaType = strings.TrimSpace(outputStr[mediaStart : mediaStart+mediaEnd])
}
// Check if storage is online by checking for connection errors in output
if strings.Contains(outputStr, "ERR") || strings.Contains(outputStr, "Error") {
daemon.Status = "Offline"
}
return daemon, nil
}
// CreatePoolRequest represents a request to create a new storage pool
type CreatePoolRequest struct {
Name string `json:"name" binding:"required"`

View File

@@ -2,7 +2,7 @@
<html class="dark" lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/png" href="/logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AtlasOS - Calypso</title>
<!-- Material Symbols -->

View File

@@ -17,16 +17,40 @@ import {
Box,
Camera,
Shield,
ShieldCheck
ShieldCheck,
ChevronRight
} from 'lucide-react'
import { useState, useEffect } from 'react'
// Listen for avatar updates
if (typeof window !== 'undefined') {
window.addEventListener('storage', () => {
// Trigger re-render when avatar is updated
window.dispatchEvent(new Event('avatar-updated'))
})
}
export default function Layout() {
const { user, clearAuth } = useAuthStore()
const navigate = useNavigate()
const location = useLocation()
const [sidebarOpen, setSidebarOpen] = useState(false)
const [avatarUpdateKey, setAvatarUpdateKey] = useState(0)
// Listen for avatar updates
useEffect(() => {
const handleAvatarUpdate = () => {
setAvatarUpdateKey(prev => prev + 1)
}
window.addEventListener('avatar-updated', handleAvatarUpdate)
window.addEventListener('storage', handleAvatarUpdate)
return () => {
window.removeEventListener('avatar-updated', handleAvatarUpdate)
window.removeEventListener('storage', handleAvatarUpdate)
}
}, [])
const [backupNavExpanded, setBackupNavExpanded] = useState(true) // Default expanded
// Set sidebar open by default on desktop, closed on mobile
useEffect(() => {
@@ -48,6 +72,15 @@ export default function Layout() {
navigate('/login')
}
// Backup Management sub-menu items
const backupSubMenu = [
{ name: 'Dashboard', href: '/backup', icon: LayoutDashboard, exact: true },
{ name: 'Jobs', href: '/backup?tab=jobs', icon: Archive },
{ name: 'Clients', href: '/backup?tab=clients', icon: ShieldCheck },
{ name: 'Storage', href: '/backup?tab=storage', icon: HardDrive },
{ name: 'Console', href: '/backup?tab=console', icon: Terminal },
]
const navigation = [
{ name: 'Dashboard', href: '/', icon: LayoutDashboard },
{ name: 'Storage', href: '/storage', icon: HardDrive },
@@ -56,8 +89,7 @@ export default function Layout() {
{ name: 'Snapshots & Replication', href: '/snapshots', icon: Camera },
{ name: 'Tape Libraries', href: '/tape', icon: Database },
{ name: 'iSCSI Management', href: '/iscsi', icon: Network },
{ name: 'Backup Management', href: '/backup', icon: Archive },
{ name: 'Bacula Clients', href: '/bacula/clients', icon: ShieldCheck },
{ name: 'Backup Management', href: '/backup', icon: Archive, hasSubMenu: true },
{ name: 'Terminal Console', href: '/terminal', icon: Terminal },
{ name: 'Share Shield', href: '/share-shield', icon: Shield },
@@ -70,6 +102,13 @@ export default function Layout() {
navigation.push({ name: 'User Management', href: '/iam', icon: Users })
}
// Check if backup nav should be expanded based on current route
useEffect(() => {
if (location.pathname.startsWith('/backup')) {
setBackupNavExpanded(true)
}
}, [location.pathname])
const isActive = (href: string) => {
if (href === '/') {
return location.pathname === '/'
@@ -130,6 +169,67 @@ export default function Layout() {
{navigation.map((item) => {
const Icon = item.icon
const active = isActive(item.href)
const isBackupManagement = item.name === 'Backup Management'
const isBackupActive = location.pathname.startsWith('/backup')
if (isBackupManagement && item.hasSubMenu) {
return (
<div key={item.name}>
<button
onClick={() => setBackupNavExpanded(!backupNavExpanded)}
className={`w-full flex items-center gap-3 px-4 py-2.5 rounded-lg transition-all ${
isBackupActive
? 'bg-primary/20 text-primary border-l-2 border-primary'
: 'text-text-secondary hover:bg-card-dark hover:text-white'
}`}
>
<Icon className={`h-5 w-5 ${isBackupActive ? 'text-primary' : ''}`} />
<span className={`text-sm font-medium flex-1 text-left ${isBackupActive ? 'font-semibold' : ''}`}>
{item.name}
</span>
<ChevronRight className={`h-4 w-4 transition-transform ${backupNavExpanded ? 'rotate-90' : ''}`} />
</button>
{/* Backup Management Sub-menu */}
{backupNavExpanded && (
<div className="ml-4 mt-1 space-y-1 border-l-2 border-border-dark pl-4">
{backupSubMenu.map((subItem) => {
const SubIcon = subItem.icon
const currentTab = new URLSearchParams(location.search).get('tab')
let subActive = false
if (subItem.exact) {
// Dashboard - active when no tab param or tab=dashboard
subActive = location.pathname === '/backup' && (!currentTab || currentTab === 'dashboard')
} else {
// Other tabs - check if tab param matches
const expectedTab = subItem.href.split('tab=')[1]
subActive = location.pathname === '/backup' && currentTab === expectedTab
}
return (
<Link
key={subItem.name}
to={subItem.href}
className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-all ${
subActive
? 'bg-primary/10 text-primary'
: 'text-text-secondary hover:bg-card-dark hover:text-white'
}`}
>
<SubIcon className={`h-4 w-4 ${subActive ? 'text-primary' : ''}`} />
<span className={`text-sm ${subActive ? 'font-semibold' : 'font-medium'}`}>
{subItem.name}
</span>
</Link>
)
})}
</div>
)}
</div>
)
}
return (
<Link
key={item.name}
@@ -155,10 +255,45 @@ export default function Layout() {
to="/profile"
className="mb-3 px-2 py-2 rounded-lg hover:bg-card-dark transition-colors block"
>
<p className="text-sm font-semibold text-white mb-0.5">{user?.username}</p>
<p className="text-xs text-text-secondary font-mono">
{user?.roles.join(', ').toUpperCase()}
</p>
<div className="flex items-center gap-3">
<div key={avatarUpdateKey} className="w-10 h-10 rounded-full bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center text-white text-sm font-bold flex-shrink-0 overflow-hidden">
{(() => {
const avatarUrl = localStorage.getItem(`avatar_${user?.id}`)
if (avatarUrl) {
return (
<>
<img
src={avatarUrl}
alt={user?.username || 'User'}
className="w-full h-full object-cover"
onError={(e) => {
const target = e.target as HTMLImageElement
target.style.display = 'none'
const fallback = target.nextElementSibling as HTMLElement
if (fallback) fallback.style.display = 'flex'
}}
/>
<span className="hidden">
{user?.full_name
? user.full_name.split(' ').map(n => n[0]).join('').substring(0, 2).toUpperCase()
: user?.username?.substring(0, 2).toUpperCase() || 'U'}
</span>
</>
)
}
const initials = user?.full_name
? user.full_name.split(' ').map(n => n[0]).join('').substring(0, 2).toUpperCase()
: user?.username?.substring(0, 2).toUpperCase() || 'U'
return <span>{initials}</span>
})()}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-white mb-0.5 truncate">{user?.username}</p>
<p className="text-xs text-text-secondary font-mono">
{user?.roles.join(', ').toUpperCase()}
</p>
</div>
</div>
</Link>
<button
onClick={handleLogout}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
import { FormEvent, useEffect, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { baculaApi } from '@/api/bacula'
import { baculaApi, BaculaClient, BaculaCapabilityHistory } from '@/api/bacula'
const backupTypeOptions = [
{ label: 'Files', value: 'files' },
@@ -20,27 +20,28 @@ function formatTimestamp(value?: string) {
export default function BaculaClientsPage() {
const queryClient = useQueryClient()
const { data: clients, isLoading, error } = useQuery(['bacula-clients'], baculaApi.listClients)
const { data: clients, isLoading, error } = useQuery({
queryKey: ['bacula-clients'],
queryFn: () => baculaApi.listClients(),
})
const [editingClientId, setEditingClientId] = useState<string | null>(null)
const [selectedTypes, setSelectedTypes] = useState<string[]>([])
const [notes, setNotes] = useState('')
const [statusMessage, setStatusMessage] = useState('')
const mutation = useMutation(
({ id, backupTypes, notes }: { id: string; backupTypes: string[]; notes: string }) =>
const mutation = useMutation({
mutationFn: ({ id, backupTypes, notes }: { id: string; backupTypes: string[]; notes: string }) =>
baculaApi.updateCapabilities(id, { backup_types: backupTypes, notes }),
{
onSuccess: () => {
queryClient.invalidateQueries(['bacula-clients'])
setStatusMessage('Capability update requested. The agent will pull changes shortly.')
},
onError: () => {
setStatusMessage('Failed to request capability update. Please try again.')
},
}
)
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['bacula-clients'] })
setStatusMessage('Capability update requested. The agent will pull changes shortly.')
},
onError: () => {
setStatusMessage('Failed to request capability update. Please try again.')
},
})
const editingClient = clients?.find((client) => client.id === editingClientId) ?? null
const editingClient = (clients as BaculaClient[] | undefined)?.find((client: BaculaClient) => client.id === editingClientId) ?? null
useEffect(() => {
if (!editingClient) {
@@ -138,15 +139,15 @@ export default function BaculaClientsPage() {
<button
type="submit"
className="inline-flex items-center justify-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-black"
disabled={!editingClient || selectedTypes.length === 0 || mutation.isLoading}
disabled={!editingClient || selectedTypes.length === 0 || mutation.isPending}
>
{mutation.isLoading ? 'Requesting...' : 'Push capabilities'}
{mutation.isPending ? 'Requesting...' : 'Push capabilities'}
</button>
</form>
</section>
<section className="space-y-4">
{clients?.map((client) => (
{(clients as BaculaClient[] | undefined)?.map((client: BaculaClient) => (
<article key={client.id} className="rounded-xl border border-border-dark bg-card-dark p-5 text-sm text-white">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
@@ -191,7 +192,7 @@ export default function BaculaClientsPage() {
<div className="mt-4 space-y-2 text-xs">
<p className="text-[11px] uppercase tracking-wide text-text-secondary">Capability history</p>
<ul className="space-y-1">
{client.capability_history?.slice(0, 3).map((history) => (
{client.capability_history?.slice(0, 3).map((history: BaculaCapabilityHistory) => (
<li key={`${client.id}-${history.requested_at}`} className="flex justify-between text-white">
<span>
{history.source.toUpperCase()} {history.backup_types.join(', ')}

View File

@@ -16,6 +16,7 @@ export default function Profile() {
email: '',
full_name: '',
})
const [avatarPreview, setAvatarPreview] = useState<string | null>(null)
// Determine which user to show
const targetUserId = id || currentUser?.id
@@ -51,6 +52,11 @@ export default function Profile() {
email: profileUser.email || '',
full_name: profileUser.full_name || '',
})
// Load avatar from localStorage
const savedAvatar = localStorage.getItem(`avatar_${profileUser.id}`)
if (savedAvatar) {
setAvatarPreview(savedAvatar)
}
}
}, [profileUser])
@@ -115,6 +121,43 @@ export default function Profile() {
email: editForm.email,
full_name: editForm.full_name,
})
// Save avatar to localStorage
if (avatarPreview && profileUser) {
localStorage.setItem(`avatar_${profileUser.id}`, avatarPreview)
// Trigger update in Layout
window.dispatchEvent(new Event('avatar-updated'))
}
}
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
// Validate file type
if (!file.type.startsWith('image/')) {
alert('Please select an image file')
return
}
// Validate file size (max 2MB)
if (file.size > 2 * 1024 * 1024) {
alert('Image size must be less than 2MB')
return
}
// Create preview
const reader = new FileReader()
reader.onloadend = () => {
setAvatarPreview(reader.result as string)
}
reader.readAsDataURL(file)
}
}
const handleRemoveAvatar = () => {
setAvatarPreview(null)
if (profileUser) {
localStorage.removeItem(`avatar_${profileUser.id}`)
// Trigger update in Layout
window.dispatchEvent(new Event('avatar-updated'))
}
}
const formatDate = (dateString: string) => {
@@ -200,8 +243,45 @@ export default function Profile() {
{/* Profile Header */}
<div className="bg-gradient-to-r from-primary/20 to-blue-600/20 p-8 border-b border-border-dark">
<div className="flex items-center gap-6">
<div className="w-24 h-24 rounded-full bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center text-white text-3xl font-bold">
{getAvatarInitials()}
<div className="relative">
<div className="w-24 h-24 rounded-full bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center text-white text-3xl font-bold overflow-hidden">
{avatarPreview ? (
<img
src={avatarPreview}
alt={profileUser.full_name || profileUser.username}
className="w-full h-full object-cover"
onError={(e) => {
const target = e.target as HTMLImageElement
target.style.display = 'none'
const fallback = target.nextElementSibling as HTMLElement
if (fallback) fallback.style.display = 'flex'
}}
/>
) : null}
<span className={avatarPreview ? 'hidden' : ''}>{getAvatarInitials()}</span>
</div>
{canEdit && (
<div className="absolute bottom-0 right-0">
<label className="cursor-pointer bg-primary hover:bg-primary/90 rounded-full p-2 border-2 border-background-dark flex items-center justify-center transition-colors">
<Edit2 className="h-4 w-4 text-white" />
<input
type="file"
accept="image/*"
onChange={handleAvatarChange}
className="hidden"
/>
</label>
</div>
)}
{canEdit && avatarPreview && (
<button
onClick={handleRemoveAvatar}
className="absolute top-0 right-0 bg-red-500 hover:bg-red-600 rounded-full p-1.5 border-2 border-background-dark flex items-center justify-center transition-colors"
title="Remove avatar"
>
<X className="h-3 w-3 text-white" />
</button>
)}
</div>
<div className="flex-1">
<h2 className="text-2xl font-bold text-white">

View File

@@ -33,6 +33,20 @@ export default defineConfig({
build: {
outDir: 'dist',
sourcemap: true,
rollupOptions: {
output: {
manualChunks: {
// Vendor chunks
'react-vendor': ['react', 'react-dom', 'react-router-dom'],
'query-vendor': ['@tanstack/react-query'],
'chart-vendor': ['recharts'],
'terminal-vendor': ['@xterm/xterm', '@xterm/addon-fit'],
'ui-vendor': ['lucide-react', 'clsx', 'tailwind-merge'],
'utils-vendor': ['axios', 'date-fns', 'zustand'],
},
},
},
chunkSizeWarningLimit: 1000, // Increase limit to 1MB (optional, untuk mengurangi warning)
},
})