add sources

This commit is contained in:
Othman H. Suseno
2026-01-15 17:39:32 +07:00
parent 1d9406c93a
commit 70b7841d1a
10 changed files with 1266 additions and 1 deletions

View File

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

View 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 })
},
}

View File

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

View 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>
)
}