diff --git a/backend/internal/backup/service.go b/backend/internal/backup/service.go
index 3617be1..4de6481 100644
--- a/backend/internal/backup/service.go
+++ b/backend/internal/backup/service.go
@@ -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"`
diff --git a/frontend/index.html b/frontend/index.html
index 6e3d703..e4d6fbd 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -2,7 +2,7 @@
-
+
AtlasOS - Calypso
diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx
index 10f7afe..df82be1 100644
--- a/frontend/src/components/Layout.tsx
+++ b/frontend/src/components/Layout.tsx
@@ -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 (
+
+
+
+ {/* Backup Management Sub-menu */}
+ {backupNavExpanded && (
+
+ {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 (
+
+
+
+ {subItem.name}
+
+
+ )
+ })}
+
+ )}
+
+ )
+ }
+
return (
- {user?.username}
-
- {user?.roles.join(', ').toUpperCase()}
-
+
+
+ {(() => {
+ const avatarUrl = localStorage.getItem(`avatar_${user?.id}`)
+ if (avatarUrl) {
+ return (
+ <>
+

{
+ const target = e.target as HTMLImageElement
+ target.style.display = 'none'
+ const fallback = target.nextElementSibling as HTMLElement
+ if (fallback) fallback.style.display = 'flex'
+ }}
+ />
+
+ {user?.full_name
+ ? user.full_name.split(' ').map(n => n[0]).join('').substring(0, 2).toUpperCase()
+ : user?.username?.substring(0, 2).toUpperCase() || 'U'}
+
+ >
+ )
+ }
+ 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
{initials}
+ })()}
+
+
+
{user?.username}
+
+ {user?.roles.join(', ').toUpperCase()}
+
+
+