working on bacula engine
This commit is contained in:
@@ -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"`
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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
@@ -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(', ')}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user