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 ( + <> + {user?.username { + 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()} +

+
+
- - - + - {/* Scrollable Content */} -
-
- {/* Navigation Tabs */} -
-
- - - - - - -
-
+ {/* Dashboard Scrollable Body */} +
{/* Conditional Content Based on Active Tab */} {activeTab === 'dashboard' && ( <> - {/* Stats Dashboard */} -
- {/* Service Status Card */} -
-
- health_and_safety -
-
-

Director Status

-
- - {dashboardStats?.director_status === 'Active' ? 'check_circle' : 'error'} - -

{dashboardStats?.director_status || 'Unknown'}

+ {/* Summary Stats Section */} +
+
+

Job Statistics (Last 24h)

+
-

- Uptime: {dashboardStats?.director_uptime || 'Unknown'} -

-
-
+
+
+

Successful Backups

+
+ + {recentJobs.filter(j => j.status === 'Completed').length} + + + trending_up + +12% + +
+
+
+

Failed Jobs

+
+ + {recentJobs.filter(j => j.status === 'Failed').length} + + + trending_down + -5% + +
+
+
+

Currently Running

+
+ {runningJobs.length} + + sync + Active + +
+
+
+

Waiting / Queued

+
+ + {recentJobs.filter(j => j.status === 'Waiting').length} + + No change +
+
+
+ - {/* Last Backup Card */} -
-
- schedule -
-
-

Last Job

-
-

- {dashboardStats?.last_job ? (dashboardStats.last_job.status === 'Completed' ? 'Success' : dashboardStats.last_job.status) : 'N/A'} -

+ {/* Storage and Capacity Row */} +
+
+
+

Storage Pool Utilization

+ info +
+
+
+
+ + {dashboardStats?.default_pool?.name || 'Default Pool'} + + + {dashboardStats?.default_pool + ? `${formatBytes(dashboardStats.default_pool.used_bytes)} / ${formatBytes(dashboardStats.default_pool.total_bytes)}` + : 'N/A'} + +
+
+
+
+
+
+
+ Offsite Cloud Sync + 1.8 TB / 5.0 TB +
+
+
+
+
+
-

- {dashboardStats?.last_job ? ( - <> - {dashboardStats.last_job.job_name} • {formatDate(dashboardStats.last_job.ended_at || dashboardStats.last_job.started_at)} - - ) : ( - 'No jobs yet' - )} -

-
-
+
+
+

Resource Summary

+ +
+
+
+

Active Volumes

+

34

+
+
+

Total Clients

+

{dashboardStats?.active_jobs_count || 0}

+
+
+

Last Full Backup

+

+ {dashboardStats?.last_job ? formatDate(dashboardStats.last_job.ended_at || dashboardStats.last_job.started_at) : 'N/A'} +

+
+
+

Catalog Size

+

12.4 GB

+
+
+
+ - {/* Active Jobs Card */} -
-
- pending_actions -
-
-

Active Jobs

-
-

- {dashboardStats?.active_jobs_count || 0} Running -

-
-
-
-
-
-
- - {/* Storage Pool Card */} -
-
- hard_drive -
-
-
-

- {dashboardStats?.default_pool?.name || 'Default Pool'} -

- - {dashboardStats?.default_pool ? `${Math.round(dashboardStats.default_pool.usage_percent)}%` : 'N/A'} - -
-
-

- {dashboardStats?.default_pool ? formatBytes(dashboardStats.default_pool.used_bytes) : 'N/A'} -

-

- {dashboardStats?.default_pool ? `/ ${formatBytes(dashboardStats.default_pool.total_bytes)}` : ''} -

-
-
-
-
-
-
-
- - {/* Recent Jobs Section */} -
-
-

Recent Job History

- -
-
-
- - - - - - - - - - - - - - - - {recentJobs.length === 0 ? ( - - - - ) : ( - recentJobs.map((job) => ( - - - - - - - - - - + {/* Running Jobs Table */} + {runningJobs.length > 0 && ( +
+
+

Running Jobs

+ +
+
+
StatusJob IDJob NameClientTypeLevelDurationBytesActions
- No recent jobs found -
{getStatusBadge(job.status)}{job.job_id}{job.job_name}{job.client_name}{job.job_type}{job.job_level}{formatDuration(job.duration_seconds)}{formatBytes(job.bytes_written)} - -
+ + + + + + + + - )) - )} - -
Job IDClient NameLevelProgressBytes ProcessedActions
-
- {/* Pagination/Footer */} -
-

Showing 4 of 128 jobs

-
- - -
-
-
-
+ + + {runningJobs.map((job) => { + const progress = job.duration_seconds ? Math.min((job.duration_seconds / 3600) * 100, 100) : 45 + return ( + + {job.job_id} + {job.client_name} + + + {job.job_level} + + + +
+
+
+
+ {Math.round(progress)}% +
+ + {formatBytes(job.bytes_written)} + + + + + ) + })} + + +
+ + )} - {/* Footer Console Widget */} -
-
-
- Console Log (tail -f) - - Connected - -
-

[14:22:01] bareos-dir: Connected to Storage at backup-srv-01:9103

-

[14:22:02] bareos-sd: Volume "Vol-0012" selected for appending

-

[14:22:05] bareos-fd: Client "filesrv-02" starting backup of /var/www/html

-

[14:23:10] warning: /var/www/html/cache/tmp locked by another process, skipping

-

[14:23:45] bareos-dir: JobId 10423: Sending Accurate information.

-
-
+ {/* History and Logs Section */} +
+
+
+

Recent Job History

+ +
+
+ {recentJobs.slice(0, 5).map((job) => ( +
+
+ {job.status === 'Completed' ? ( + check_circle + ) : job.status === 'Failed' ? ( + error + ) : ( + sync + )} +
+

{job.job_name} - {job.client_name}

+

+ {job.status === 'Failed' + ? `Error: ${job.error_message || 'Unknown error'}` + : `Duration: ${formatDuration(job.duration_seconds)} • Size: ${formatBytes(job.bytes_written)}` + } +

+
+
+ + {job.ended_at ? new Date(job.ended_at).toLocaleTimeString() : new Date(job.started_at || '').toLocaleTimeString()} + +
+ ))} + {recentJobs.length === 0 && ( +
+ No recent jobs found +
+ )} +
+
+ {/* Live Log View */} +
+
+

+ + Live Console Log +

+ +
+
+

15:40:12 [Director] Starting JobId {runningJobs[0]?.job_id || '10928'}

+

15:40:15 [SD] Ready to receive data on port 9103

+

15:40:18 [FD] Sending attribute data for /etc/passwd

+ {dashboardStats?.last_job && ( +

15:42:01 [Director] JobId {dashboardStats.last_job.job_id} finished successfully

+ )} +

15:42:05 [Catalog] Pruning expired volumes for Pool "Default"

+

15:43:00 [System] Health check completed - All systems OK

+

15:43:45 [SD] Warning: Volume VOL-0045 reached 90% capacity

+

15:45:10 [Director] Scheduling next job "NightlyAudit"

+
+
+
)} @@ -432,24 +504,18 @@ export default function BackupManagement() { )} {activeTab === 'clients' && ( - setActiveTab('console')} /> + changeTab('console')} /> )} {activeTab === 'storage' && ( )} - {activeTab === 'restore' && ( -
- Restore tab coming soon -
- )} - {activeTab === 'console' && ( )}
-
+
) } @@ -457,27 +523,23 @@ export default function BackupManagement() { // Jobs Management Tab Component function JobsManagementTab() { const queryClient = useQueryClient() - const [searchQuery, setSearchQuery] = useState('') const [statusFilter, setStatusFilter] = useState('') - const [jobTypeFilter, setJobTypeFilter] = useState('') - const [page, setPage] = useState(1) + const [expandedJobId, setExpandedJobId] = useState(null) const [showCreateForm, setShowCreateForm] = useState(false) - const limit = 20 + const [currentMonth, setCurrentMonth] = useState(new Date()) const { data, isLoading, error } = useQuery({ - queryKey: ['backup-jobs', statusFilter, jobTypeFilter, searchQuery, page], + queryKey: ['backup-jobs', statusFilter], queryFn: () => backupAPI.listJobs({ status: statusFilter || undefined, - job_type: jobTypeFilter || undefined, - job_name: searchQuery || undefined, - limit, - offset: (page - 1) * limit, + limit: 50, }), }) const jobs = data?.jobs || [] - const total = data?.total || 0 - const totalPages = Math.ceil(total / limit) + const runningJobs = jobs.filter(j => j.status === 'Running') + const completedJobs = jobs.filter(j => j.status === 'Completed') + const failedJobs = jobs.filter(j => j.status === 'Failed') const formatBytes = (bytes: number): string => { if (bytes === 0) return '0 B' @@ -487,223 +549,424 @@ function JobsManagementTab() { 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')}` + const formatTimeAgo = (dateString?: string): string => { + if (!dateString) return '-' + try { + const date = new Date(dateString) + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffMins = Math.floor(diffMs / 60000) + const diffHours = Math.floor(diffMins / 60) + const diffDays = Math.floor(diffHours / 24) + + 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 '-' } - return `${minutes}:${secs.toString().padStart(2, '0')}` } - const getStatusBadge = (status: string) => { - const statusMap: Record = { - 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 formatNextRun = (job: any): string => { + // This would need to be calculated based on schedule, for now return placeholder + if (job.status === 'Running') return 'N/A' + return 'Today 22:00' // Placeholder + } + + const getStatusDisplay = (status: string) => { + switch (status) { + case 'Completed': + return { dot: 'bg-success', text: 'text-success', label: 'Success' } + case 'Running': + return { dot: 'bg-primary', text: 'text-primary', label: 'Running' } + case 'Failed': + return { dot: 'bg-danger', text: 'text-danger', label: 'Failed' } + case 'Canceled': + return { dot: 'bg-warning', text: 'text-warning', label: 'Warning' } + default: + return { dot: 'bg-success', text: 'text-success', label: 'Success' } } + } - const config = statusMap[status] || statusMap.Waiting - + const getLevelBadge = (level: string) => { + const levelMap: Record = { + 'Full': { bg: 'bg-white/10', text: 'text-white', border: 'border-white/20' }, + 'Incremental': { bg: 'bg-blue-500/10', text: 'text-primary', border: 'border-primary/20' }, + 'Differential': { bg: 'bg-amber-500/10', text: 'text-warning', border: 'border-warning/20' }, + } + const config = levelMap[level] || levelMap['Incremental'] return ( - - {status === 'Running' && ( - - )} - {status !== 'Running' && ( - {config.icon} - )} - {status} + + {level === 'Incremental' ? 'Inc' : level === 'Differential' ? 'Diff' : level} ) } + // Calendar helpers + const getDaysInMonth = (date: Date) => { + const year = date.getFullYear() + const month = date.getMonth() + const firstDay = new Date(year, month, 1) + const lastDay = new Date(year, month + 1, 0) + const daysInMonth = lastDay.getDate() + const startingDayOfWeek = firstDay.getDay() + + const days: (number | null)[] = [] + // Add empty cells for days before month starts + for (let i = 0; i < startingDayOfWeek; i++) { + days.push(null) + } + // Add days of the month + for (let i = 1; i <= daysInMonth; i++) { + days.push(i) + } + return days + } + + const calendarDays = getDaysInMonth(currentMonth) + const today = new Date().getDate() + + // Calculate health score (placeholder logic) + const healthScore = jobs.length > 0 + ? ((completedJobs.length / jobs.length) * 100).toFixed(1) + : '100.0' + return ( -
- {/* Header */} -
+
+ {/* Toolbar & Page Heading */} +
-

Backup Jobs

-

Manage and monitor backup job executions

+

Jobs & Schedules

+

+ Configure, monitor, and manage Bacula backup policies and automated job schedules from a unified cockpit. +

- + + +
+
+ + {/* Filters Section */} +
+ + + + + +
+ + +
- {/* Filters */} -
- {/* Search */} -
- - { - setSearchQuery(e.target.value) - setPage(1) - }} - className="w-full pl-10 pr-4 py-2 bg-[#111a22] border border-border-dark rounded-lg text-white text-sm placeholder-text-secondary focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" - /> +
+ {/* Left: Job Management Table */} +
+
+ {isLoading ? ( +
Loading jobs...
+ ) : error ? ( +
Failed to load jobs
+ ) : jobs.length === 0 ? ( +
No jobs found
+ ) : ( + <> + + + + + + + + + + + + + {jobs.slice(0, 10).map((job) => { + const statusDisplay = getStatusDisplay(job.status) + const isExpanded = expandedJobId === job.id + return ( + <> + setExpandedJobId(isExpanded ? null : job.id)} + > + + + + + + + + {isExpanded && ( + + + + )} + + ) + })} + +
StatusJob NameLevelLast RunNext RunActions
+
+
+ + {statusDisplay.label} + +
+
+
+ {job.job_name} + + SD: {job.storage_name || 'N/A'} | Pool: {job.pool_name || 'N/A'} + +
+
+ {getLevelBadge(job.job_level)} + + {job.status === 'Running' ? 'Active' : formatTimeAgo(job.ended_at || job.started_at)} + + {formatNextRun(job)} + + {job.status === 'Running' ? ( + + ) : ( + + )} +
+
+
+

Live Stats

+
+
+

Files Tracked

+

{job.files_written.toLocaleString()}

+
+
+

Storage Used

+

{formatBytes(job.bytes_written)}

+
+
+

Duration

+

+ {job.duration_seconds ? `${Math.floor(job.duration_seconds / 60)}m` : '-'} +

+
+
+
+
+

Job Details

+
    +
  • + Client + {job.client_name} +
  • +
  • + Type + {job.job_type} +
  • +
  • + Job ID + {job.job_id} +
  • +
+
+
+
+ + )} +
- {/* Status Filter */} - - - {/* Job Type Filter */} - -
- - {/* Jobs Table */} -
- {isLoading ? ( -
Loading jobs...
- ) : error ? ( -
Failed to load jobs
- ) : jobs.length === 0 ? ( -
-

No jobs found

-
- ) : ( - <> -
- - - - - - - - - - - - - - - - - {jobs.map((job) => ( - - - - - - - - - - - - - ))} - -
StatusJob IDJob NameClientTypeLevelDurationBytesFilesActions
{getStatusBadge(job.status)}{job.job_id}{job.job_name}{job.client_name}{job.job_type}{job.job_level}{formatDuration(job.duration_seconds)}{formatBytes(job.bytes_written)}{job.files_written.toLocaleString()} - -
-
- {/* Pagination */} -
-

- Showing {(page - 1) * limit + 1}-{Math.min(page * limit, total)} of {total} jobs -

-
- -
- - )} +
+ {['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((day, idx) => ( +
{day}
+ ))} + {calendarDays.map((day, idx) => { + if (day === null) { + return
+ } + const isToday = day === today && currentMonth.getMonth() === new Date().getMonth() + const hasJob = Math.random() > 0.7 // Placeholder logic + return ( +
+ {day} + {hasJob && !isToday && ( +
+ )} +
+ ) + })} +
+
+

Upcoming Jobs

+
+
+

Today

+

22:00

+
+
+

Cloud_Incremental

+

Priority: 10

+
+
+
+
+

Today

+

23:30

+
+
+

VM_Snapshot_Cluster

+

Priority: 15

+
+
+
+
+ + {/* Info Box */} +
+
+
+ verified +

Backup Health Score

+
+ {healthScore}% + +0.4% this week +
+
+
+
+

+ The last {jobs.length} jobs completed. {failedJobs.length} job{failedJobs.length !== 1 ? 's' : ''} required manual intervention. +

+
+
+
+ {/* Footer Status */} +
+
+ + Director: Online + + + Storage: Online + + + Database: 0.1ms + +
+
+ v13.0.2 Bacula Community + Up: 12d 4h 12m +
+
+ {/* Create Job Form Modal */} {showCreateForm && ( } function StorageManagementTab() { - const [activeView, setActiveView] = useState<'pools' | 'volumes' | 'daemons'>('pools') + const location = useLocation() + const searchParams = new URLSearchParams(location.search) + const viewFromUrl = searchParams.get('view') as 'pools' | 'volumes' | 'daemons' | null + + const [activeView, setActiveView] = useState<'pools' | 'volumes' | 'daemons'>( + viewFromUrl || 'pools' + ) + + // Sync activeView with URL query parameter when URL changes + useEffect(() => { + const currentView = new URLSearchParams(location.search).get('view') as typeof activeView | null + const newView = currentView || 'pools' + if (newView !== activeView && (newView === 'pools' || newView === 'volumes' || newView === 'daemons')) { + setActiveView(newView) + } + }, [location.search]) + + // Helper function to change view and update URL + const changeView = (view: typeof activeView) => { + setActiveView(view) + const newSearchParams = new URLSearchParams(location.search) + // Ensure tab=storage is set + newSearchParams.set('tab', 'storage') + if (view === 'pools') { + newSearchParams.delete('view') + } else { + newSearchParams.set('view', view) + } + const newUrl = `${location.pathname}?${newSearchParams.toString()}` + window.history.replaceState({}, '', newUrl) + } const [poolAction, setPoolAction] = useState<'list' | 'add' | 'delete'>('list') const [showDeleteModal, setShowDeleteModal] = useState(false) const [poolToDelete, setPoolToDelete] = useState(null) @@ -1882,7 +2175,7 @@ function StorageManagementTab() {
- {clients?.map((client) => ( + {(clients as BaculaClient[] | undefined)?.map((client: BaculaClient) => (
@@ -191,7 +192,7 @@ export default function BaculaClientsPage() {

Capability history

    - {client.capability_history?.slice(0, 3).map((history) => ( + {client.capability_history?.slice(0, 3).map((history: BaculaCapabilityHistory) => (
  • {history.source.toUpperCase()} • {history.backup_types.join(', ')} diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx index 438ec1b..24e082b 100644 --- a/frontend/src/pages/Profile.tsx +++ b/frontend/src/pages/Profile.tsx @@ -16,6 +16,7 @@ export default function Profile() { email: '', full_name: '', }) + const [avatarPreview, setAvatarPreview] = useState(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) => { + 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 */}
    -
    - {getAvatarInitials()} +
    +
    + {avatarPreview ? ( + {profileUser.full_name { + const target = e.target as HTMLImageElement + target.style.display = 'none' + const fallback = target.nextElementSibling as HTMLElement + if (fallback) fallback.style.display = 'flex' + }} + /> + ) : null} + {getAvatarInitials()} +
    + {canEdit && ( +
    + +
    + )} + {canEdit && avatarPreview && ( + + )}

    diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 7cb9b11..b31a7bd 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -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) }, })