add some changes
This commit is contained in:
@@ -51,6 +51,7 @@ export interface BackupClient {
|
||||
name: string
|
||||
uname?: string
|
||||
enabled: boolean
|
||||
category?: 'File' | 'Database' | 'Virtual'
|
||||
auto_prune?: boolean
|
||||
file_retention?: number
|
||||
job_retention?: number
|
||||
@@ -68,6 +69,7 @@ export interface ListClientsResponse {
|
||||
export interface ListClientsParams {
|
||||
enabled?: boolean
|
||||
search?: string
|
||||
category?: 'File' | 'Database' | 'Virtual'
|
||||
}
|
||||
|
||||
export interface PoolStats {
|
||||
@@ -120,6 +122,7 @@ export const backupAPI = {
|
||||
const queryParams = new URLSearchParams()
|
||||
if (params?.enabled !== undefined) queryParams.append('enabled', params.enabled.toString())
|
||||
if (params?.search) queryParams.append('search', params.search)
|
||||
if (params?.category) queryParams.append('category', params.category)
|
||||
|
||||
const response = await apiClient.get<ListClientsResponse>(
|
||||
`/backup/clients${queryParams.toString() ? `?${queryParams.toString()}` : ''}`
|
||||
|
||||
@@ -90,6 +90,13 @@ export const objectStorageApi = {
|
||||
deleteServiceAccount: async (accessKey: string): Promise<void> => {
|
||||
await apiClient.delete(`/object-storage/service-accounts/${encodeURIComponent(accessKey)}`)
|
||||
},
|
||||
|
||||
// Object browsing
|
||||
listObjects: async (bucketName: string, prefix?: string): Promise<Object[]> => {
|
||||
const params = prefix ? `?prefix=${encodeURIComponent(prefix)}` : ''
|
||||
const response = await apiClient.get<{ objects: Object[] }>(`/object-storage/buckets/${encodeURIComponent(bucketName)}/objects${params}`)
|
||||
return response.data.objects || []
|
||||
},
|
||||
}
|
||||
|
||||
export interface PoolDatasetInfo {
|
||||
@@ -143,3 +150,12 @@ export interface CreateServiceAccountRequest {
|
||||
policy?: string
|
||||
expiration?: string // ISO 8601 format
|
||||
}
|
||||
|
||||
export interface Object {
|
||||
name: string
|
||||
key: string // Full path/key
|
||||
size: number // Size in bytes (0 for folders)
|
||||
last_modified: string
|
||||
is_dir: boolean // True if this is a folder/prefix
|
||||
etag?: string
|
||||
}
|
||||
|
||||
@@ -165,6 +165,121 @@ export const zfsApi = {
|
||||
},
|
||||
}
|
||||
|
||||
// Snapshot interfaces and API
|
||||
export interface Snapshot {
|
||||
id: string
|
||||
name: string // Full snapshot name (e.g., "pool/dataset@snapshot-name")
|
||||
dataset: string // Dataset name (e.g., "pool/dataset")
|
||||
snapshot_name: string // Snapshot name only (e.g., "snapshot-name")
|
||||
created: string
|
||||
referenced: number // Size in bytes
|
||||
used: number // Used space in bytes
|
||||
is_latest: boolean // Whether this is the latest snapshot for the dataset
|
||||
}
|
||||
|
||||
export interface CreateSnapshotRequest {
|
||||
dataset: string
|
||||
name: string
|
||||
recursive?: boolean
|
||||
}
|
||||
|
||||
export interface CloneSnapshotRequest {
|
||||
clone_name: string
|
||||
}
|
||||
|
||||
export const snapshotApi = {
|
||||
listSnapshots: async (dataset?: string): Promise<Snapshot[]> => {
|
||||
const params = dataset ? `?dataset=${encodeURIComponent(dataset)}` : ''
|
||||
const response = await apiClient.get<{ snapshots: Snapshot[] }>(`/storage/zfs/snapshots${params}`)
|
||||
return response.data.snapshots || []
|
||||
},
|
||||
|
||||
createSnapshot: async (data: CreateSnapshotRequest): Promise<void> => {
|
||||
await apiClient.post('/storage/zfs/snapshots', data)
|
||||
},
|
||||
|
||||
deleteSnapshot: async (snapshotName: string, recursive?: boolean): Promise<void> => {
|
||||
const params = recursive ? '?recursive=true' : ''
|
||||
await apiClient.delete(`/storage/zfs/snapshots/${encodeURIComponent(snapshotName)}${params}`)
|
||||
},
|
||||
|
||||
rollbackSnapshot: async (snapshotName: string, force?: boolean): Promise<void> => {
|
||||
await apiClient.post(`/storage/zfs/snapshots/${encodeURIComponent(snapshotName)}/rollback`, { force: force || false })
|
||||
},
|
||||
|
||||
cloneSnapshot: async (snapshotName: string, data: CloneSnapshotRequest): Promise<void> => {
|
||||
await apiClient.post(`/storage/zfs/snapshots/${encodeURIComponent(snapshotName)}/clone`, data)
|
||||
},
|
||||
}
|
||||
|
||||
// Snapshot Schedule interfaces and API
|
||||
export interface SnapshotSchedule {
|
||||
id: string
|
||||
name: string
|
||||
dataset: string
|
||||
snapshot_name_template: string
|
||||
schedule_type: 'hourly' | 'daily' | 'weekly' | 'monthly' | 'cron'
|
||||
schedule_config: {
|
||||
time?: string // For daily, weekly, monthly (HH:MM format)
|
||||
day?: number // For weekly (0-6, Sunday=0), monthly (1-31)
|
||||
cron?: string // For cron type
|
||||
}
|
||||
recursive: boolean
|
||||
enabled: boolean
|
||||
retention_count?: number
|
||||
retention_days?: number
|
||||
last_run_at?: string
|
||||
next_run_at?: string
|
||||
created_by?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface CreateSnapshotScheduleRequest {
|
||||
name: string
|
||||
dataset: string
|
||||
snapshot_name_template: string
|
||||
schedule_type: 'hourly' | 'daily' | 'weekly' | 'monthly' | 'cron'
|
||||
schedule_config: {
|
||||
time?: string
|
||||
day?: number
|
||||
cron?: string
|
||||
}
|
||||
recursive?: boolean
|
||||
retention_count?: number
|
||||
retention_days?: number
|
||||
}
|
||||
|
||||
export const snapshotScheduleApi = {
|
||||
listSchedules: async (): Promise<SnapshotSchedule[]> => {
|
||||
const response = await apiClient.get<{ schedules: SnapshotSchedule[] }>('/storage/zfs/snapshot-schedules')
|
||||
return response.data.schedules || []
|
||||
},
|
||||
|
||||
getSchedule: async (id: string): Promise<SnapshotSchedule> => {
|
||||
const response = await apiClient.get<SnapshotSchedule>(`/storage/zfs/snapshot-schedules/${id}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
createSchedule: async (data: CreateSnapshotScheduleRequest): Promise<SnapshotSchedule> => {
|
||||
const response = await apiClient.post<SnapshotSchedule>('/storage/zfs/snapshot-schedules', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
updateSchedule: async (id: string, data: CreateSnapshotScheduleRequest): Promise<SnapshotSchedule> => {
|
||||
const response = await apiClient.put<SnapshotSchedule>(`/storage/zfs/snapshot-schedules/${id}`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
deleteSchedule: async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/storage/zfs/snapshot-schedules/${id}`)
|
||||
},
|
||||
|
||||
toggleSchedule: async (id: string, enabled: boolean): Promise<void> => {
|
||||
await apiClient.post(`/storage/zfs/snapshot-schedules/${id}/toggle`, { enabled })
|
||||
},
|
||||
}
|
||||
|
||||
export interface ZFSDataset {
|
||||
id: string
|
||||
name: string
|
||||
@@ -195,3 +310,100 @@ export interface ARCStats {
|
||||
collected_at: string
|
||||
}
|
||||
|
||||
// Replication Task interfaces and API
|
||||
export interface ReplicationTask {
|
||||
id: string
|
||||
name: string
|
||||
direction: 'outbound' | 'inbound'
|
||||
source_dataset?: string
|
||||
target_host?: string
|
||||
target_port?: number
|
||||
target_user?: string
|
||||
target_dataset?: string
|
||||
target_ssh_key_path?: string
|
||||
source_host?: string
|
||||
source_port?: number
|
||||
source_user?: string
|
||||
local_dataset?: string
|
||||
schedule_type?: string
|
||||
schedule_config?: {
|
||||
time?: string
|
||||
day?: number
|
||||
cron?: string
|
||||
}
|
||||
compression: string
|
||||
encryption: boolean
|
||||
recursive: boolean
|
||||
incremental: boolean
|
||||
auto_snapshot: boolean
|
||||
enabled: boolean
|
||||
status: 'idle' | 'running' | 'failed' | 'paused'
|
||||
last_run_at?: string
|
||||
last_run_status?: 'success' | 'failed' | 'partial'
|
||||
last_run_error?: string
|
||||
next_run_at?: string
|
||||
last_snapshot_sent?: string
|
||||
last_snapshot_received?: string
|
||||
total_runs: number
|
||||
successful_runs: number
|
||||
failed_runs: number
|
||||
bytes_sent: number
|
||||
bytes_received: number
|
||||
created_by?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface CreateReplicationTaskRequest {
|
||||
name: string
|
||||
direction: 'outbound' | 'inbound'
|
||||
source_dataset?: string
|
||||
target_host?: string
|
||||
target_port?: number
|
||||
target_user?: string
|
||||
target_dataset?: string
|
||||
target_ssh_key_path?: string
|
||||
source_host?: string
|
||||
source_port?: number
|
||||
source_user?: string
|
||||
local_dataset?: string
|
||||
schedule_type?: string
|
||||
schedule_config?: {
|
||||
time?: string
|
||||
day?: number
|
||||
cron?: string
|
||||
}
|
||||
compression?: string
|
||||
encryption?: boolean
|
||||
recursive?: boolean
|
||||
incremental?: boolean
|
||||
auto_snapshot?: boolean
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export const replicationApi = {
|
||||
listTasks: async (direction?: 'outbound' | 'inbound'): Promise<ReplicationTask[]> => {
|
||||
const params = direction ? `?direction=${direction}` : ''
|
||||
const response = await apiClient.get<{ tasks: ReplicationTask[] }>(`/storage/zfs/replication-tasks${params}`)
|
||||
return response.data.tasks || []
|
||||
},
|
||||
|
||||
getTask: async (id: string): Promise<ReplicationTask> => {
|
||||
const response = await apiClient.get<ReplicationTask>(`/storage/zfs/replication-tasks/${id}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
createTask: async (data: CreateReplicationTaskRequest): Promise<ReplicationTask> => {
|
||||
const response = await apiClient.post<ReplicationTask>('/storage/zfs/replication-tasks', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
updateTask: async (id: string, data: CreateReplicationTaskRequest): Promise<ReplicationTask> => {
|
||||
const response = await apiClient.put<ReplicationTask>(`/storage/zfs/replication-tasks/${id}`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
deleteTask: async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/storage/zfs/replication-tasks/${id}`)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1066,6 +1066,7 @@ function BackupConsoleTab() {
|
||||
<span className="px-4 text-primary font-mono text-sm">$</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
data-console-input
|
||||
type="text"
|
||||
value={currentCommand}
|
||||
onChange={(e) => setCurrentCommand(e.target.value)}
|
||||
@@ -1091,18 +1092,46 @@ function BackupConsoleTab() {
|
||||
function ClientsManagementTab({ onSwitchToConsole }: { onSwitchToConsole?: () => void }) {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('all')
|
||||
const [expandedRows, setExpandedRows] = useState<Set<number>>(new Set())
|
||||
const [selectAll, setSelectAll] = useState(false)
|
||||
const [selectedClients, setSelectedClients] = useState<Set<number>>(new Set())
|
||||
const [openMenuId, setOpenMenuId] = useState<number | null>(null)
|
||||
const [isStartingBackup, setIsStartingBackup] = useState<number | null>(null)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['backup-clients', statusFilter, searchQuery],
|
||||
queryKey: ['backup-clients', statusFilter, searchQuery, categoryFilter],
|
||||
queryFn: () => backupAPI.listClients({
|
||||
enabled: statusFilter === 'all' ? undefined : statusFilter === 'enabled',
|
||||
search: searchQuery || undefined,
|
||||
category: categoryFilter === 'all' ? undefined : categoryFilter as 'File' | 'Database' | 'Virtual',
|
||||
}),
|
||||
})
|
||||
|
||||
// Mutation untuk start backup job
|
||||
const startBackupMutation = useMutation({
|
||||
mutationFn: async (clientName: string) => {
|
||||
// Gunakan bconsole command untuk run backup job
|
||||
// Format: run job=<job_name> client=<client_name>
|
||||
// Kita akan coba run job dengan nama yang sama dengan client name, atau "Backup-<client_name>"
|
||||
const jobName = `Backup-${clientName}`
|
||||
const command = `run job=${jobName} client=${clientName} yes`
|
||||
return backupAPI.executeBconsoleCommand(command)
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Refresh clients list dan jobs list
|
||||
queryClient.invalidateQueries({ queryKey: ['backup-clients'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['backup-jobs'] })
|
||||
setIsStartingBackup(null)
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error('Failed to start backup:', error)
|
||||
setIsStartingBackup(null)
|
||||
alert(`Failed to start backup: ${error?.response?.data?.details || error.message || 'Unknown error'}`)
|
||||
},
|
||||
})
|
||||
|
||||
const clients = data?.clients || []
|
||||
const total = data?.total || 0
|
||||
|
||||
@@ -1188,6 +1217,81 @@ function ClientsManagementTab({ onSwitchToConsole }: { onSwitchToConsole?: () =>
|
||||
}
|
||||
}
|
||||
|
||||
// Handler untuk start backup
|
||||
const handleStartBackup = (client: any) => {
|
||||
if (!client.name) {
|
||||
alert('Client name is required')
|
||||
return
|
||||
}
|
||||
setIsStartingBackup(client.client_id)
|
||||
startBackupMutation.mutate(client.name)
|
||||
}
|
||||
|
||||
// Handler untuk edit config - redirect ke console dengan command
|
||||
const handleEditConfig = (client: any) => {
|
||||
const command = `show client=${client.name}`
|
||||
if (onSwitchToConsole) {
|
||||
onSwitchToConsole()
|
||||
// Set command di console setelah switch menggunakan custom event
|
||||
setTimeout(() => {
|
||||
const consoleInput = document.querySelector('input[data-console-input]') as HTMLInputElement
|
||||
if (consoleInput) {
|
||||
// Use Object.defineProperty to set value and trigger React onChange
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set
|
||||
if (nativeInputValueSetter) {
|
||||
nativeInputValueSetter.call(consoleInput, command)
|
||||
const event = new Event('input', { bubbles: true })
|
||||
consoleInput.dispatchEvent(event)
|
||||
} else {
|
||||
consoleInput.value = command
|
||||
consoleInput.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
}
|
||||
consoleInput.focus()
|
||||
}
|
||||
}, 200)
|
||||
} else {
|
||||
// Fallback: buka console tab
|
||||
const consoleTab = document.querySelector('[data-tab="console"]') as HTMLElement
|
||||
if (consoleTab) {
|
||||
consoleTab.click()
|
||||
setTimeout(() => {
|
||||
const consoleInput = document.querySelector('input[data-console-input]') as HTMLInputElement
|
||||
if (consoleInput) {
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set
|
||||
if (nativeInputValueSetter) {
|
||||
nativeInputValueSetter.call(consoleInput, command)
|
||||
const event = new Event('input', { bubbles: true })
|
||||
consoleInput.dispatchEvent(event)
|
||||
} else {
|
||||
consoleInput.value = command
|
||||
consoleInput.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
}
|
||||
consoleInput.focus()
|
||||
}
|
||||
}, 200)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handler untuk toggle dropdown menu
|
||||
const toggleMenu = (clientId: number) => {
|
||||
setOpenMenuId(openMenuId === clientId ? null : clientId)
|
||||
}
|
||||
|
||||
// Close menu when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (openMenuId !== null) {
|
||||
const target = event.target as HTMLElement
|
||||
if (!target.closest('.dropdown-menu') && !target.closest('.menu-trigger')) {
|
||||
setOpenMenuId(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [openMenuId])
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{clientManagementStyles}</style>
|
||||
@@ -1240,47 +1344,91 @@ function ClientsManagementTab({ onSwitchToConsole }: { onSwitchToConsole?: () =>
|
||||
className="w-full bg-surface-highlight border border-border-dark text-white text-sm rounded-lg pl-10 pr-4 py-2 focus:ring-1 focus:ring-primary focus:border-primary outline-none placeholder-text-secondary/70 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div className="hidden md:flex bg-surface-highlight border border-border-dark rounded-lg p-1 gap-1">
|
||||
<button
|
||||
onClick={() => setStatusFilter('all')}
|
||||
className={`px-3 py-1 rounded text-xs font-bold transition-colors ${
|
||||
statusFilter === 'all'
|
||||
? 'bg-surface-dark text-white shadow-sm border border-border-dark/50'
|
||||
: 'text-text-secondary hover:text-white hover:bg-surface-dark'
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('online')}
|
||||
className={`px-3 py-1 rounded text-xs font-medium transition-colors ${
|
||||
statusFilter === 'online'
|
||||
? 'bg-surface-dark text-white shadow-sm border border-border-dark/50'
|
||||
: 'text-text-secondary hover:text-white hover:bg-surface-dark'
|
||||
}`}
|
||||
>
|
||||
Online
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('offline')}
|
||||
className={`px-3 py-1 rounded text-xs font-medium transition-colors ${
|
||||
statusFilter === 'offline'
|
||||
? 'bg-surface-dark text-white shadow-sm border border-border-dark/50'
|
||||
: 'text-text-secondary hover:text-white hover:bg-surface-dark'
|
||||
}`}
|
||||
>
|
||||
Offline
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('problems')}
|
||||
className={`px-3 py-1 rounded text-xs font-medium transition-colors ${
|
||||
statusFilter === 'problems'
|
||||
? 'bg-surface-dark text-white shadow-sm border border-border-dark/50'
|
||||
: 'text-text-secondary hover:text-white hover:bg-surface-dark'
|
||||
}`}
|
||||
>
|
||||
Problems
|
||||
</button>
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
<div className="flex bg-surface-highlight border border-border-dark rounded-lg p-1 gap-1">
|
||||
<button
|
||||
onClick={() => setStatusFilter('all')}
|
||||
className={`px-3 py-1 rounded text-xs font-bold transition-colors ${
|
||||
statusFilter === 'all'
|
||||
? 'bg-surface-dark text-white shadow-sm border border-border-dark/50'
|
||||
: 'text-text-secondary hover:text-white hover:bg-surface-dark'
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('online')}
|
||||
className={`px-3 py-1 rounded text-xs font-medium transition-colors ${
|
||||
statusFilter === 'online'
|
||||
? 'bg-surface-dark text-white shadow-sm border border-border-dark/50'
|
||||
: 'text-text-secondary hover:text-white hover:bg-surface-dark'
|
||||
}`}
|
||||
>
|
||||
Online
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('offline')}
|
||||
className={`px-3 py-1 rounded text-xs font-medium transition-colors ${
|
||||
statusFilter === 'offline'
|
||||
? 'bg-surface-dark text-white shadow-sm border border-border-dark/50'
|
||||
: 'text-text-secondary hover:text-white hover:bg-surface-dark'
|
||||
}`}
|
||||
>
|
||||
Offline
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('problems')}
|
||||
className={`px-3 py-1 rounded text-xs font-medium transition-colors ${
|
||||
statusFilter === 'problems'
|
||||
? 'bg-surface-dark text-white shadow-sm border border-border-dark/50'
|
||||
: 'text-text-secondary hover:text-white hover:bg-surface-dark'
|
||||
}`}
|
||||
>
|
||||
Problems
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex bg-surface-highlight border border-border-dark rounded-lg p-1 gap-1">
|
||||
<button
|
||||
onClick={() => setCategoryFilter('all')}
|
||||
className={`px-3 py-1 rounded text-xs font-bold transition-colors ${
|
||||
categoryFilter === 'all'
|
||||
? 'bg-surface-dark text-white shadow-sm border border-border-dark/50'
|
||||
: 'text-text-secondary hover:text-white hover:bg-surface-dark'
|
||||
}`}
|
||||
>
|
||||
All Types
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCategoryFilter('File')}
|
||||
className={`px-3 py-1 rounded text-xs font-medium transition-colors ${
|
||||
categoryFilter === 'File'
|
||||
? 'bg-surface-dark text-white shadow-sm border border-border-dark/50'
|
||||
: 'text-text-secondary hover:text-white hover:bg-surface-dark'
|
||||
}`}
|
||||
>
|
||||
File
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCategoryFilter('Database')}
|
||||
className={`px-3 py-1 rounded text-xs font-medium transition-colors ${
|
||||
categoryFilter === 'Database'
|
||||
? 'bg-surface-dark text-white shadow-sm border border-border-dark/50'
|
||||
: 'text-text-secondary hover:text-white hover:bg-surface-dark'
|
||||
}`}
|
||||
>
|
||||
Database
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCategoryFilter('Virtual')}
|
||||
className={`px-3 py-1 rounded text-xs font-medium transition-colors ${
|
||||
categoryFilter === 'Virtual'
|
||||
? 'bg-surface-dark text-white shadow-sm border border-border-dark/50'
|
||||
: 'text-text-secondary hover:text-white hover:bg-surface-dark'
|
||||
}`}
|
||||
>
|
||||
Virtual
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
@@ -1320,6 +1468,7 @@ function ClientsManagementTab({ onSwitchToConsole }: { onSwitchToConsole?: () =>
|
||||
</th>
|
||||
<th className="px-6 py-4 font-semibold w-8"></th>
|
||||
<th className="px-6 py-4 font-semibold">Client Name</th>
|
||||
<th className="px-6 py-4 font-semibold">Category</th>
|
||||
<th className="px-6 py-4 font-semibold">Connection</th>
|
||||
<th className="px-6 py-4 font-semibold">Status</th>
|
||||
<th className="px-6 py-4 font-semibold">Last Backup</th>
|
||||
@@ -1372,6 +1521,26 @@ function ClientsManagementTab({ onSwitchToConsole }: { onSwitchToConsole?: () =>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{client.category ? (
|
||||
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium border ${
|
||||
client.category === 'File'
|
||||
? 'bg-blue-500/10 text-blue-400 border-blue-500/20'
|
||||
: client.category === 'Database'
|
||||
? 'bg-purple-500/10 text-purple-400 border-purple-500/20'
|
||||
: 'bg-orange-500/10 text-orange-400 border-orange-500/20'
|
||||
}`}>
|
||||
{client.category === 'File' && <span className="material-symbols-outlined text-[12px]">folder</span>}
|
||||
{client.category === 'Database' && <span className="material-symbols-outlined text-[12px]">storage</span>}
|
||||
{client.category === 'Virtual' && <span className="material-symbols-outlined text-[12px]">computer</span>}
|
||||
{client.category}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium bg-surface-dark text-text-secondary border border-border-dark">
|
||||
File
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<p className="text-text-secondary font-mono text-xs">{connection.ip}</p>
|
||||
@@ -1414,22 +1583,139 @@ function ClientsManagementTab({ onSwitchToConsole }: { onSwitchToConsole?: () =>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<div className="flex items-center justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button className="p-1.5 text-text-secondary hover:text-white hover:bg-surface-dark rounded transition-colors" title="Start Backup">
|
||||
<span className="material-symbols-outlined text-[20px]">play_arrow</span>
|
||||
<div className="flex items-center justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity relative">
|
||||
<button
|
||||
onClick={() => handleStartBackup(client)}
|
||||
disabled={isStartingBackup === client.client_id}
|
||||
className="p-1.5 text-text-secondary hover:text-white hover:bg-surface-dark rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Start Backup"
|
||||
>
|
||||
{isStartingBackup === client.client_id ? (
|
||||
<span className="material-symbols-outlined text-[20px] animate-spin">refresh</span>
|
||||
) : (
|
||||
<span className="material-symbols-outlined text-[20px]">play_arrow</span>
|
||||
)}
|
||||
</button>
|
||||
<button className="p-1.5 text-text-secondary hover:text-white hover:bg-surface-dark rounded transition-colors" title="Edit Config">
|
||||
<button
|
||||
onClick={() => handleEditConfig(client)}
|
||||
className="p-1.5 text-text-secondary hover:text-white hover:bg-surface-dark rounded transition-colors"
|
||||
title="Edit Config"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px]">edit</span>
|
||||
</button>
|
||||
<button className="p-1.5 text-text-secondary hover:text-white hover:bg-surface-dark rounded transition-colors">
|
||||
<span className="material-symbols-outlined text-[20px]">more_vert</span>
|
||||
</button>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => toggleMenu(client.client_id)}
|
||||
className="menu-trigger p-1.5 text-text-secondary hover:text-white hover:bg-surface-dark rounded transition-colors"
|
||||
title="More Options"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px]">more_vert</span>
|
||||
</button>
|
||||
{openMenuId === client.client_id && (
|
||||
<div className="dropdown-menu absolute right-0 mt-1 w-48 bg-surface-dark border border-border-dark rounded-lg shadow-lg z-50 py-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
handleStartBackup(client)
|
||||
setOpenMenuId(null)
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm text-white hover:bg-surface-highlight flex items-center gap-2"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">play_arrow</span>
|
||||
Start Backup
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
handleEditConfig(client)
|
||||
setOpenMenuId(null)
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm text-white hover:bg-surface-highlight flex items-center gap-2"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">edit</span>
|
||||
Edit Config
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const command = `status client=${client.name}`
|
||||
if (onSwitchToConsole) {
|
||||
onSwitchToConsole()
|
||||
setTimeout(() => {
|
||||
const consoleInput = document.querySelector('input[data-console-input]') as HTMLInputElement
|
||||
if (consoleInput) {
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set
|
||||
if (nativeInputValueSetter) {
|
||||
nativeInputValueSetter.call(consoleInput, command)
|
||||
const event = new Event('input', { bubbles: true })
|
||||
consoleInput.dispatchEvent(event)
|
||||
} else {
|
||||
consoleInput.value = command
|
||||
consoleInput.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
}
|
||||
consoleInput.focus()
|
||||
}
|
||||
}, 200)
|
||||
}
|
||||
setOpenMenuId(null)
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm text-white hover:bg-surface-highlight flex items-center gap-2"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">info</span>
|
||||
View Status
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const command = `list jobs client=${client.name}`
|
||||
if (onSwitchToConsole) {
|
||||
onSwitchToConsole()
|
||||
setTimeout(() => {
|
||||
const consoleInput = document.querySelector('input[data-console-input]') as HTMLInputElement
|
||||
if (consoleInput) {
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set
|
||||
if (nativeInputValueSetter) {
|
||||
nativeInputValueSetter.call(consoleInput, command)
|
||||
const event = new Event('input', { bubbles: true })
|
||||
consoleInput.dispatchEvent(event)
|
||||
} else {
|
||||
consoleInput.value = command
|
||||
consoleInput.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
}
|
||||
consoleInput.focus()
|
||||
}
|
||||
}, 200)
|
||||
}
|
||||
setOpenMenuId(null)
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm text-white hover:bg-surface-highlight flex items-center gap-2"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">history</span>
|
||||
View Jobs
|
||||
</button>
|
||||
<div className="border-t border-border-dark my-1"></div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Are you sure you want to disable client "${client.name}"?`)) {
|
||||
const command = `disable client=${client.name}`
|
||||
backupAPI.executeBconsoleCommand(command).then(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['backup-clients'] })
|
||||
setOpenMenuId(null)
|
||||
}).catch((err) => {
|
||||
alert(`Failed to disable client: ${err.message}`)
|
||||
})
|
||||
}
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-surface-highlight flex items-center gap-2"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">block</span>
|
||||
Disable Client
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{isExpanded && (
|
||||
<tr className="bg-surface-dark/10">
|
||||
<td className="p-0 border-b border-border-dark" colSpan={8}>
|
||||
<td className="p-0 border-b border-border-dark" colSpan={9}>
|
||||
<div className="flex flex-col pl-16 py-2 pr-6 border-l-4 border-primary/50 ml-[26px]">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-xs font-bold text-text-secondary uppercase tracking-wider">Installed Agents & Plugins</span>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user