working on the backup management parts
This commit is contained in:
75
frontend/src/api/backup.ts
Normal file
75
frontend/src/api/backup.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import apiClient from './client'
|
||||
|
||||
export interface BackupJob {
|
||||
id: string
|
||||
job_id: number
|
||||
job_name: string
|
||||
client_name: string
|
||||
job_type: string
|
||||
job_level: string
|
||||
status: 'Running' | 'Completed' | 'Failed' | 'Canceled' | 'Waiting'
|
||||
bytes_written: number
|
||||
files_written: number
|
||||
duration_seconds?: number
|
||||
started_at?: string
|
||||
ended_at?: string
|
||||
error_message?: string
|
||||
storage_name?: string
|
||||
pool_name?: string
|
||||
volume_name?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface ListJobsResponse {
|
||||
jobs: BackupJob[]
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
}
|
||||
|
||||
export interface ListJobsParams {
|
||||
status?: string
|
||||
job_type?: string
|
||||
client_name?: string
|
||||
job_name?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
}
|
||||
|
||||
export interface CreateJobRequest {
|
||||
job_name: string
|
||||
client_name: string
|
||||
job_type: string
|
||||
job_level: string
|
||||
storage_name?: string
|
||||
pool_name?: string
|
||||
}
|
||||
|
||||
export const backupAPI = {
|
||||
listJobs: async (params?: ListJobsParams): Promise<ListJobsResponse> => {
|
||||
const queryParams = new URLSearchParams()
|
||||
if (params?.status) queryParams.append('status', params.status)
|
||||
if (params?.job_type) queryParams.append('job_type', params.job_type)
|
||||
if (params?.client_name) queryParams.append('client_name', params.client_name)
|
||||
if (params?.job_name) queryParams.append('job_name', params.job_name)
|
||||
if (params?.limit) queryParams.append('limit', params.limit.toString())
|
||||
if (params?.offset) queryParams.append('offset', params.offset.toString())
|
||||
|
||||
const response = await apiClient.get<ListJobsResponse>(
|
||||
`/backup/jobs${queryParams.toString() ? `?${queryParams.toString()}` : ''}`
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
getJob: async (id: string): Promise<BackupJob> => {
|
||||
const response = await apiClient.get<BackupJob>(`/backup/jobs/${id}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
createJob: async (data: CreateJobRequest): Promise<BackupJob> => {
|
||||
const response = await apiClient.post<BackupJob>('/backup/jobs', data)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface SCSTTarget {
|
||||
iqn: string
|
||||
alias?: string
|
||||
is_active: boolean
|
||||
lun_count?: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
@@ -31,7 +32,11 @@ export interface SCSTInitiator {
|
||||
iqn: string
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
updated_at?: string
|
||||
target_id?: string
|
||||
target_iqn?: string
|
||||
target_name?: string
|
||||
group_name?: string
|
||||
}
|
||||
|
||||
export interface SCSTInitiatorGroup {
|
||||
@@ -45,9 +50,19 @@ export interface SCSTInitiatorGroup {
|
||||
|
||||
export interface SCSTHandler {
|
||||
name: string
|
||||
label: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface SCSTPortal {
|
||||
id: string
|
||||
ip_address: string
|
||||
port: number
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface CreateTargetRequest {
|
||||
iqn: string
|
||||
target_type: string
|
||||
@@ -80,6 +95,7 @@ export const scstAPI = {
|
||||
getTarget: async (id: string): Promise<{
|
||||
target: SCSTTarget
|
||||
luns: SCSTLUN[]
|
||||
initiator_groups?: SCSTInitiatorGroup[]
|
||||
}> => {
|
||||
const response = await apiClient.get(`/scst/targets/${id}`)
|
||||
return response.data
|
||||
@@ -87,7 +103,8 @@ export const scstAPI = {
|
||||
|
||||
createTarget: async (data: CreateTargetRequest): Promise<SCSTTarget> => {
|
||||
const response = await apiClient.post('/scst/targets', data)
|
||||
return response.data.target
|
||||
// Backend returns target directly, not wrapped in { target: ... }
|
||||
return response.data
|
||||
},
|
||||
|
||||
addLUN: async (targetId: string, data: AddLUNRequest): Promise<{ task_id: string }> => {
|
||||
@@ -109,5 +126,81 @@ export const scstAPI = {
|
||||
const response = await apiClient.get('/scst/handlers')
|
||||
return response.data.handlers || []
|
||||
},
|
||||
|
||||
listPortals: async (): Promise<SCSTPortal[]> => {
|
||||
const response = await apiClient.get('/scst/portals')
|
||||
return response.data.portals || []
|
||||
},
|
||||
|
||||
getPortal: async (id: string): Promise<SCSTPortal> => {
|
||||
const response = await apiClient.get(`/scst/portals/${id}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
createPortal: async (data: { ip_address: string; port?: number; is_active?: boolean }): Promise<SCSTPortal> => {
|
||||
const response = await apiClient.post('/scst/portals', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
updatePortal: async (id: string, data: { ip_address: string; port?: number; is_active?: boolean }): Promise<SCSTPortal> => {
|
||||
const response = await apiClient.put(`/scst/portals/${id}`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
deletePortal: async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/scst/portals/${id}`)
|
||||
},
|
||||
|
||||
enableTarget: async (targetId: string): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post(`/scst/targets/${targetId}/enable`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
disableTarget: async (targetId: string): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post(`/scst/targets/${targetId}/disable`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
listInitiators: async (): Promise<SCSTInitiator[]> => {
|
||||
const response = await apiClient.get('/scst/initiators')
|
||||
return response.data.initiators || []
|
||||
},
|
||||
|
||||
getInitiator: async (id: string): Promise<SCSTInitiator> => {
|
||||
const response = await apiClient.get(`/scst/initiators/${id}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
removeInitiator: async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/scst/initiators/${id}`)
|
||||
},
|
||||
|
||||
listExtents: async (): Promise<SCSTExtent[]> => {
|
||||
const response = await apiClient.get('/scst/extents')
|
||||
return response.data.extents || []
|
||||
},
|
||||
|
||||
createExtent: async (extent: CreateExtentRequest): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post('/scst/extents', extent)
|
||||
return response.data
|
||||
},
|
||||
|
||||
deleteExtent: async (deviceName: string): Promise<void> => {
|
||||
await apiClient.delete(`/scst/extents/${deviceName}`)
|
||||
},
|
||||
}
|
||||
|
||||
export interface SCSTExtent {
|
||||
handler_type: string
|
||||
device_name: string
|
||||
device_path: string
|
||||
is_in_use: boolean
|
||||
lun_count: number
|
||||
}
|
||||
|
||||
export interface CreateExtentRequest {
|
||||
device_name: string
|
||||
device_path: string
|
||||
handler_type: string
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { backupAPI } from '@/api/backup'
|
||||
import { Search, X } from 'lucide-react'
|
||||
|
||||
export default function BackupManagement() {
|
||||
const [activeTab, setActiveTab] = useState<'dashboard' | 'jobs' | 'clients' | 'storage' | 'restore'>('dashboard')
|
||||
@@ -96,6 +99,9 @@ export default function BackupManagement() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conditional Content Based on Active Tab */}
|
||||
{activeTab === 'dashboard' && (
|
||||
<>
|
||||
{/* Stats Dashboard */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Service Status Card */}
|
||||
@@ -307,9 +313,499 @@ export default function BackupManagement() {
|
||||
<p>[14:23:45] bareos-dir: JobId 10423: Sending Accurate information.</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'jobs' && (
|
||||
<JobsManagementTab />
|
||||
)}
|
||||
|
||||
{activeTab === 'clients' && (
|
||||
<div className="p-8 text-center text-text-secondary">
|
||||
Clients tab coming soon
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'storage' && (
|
||||
<div className="p-8 text-center text-text-secondary">
|
||||
Storage tab coming soon
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'restore' && (
|
||||
<div className="p-8 text-center text-text-secondary">
|
||||
Restore tab coming soon
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Jobs Management Tab Component
|
||||
function JobsManagementTab() {
|
||||
const queryClient = useQueryClient()
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('')
|
||||
const [jobTypeFilter, setJobTypeFilter] = useState<string>('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [showCreateForm, setShowCreateForm] = useState(false)
|
||||
const limit = 20
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['backup-jobs', statusFilter, jobTypeFilter, searchQuery, page],
|
||||
queryFn: () => backupAPI.listJobs({
|
||||
status: statusFilter || undefined,
|
||||
job_type: jobTypeFilter || undefined,
|
||||
job_name: searchQuery || undefined,
|
||||
limit,
|
||||
offset: (page - 1) * limit,
|
||||
}),
|
||||
})
|
||||
|
||||
const jobs = data?.jobs || []
|
||||
const total = data?.total || 0
|
||||
const totalPages = Math.ceil(total / limit)
|
||||
|
||||
const formatBytes = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
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')}`
|
||||
}
|
||||
return `${minutes}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const statusMap: Record<string, { bg: string; text: string; border: string; icon: string }> = {
|
||||
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 config = statusMap[status] || statusMap.Waiting
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 rounded px-2 py-1 text-xs font-medium ${config.bg} ${config.text} border ${config.border}`}>
|
||||
{status === 'Running' && (
|
||||
<span className="block h-1.5 w-1.5 rounded-full bg-blue-400 animate-pulse"></span>
|
||||
)}
|
||||
{status !== 'Running' && (
|
||||
<span className="material-symbols-outlined text-[14px]">{config.icon}</span>
|
||||
)}
|
||||
{status}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-white text-2xl font-bold">Backup Jobs</h2>
|
||||
<p className="text-text-secondary text-sm mt-1">Manage and monitor backup job executions</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
className="flex items-center gap-2 cursor-pointer justify-center rounded-lg h-10 px-4 bg-primary text-white text-sm font-bold shadow-lg shadow-primary/20 hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-base">add</span>
|
||||
<span>Create Job</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-4 p-4 bg-[#1c2936] border border-border-dark rounded-lg">
|
||||
{/* Search */}
|
||||
<div className="flex-1 min-w-[200px] relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-text-secondary" size={18} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by job name..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => {
|
||||
setStatusFilter(e.target.value)
|
||||
setPage(1)
|
||||
}}
|
||||
className="px-4 py-2 bg-[#111a22] border border-border-dark rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent appearance-none cursor-pointer"
|
||||
style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23ffffff' d='M6 9L1 4h10z'/%3E%3C/svg%3E")`,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'right 0.75rem center',
|
||||
paddingRight: '2.5rem',
|
||||
}}
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="Running">Running</option>
|
||||
<option value="Completed">Completed</option>
|
||||
<option value="Failed">Failed</option>
|
||||
<option value="Canceled">Canceled</option>
|
||||
<option value="Waiting">Waiting</option>
|
||||
</select>
|
||||
|
||||
{/* Job Type Filter */}
|
||||
<select
|
||||
value={jobTypeFilter}
|
||||
onChange={(e) => {
|
||||
setJobTypeFilter(e.target.value)
|
||||
setPage(1)
|
||||
}}
|
||||
className="px-4 py-2 bg-[#111a22] border border-border-dark rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent appearance-none cursor-pointer"
|
||||
style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23ffffff' d='M6 9L1 4h10z'/%3E%3C/svg%3E")`,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'right 0.75rem center',
|
||||
paddingRight: '2.5rem',
|
||||
}}
|
||||
>
|
||||
<option value="">All Types</option>
|
||||
<option value="Backup">Backup</option>
|
||||
<option value="Restore">Restore</option>
|
||||
<option value="Verify">Verify</option>
|
||||
<option value="Copy">Copy</option>
|
||||
<option value="Migrate">Migrate</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Jobs Table */}
|
||||
<div className="rounded-lg border border-border-dark bg-[#1c2936] overflow-hidden shadow-sm">
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center text-text-secondary">Loading jobs...</div>
|
||||
) : error ? (
|
||||
<div className="p-8 text-center text-red-400">Failed to load jobs</div>
|
||||
) : jobs.length === 0 ? (
|
||||
<div className="p-12 text-center">
|
||||
<p className="text-text-secondary">No jobs found</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-[#111a22] border-b border-border-dark text-text-secondary text-xs uppercase tracking-wider">
|
||||
<th className="px-6 py-4 font-semibold">Status</th>
|
||||
<th className="px-6 py-4 font-semibold">Job ID</th>
|
||||
<th className="px-6 py-4 font-semibold">Job Name</th>
|
||||
<th className="px-6 py-4 font-semibold">Client</th>
|
||||
<th className="px-6 py-4 font-semibold">Type</th>
|
||||
<th className="px-6 py-4 font-semibold">Level</th>
|
||||
<th className="px-6 py-4 font-semibold">Duration</th>
|
||||
<th className="px-6 py-4 font-semibold">Bytes</th>
|
||||
<th className="px-6 py-4 font-semibold">Files</th>
|
||||
<th className="px-6 py-4 font-semibold text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border-dark text-sm">
|
||||
{jobs.map((job) => (
|
||||
<tr key={job.id} className="hover:bg-[#111a22]/50 transition-colors">
|
||||
<td className="px-6 py-4">{getStatusBadge(job.status)}</td>
|
||||
<td className="px-6 py-4 text-text-secondary font-mono">{job.job_id}</td>
|
||||
<td className="px-6 py-4 text-white font-medium">{job.job_name}</td>
|
||||
<td className="px-6 py-4 text-text-secondary">{job.client_name}</td>
|
||||
<td className="px-6 py-4 text-text-secondary">{job.job_type}</td>
|
||||
<td className="px-6 py-4 text-text-secondary">{job.job_level}</td>
|
||||
<td className="px-6 py-4 text-text-secondary font-mono">{formatDuration(job.duration_seconds)}</td>
|
||||
<td className="px-6 py-4 text-text-secondary font-mono">{formatBytes(job.bytes_written)}</td>
|
||||
<td className="px-6 py-4 text-text-secondary">{job.files_written.toLocaleString()}</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<button className="text-text-secondary hover:text-white p-1 rounded hover:bg-[#111a22] transition-colors">
|
||||
<span className="material-symbols-outlined text-[20px]">more_vert</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/* Pagination */}
|
||||
<div className="bg-[#111a22] border-t border-border-dark px-6 py-3 flex items-center justify-between">
|
||||
<p className="text-text-secondary text-xs">
|
||||
Showing {(page - 1) * limit + 1}-{Math.min(page * limit, total)} of {total} jobs
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="p-1 rounded text-text-secondary hover:text-white disabled:opacity-50 hover:bg-[#1c2936]"
|
||||
>
|
||||
<span className="material-symbols-outlined text-base">chevron_left</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages}
|
||||
className="p-1 rounded text-text-secondary hover:text-white disabled:opacity-50 hover:bg-[#1c2936]"
|
||||
>
|
||||
<span className="material-symbols-outlined text-base">chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create Job Form Modal */}
|
||||
{showCreateForm && (
|
||||
<CreateJobForm
|
||||
onClose={() => setShowCreateForm(false)}
|
||||
onSuccess={async () => {
|
||||
setShowCreateForm(false)
|
||||
await queryClient.invalidateQueries({ queryKey: ['backup-jobs'] })
|
||||
await queryClient.refetchQueries({ queryKey: ['backup-jobs'] })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Create Job Form Component
|
||||
interface CreateJobFormProps {
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
function CreateJobForm({ onClose, onSuccess }: CreateJobFormProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
job_name: '',
|
||||
client_name: '',
|
||||
job_type: 'Backup',
|
||||
job_level: 'Full',
|
||||
storage_name: '',
|
||||
pool_name: '',
|
||||
})
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const createJobMutation = useMutation({
|
||||
mutationFn: backupAPI.createJob,
|
||||
onSuccess: () => {
|
||||
onSuccess()
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setError(err.response?.data?.error || 'Failed to create job')
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
|
||||
const payload: any = {
|
||||
job_name: formData.job_name,
|
||||
client_name: formData.client_name,
|
||||
job_type: formData.job_type,
|
||||
job_level: formData.job_level,
|
||||
}
|
||||
|
||||
if (formData.storage_name) {
|
||||
payload.storage_name = formData.storage_name
|
||||
}
|
||||
if (formData.pool_name) {
|
||||
payload.pool_name = formData.pool_name
|
||||
}
|
||||
|
||||
createJobMutation.mutate(payload)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-[#1c2936] border border-border-dark rounded-lg shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-border-dark">
|
||||
<h2 className="text-white text-xl font-bold">Create Backup Job</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-text-secondary hover:text-white transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Job Name */}
|
||||
<div>
|
||||
<label className="block text-white text-sm font-semibold mb-2">
|
||||
Job Name <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.job_name}
|
||||
onChange={(e) => setFormData({ ...formData, job_name: e.target.value })}
|
||||
className="w-full px-4 py-2 bg-[#111a22] border border-border-dark rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
placeholder="e.g., DailyBackup"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Client Name */}
|
||||
<div>
|
||||
<label className="block text-white text-sm font-semibold mb-2">
|
||||
Client Name <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.client_name}
|
||||
onChange={(e) => setFormData({ ...formData, client_name: e.target.value })}
|
||||
className="w-full px-4 py-2 bg-[#111a22] border border-border-dark rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
placeholder="e.g., filesrv-02"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Job Type & Level */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-white text-sm font-semibold mb-2">
|
||||
Job Type <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<select
|
||||
required
|
||||
value={formData.job_type}
|
||||
onChange={(e) => setFormData({ ...formData, job_type: e.target.value })}
|
||||
className="w-full px-4 py-2 bg-[#111a22] border border-border-dark rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent appearance-none cursor-pointer"
|
||||
style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23ffffff' d='M6 9L1 4h10z'/%3E%3C/svg%3E")`,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'right 0.75rem center',
|
||||
paddingRight: '2.5rem',
|
||||
}}
|
||||
>
|
||||
<option value="Backup">Backup</option>
|
||||
<option value="Restore">Restore</option>
|
||||
<option value="Verify">Verify</option>
|
||||
<option value="Copy">Copy</option>
|
||||
<option value="Migrate">Migrate</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-white text-sm font-semibold mb-2">
|
||||
Job Level <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<select
|
||||
required
|
||||
value={formData.job_level}
|
||||
onChange={(e) => setFormData({ ...formData, job_level: e.target.value })}
|
||||
className="w-full px-4 py-2 bg-[#111a22] border border-border-dark rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent appearance-none cursor-pointer"
|
||||
style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23ffffff' d='M6 9L1 4h10z'/%3E%3C/svg%3E")`,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'right 0.75rem center',
|
||||
paddingRight: '2.5rem',
|
||||
}}
|
||||
>
|
||||
<option value="Full">Full</option>
|
||||
<option value="Incremental">Incremental</option>
|
||||
<option value="Differential">Differential</option>
|
||||
<option value="Since">Since</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Storage Name */}
|
||||
<div>
|
||||
<label className="block text-white text-sm font-semibold mb-2">
|
||||
Storage Name (Optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.storage_name}
|
||||
onChange={(e) => setFormData({ ...formData, storage_name: e.target.value })}
|
||||
className="w-full px-4 py-2 bg-[#111a22] border border-border-dark rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
placeholder="e.g., backup-srv-01"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Pool Name */}
|
||||
<div>
|
||||
<label className="block text-white text-sm font-semibold mb-2">
|
||||
Pool Name (Optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.pool_name}
|
||||
onChange={(e) => setFormData({ ...formData, pool_name: e.target.value })}
|
||||
className="w-full px-4 py-2 bg-[#111a22] border border-border-dark rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
placeholder="e.g., Default"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border-dark">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-[#111a22] border border-border-dark rounded-lg text-white text-sm font-semibold hover:bg-[#1c2936] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createJobMutation.isPending}
|
||||
className="px-4 py-2 bg-primary text-white rounded-lg text-sm font-semibold hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{createJobMutation.isPending ? 'Creating...' : 'Create Job'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { scstAPI, type SCSTHandler } from '@/api/scst'
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ArrowLeft, Plus, RefreshCw, HardDrive, Users } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export default function ISCSITargetDetail() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
@@ -13,6 +13,10 @@ export default function ISCSITargetDetail() {
|
||||
const [showAddLUN, setShowAddLUN] = useState(false)
|
||||
const [showAddInitiator, setShowAddInitiator] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
console.log('showAddLUN state:', showAddLUN)
|
||||
}, [showAddLUN])
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['scst-target', id],
|
||||
queryFn: () => scstAPI.getTarget(id!),
|
||||
@@ -22,6 +26,8 @@ export default function ISCSITargetDetail() {
|
||||
const { data: handlers } = useQuery<SCSTHandler[]>({
|
||||
queryKey: ['scst-handlers'],
|
||||
queryFn: scstAPI.listHandlers,
|
||||
staleTime: 0, // Always fetch fresh data
|
||||
refetchOnMount: true,
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
@@ -33,6 +39,8 @@ export default function ISCSITargetDetail() {
|
||||
}
|
||||
|
||||
const { target, luns } = data
|
||||
// Ensure luns is always an array, not null
|
||||
const lunsArray = luns || []
|
||||
|
||||
return (
|
||||
<div className="space-y-6 min-h-screen bg-background-dark p-6">
|
||||
@@ -91,12 +99,12 @@ export default function ISCSITargetDetail() {
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-secondary">Total LUNs:</span>
|
||||
<span className="font-medium text-white">{luns.length}</span>
|
||||
<span className="font-medium text-white">{lunsArray.length}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-secondary">Active:</span>
|
||||
<span className="font-medium text-white">
|
||||
{luns.filter((l) => l.is_active).length}
|
||||
{lunsArray.filter((l) => l.is_active).length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -140,14 +148,22 @@ export default function ISCSITargetDetail() {
|
||||
<CardTitle>LUNs (Logical Unit Numbers)</CardTitle>
|
||||
<CardDescription>Storage devices exported by this target</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setShowAddLUN(true)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
console.log('Add LUN button clicked, setting showAddLUN to true')
|
||||
setShowAddLUN(true)
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add LUN
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{luns.length > 0 ? (
|
||||
{lunsArray.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-[#1a2632]">
|
||||
@@ -170,7 +186,7 @@ export default function ISCSITargetDetail() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-card-dark divide-y divide-border-dark">
|
||||
{luns.map((lun) => (
|
||||
{lunsArray.map((lun) => (
|
||||
<tr key={lun.id} className="hover:bg-[#233648]">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-white">
|
||||
{lun.lun_number}
|
||||
@@ -204,7 +220,14 @@ export default function ISCSITargetDetail() {
|
||||
<div className="text-center py-8">
|
||||
<HardDrive className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-sm text-text-secondary mb-4">No LUNs configured</p>
|
||||
<Button variant="outline" onClick={() => setShowAddLUN(true)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
console.log('Add First LUN button clicked, setting showAddLUN to true')
|
||||
setShowAddLUN(true)
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add First LUN
|
||||
</Button>
|
||||
@@ -254,12 +277,21 @@ function AddLUNForm({ targetId, handlers, onClose, onSuccess }: AddLUNFormProps)
|
||||
const [deviceName, setDeviceName] = useState('')
|
||||
const [lunNumber, setLunNumber] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
console.log('AddLUNForm mounted, targetId:', targetId, 'handlers:', handlers)
|
||||
}, [targetId, handlers])
|
||||
|
||||
const addLUNMutation = useMutation({
|
||||
mutationFn: (data: { device_name: string; device_path: string; lun_number: number; handler_type: string }) =>
|
||||
scstAPI.addLUN(targetId, data),
|
||||
onSuccess: () => {
|
||||
onSuccess()
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error('Failed to add LUN:', error)
|
||||
const errorMessage = error.response?.data?.error || error.message || 'Failed to add LUN'
|
||||
alert(errorMessage)
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
@@ -278,35 +310,62 @@ function AddLUNForm({ targetId, handlers, onClose, onSuccess }: AddLUNFormProps)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Add LUN</CardTitle>
|
||||
<CardDescription>Add a storage device to this target</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-card-dark border border-border-dark rounded-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b border-border-dark">
|
||||
<h2 className="text-xl font-bold text-white">Add LUN</h2>
|
||||
<p className="text-sm text-text-secondary mt-1">Bind a ZFS volume or storage device to this target</p>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
<div>
|
||||
<label htmlFor="handlerType" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label htmlFor="handlerType" className="block text-sm font-medium text-white mb-1">
|
||||
Handler Type *
|
||||
</label>
|
||||
<select
|
||||
id="handlerType"
|
||||
value={handlerType}
|
||||
onChange={(e) => setHandlerType(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
className="w-full px-3 py-2 bg-[#0f161d] border border-border-dark rounded-lg text-white text-sm focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary"
|
||||
required
|
||||
>
|
||||
<option value="">Select a handler</option>
|
||||
{handlers.map((h) => (
|
||||
<option key={h.name} value={h.name}>
|
||||
{h.name} {h.description && `- ${h.description}`}
|
||||
{h.label || h.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="deviceName" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label htmlFor="devicePath" className="block text-sm font-medium text-white mb-1">
|
||||
ZFS Volume Path *
|
||||
</label>
|
||||
<input
|
||||
id="devicePath"
|
||||
type="text"
|
||||
value={devicePath}
|
||||
onChange={(e) => {
|
||||
const path = e.target.value.trim()
|
||||
setDevicePath(path)
|
||||
// Auto-generate device name from path (e.g., /dev/zvol/pool/volume -> volume)
|
||||
if (path && !deviceName) {
|
||||
const parts = path.split('/')
|
||||
const name = parts[parts.length - 1] || parts[parts.length - 2] || 'device'
|
||||
setDeviceName(name)
|
||||
}
|
||||
}}
|
||||
placeholder="/dev/zvol/pool/volume or /dev/sda"
|
||||
className="w-full px-3 py-2 bg-[#0f161d] border border-border-dark rounded-lg text-white text-sm focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary font-mono"
|
||||
required
|
||||
/>
|
||||
<p className="mt-1 text-xs text-text-secondary">
|
||||
Enter ZFS volume path (e.g., /dev/zvol/pool/volume) or block device path
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="deviceName" className="block text-sm font-medium text-white mb-1">
|
||||
Device Name *
|
||||
</label>
|
||||
<input
|
||||
@@ -315,28 +374,16 @@ function AddLUNForm({ targetId, handlers, onClose, onSuccess }: AddLUNFormProps)
|
||||
value={deviceName}
|
||||
onChange={(e) => setDeviceName(e.target.value)}
|
||||
placeholder="device1"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
className="w-full px-3 py-2 bg-[#0f161d] border border-border-dark rounded-lg text-white text-sm focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary"
|
||||
required
|
||||
/>
|
||||
<p className="mt-1 text-xs text-text-secondary">
|
||||
Logical name for this device in SCST (auto-filled from volume path)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="devicePath" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Device Path *
|
||||
</label>
|
||||
<input
|
||||
id="devicePath"
|
||||
type="text"
|
||||
value={devicePath}
|
||||
onChange={(e) => setDevicePath(e.target.value)}
|
||||
placeholder="/dev/sda or /dev/calypso/vg1/lv1"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 font-mono text-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="lunNumber" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label htmlFor="lunNumber" className="block text-sm font-medium text-white mb-1">
|
||||
LUN Number *
|
||||
</label>
|
||||
<input
|
||||
@@ -345,12 +392,15 @@ function AddLUNForm({ targetId, handlers, onClose, onSuccess }: AddLUNFormProps)
|
||||
value={lunNumber}
|
||||
onChange={(e) => setLunNumber(parseInt(e.target.value) || 0)}
|
||||
min="0"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
className="w-full px-3 py-2 bg-[#0f161d] border border-border-dark rounded-lg text-white text-sm focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary"
|
||||
required
|
||||
/>
|
||||
<p className="mt-1 text-xs text-text-secondary">
|
||||
Logical Unit Number (0-255, typically start from 0)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<div className="flex justify-end gap-2 pt-4 border-t border-border-dark">
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
@@ -359,8 +409,8 @@ function AddLUNForm({ targetId, handlers, onClose, onSuccess }: AddLUNFormProps)
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -394,7 +394,13 @@ export default function TapeLibraries() {
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4 px-6">
|
||||
<div className="flex items-center gap-2 group/copy cursor-pointer">
|
||||
<div
|
||||
className="flex items-center gap-2 group/copy cursor-pointer"
|
||||
onClick={() => {
|
||||
const iqn = `iqn.2023-10.com.vtl:${library.name.toLowerCase().replace(/\s+/g, '')}`
|
||||
navigator.clipboard.writeText(iqn)
|
||||
}}
|
||||
>
|
||||
<code className="text-xs text-text-secondary font-mono bg-[#111a22] px-2 py-1 rounded border border-border-dark group-hover/copy:text-white transition-colors">
|
||||
iqn.2023-10.com.vtl:{library.name.toLowerCase().replace(/\s+/g, '')}
|
||||
</code>
|
||||
|
||||
Reference in New Issue
Block a user