add sources
This commit is contained in:
@@ -12,6 +12,8 @@ import ISCSITargetsPage from '@/pages/ISCSITargets'
|
||||
import ISCSITargetDetailPage from '@/pages/ISCSITargetDetail'
|
||||
import SystemPage from '@/pages/System'
|
||||
import BackupManagementPage from '@/pages/BackupManagement'
|
||||
import BaculaClientsPage from '@/pages/BaculaClients'
|
||||
|
||||
import TerminalConsolePage from '@/pages/TerminalConsole'
|
||||
import SharesPage from '@/pages/Shares'
|
||||
import IAMPage from '@/pages/IAM'
|
||||
@@ -65,7 +67,9 @@ function App() {
|
||||
<Route path="iscsi" element={<ISCSITargetsPage />} />
|
||||
<Route path="iscsi/:id" element={<ISCSITargetDetailPage />} />
|
||||
<Route path="backup" element={<BackupManagementPage />} />
|
||||
<Route path="bacula/clients" element={<BaculaClientsPage />} />
|
||||
<Route path="shares" element={<SharesPage />} />
|
||||
|
||||
<Route path="terminal" element={<TerminalConsolePage />} />
|
||||
<Route path="object-storage" element={<ObjectStoragePage />} />
|
||||
<Route path="snapshots" element={<SnapshotReplicationPage />} />
|
||||
|
||||
83
frontend/src/api/bacula.ts
Normal file
83
frontend/src/api/bacula.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import apiClient from './client'
|
||||
|
||||
export interface BaculaCapabilityHistory {
|
||||
backup_types: string[]
|
||||
source: string
|
||||
requested_by?: string
|
||||
requested_at: string
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export interface BaculaClient {
|
||||
id: string
|
||||
hostname: string
|
||||
ip_address: string
|
||||
agent_version: string
|
||||
status: string
|
||||
backup_types: string[]
|
||||
pending_backup_types?: string[]
|
||||
pending_requested_by?: string
|
||||
pending_requested_at?: string
|
||||
pending_notes?: string
|
||||
metadata?: Record<string, unknown>
|
||||
registered_by: string
|
||||
last_seen?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
capability_history?: BaculaCapabilityHistory[]
|
||||
}
|
||||
|
||||
export interface RegisterBaculaClientRequest {
|
||||
hostname: string
|
||||
ip_address: string
|
||||
agent_version?: string
|
||||
backup_types: string[]
|
||||
status?: string
|
||||
metadata?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface UpdateCapabilitiesRequest {
|
||||
backup_types: string[]
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export interface PendingBaculaUpdate {
|
||||
backup_types: string[]
|
||||
requested_by?: string
|
||||
requested_at: string
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export const baculaApi = {
|
||||
listClients: async (): Promise<BaculaClient[]> => {
|
||||
const response = await apiClient.get<BaculaClient[]>('/bacula/clients')
|
||||
return response.data
|
||||
},
|
||||
|
||||
getClient: async (id: string): Promise<BaculaClient> => {
|
||||
const response = await apiClient.get<BaculaClient>(`/bacula/clients/${id}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
registerClient: async (payload: RegisterBaculaClientRequest): Promise<BaculaClient> => {
|
||||
const response = await apiClient.post<BaculaClient>('/bacula/clients/register', payload)
|
||||
return response.data
|
||||
},
|
||||
|
||||
updateCapabilities: async (id: string, payload: UpdateCapabilitiesRequest): Promise<PendingBaculaUpdate> => {
|
||||
const response = await apiClient.post<PendingBaculaUpdate>(`/bacula/clients/${id}/capabilities`, payload)
|
||||
return response.data
|
||||
},
|
||||
|
||||
getPendingUpdate: async (id: string): Promise<PendingBaculaUpdate | null> => {
|
||||
const response = await apiClient.get<PendingBaculaUpdate>(`/bacula/clients/${id}/pending-update`)
|
||||
if (response.status === 204) {
|
||||
return null
|
||||
}
|
||||
return response.data
|
||||
},
|
||||
|
||||
ping: async (id: string, status?: string): Promise<void> => {
|
||||
await apiClient.post(`/bacula/clients/${id}/ping`, { status })
|
||||
},
|
||||
}
|
||||
@@ -16,8 +16,10 @@ import {
|
||||
Activity,
|
||||
Box,
|
||||
Camera,
|
||||
Shield
|
||||
Shield,
|
||||
ShieldCheck
|
||||
} from 'lucide-react'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export default function Layout() {
|
||||
@@ -55,7 +57,9 @@ export default function Layout() {
|
||||
{ name: 'Tape Libraries', href: '/tape', icon: Database },
|
||||
{ name: 'iSCSI Management', href: '/iscsi', icon: Network },
|
||||
{ name: 'Backup Management', href: '/backup', icon: Archive },
|
||||
{ name: 'Bacula Clients', href: '/bacula/clients', icon: ShieldCheck },
|
||||
{ name: 'Terminal Console', href: '/terminal', icon: Terminal },
|
||||
|
||||
{ name: 'Share Shield', href: '/share-shield', icon: Shield },
|
||||
{ name: 'Monitoring & Logs', href: '/monitoring', icon: Activity },
|
||||
{ name: 'Alerts', href: '/alerts', icon: Bell },
|
||||
|
||||
212
frontend/src/pages/BaculaClients.tsx
Normal file
212
frontend/src/pages/BaculaClients.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { baculaApi } from '@/api/bacula'
|
||||
|
||||
const backupTypeOptions = [
|
||||
{ label: 'Files', value: 'files' },
|
||||
{ label: 'Database', value: 'database' },
|
||||
{ label: 'Application', value: 'application' },
|
||||
{ label: 'Exchange', value: 'exchange' },
|
||||
{ label: 'Mail', value: 'mail' },
|
||||
]
|
||||
|
||||
function formatTimestamp(value?: string) {
|
||||
if (!value) {
|
||||
return 'Never'
|
||||
}
|
||||
const date = new Date(value)
|
||||
return date.toLocaleString()
|
||||
}
|
||||
|
||||
export default function BaculaClientsPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const { data: clients, isLoading, error } = useQuery(['bacula-clients'], baculaApi.listClients)
|
||||
const [editingClientId, setEditingClientId] = useState<string | null>(null)
|
||||
const [selectedTypes, setSelectedTypes] = useState<string[]>([])
|
||||
const [notes, setNotes] = useState('')
|
||||
const [statusMessage, setStatusMessage] = useState('')
|
||||
|
||||
const mutation = useMutation(
|
||||
({ id, backupTypes, notes }: { id: string; backupTypes: string[]; notes: string }) =>
|
||||
baculaApi.updateCapabilities(id, { backup_types: backupTypes, notes }),
|
||||
{
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['bacula-clients'])
|
||||
setStatusMessage('Capability update requested. The agent will pull changes shortly.')
|
||||
},
|
||||
onError: () => {
|
||||
setStatusMessage('Failed to request capability update. Please try again.')
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const editingClient = clients?.find((client) => client.id === editingClientId) ?? null
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingClient) {
|
||||
setSelectedTypes([])
|
||||
setNotes('')
|
||||
return
|
||||
}
|
||||
|
||||
setSelectedTypes(
|
||||
editingClient.pending_backup_types?.length
|
||||
? editingClient.pending_backup_types
|
||||
: editingClient.backup_types
|
||||
)
|
||||
setNotes(editingClient.pending_notes ?? '')
|
||||
}, [editingClient])
|
||||
|
||||
const toggleType = (type: string) => {
|
||||
setSelectedTypes((prev) =>
|
||||
prev.includes(type) ? prev.filter((value) => value !== type) : [...prev, type]
|
||||
)
|
||||
}
|
||||
|
||||
const handleSubmit = (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
if (!editingClient) {
|
||||
return
|
||||
}
|
||||
|
||||
mutation.mutate({ id: editingClient.id, backupTypes: selectedTypes, notes })
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <p className="text-white">Loading Bacula clients...</p>
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <p className="text-red-400">Failed to load Bacula clients.</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="text-3xl font-bold text-white">Bacula Client Management</h1>
|
||||
<p className="text-sm text-text-secondary">Register agents, monitor their capabilities, and push updates.</p>
|
||||
</header>
|
||||
|
||||
<section className="bg-card-dark border border-border-dark rounded-xl p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-text-secondary">Edit capabilities</p>
|
||||
<p className="text-lg font-bold text-white">
|
||||
{editingClient ? editingClient.hostname : 'Select a client from the list'}
|
||||
</p>
|
||||
</div>
|
||||
{statusMessage && <p className="text-xs text-primary">{statusMessage}</p>}
|
||||
</div>
|
||||
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="text-sm font-semibold text-white">Backup types</legend>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{backupTypeOptions.map((option) => (
|
||||
<label
|
||||
key={option.value}
|
||||
className="flex items-center gap-2 rounded-lg border border-border-dark px-3 py-2 text-sm"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedTypes.includes(option.value)}
|
||||
onChange={() => toggleType(option.value)}
|
||||
className="h-4 w-4 rounded border-border-dark text-primary focus:ring-primary"
|
||||
disabled={!editingClient}
|
||||
/>
|
||||
<span className="text-white">{option.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold text-white" htmlFor="notes">
|
||||
Notes for agent
|
||||
</label>
|
||||
<textarea
|
||||
id="notes"
|
||||
rows={3}
|
||||
value={notes}
|
||||
onChange={(event) => setNotes(event.target.value)}
|
||||
className="w-full rounded-lg border border-border-dark bg-[#111a22] px-3 py-2 text-sm text-white placeholder:text-text-secondary focus:border-primary focus:outline-none"
|
||||
placeholder="Optional context for the capability update"
|
||||
disabled={!editingClient}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-black"
|
||||
disabled={!editingClient || selectedTypes.length === 0 || mutation.isLoading}
|
||||
>
|
||||
{mutation.isLoading ? 'Requesting...' : 'Push capabilities'}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
{clients?.map((client) => (
|
||||
<article key={client.id} className="rounded-xl border border-border-dark bg-card-dark p-5 text-sm text-white">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-white">{client.hostname}</p>
|
||||
<p className="text-xs text-text-secondary">{client.agent_version || 'Unknown version'}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs uppercase tracking-wide text-text-secondary">Status</span>
|
||||
<span className="text-sm font-semibold text-primary">{client.status}</span>
|
||||
</div>
|
||||
<button
|
||||
className="rounded-lg border border-border-dark px-3 py-1 text-xs font-semibold text-white"
|
||||
onClick={() => setEditingClientId(client.id)}
|
||||
>
|
||||
Edit capabilities
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-4 text-xs">
|
||||
<div>
|
||||
<p className="text-[11px] uppercase tracking-wide text-text-secondary">Backup types</p>
|
||||
<p className="text-sm text-white">{client.backup_types.join(', ')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] uppercase tracking-wide text-text-secondary">Pending push</p>
|
||||
<p className="text-sm text-white">
|
||||
{client.pending_backup_types?.length
|
||||
? client.pending_backup_types.join(', ')
|
||||
: 'None'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] uppercase tracking-wide text-text-secondary">Last seen</p>
|
||||
<p className="text-sm text-white">{formatTimestamp(client.last_seen)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] uppercase tracking-wide text-text-secondary">Registered by</p>
|
||||
<p className="text-sm text-white">{client.registered_by}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2 text-xs">
|
||||
<p className="text-[11px] uppercase tracking-wide text-text-secondary">Capability history</p>
|
||||
<ul className="space-y-1">
|
||||
{client.capability_history?.slice(0, 3).map((history) => (
|
||||
<li key={`${client.id}-${history.requested_at}`} className="flex justify-between text-white">
|
||||
<span>
|
||||
{history.source.toUpperCase()} • {history.backup_types.join(', ')}
|
||||
</span>
|
||||
<span className="text-text-secondary">{formatTimestamp(history.requested_at)}</span>
|
||||
</li>
|
||||
))}
|
||||
{!client.capability_history?.length && (
|
||||
<li className="text-text-secondary">No history yet.</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user