add some changes

This commit is contained in:
2026-01-15 09:44:57 +00:00
parent 9b1f85479b
commit 1d9406c93a
19 changed files with 4922 additions and 887 deletions

View File

@@ -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()}` : ''}`

View File

@@ -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
}

View File

@@ -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}`)
},
}

View File

@@ -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