add feature license management
This commit is contained in:
@@ -12,8 +12,13 @@ import ISCSITargetsPage from '@/pages/ISCSITargets'
|
||||
import ISCSITargetDetailPage from '@/pages/ISCSITargetDetail'
|
||||
import SystemPage from '@/pages/System'
|
||||
import BackupManagementPage from '@/pages/BackupManagement'
|
||||
import TerminalConsolePage from '@/pages/TerminalConsole'
|
||||
import SharesPage from '@/pages/Shares'
|
||||
import IAMPage from '@/pages/IAM'
|
||||
import ProfilePage from '@/pages/Profile'
|
||||
import MonitoringPage from '@/pages/Monitoring'
|
||||
import ObjectStoragePage from '@/pages/ObjectStorage'
|
||||
import SnapshotReplicationPage from '@/pages/SnapshotReplication'
|
||||
import Layout from '@/components/Layout'
|
||||
|
||||
// Create a client
|
||||
@@ -59,6 +64,11 @@ function App() {
|
||||
<Route path="iscsi" element={<ISCSITargetsPage />} />
|
||||
<Route path="iscsi/:id" element={<ISCSITargetDetailPage />} />
|
||||
<Route path="backup" element={<BackupManagementPage />} />
|
||||
<Route path="shares" element={<SharesPage />} />
|
||||
<Route path="terminal" element={<TerminalConsolePage />} />
|
||||
<Route path="object-storage" element={<ObjectStoragePage />} />
|
||||
<Route path="snapshots" element={<SnapshotReplicationPage />} />
|
||||
<Route path="monitoring" element={<MonitoringPage />} />
|
||||
<Route path="alerts" element={<AlertsPage />} />
|
||||
<Route path="system" element={<SystemPage />} />
|
||||
<Route path="iam" element={<IAMPage />} />
|
||||
|
||||
@@ -300,6 +300,22 @@ export const scstAPI = {
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Config file management
|
||||
getConfigFile: async (path?: string): Promise<{ content: string; path: string }> => {
|
||||
const response = await apiClient.get('/scst/config/file', {
|
||||
params: path ? { path } : {},
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
updateConfigFile: async (content: string, path?: string): Promise<{ message: string; path: string }> => {
|
||||
const response = await apiClient.put('/scst/config/file', {
|
||||
content,
|
||||
path,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
export interface SCSTExtent {
|
||||
|
||||
75
frontend/src/api/shares.ts
Normal file
75
frontend/src/api/shares.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import apiClient from './client'
|
||||
|
||||
export interface Share {
|
||||
id: string
|
||||
dataset_id: string
|
||||
dataset_name: string
|
||||
mount_point: string
|
||||
share_type: 'nfs' | 'smb' | 'both' | 'none'
|
||||
nfs_enabled: boolean
|
||||
nfs_options?: string
|
||||
nfs_clients?: string[]
|
||||
smb_enabled: boolean
|
||||
smb_share_name?: string
|
||||
smb_path?: string
|
||||
smb_comment?: string
|
||||
smb_guest_ok: boolean
|
||||
smb_read_only: boolean
|
||||
smb_browseable: boolean
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
created_by: string
|
||||
}
|
||||
|
||||
export interface CreateShareRequest {
|
||||
dataset_id: string
|
||||
nfs_enabled: boolean
|
||||
nfs_options?: string
|
||||
nfs_clients?: string[]
|
||||
smb_enabled: boolean
|
||||
smb_share_name?: string
|
||||
smb_comment?: string
|
||||
smb_guest_ok?: boolean
|
||||
smb_read_only?: boolean
|
||||
smb_browseable?: boolean
|
||||
}
|
||||
|
||||
export interface UpdateShareRequest {
|
||||
nfs_enabled?: boolean
|
||||
nfs_options?: string
|
||||
nfs_clients?: string[]
|
||||
smb_enabled?: boolean
|
||||
smb_share_name?: string
|
||||
smb_comment?: string
|
||||
smb_guest_ok?: boolean
|
||||
smb_read_only?: boolean
|
||||
smb_browseable?: boolean
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
export const sharesAPI = {
|
||||
listShares: async (): Promise<Share[]> => {
|
||||
const response = await apiClient.get<{ shares: Share[] }>('/shares')
|
||||
return response.data.shares || []
|
||||
},
|
||||
|
||||
getShare: async (id: string): Promise<Share> => {
|
||||
const response = await apiClient.get<Share>(`/shares/${id}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
createShare: async (data: CreateShareRequest): Promise<Share> => {
|
||||
const response = await apiClient.post<Share>('/shares', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
updateShare: async (id: string, data: UpdateShareRequest): Promise<Share> => {
|
||||
const response = await apiClient.put<Share>(`/shares/${id}`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
deleteShare: async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/shares/${id}`)
|
||||
},
|
||||
}
|
||||
@@ -166,6 +166,7 @@ export const zfsApi = {
|
||||
}
|
||||
|
||||
export interface ZFSDataset {
|
||||
id: string
|
||||
name: string
|
||||
pool: string
|
||||
type: string // filesystem, volume, snapshot
|
||||
|
||||
@@ -7,11 +7,15 @@ import {
|
||||
HardDrive,
|
||||
Database,
|
||||
Network,
|
||||
Settings,
|
||||
Bell,
|
||||
Server,
|
||||
Users,
|
||||
Archive
|
||||
Archive,
|
||||
Terminal,
|
||||
Share,
|
||||
Activity,
|
||||
Box,
|
||||
Camera
|
||||
} from 'lucide-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
@@ -44,10 +48,14 @@ export default function Layout() {
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', href: '/', icon: LayoutDashboard },
|
||||
{ name: 'Storage', href: '/storage', icon: HardDrive },
|
||||
{ name: 'Object Storage', href: '/object-storage', icon: Box },
|
||||
{ name: 'Shares', href: '/shares', icon: Share },
|
||||
{ name: 'Snapshots & Replication', href: '/snapshots', icon: Camera },
|
||||
{ name: 'Tape Libraries', href: '/tape', icon: Database },
|
||||
{ name: 'iSCSI Management', href: '/iscsi', icon: Network },
|
||||
{ name: 'Backup Management', href: '/backup', icon: Archive },
|
||||
{ name: 'Tasks', href: '/tasks', icon: Settings },
|
||||
{ name: 'Terminal Console', href: '/terminal', icon: Terminal },
|
||||
{ name: 'Monitoring & Logs', href: '/monitoring', icon: Activity },
|
||||
{ name: 'Alerts', href: '/alerts', icon: Bell },
|
||||
{ name: 'System', href: '/system', icon: Server },
|
||||
]
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { scstAPI, type SCSTTarget, type SCSTInitiatorGroup, type SCSTPortal, type SCSTInitiator, type SCSTExtent, type CreateExtentRequest } from '@/api/scst'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Plus, Settings, ChevronRight, Search, ChevronLeft, ChevronRight as ChevronRightIcon, CheckCircle, HardDrive, ArrowUpDown, ArrowUp, ChevronUp, ChevronDown, Copy, Network, X, Trash2 } from 'lucide-react'
|
||||
import { Plus, Settings, ChevronRight, Search, ChevronLeft, ChevronRight as ChevronRightIcon, CheckCircle, HardDrive, ArrowUpDown, ArrowUp, ChevronUp, ChevronDown, Copy, Network, X, Trash2, Save, RefreshCw, Terminal } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
export default function ISCSITargets() {
|
||||
@@ -179,6 +179,19 @@ export default function ISCSITargets() {
|
||||
<div className="absolute bottom-0 left-0 w-full h-0.5 bg-primary rounded-t-full"></div>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('config')}
|
||||
className={`relative py-4 text-sm tracking-wide transition-colors ${
|
||||
activeTab === 'config'
|
||||
? 'text-primary font-bold'
|
||||
: 'text-text-secondary hover:text-white font-medium'
|
||||
}`}
|
||||
>
|
||||
Config Editor
|
||||
{activeTab === 'config' && (
|
||||
<div className="absolute bottom-0 left-0 w-full h-0.5 bg-primary rounded-t-full"></div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -275,6 +288,10 @@ export default function ISCSITargets() {
|
||||
{activeTab === 'groups' && (
|
||||
<InitiatorGroupsTab />
|
||||
)}
|
||||
|
||||
{activeTab === 'config' && (
|
||||
<ConfigEditorTab />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2310,3 +2327,156 @@ function AddInitiatorToGroupModal({ groupName, onClose, isLoading, onSubmit }: {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Config Editor Tab Component
|
||||
function ConfigEditorTab() {
|
||||
const [content, setContent] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
const [originalContent, setOriginalContent] = useState('')
|
||||
const [configPath, setConfigPath] = useState('/etc/scst.conf')
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const { data: configData, refetch: refetchConfig, isFetching } = useQuery<{ content: string; path: string }>({
|
||||
queryKey: ['scst-config-file'],
|
||||
queryFn: () => scstAPI.getConfigFile(),
|
||||
})
|
||||
|
||||
// Handle config data changes
|
||||
useEffect(() => {
|
||||
if (configData) {
|
||||
setContent(configData.content)
|
||||
setOriginalContent(configData.content)
|
||||
setConfigPath(configData.path)
|
||||
setHasChanges(false)
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [configData])
|
||||
|
||||
// Handle loading state
|
||||
useEffect(() => {
|
||||
if (isFetching) {
|
||||
setIsLoading(true)
|
||||
} else if (configData) {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [isFetching, configData])
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (content: string) => scstAPI.updateConfigFile(content),
|
||||
onSuccess: () => {
|
||||
setOriginalContent(content)
|
||||
setHasChanges(false)
|
||||
alert('Configuration file saved successfully!')
|
||||
},
|
||||
onError: (error: any) => {
|
||||
alert(`Failed to save configuration: ${error.response?.data?.error || error.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setContent(e.target.value)
|
||||
setHasChanges(e.target.value !== originalContent)
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
if (!hasChanges) return
|
||||
if (confirm('Save changes to scst.conf? This will update the SCST configuration.')) {
|
||||
saveMutation.mutate(content)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReload = () => {
|
||||
if (hasChanges && !confirm('You have unsaved changes. Reload anyway?')) {
|
||||
return
|
||||
}
|
||||
setIsLoading(true)
|
||||
refetchConfig()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.focus()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-[600px] bg-[#0a0f14] border border-border-dark rounded-lg overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex-none px-6 py-4 border-b border-border-dark bg-[#141d26] flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Terminal className="text-primary" size={20} />
|
||||
<div>
|
||||
<h3 className="text-white font-bold text-sm">SCST Configuration Editor</h3>
|
||||
<p className="text-text-secondary text-xs mt-0.5">
|
||||
Edit /etc/scst.conf file directly
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleReload}
|
||||
disabled={isLoading}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw size={16} className={isLoading ? 'animate-spin' : ''} />
|
||||
<span>Reload</span>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || saveMutation.isPending}
|
||||
className="flex items-center gap-2 bg-primary hover:bg-blue-600"
|
||||
>
|
||||
<Save size={16} />
|
||||
<span>{saveMutation.isPending ? 'Saving...' : 'Save'}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editor */}
|
||||
<div className="flex-1 relative overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-text-secondary">
|
||||
<RefreshCw size={24} className="animate-spin mx-auto mb-2" />
|
||||
<p>Loading configuration...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={handleContentChange}
|
||||
className="w-full h-full p-4 bg-[#0a0f14] text-green-400 font-mono text-sm resize-none focus:outline-none focus:ring-0 border-0"
|
||||
style={{
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
|
||||
lineHeight: '1.6',
|
||||
tabSize: 2,
|
||||
}}
|
||||
spellCheck={false}
|
||||
placeholder="Loading configuration file..."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex-none px-6 py-3 border-t border-border-dark bg-[#141d26] flex items-center justify-between text-xs">
|
||||
<div className="flex items-center gap-4 text-text-secondary">
|
||||
<span>Path: {configPath}</span>
|
||||
{hasChanges && (
|
||||
<span className="text-yellow-400 flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-yellow-400 rounded-full"></span>
|
||||
Unsaved changes
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-text-secondary">
|
||||
{content.split('\n').length} lines
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
627
frontend/src/pages/Monitoring.tsx
Normal file
627
frontend/src/pages/Monitoring.tsx
Normal file
@@ -0,0 +1,627 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { monitoringApi } from '@/api/monitoring'
|
||||
import { systemAPI } from '@/api/system'
|
||||
import { zfsApi } from '@/api/storage'
|
||||
import { formatBytes } from '@/lib/format'
|
||||
import {
|
||||
RefreshCw,
|
||||
TrendingDown,
|
||||
CheckCircle2,
|
||||
Cpu,
|
||||
MemoryStick,
|
||||
HardDrive,
|
||||
Clock,
|
||||
Search,
|
||||
AlertCircle,
|
||||
Info,
|
||||
AlertTriangle
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
Area,
|
||||
AreaChart,
|
||||
} from 'recharts'
|
||||
|
||||
const MOCK_ACTIVE_JOBS = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Daily Backup: VM-Cluster-01',
|
||||
type: 'Replication',
|
||||
progress: 45,
|
||||
speed: '145 MB/s',
|
||||
status: 'running',
|
||||
eta: '1h 12m',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'ZFS Scrub: Pool-01',
|
||||
type: 'Maintenance',
|
||||
progress: 78,
|
||||
speed: '1.2 GB/s',
|
||||
status: 'running',
|
||||
},
|
||||
]
|
||||
|
||||
export default function Monitoring() {
|
||||
const [activeTab, setActiveTab] = useState<'jobs' | 'logs' | 'alerts'>('jobs')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const refreshInterval = 5
|
||||
|
||||
// Fetch metrics
|
||||
const { data: metrics, isLoading: metricsLoading } = useQuery({
|
||||
queryKey: ['monitoring-metrics'],
|
||||
queryFn: monitoringApi.getMetrics,
|
||||
refetchInterval: refreshInterval * 1000,
|
||||
})
|
||||
|
||||
// Fetch system logs
|
||||
const { data: systemLogs = [], isLoading: logsLoading } = useQuery({
|
||||
queryKey: ['monitoring-logs'],
|
||||
queryFn: () => systemAPI.getSystemLogs(50),
|
||||
refetchInterval: 10 * 1000,
|
||||
})
|
||||
|
||||
// Fetch network throughput
|
||||
const { data: networkData = [] } = useQuery({
|
||||
queryKey: ['monitoring-network'],
|
||||
queryFn: () => systemAPI.getNetworkThroughput('15m'),
|
||||
refetchInterval: refreshInterval * 1000,
|
||||
})
|
||||
|
||||
// Fetch ZFS pools for health status
|
||||
const { data: pools = [] } = useQuery({
|
||||
queryKey: ['monitoring-pools'],
|
||||
queryFn: zfsApi.listPools,
|
||||
refetchInterval: 30 * 1000,
|
||||
})
|
||||
|
||||
// Fetch alerts
|
||||
const { data: alertsData } = useQuery({
|
||||
queryKey: ['monitoring-alerts'],
|
||||
queryFn: () => monitoringApi.listAlerts({ limit: 20 }),
|
||||
refetchInterval: 10 * 1000,
|
||||
})
|
||||
|
||||
// Format uptime
|
||||
const formatUptime = (seconds: number) => {
|
||||
const days = Math.floor(seconds / 86400)
|
||||
const hours = Math.floor((seconds % 86400) / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
return `${days}d ${hours}h ${minutes}m`
|
||||
}
|
||||
|
||||
// Get ZFS pool health
|
||||
const zfsHealth = pools.length > 0 ? pools[0] : null
|
||||
const zfsStatus = zfsHealth?.health_status === 'online' ? 'Online' : 'Degraded'
|
||||
const zfsHealthy = zfsHealth?.health_status === 'online'
|
||||
|
||||
// Filter logs by search query
|
||||
const filteredLogs = systemLogs.filter(log =>
|
||||
log.message.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
log.source.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
|
||||
// Get log level color
|
||||
const getLogLevelColor = (level: string) => {
|
||||
const upperLevel = level.toUpperCase()
|
||||
if (upperLevel === 'INFO' || upperLevel === 'DEBUG') return 'text-emerald-500'
|
||||
if (upperLevel === 'WARN' || upperLevel === 'WARNING') return 'text-yellow-500'
|
||||
if (upperLevel === 'ERROR' || upperLevel === 'CRITICAL' || upperLevel === 'FATAL') return 'text-red-500'
|
||||
return 'text-text-secondary'
|
||||
}
|
||||
|
||||
// Calculate network peak
|
||||
const networkPeak = networkData.length > 0
|
||||
? Math.max(...networkData.map(d => Math.max(d.inbound, d.outbound)))
|
||||
: 0
|
||||
|
||||
// Calculate current network throughput (convert Mbps to Gbps)
|
||||
const currentThroughput = networkData.length > 0
|
||||
? ((networkData[networkData.length - 1].inbound + networkData[networkData.length - 1].outbound) / 1000).toFixed(1)
|
||||
: '0.0'
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-hidden flex flex-col bg-background-dark">
|
||||
{/* Header */}
|
||||
<header className="flex-none px-6 py-5 border-b border-border-dark bg-background-dark/95 backdrop-blur z-10">
|
||||
<div className="flex flex-wrap justify-between items-end gap-3 max-w-[1600px] mx-auto">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-white text-3xl font-black tracking-tight">System Monitor</h2>
|
||||
<p className="text-text-secondary text-sm">Real-time telemetry, ZFS health, and system event logs</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-card-dark rounded-lg border border-border-dark">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-500"></span>
|
||||
</span>
|
||||
<span className="text-xs font-medium text-emerald-400">System Healthy</span>
|
||||
</div>
|
||||
<button className="flex items-center gap-2 h-10 px-4 bg-card-dark hover:bg-[#233648] border border-border-dark text-white text-sm font-bold rounded-lg transition-colors">
|
||||
<RefreshCw size={18} />
|
||||
<span>Refresh: {refreshInterval}s</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-6">
|
||||
<div className="flex flex-col gap-6 max-w-[1600px] mx-auto pb-10">
|
||||
{/* Top Stats Row */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* CPU */}
|
||||
<div className="flex flex-col gap-2 rounded-xl p-5 border border-border-dark bg-card-dark">
|
||||
<div className="flex justify-between items-start">
|
||||
<p className="text-text-secondary text-sm font-medium">CPU Load</p>
|
||||
<Cpu className="text-text-secondary" size={20} />
|
||||
</div>
|
||||
<div className="flex items-end gap-3 mt-1">
|
||||
<p className="text-white text-3xl font-bold">
|
||||
{metricsLoading ? '...' : `${metrics?.system?.cpu_usage_percent?.toFixed(0) || 0}%`}
|
||||
</p>
|
||||
<span className="text-emerald-500 text-sm font-medium mb-1 flex items-center">
|
||||
<TrendingDown size={16} className="mr-1" />
|
||||
2%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full bg-[#233648] rounded-full mt-3 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary rounded-full transition-all"
|
||||
style={{ width: `${metrics?.system?.cpu_usage_percent || 0}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RAM */}
|
||||
<div className="flex flex-col gap-2 rounded-xl p-5 border border-border-dark bg-card-dark">
|
||||
<div className="flex justify-between items-start">
|
||||
<p className="text-text-secondary text-sm font-medium">RAM Usage</p>
|
||||
<MemoryStick className="text-text-secondary" size={20} />
|
||||
</div>
|
||||
<div className="flex items-end gap-3 mt-1">
|
||||
<p className="text-white text-3xl font-bold">
|
||||
{metricsLoading ? '...' : formatBytes(metrics?.system?.memory_used_bytes || 0)}
|
||||
</p>
|
||||
<span className="text-text-secondary text-xs mb-2">
|
||||
/ {formatBytes(metrics?.system?.memory_total_bytes || 0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full bg-[#233648] rounded-full mt-3 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-emerald-500 rounded-full transition-all"
|
||||
style={{ width: `${metrics?.system?.memory_usage_percent || 0}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ZFS Health */}
|
||||
<div className="flex flex-col gap-2 rounded-xl p-5 border border-border-dark bg-card-dark">
|
||||
<div className="flex justify-between items-start">
|
||||
<p className="text-text-secondary text-sm font-medium">ZFS Pool Status</p>
|
||||
<CheckCircle2 className={zfsHealthy ? 'text-emerald-500' : 'text-yellow-500'} size={20} />
|
||||
</div>
|
||||
<div className="flex items-end gap-3 mt-1">
|
||||
<p className="text-white text-3xl font-bold">{zfsStatus}</p>
|
||||
<span className="text-text-secondary text-sm font-medium mb-1">No Errors</span>
|
||||
</div>
|
||||
<div className="flex gap-1 mt-3">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`h-1.5 flex-1 rounded-full ${
|
||||
i === 1 ? 'rounded-l-full' : i === 4 ? 'rounded-r-full' : ''
|
||||
} ${zfsHealthy ? 'bg-emerald-500' : 'bg-yellow-500'}`}
|
||||
></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Uptime */}
|
||||
<div className="flex flex-col gap-2 rounded-xl p-5 border border-border-dark bg-card-dark">
|
||||
<div className="flex justify-between items-start">
|
||||
<p className="text-text-secondary text-sm font-medium">System Uptime</p>
|
||||
<Clock className="text-text-secondary" size={20} />
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<p className="text-white text-3xl font-bold">
|
||||
{metricsLoading ? '...' : formatUptime(metrics?.system?.uptime_seconds || 0)}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-text-secondary text-xs mt-3">Last reboot: Manual Patching</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Middle Section: Charts & Disks */}
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||
{/* Charts Column (2/3) */}
|
||||
<div className="xl:col-span-2 flex flex-col gap-6">
|
||||
{/* Network Chart */}
|
||||
<div className="bg-card-dark border border-border-dark rounded-xl p-6 shadow-sm">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h3 className="text-white text-lg font-bold">Network Throughput</h3>
|
||||
<p className="text-text-secondary text-sm">Inbound vs Outbound (eth0)</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-white text-2xl font-bold leading-tight">{currentThroughput} Gbps</p>
|
||||
<p className="text-emerald-500 text-sm">Peak: {(networkPeak / 1000).toFixed(1)} Gbps</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[200px] w-full">
|
||||
{networkData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={networkData.map(d => ({
|
||||
time: new Date(d.time).toLocaleTimeString(),
|
||||
inbound: d.inbound,
|
||||
outbound: d.outbound,
|
||||
}))}>
|
||||
<defs>
|
||||
<linearGradient id="gradientPrimary" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor="#137fec" stopOpacity={0.2} />
|
||||
<stop offset="100%" stopColor="#137fec" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#324d67" />
|
||||
<XAxis dataKey="time" stroke="#92adc9" style={{ fontSize: '12px' }} />
|
||||
<YAxis stroke="#92adc9" style={{ fontSize: '12px' }} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#1a2632',
|
||||
border: '1px solid #324d67',
|
||||
borderRadius: '0.5rem',
|
||||
}}
|
||||
/>
|
||||
<Legend />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="outbound"
|
||||
stroke="#92adc9"
|
||||
strokeDasharray="5 5"
|
||||
strokeWidth={2}
|
||||
fill="none"
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="inbound"
|
||||
stroke="#137fec"
|
||||
strokeWidth={3}
|
||||
fill="url(#gradientPrimary)"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center text-text-secondary">
|
||||
Loading network data...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ZFS ARC Chart */}
|
||||
<div className="bg-card-dark border border-border-dark rounded-xl p-6 shadow-sm">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h3 className="text-white text-lg font-bold">ZFS ARC Hit Ratio</h3>
|
||||
<p className="text-text-secondary text-sm">Cache efficiency</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-white text-2xl font-bold leading-tight">94%</p>
|
||||
<p className="text-text-secondary text-sm">Target: >90%</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[150px] w-full relative">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={[
|
||||
{ time: '10:00', ratio: 95 },
|
||||
{ time: '10:15', ratio: 94 },
|
||||
{ time: '10:30', ratio: 96 },
|
||||
{ time: '10:45', ratio: 93 },
|
||||
{ time: '11:00', ratio: 94 },
|
||||
]}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#324d67" />
|
||||
<XAxis dataKey="time" stroke="#92adc9" style={{ fontSize: '12px' }} />
|
||||
<YAxis stroke="#92adc9" domain={[90, 100]} style={{ fontSize: '12px' }} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#1a2632',
|
||||
border: '1px solid #324d67',
|
||||
borderRadius: '0.5rem',
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="ratio"
|
||||
stroke="#10b981"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="w-full h-[1px] bg-border-dark absolute top-[20%]"></div>
|
||||
<div className="absolute top-[20%] right-0 text-xs text-text-secondary -mt-5">95%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Disk Health Column (1/3) */}
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="bg-card-dark border border-border-dark rounded-xl p-6 h-full shadow-sm flex flex-col">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-white text-lg font-bold">Disk Health</h3>
|
||||
<span className="bg-[#233648] text-white text-xs px-2 py-1 rounded border border-border-dark">
|
||||
Pool 1
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-3 flex-1 content-start">
|
||||
{/* Mock disk health - would be replaced with real data */}
|
||||
{[0, 1, 2, 3, 4, 5, 6, 7].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`aspect-square border rounded flex flex-col items-center justify-center ${
|
||||
i === 5
|
||||
? 'bg-[#332a18] border-yellow-700/50'
|
||||
: 'bg-[#1a2e22] border-emerald-800'
|
||||
}`}
|
||||
>
|
||||
{i === 5 ? (
|
||||
<>
|
||||
<span className="absolute top-1 right-1 h-2 w-2 rounded-full bg-yellow-500 animate-pulse"></span>
|
||||
<AlertTriangle className="text-yellow-500" size={20} />
|
||||
</>
|
||||
) : (
|
||||
<HardDrive className="text-emerald-500" size={20} />
|
||||
)}
|
||||
<span className={`text-[10px] font-mono mt-1 ${
|
||||
i === 5 ? 'text-yellow-500' : 'text-emerald-500'
|
||||
}`}>
|
||||
da{i}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{/* Empty slots */}
|
||||
{[8, 9, 10, 11].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="aspect-square bg-[#161f29] border border-border-dark border-dashed rounded flex flex-col items-center justify-center opacity-50"
|
||||
>
|
||||
<span className="text-[10px] text-text-secondary font-mono">Empty</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t border-border-dark">
|
||||
<div className="flex justify-between text-sm text-text-secondary">
|
||||
<span>Total Capacity</span>
|
||||
<span className="text-white font-bold">
|
||||
{formatBytes(metrics?.storage?.total_capacity_bytes || 0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-[#233648] h-2 rounded-full mt-2 overflow-hidden">
|
||||
<div
|
||||
className="bg-primary h-full transition-all"
|
||||
style={{
|
||||
width: `${
|
||||
metrics?.storage?.total_capacity_bytes
|
||||
? (metrics.storage.used_capacity_bytes / metrics.storage.total_capacity_bytes) * 100
|
||||
: 0
|
||||
}%`,
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-text-secondary mt-1">
|
||||
<span>Used: {formatBytes(metrics?.storage?.used_capacity_bytes || 0)}</span>
|
||||
<span>Free: {formatBytes((metrics?.storage?.total_capacity_bytes || 0) - (metrics?.storage?.used_capacity_bytes || 0))}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Section: Tabs & Logs */}
|
||||
<div className="bg-card-dark border border-border-dark rounded-xl shadow-sm overflow-hidden flex flex-col h-[400px]">
|
||||
{/* Tabs Header */}
|
||||
<div className="flex border-b border-border-dark bg-[#161f29]">
|
||||
<button
|
||||
onClick={() => setActiveTab('jobs')}
|
||||
className={`px-6 py-4 text-sm font-bold flex items-center transition-colors ${
|
||||
activeTab === 'jobs'
|
||||
? 'text-primary border-b-2 border-primary bg-card-dark'
|
||||
: 'text-text-secondary hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Active Jobs{' '}
|
||||
<span className="ml-2 bg-primary/20 text-primary px-1.5 py-0.5 rounded text-xs">
|
||||
{MOCK_ACTIVE_JOBS.length}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('logs')}
|
||||
className={`px-6 py-4 text-sm transition-colors ${
|
||||
activeTab === 'logs'
|
||||
? 'text-primary border-b-2 border-primary bg-card-dark font-bold'
|
||||
: 'text-text-secondary hover:text-white font-medium'
|
||||
}`}
|
||||
>
|
||||
System Logs
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('alerts')}
|
||||
className={`px-6 py-4 text-sm transition-colors ${
|
||||
activeTab === 'alerts'
|
||||
? 'text-primary border-b-2 border-primary bg-card-dark font-bold'
|
||||
: 'text-text-secondary hover:text-white font-medium'
|
||||
}`}
|
||||
>
|
||||
Alerts History
|
||||
</button>
|
||||
<div className="flex-1 flex justify-end items-center px-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1.5 text-text-secondary" size={18} />
|
||||
<input
|
||||
className="bg-[#111a22] border border-border-dark rounded-md py-1 pl-8 pr-3 text-sm text-white focus:outline-none focus:border-primary w-48 transition-all"
|
||||
placeholder="Search logs..."
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
{activeTab === 'jobs' && (
|
||||
<div className="p-0 overflow-y-auto custom-scrollbar">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead className="bg-[#1a2632] text-xs uppercase text-text-secondary font-medium sticky top-0 z-10">
|
||||
<tr>
|
||||
<th className="px-6 py-3 border-b border-border-dark">Job Name</th>
|
||||
<th className="px-6 py-3 border-b border-border-dark">Type</th>
|
||||
<th className="px-6 py-3 border-b border-border-dark w-1/3">Progress</th>
|
||||
<th className="px-6 py-3 border-b border-border-dark">Speed</th>
|
||||
<th className="px-6 py-3 border-b border-border-dark">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-sm divide-y divide-border-dark">
|
||||
{MOCK_ACTIVE_JOBS.map((job) => (
|
||||
<tr key={job.id} className="group hover:bg-[#233648] transition-colors">
|
||||
<td className="px-6 py-4 font-medium text-white">{job.name}</td>
|
||||
<td className="px-6 py-4 text-text-secondary">{job.type}</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-full bg-[#111a22] rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
className="bg-primary h-full rounded-full relative overflow-hidden"
|
||||
style={{ width: `${job.progress}%` }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-white/20 animate-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs font-mono text-white">{job.progress}%</span>
|
||||
</div>
|
||||
{job.eta && (
|
||||
<p className="text-[10px] text-text-secondary mt-1">ETA: {job.eta}</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-text-secondary font-mono">{job.speed}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-primary/20 text-primary">
|
||||
Running
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'logs' && (
|
||||
<>
|
||||
{/* Logs Section Header */}
|
||||
<div className="px-6 py-2 bg-[#161f29] border-y border-border-dark flex items-center justify-between">
|
||||
<h4 className="text-xs uppercase text-text-secondary font-bold tracking-wider">
|
||||
Recent System Events
|
||||
</h4>
|
||||
<button className="text-xs text-primary hover:text-white transition-colors">
|
||||
View All Logs
|
||||
</button>
|
||||
</div>
|
||||
{/* Logs Table */}
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar bg-[#111a22]">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<tbody className="text-sm font-mono divide-y divide-border-dark/50">
|
||||
{logsLoading ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-6 py-4 text-center text-text-secondary">
|
||||
Loading logs...
|
||||
</td>
|
||||
</tr>
|
||||
) : filteredLogs.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-6 py-4 text-center text-text-secondary">
|
||||
No logs found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredLogs.map((log, idx) => (
|
||||
<tr key={idx} className="group hover:bg-[#233648] transition-colors">
|
||||
<td className="px-6 py-2 text-text-secondary w-32 whitespace-nowrap">
|
||||
{new Date(log.time).toLocaleTimeString()}
|
||||
</td>
|
||||
<td className="px-6 py-2 w-24">
|
||||
<span className={getLogLevelColor(log.level)}>{log.level}</span>
|
||||
</td>
|
||||
<td className="px-6 py-2 w-32 text-white">{log.source}</td>
|
||||
<td className="px-6 py-2 text-text-secondary truncate max-w-lg">{log.message}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'alerts' && (
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar bg-[#111a22] p-6">
|
||||
{alertsData?.alerts && alertsData.alerts.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{alertsData.alerts.map((alert) => (
|
||||
<div
|
||||
key={alert.id}
|
||||
className="bg-[#1a2632] border border-border-dark rounded-lg p-4 hover:bg-[#233648] transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
{alert.severity === 'critical' ? (
|
||||
<AlertCircle className="text-red-500 mt-1" size={20} />
|
||||
) : alert.severity === 'warning' ? (
|
||||
<AlertTriangle className="text-yellow-500 mt-1" size={20} />
|
||||
) : (
|
||||
<Info className="text-blue-500 mt-1" size={20} />
|
||||
)}
|
||||
<div>
|
||||
<h4 className="text-white font-medium">{alert.title}</h4>
|
||||
<p className="text-text-secondary text-sm mt-1">{alert.message}</p>
|
||||
<p className="text-text-secondary text-xs mt-2">
|
||||
{new Date(alert.created_at).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
alert.severity === 'critical'
|
||||
? 'bg-red-500/20 text-red-400'
|
||||
: alert.severity === 'warning'
|
||||
? 'bg-yellow-500/20 text-yellow-400'
|
||||
: 'bg-blue-500/20 text-blue-400'
|
||||
}`}
|
||||
>
|
||||
{alert.severity.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-text-secondary py-8">No alerts</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
475
frontend/src/pages/ObjectStorage.tsx
Normal file
475
frontend/src/pages/ObjectStorage.tsx
Normal file
@@ -0,0 +1,475 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { formatBytes } from '@/lib/format'
|
||||
import {
|
||||
Folder,
|
||||
Share2,
|
||||
Globe,
|
||||
Search,
|
||||
Plus,
|
||||
MoreVertical,
|
||||
CheckCircle2,
|
||||
HardDrive,
|
||||
Database,
|
||||
Clock,
|
||||
Link as LinkIcon,
|
||||
Copy,
|
||||
FileText,
|
||||
Settings,
|
||||
Users,
|
||||
Activity,
|
||||
Filter
|
||||
} from 'lucide-react'
|
||||
|
||||
// Mock data - will be replaced with API calls
|
||||
const MOCK_BUCKETS = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'backup-archive-01',
|
||||
type: 'immutable',
|
||||
usage: 4.2 * 1024 * 1024 * 1024 * 1024, // 4.2 TB in bytes
|
||||
usagePercent: 75,
|
||||
objects: 14200,
|
||||
accessPolicy: 'private',
|
||||
created: '2023-10-24',
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'daily-snapshots',
|
||||
type: 'standard',
|
||||
usage: 120 * 1024 * 1024 * 1024, // 120 GB
|
||||
usagePercent: 15,
|
||||
objects: 400,
|
||||
accessPolicy: 'private',
|
||||
created: '2023-11-01',
|
||||
color: 'purple',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'public-assets',
|
||||
type: 'standard',
|
||||
usage: 500 * 1024 * 1024 * 1024, // 500 GB
|
||||
usagePercent: 30,
|
||||
objects: 12050,
|
||||
accessPolicy: 'public-read',
|
||||
created: '2023-12-15',
|
||||
color: 'orange',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'logs-retention',
|
||||
type: 'archive',
|
||||
usage: 2.1 * 1024 * 1024 * 1024 * 1024, // 2.1 TB
|
||||
usagePercent: 55,
|
||||
objects: 850221,
|
||||
accessPolicy: 'private',
|
||||
created: '2024-01-10',
|
||||
color: 'blue',
|
||||
},
|
||||
]
|
||||
|
||||
const S3_ENDPOINT = 'https://s3.appliance.local:9000'
|
||||
|
||||
export default function ObjectStorage() {
|
||||
const [activeTab, setActiveTab] = useState<'buckets' | 'users' | 'monitoring' | 'settings'>('buckets')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const itemsPerPage = 10
|
||||
|
||||
// Mock queries - replace with real API calls
|
||||
const { data: buckets = MOCK_BUCKETS } = useQuery({
|
||||
queryKey: ['object-storage-buckets'],
|
||||
queryFn: async () => MOCK_BUCKETS,
|
||||
})
|
||||
|
||||
// Filter buckets by search query
|
||||
const filteredBuckets = buckets.filter(bucket =>
|
||||
bucket.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
|
||||
// Pagination
|
||||
const totalPages = Math.ceil(filteredBuckets.length / itemsPerPage)
|
||||
const paginatedBuckets = filteredBuckets.slice(
|
||||
(currentPage - 1) * itemsPerPage,
|
||||
currentPage * itemsPerPage
|
||||
)
|
||||
|
||||
// Calculate totals
|
||||
const totalUsage = buckets.reduce((sum, b) => sum + b.usage, 0)
|
||||
const totalObjects = buckets.reduce((sum, b) => sum + b.objects, 0)
|
||||
|
||||
// Copy endpoint to clipboard
|
||||
const copyEndpoint = () => {
|
||||
navigator.clipboard.writeText(S3_ENDPOINT)
|
||||
// You could add a toast notification here
|
||||
}
|
||||
|
||||
// Get bucket icon
|
||||
const getBucketIcon = (bucket: typeof MOCK_BUCKETS[0]) => {
|
||||
if (bucket.accessPolicy === 'public-read') {
|
||||
return <Globe className="text-orange-500" size={20} />
|
||||
}
|
||||
if (bucket.type === 'immutable') {
|
||||
return <Folder className="text-blue-500" size={20} />
|
||||
}
|
||||
return <Share2 className="text-purple-500" size={20} />
|
||||
}
|
||||
|
||||
// Get bucket color class
|
||||
const getBucketColorClass = (color: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
blue: 'bg-blue-500/10 text-blue-500',
|
||||
purple: 'bg-purple-500/10 text-purple-500',
|
||||
orange: 'bg-orange-500/10 text-orange-500',
|
||||
}
|
||||
return colors[color] || colors.blue
|
||||
}
|
||||
|
||||
// Get progress bar color
|
||||
const getProgressColor = (color: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
blue: 'bg-primary',
|
||||
purple: 'bg-purple-500',
|
||||
orange: 'bg-orange-500',
|
||||
}
|
||||
return colors[color] || colors.blue
|
||||
}
|
||||
|
||||
// Get access policy badge
|
||||
const getAccessPolicyBadge = (policy: string) => {
|
||||
if (policy === 'public-read') {
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-full bg-orange-500/10 px-2.5 py-0.5 text-xs font-medium text-orange-500 border border-orange-500/20">
|
||||
Public Read
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-full bg-green-500/10 px-2.5 py-0.5 text-xs font-medium text-green-500 border border-green-500/20">
|
||||
Private
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Format date
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-hidden flex flex-col bg-background-dark">
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 flex flex-col overflow-y-auto relative scroll-smooth">
|
||||
<div className="flex flex-col max-w-[1200px] w-full mx-auto p-6 md:p-8 lg:p-12 gap-8">
|
||||
{/* Page Heading */}
|
||||
<div className="flex flex-wrap justify-between items-start gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-white tracking-tight text-[32px] font-bold leading-tight">
|
||||
Object Storage Service
|
||||
</h1>
|
||||
<p className="text-text-secondary text-sm font-normal max-w-xl">
|
||||
Manage S3-compatible buckets, configure access policies, and monitor real-time object storage performance.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button className="flex h-10 items-center justify-center rounded-lg border border-border-dark px-4 text-white text-sm font-medium hover:bg-[#233648] transition-colors">
|
||||
<FileText className="mr-2" size={20} />
|
||||
Documentation
|
||||
</button>
|
||||
<button className="flex h-10 items-center justify-center rounded-lg bg-[#233648] px-4 text-white text-sm font-medium hover:bg-[#2b4055] transition-colors border border-border-dark">
|
||||
<Settings className="mr-2" size={20} />
|
||||
Config
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Service Status */}
|
||||
<div className="flex flex-col gap-2 rounded-lg p-5 border border-border-dark bg-[#1c2936]">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-text-secondary text-sm font-medium">Service Status</p>
|
||||
<CheckCircle2 className="text-[#0bda5b]" size={20} />
|
||||
</div>
|
||||
<p className="text-white tracking-tight text-2xl font-bold">Running</p>
|
||||
<p className="text-text-secondary text-xs">Port 9000 (TLS Enabled)</p>
|
||||
</div>
|
||||
|
||||
{/* Total Usage */}
|
||||
<div className="flex flex-col gap-2 rounded-lg p-5 border border-border-dark bg-[#1c2936]">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-text-secondary text-sm font-medium">Total Usage</p>
|
||||
<HardDrive className="text-primary" size={20} />
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<p className="text-white tracking-tight text-2xl font-bold">{formatBytes(totalUsage, 1)}</p>
|
||||
<p className="text-[#0bda5b] text-sm font-medium">+2.1%</p>
|
||||
</div>
|
||||
<div className="w-full bg-[#233648] rounded-full h-1.5 mt-1">
|
||||
<div className="bg-primary h-1.5 rounded-full" style={{ width: '45%' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Object Count */}
|
||||
<div className="flex flex-col gap-2 rounded-lg p-5 border border-border-dark bg-[#1c2936]">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-text-secondary text-sm font-medium">Object Count</p>
|
||||
<Database className="text-blue-400" size={20} />
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<p className="text-white tracking-tight text-2xl font-bold">
|
||||
{(totalObjects / 1000000).toFixed(1)}M
|
||||
</p>
|
||||
<p className="text-[#0bda5b] text-sm font-medium">+0.5%</p>
|
||||
</div>
|
||||
<p className="text-text-secondary text-xs">Total objects across all buckets</p>
|
||||
</div>
|
||||
|
||||
{/* Uptime */}
|
||||
<div className="flex flex-col gap-2 rounded-lg p-5 border border-border-dark bg-[#1c2936]">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-text-secondary text-sm font-medium">Uptime</p>
|
||||
<Clock className="text-orange-400" size={20} />
|
||||
</div>
|
||||
<p className="text-white tracking-tight text-2xl font-bold">24d 12h</p>
|
||||
<p className="text-text-secondary text-xs">Since last patch</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Endpoint Info */}
|
||||
<div className="flex flex-col md:flex-row items-center justify-between gap-6 rounded-lg border border-border-dark bg-[#16202a] p-6 relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 h-full w-1/3 bg-gradient-to-l from-primary/10 to-transparent pointer-events-none"></div>
|
||||
<div className="flex flex-col gap-2 z-10 max-w-2xl">
|
||||
<div className="flex items-center gap-2">
|
||||
<LinkIcon className="text-primary" size={20} />
|
||||
<h2 className="text-white text-lg font-bold">S3 Endpoint URL</h2>
|
||||
</div>
|
||||
<p className="text-text-secondary text-sm">
|
||||
Use this URL to configure your S3 clients (MinIO, AWS CLI, Veeam, etc.).
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-full md:w-auto min-w-[320px] max-w-[500px] z-10">
|
||||
<div className="flex w-full items-stretch rounded-lg h-12 shadow-md">
|
||||
<input
|
||||
className="flex-1 bg-[#233648] border border-border-dark border-r-0 rounded-l-lg px-4 text-white text-sm font-mono focus:ring-1 focus:ring-primary focus:border-primary outline-none"
|
||||
readOnly
|
||||
value={S3_ENDPOINT}
|
||||
/>
|
||||
<button
|
||||
onClick={copyEndpoint}
|
||||
className="bg-primary hover:bg-blue-600 text-white px-4 rounded-r-lg text-sm font-bold transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Copy size={18} />
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs & Main Action Area */}
|
||||
<div className="flex flex-col gap-0">
|
||||
{/* Tabs Header */}
|
||||
<div className="border-b border-border-dark">
|
||||
<div className="flex gap-8 px-2">
|
||||
<button
|
||||
onClick={() => setActiveTab('buckets')}
|
||||
className={`flex items-center gap-2 border-b-2 pb-3 pt-2 transition-colors ${
|
||||
activeTab === 'buckets'
|
||||
? 'border-primary text-white'
|
||||
: 'border-transparent text-text-secondary hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Folder size={20} />
|
||||
<span className="text-sm font-bold">Buckets</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('users')}
|
||||
className={`flex items-center gap-2 border-b-2 pb-3 pt-2 transition-colors ${
|
||||
activeTab === 'users'
|
||||
? 'border-primary text-white'
|
||||
: 'border-transparent text-text-secondary hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Users size={20} />
|
||||
<span className="text-sm font-bold">Users & Keys</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('monitoring')}
|
||||
className={`flex items-center gap-2 border-b-2 pb-3 pt-2 transition-colors ${
|
||||
activeTab === 'monitoring'
|
||||
? 'border-primary text-white'
|
||||
: 'border-transparent text-text-secondary hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Activity size={20} />
|
||||
<span className="text-sm font-bold">Monitoring</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('settings')}
|
||||
className={`flex items-center gap-2 border-b-2 pb-3 pt-2 transition-colors ${
|
||||
activeTab === 'settings'
|
||||
? 'border-primary text-white'
|
||||
: 'border-transparent text-text-secondary hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Filter size={20} />
|
||||
<span className="text-sm font-bold">Settings</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="py-6 flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<div className="relative w-full md:w-96">
|
||||
<Search className="absolute inset-y-0 left-0 flex items-center pl-3 text-text-secondary" size={20} />
|
||||
<input
|
||||
className="w-full bg-[#233648] border border-border-dark text-white text-sm rounded-lg pl-10 pr-4 py-2.5 focus:ring-1 focus:ring-primary focus:border-primary outline-none placeholder:text-text-secondary"
|
||||
placeholder="Filter buckets..."
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button className="flex items-center justify-center gap-2 bg-primary hover:bg-blue-600 text-white px-5 py-2.5 rounded-lg text-sm font-bold transition-colors w-full md:w-auto shadow-lg shadow-blue-900/20">
|
||||
<Plus size={20} />
|
||||
Create Bucket
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'buckets' && (
|
||||
<>
|
||||
{/* Data Table */}
|
||||
<div className="w-full overflow-hidden rounded-lg border border-border-dark bg-[#1c2936]">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-border-dark bg-[#16202a]">
|
||||
<th className="px-6 py-4 text-xs font-semibold uppercase tracking-wider text-text-secondary">
|
||||
Name
|
||||
</th>
|
||||
<th className="px-6 py-4 text-xs font-semibold uppercase tracking-wider text-text-secondary">
|
||||
Usage
|
||||
</th>
|
||||
<th className="px-6 py-4 text-xs font-semibold uppercase tracking-wider text-text-secondary">
|
||||
Objects
|
||||
</th>
|
||||
<th className="px-6 py-4 text-xs font-semibold uppercase tracking-wider text-text-secondary">
|
||||
Access Policy
|
||||
</th>
|
||||
<th className="px-6 py-4 text-xs font-semibold uppercase tracking-wider text-text-secondary">
|
||||
Created
|
||||
</th>
|
||||
<th className="px-6 py-4 text-xs font-semibold uppercase tracking-wider text-text-secondary text-right">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border-dark">
|
||||
{paginatedBuckets.map((bucket) => (
|
||||
<tr key={bucket.id} className="group hover:bg-[#233648] transition-colors">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-lg ${getBucketColorClass(
|
||||
bucket.color
|
||||
)}`}
|
||||
>
|
||||
{getBucketIcon(bucket)}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<p className="text-white text-sm font-bold">{bucket.name}</p>
|
||||
<p className="text-text-secondary text-xs">{bucket.type}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex flex-col gap-1 w-32">
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-white font-medium">{formatBytes(bucket.usage, 1)}</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full bg-[#111a22] rounded-full">
|
||||
<div
|
||||
className={`h-1.5 rounded-full ${getProgressColor(bucket.color)}`}
|
||||
style={{ width: `${bucket.usagePercent}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<p className="text-white text-sm">{bucket.objects.toLocaleString()}</p>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">{getAccessPolicyBadge(bucket.accessPolicy)}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<p className="text-text-secondary text-sm">{formatDate(bucket.created)}</p>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||
<button className="text-text-secondary hover:text-white transition-colors p-2 rounded hover:bg-white/5">
|
||||
<MoreVertical size={18} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between border-t border-border-dark px-6 py-3 bg-[#16202a]">
|
||||
<p className="text-xs text-text-secondary">
|
||||
Showing <span className="font-medium text-white">{paginatedBuckets.length > 0 ? (currentPage - 1) * itemsPerPage + 1 : 0}</span> to{' '}
|
||||
<span className="font-medium text-white">
|
||||
{Math.min(currentPage * itemsPerPage, filteredBuckets.length)}
|
||||
</span>{' '}
|
||||
of <span className="font-medium text-white">{filteredBuckets.length}</span> buckets
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="flex items-center justify-center h-8 w-8 rounded bg-[#233648] text-text-secondary hover:text-white hover:bg-[#2b4055] transition-colors disabled:opacity-50"
|
||||
>
|
||||
<span className="text-sm">‹</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="flex items-center justify-center h-8 w-8 rounded bg-[#233648] text-text-secondary hover:text-white hover:bg-[#2b4055] transition-colors disabled:opacity-50"
|
||||
>
|
||||
<span className="text-sm">›</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'users' && (
|
||||
<div className="bg-[#1c2936] border border-border-dark rounded-lg p-8 text-center">
|
||||
<Users className="mx-auto mb-4 text-text-secondary" size={48} />
|
||||
<p className="text-text-secondary text-sm">Users & Keys management coming soon</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'monitoring' && (
|
||||
<div className="bg-[#1c2936] border border-border-dark rounded-lg p-8 text-center">
|
||||
<Activity className="mx-auto mb-4 text-text-secondary" size={48} />
|
||||
<p className="text-text-secondary text-sm">Monitoring dashboard coming soon</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'settings' && (
|
||||
<div className="bg-[#1c2936] border border-border-dark rounded-lg p-8 text-center">
|
||||
<Settings className="mx-auto mb-4 text-text-secondary" size={48} />
|
||||
<p className="text-text-secondary text-sm">Settings configuration coming soon</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-12 w-full shrink-0"></div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
971
frontend/src/pages/Shares.tsx
Normal file
971
frontend/src/pages/Shares.tsx
Normal file
@@ -0,0 +1,971 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { sharesAPI, type Share, type CreateShareRequest, type UpdateShareRequest } from '@/api/shares'
|
||||
import { zfsApi, type ZFSDataset } from '@/api/storage'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Plus, Search, FolderOpen, Share as ShareIcon, Cloud, Settings, X, ChevronRight,
|
||||
FolderSymlink, Network, Lock, History, Save, Gauge, Server,
|
||||
ChevronDown, Ban
|
||||
} from 'lucide-react'
|
||||
|
||||
export default function SharesPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const [selectedShare, setSelectedShare] = useState<Share | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [showCreateForm, setShowCreateForm] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState<'configuration' | 'permissions' | 'clients'>('configuration')
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
const [formData, setFormData] = useState<CreateShareRequest>({
|
||||
dataset_id: '',
|
||||
nfs_enabled: false,
|
||||
nfs_options: 'rw,sync,no_subtree_check',
|
||||
nfs_clients: [],
|
||||
smb_enabled: false,
|
||||
smb_share_name: '',
|
||||
smb_comment: '',
|
||||
smb_guest_ok: false,
|
||||
smb_read_only: false,
|
||||
smb_browseable: true,
|
||||
})
|
||||
const [nfsClientInput, setNfsClientInput] = useState('')
|
||||
|
||||
const { data: shares = [], isLoading } = useQuery<Share[]>({
|
||||
queryKey: ['shares'],
|
||||
queryFn: sharesAPI.listShares,
|
||||
refetchInterval: 5000,
|
||||
staleTime: 0,
|
||||
})
|
||||
|
||||
// Get all datasets for create form
|
||||
const { data: pools = [] } = useQuery({
|
||||
queryKey: ['storage', 'zfs', 'pools'],
|
||||
queryFn: zfsApi.listPools,
|
||||
})
|
||||
|
||||
const [datasets, setDatasets] = useState<ZFSDataset[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDatasets = async () => {
|
||||
const allDatasets: ZFSDataset[] = []
|
||||
for (const pool of pools) {
|
||||
try {
|
||||
const poolDatasets = await zfsApi.listDatasets(pool.id)
|
||||
// Filter only filesystem datasets
|
||||
const filesystems = poolDatasets.filter(ds => ds.type === 'filesystem')
|
||||
allDatasets.push(...filesystems)
|
||||
} catch (err) {
|
||||
console.error(`Failed to fetch datasets for pool ${pool.id}:`, err)
|
||||
}
|
||||
}
|
||||
setDatasets(allDatasets)
|
||||
}
|
||||
if (pools.length > 0) {
|
||||
fetchDatasets()
|
||||
}
|
||||
}, [pools])
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: sharesAPI.createShare,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['shares'] })
|
||||
setShowCreateForm(false)
|
||||
setFormData({
|
||||
dataset_id: '',
|
||||
nfs_enabled: false,
|
||||
nfs_options: 'rw,sync,no_subtree_check',
|
||||
nfs_clients: [],
|
||||
smb_enabled: false,
|
||||
smb_share_name: '',
|
||||
smb_comment: '',
|
||||
smb_guest_ok: false,
|
||||
smb_read_only: false,
|
||||
smb_browseable: true,
|
||||
})
|
||||
alert('Share created successfully!')
|
||||
},
|
||||
onError: (error: any) => {
|
||||
const errorMessage = error?.response?.data?.error || error?.message || 'Failed to create share'
|
||||
alert(`Error: ${errorMessage}`)
|
||||
console.error('Failed to create share:', error)
|
||||
},
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: UpdateShareRequest }) =>
|
||||
sharesAPI.updateShare(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['shares'] })
|
||||
},
|
||||
})
|
||||
|
||||
const filteredShares = shares.filter(share =>
|
||||
share.dataset_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
share.mount_point.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
|
||||
const handleCreateShare = (e?: React.MouseEvent) => {
|
||||
e?.preventDefault()
|
||||
e?.stopPropagation()
|
||||
|
||||
console.log('Creating share with data:', formData)
|
||||
|
||||
if (!formData.dataset_id) {
|
||||
alert('Please select a dataset')
|
||||
return
|
||||
}
|
||||
if (!formData.nfs_enabled && !formData.smb_enabled) {
|
||||
alert('At least one protocol (NFS or SMB) must be enabled')
|
||||
return
|
||||
}
|
||||
|
||||
// Prepare the data to send
|
||||
const submitData: CreateShareRequest = {
|
||||
dataset_id: formData.dataset_id,
|
||||
nfs_enabled: formData.nfs_enabled,
|
||||
smb_enabled: formData.smb_enabled,
|
||||
}
|
||||
|
||||
if (formData.nfs_enabled) {
|
||||
submitData.nfs_options = formData.nfs_options || 'rw,sync,no_subtree_check'
|
||||
submitData.nfs_clients = formData.nfs_clients || []
|
||||
}
|
||||
|
||||
if (formData.smb_enabled) {
|
||||
submitData.smb_share_name = formData.smb_share_name || ''
|
||||
submitData.smb_comment = formData.smb_comment || ''
|
||||
submitData.smb_guest_ok = formData.smb_guest_ok || false
|
||||
submitData.smb_read_only = formData.smb_read_only || false
|
||||
submitData.smb_browseable = formData.smb_browseable !== undefined ? formData.smb_browseable : true
|
||||
}
|
||||
|
||||
console.log('Submitting share data:', submitData)
|
||||
createMutation.mutate(submitData)
|
||||
}
|
||||
|
||||
const handleToggleNFS = (share: Share) => {
|
||||
updateMutation.mutate({
|
||||
id: share.id,
|
||||
data: { nfs_enabled: !share.nfs_enabled },
|
||||
})
|
||||
}
|
||||
|
||||
const handleToggleSMB = (share: Share) => {
|
||||
updateMutation.mutate({
|
||||
id: share.id,
|
||||
data: { smb_enabled: !share.smb_enabled },
|
||||
})
|
||||
}
|
||||
|
||||
const handleAddNFSClient = (share: Share) => {
|
||||
if (!nfsClientInput.trim()) return
|
||||
const newClients = [...(share.nfs_clients || []), nfsClientInput.trim()]
|
||||
updateMutation.mutate({
|
||||
id: share.id,
|
||||
data: { nfs_clients: newClients },
|
||||
})
|
||||
setNfsClientInput('')
|
||||
}
|
||||
|
||||
const handleRemoveNFSClient = (share: Share, client: string) => {
|
||||
const newClients = (share.nfs_clients || []).filter(c => c !== client)
|
||||
updateMutation.mutate({
|
||||
id: share.id,
|
||||
data: { nfs_clients: newClients },
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-hidden flex flex-col bg-background-dark">
|
||||
{/* Header */}
|
||||
<div className="flex-shrink-0 border-b border-border-dark bg-background-dark/95 backdrop-blur z-10">
|
||||
<div className="flex flex-col gap-4 p-6 pb-4">
|
||||
<div className="flex flex-wrap justify-between gap-3 items-center">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-white text-3xl font-black leading-tight tracking-[-0.033em]">Shares Management</h2>
|
||||
<div className="flex items-center gap-2 text-text-secondary text-sm">
|
||||
<span>Storage</span>
|
||||
<ChevronRight size={14} />
|
||||
<span>Shares</span>
|
||||
<ChevronRight size={14} />
|
||||
<span className="text-white">Overview</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
className="flex items-center gap-2 px-4 h-10 rounded-lg bg-primary hover:bg-blue-600 text-white text-sm font-bold"
|
||||
>
|
||||
<Plus size={20} />
|
||||
<span>Create New Share</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Status Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-2">
|
||||
<div className="flex flex-col gap-1 rounded-lg p-4 border border-border-dark bg-surface-dark/50">
|
||||
<div className="flex justify-between items-start">
|
||||
<p className="text-text-secondary text-xs font-bold uppercase tracking-wider">SMB Service</p>
|
||||
<div className="size-2 rounded-full bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]"></div>
|
||||
</div>
|
||||
<p className="text-white text-xl font-bold leading-tight">Running</p>
|
||||
<p className="text-emerald-500 text-xs mt-1">Port 445 Active</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 rounded-lg p-4 border border-border-dark bg-surface-dark/50">
|
||||
<div className="flex justify-between items-start">
|
||||
<p className="text-text-secondary text-xs font-bold uppercase tracking-wider">NFS Service</p>
|
||||
<div className="size-2 rounded-full bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]"></div>
|
||||
</div>
|
||||
<p className="text-white text-xl font-bold leading-tight">Running</p>
|
||||
<p className="text-emerald-500 text-xs mt-1">Port 2049 Active</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 rounded-lg p-4 border border-border-dark bg-surface-dark/50">
|
||||
<div className="flex justify-between items-start">
|
||||
<p className="text-text-secondary text-xs font-bold uppercase tracking-wider">Throughput</p>
|
||||
<Gauge className="text-text-secondary" size={20} />
|
||||
</div>
|
||||
<p className="text-white text-xl font-bold leading-tight">565 MB/s</p>
|
||||
<p className="text-text-secondary text-xs mt-1">14 Clients Connected</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Master-Detail Layout */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Left Panel: Shares List */}
|
||||
<div className="w-full lg:w-[400px] flex flex-col border-r border-border-dark bg-background-dark flex-shrink-0">
|
||||
{/* Search */}
|
||||
<div className="p-4 border-b border-border-dark bg-background-dark sticky top-0 z-10">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-2.5 text-text-secondary" size={18} />
|
||||
<input
|
||||
className="w-full bg-surface-dark border border-border-dark rounded-lg pl-10 pr-4 py-2.5 text-sm text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary placeholder-text-secondary"
|
||||
placeholder="Filter shares..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* List Items */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="p-4 text-center text-text-secondary text-sm">Loading shares...</div>
|
||||
) : filteredShares.length === 0 ? (
|
||||
<div className="p-4 text-center text-text-secondary text-sm">No shares found</div>
|
||||
) : (
|
||||
filteredShares.map((share) => (
|
||||
<div
|
||||
key={share.id}
|
||||
onClick={() => setSelectedShare(share)}
|
||||
className={`group flex flex-col border-b border-border-dark/50 cursor-pointer transition-colors ${
|
||||
selectedShare?.id === share.id
|
||||
? 'bg-primary/10 border-l-4 border-l-primary'
|
||||
: 'hover:bg-surface-dark'
|
||||
}`}
|
||||
>
|
||||
<div className={`px-4 py-3 flex items-start gap-3 ${selectedShare?.id === share.id ? 'pl-3' : ''}`}>
|
||||
{selectedShare?.id === share.id ? (
|
||||
<Server className="text-primary mt-1" size={20} />
|
||||
) : (
|
||||
<FolderOpen className="text-text-secondary mt-1" size={20} />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<h3 className={`text-sm truncate ${selectedShare?.id === share.id ? 'font-bold text-white' : 'font-medium text-white'}`}>
|
||||
{share.dataset_name}
|
||||
</h3>
|
||||
</div>
|
||||
<p className={`text-xs truncate mb-2 ${selectedShare?.id === share.id ? 'text-primary/80' : 'text-text-secondary'}`}>
|
||||
{share.mount_point || 'No mount point'}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
{share.smb_enabled ? (
|
||||
<span className={`px-1.5 py-0.5 rounded text-[10px] font-bold border ${
|
||||
selectedShare?.id === share.id
|
||||
? 'bg-surface-dark text-text-secondary border-border-dark'
|
||||
: 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20'
|
||||
}`}>
|
||||
SMB
|
||||
</span>
|
||||
) : null}
|
||||
{share.nfs_enabled && (
|
||||
<span className="px-1.5 py-0.5 rounded text-[10px] font-bold bg-emerald-500/10 text-emerald-500 border border-emerald-500/20">
|
||||
NFS
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{selectedShare?.id !== share.id && (
|
||||
<ChevronRight className="text-text-secondary text-[18px]" size={18} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4 border-t border-border-dark bg-background-dark text-center">
|
||||
<p className="text-xs text-text-secondary">
|
||||
Showing {filteredShares.length} of {shares.length} shares
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Panel: Share Details */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden bg-background-light dark:bg-[#0d141c]">
|
||||
{selectedShare ? (
|
||||
<>
|
||||
{/* Detail Header */}
|
||||
<div className="p-6 pb-0 flex flex-col gap-6">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="bg-primary p-2 rounded-lg text-white">
|
||||
<Server size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white">
|
||||
{selectedShare.dataset_name.split('/').pop() || selectedShare.dataset_name}
|
||||
</h2>
|
||||
<p className="text-text-secondary text-sm font-mono">{selectedShare.dataset_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex items-center justify-center rounded-lg h-9 px-4 border border-border-dark text-white text-sm font-medium hover:bg-surface-dark transition-colors"
|
||||
>
|
||||
<History size={18} className="mr-2" />
|
||||
Revert
|
||||
</Button>
|
||||
<Button
|
||||
className="flex items-center justify-center rounded-lg h-9 px-4 bg-primary text-white text-sm font-bold shadow-lg shadow-primary/20 hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
<Save size={18} className="mr-2" />
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Protocol Toggles */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* SMB Toggle */}
|
||||
<div className={`flex items-center justify-between p-4 rounded-xl border ${
|
||||
selectedShare.smb_enabled
|
||||
? 'border-primary/50 bg-primary/5'
|
||||
: 'border-border-dark bg-surface-dark/40'
|
||||
}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${
|
||||
selectedShare.smb_enabled
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'bg-surface-dark text-text-secondary'
|
||||
}`}>
|
||||
<ShareIcon size={20} />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-bold text-white">SMB Protocol</span>
|
||||
<span className="text-xs text-text-secondary">Windows File Sharing</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleToggleSMB(selectedShare)}
|
||||
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:ring-offset-background-dark ${
|
||||
selectedShare.smb_enabled ? 'bg-primary' : 'bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
<span className="sr-only">Use setting</span>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||
selectedShare.smb_enabled ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* NFS Toggle */}
|
||||
<div className={`flex items-center justify-between p-4 rounded-xl border ${
|
||||
selectedShare.nfs_enabled
|
||||
? 'border-primary/50 bg-primary/5'
|
||||
: 'border-border-dark bg-surface-dark/40'
|
||||
}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${
|
||||
selectedShare.nfs_enabled
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'bg-surface-dark text-text-secondary'
|
||||
}`}>
|
||||
<Cloud size={20} />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-bold text-white">NFS Protocol</span>
|
||||
<span className="text-xs text-text-secondary">Unix/Linux File Sharing</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleToggleNFS(selectedShare)}
|
||||
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:ring-offset-background-dark ${
|
||||
selectedShare.nfs_enabled ? 'bg-primary' : 'bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
<span className="sr-only">Use setting</span>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||
selectedShare.nfs_enabled ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-border-dark mt-2">
|
||||
<div className="flex gap-6">
|
||||
<button
|
||||
onClick={() => setActiveTab('configuration')}
|
||||
className={`pb-3 border-b-2 text-sm flex items-center gap-2 transition-colors ${
|
||||
activeTab === 'configuration'
|
||||
? 'border-primary text-primary font-bold'
|
||||
: 'border-transparent text-text-secondary hover:text-white font-medium'
|
||||
}`}
|
||||
>
|
||||
<Settings size={18} />
|
||||
Configuration
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('permissions')}
|
||||
className={`pb-3 border-b-2 text-sm flex items-center gap-2 transition-colors ${
|
||||
activeTab === 'permissions'
|
||||
? 'border-primary text-primary font-bold'
|
||||
: 'border-transparent text-text-secondary hover:text-white font-medium'
|
||||
}`}
|
||||
>
|
||||
<Lock size={18} />
|
||||
Permissions (ACL)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('clients')}
|
||||
className={`pb-3 border-b-2 text-sm flex items-center gap-2 transition-colors ${
|
||||
activeTab === 'clients'
|
||||
? 'border-primary text-primary font-bold'
|
||||
: 'border-transparent text-text-secondary hover:text-white font-medium'
|
||||
}`}
|
||||
>
|
||||
<Network size={18} />
|
||||
Connected Clients
|
||||
<span className="bg-surface-dark text-white text-[10px] px-1.5 py-0.5 rounded-full ml-1">8</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="max-w-4xl flex flex-col gap-6">
|
||||
{activeTab === 'configuration' && (
|
||||
<>
|
||||
{/* NFS Settings Card */}
|
||||
{selectedShare.nfs_enabled && (
|
||||
<div className="rounded-xl border border-border-dark bg-surface-dark overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-border-dark flex justify-between items-center bg-[#1c2a39]">
|
||||
<h3 className="text-sm font-bold text-white flex items-center gap-2">
|
||||
<Network className="text-primary" size={20} />
|
||||
NFS Configuration
|
||||
</h3>
|
||||
<span className="text-xs text-emerald-500 font-medium px-2 py-1 bg-emerald-500/10 rounded border border-emerald-500/20">
|
||||
Active
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-5 flex flex-col gap-5">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs font-semibold text-text-secondary uppercase">Allowed Subnets / IPs</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="flex-1 bg-background-dark border border-border-dark rounded-lg px-3 py-2 text-sm text-white focus:border-primary focus:ring-1 focus:ring-primary font-mono"
|
||||
type="text"
|
||||
placeholder="192.168.10.0/24"
|
||||
value={nfsClientInput}
|
||||
onChange={(e) => setNfsClientInput(e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleAddNFSClient(selectedShare)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleAddNFSClient(selectedShare)}
|
||||
className="p-2 bg-surface-dark hover:bg-border-dark border border-border-dark rounded-lg text-white"
|
||||
>
|
||||
<Plus size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-text-secondary">CIDR notation supported. Use comma for multiple entries.</p>
|
||||
{selectedShare.nfs_clients && selectedShare.nfs_clients.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{selectedShare.nfs_clients.map((client) => (
|
||||
<span
|
||||
key={client}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 bg-primary/20 text-primary text-xs rounded border border-primary/30"
|
||||
>
|
||||
{client}
|
||||
<button
|
||||
onClick={() => handleRemoveNFSClient(selectedShare, client)}
|
||||
className="hover:text-red-400"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs font-semibold text-text-secondary uppercase">Map Root User</label>
|
||||
<div className="relative">
|
||||
<select className="w-full bg-background-dark border border-border-dark rounded-lg px-3 py-2 text-sm text-white focus:border-primary focus:ring-1 focus:ring-primary appearance-none">
|
||||
<option>root (User ID 0)</option>
|
||||
<option>admin</option>
|
||||
<option>nobody</option>
|
||||
</select>
|
||||
<ChevronDown className="absolute right-3 top-2.5 text-text-secondary pointer-events-none" size={18} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs font-semibold text-text-secondary uppercase">Security Profile</label>
|
||||
<div className="flex gap-2">
|
||||
<label className="flex items-center gap-2 px-3 py-2 rounded-lg border border-border-dark bg-background-dark cursor-pointer flex-1">
|
||||
<input
|
||||
checked
|
||||
className="text-primary focus:ring-primary bg-surface-dark border-border-dark"
|
||||
name="sec"
|
||||
type="radio"
|
||||
/>
|
||||
<span className="text-sm text-white">sys (Default)</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 px-3 py-2 rounded-lg border border-border-dark bg-background-dark cursor-pointer flex-1">
|
||||
<input
|
||||
className="text-primary focus:ring-primary bg-surface-dark border-border-dark"
|
||||
name="sec"
|
||||
type="radio"
|
||||
/>
|
||||
<span className="text-sm text-white">krb5</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs font-semibold text-text-secondary uppercase">Sync Mode</label>
|
||||
<div className="relative">
|
||||
<select className="w-full bg-background-dark border border-border-dark rounded-lg px-3 py-2 text-sm text-white focus:border-primary focus:ring-1 focus:ring-primary appearance-none">
|
||||
<option>Standard</option>
|
||||
<option>Always Sync</option>
|
||||
<option>Disabled (Async)</option>
|
||||
</select>
|
||||
<ChevronDown className="absolute right-3 top-2.5 text-text-secondary pointer-events-none" size={18} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SMB Settings Card */}
|
||||
{selectedShare.smb_enabled && (
|
||||
<div className="rounded-xl border border-border-dark bg-surface-dark overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-border-dark flex justify-between items-center bg-[#1c2a39]">
|
||||
<h3 className="text-sm font-bold text-white flex items-center gap-2">
|
||||
<ShareIcon className="text-primary" size={20} />
|
||||
SMB Configuration
|
||||
</h3>
|
||||
<span className="text-xs text-emerald-500 font-medium px-2 py-1 bg-emerald-500/10 rounded border border-emerald-500/20">
|
||||
Active
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-5 flex flex-col gap-5">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs font-semibold text-text-secondary uppercase">Share Name</label>
|
||||
<input
|
||||
className="w-full bg-background-dark border border-border-dark rounded-lg px-3 py-2 text-sm text-white focus:border-primary focus:ring-1 focus:ring-primary"
|
||||
type="text"
|
||||
value={selectedShare.smb_share_name || ''}
|
||||
onChange={(e) => {
|
||||
updateMutation.mutate({
|
||||
id: selectedShare.id,
|
||||
data: { smb_share_name: e.target.value },
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs font-semibold text-text-secondary uppercase">Path</label>
|
||||
<input
|
||||
className="w-full bg-background-dark border border-border-dark rounded-lg px-3 py-2 text-sm text-white focus:border-primary focus:ring-1 focus:ring-primary font-mono"
|
||||
type="text"
|
||||
value={selectedShare.smb_path || selectedShare.mount_point || ''}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs font-semibold text-text-secondary uppercase">Comment</label>
|
||||
<input
|
||||
className="w-full bg-background-dark border border-border-dark rounded-lg px-3 py-2 text-sm text-white focus:border-primary focus:ring-1 focus:ring-primary"
|
||||
type="text"
|
||||
value={selectedShare.smb_comment || ''}
|
||||
onChange={(e) => {
|
||||
updateMutation.mutate({
|
||||
id: selectedShare.id,
|
||||
data: { smb_comment: e.target.value },
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedShare.smb_guest_ok}
|
||||
onChange={(e) => {
|
||||
updateMutation.mutate({
|
||||
id: selectedShare.id,
|
||||
data: { smb_guest_ok: e.target.checked },
|
||||
})
|
||||
}}
|
||||
className="rounded border-border-dark bg-background-dark text-primary focus:ring-primary h-4 w-4"
|
||||
/>
|
||||
<span className="text-sm text-white">Allow Guest Access</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedShare.smb_read_only}
|
||||
onChange={(e) => {
|
||||
updateMutation.mutate({
|
||||
id: selectedShare.id,
|
||||
data: { smb_read_only: e.target.checked },
|
||||
})
|
||||
}}
|
||||
className="rounded border-border-dark bg-background-dark text-primary focus:ring-primary h-4 w-4"
|
||||
/>
|
||||
<span className="text-sm text-white">Read Only</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedShare.smb_browseable}
|
||||
onChange={(e) => {
|
||||
updateMutation.mutate({
|
||||
id: selectedShare.id,
|
||||
data: { smb_browseable: e.target.checked },
|
||||
})
|
||||
}}
|
||||
className="rounded border-border-dark bg-background-dark text-primary focus:ring-primary h-4 w-4"
|
||||
/>
|
||||
<span className="text-sm text-white">Browseable</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Advanced Attributes */}
|
||||
<div className="rounded-xl border border-border-dark bg-surface-dark overflow-hidden">
|
||||
<button
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="w-full px-5 py-4 flex justify-between items-center hover:bg-[#1c2a39] transition-colors text-left"
|
||||
>
|
||||
<h3 className="text-sm font-bold text-white flex items-center gap-2">
|
||||
<Settings className="text-text-secondary" size={20} />
|
||||
Advanced Attributes
|
||||
</h3>
|
||||
<ChevronDown
|
||||
className={`text-text-secondary transition-transform ${showAdvanced ? 'rotate-180' : ''}`}
|
||||
size={20}
|
||||
/>
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div className="p-5 border-t border-border-dark flex flex-wrap gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-border-dark bg-background-dark text-primary focus:ring-primary h-4 w-4"
|
||||
/>
|
||||
<span className="text-sm text-white">Read Only</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
checked
|
||||
type="checkbox"
|
||||
className="rounded border-border-dark bg-background-dark text-primary focus:ring-primary h-4 w-4"
|
||||
/>
|
||||
<span className="text-sm text-white">Enable Compression (LZ4)</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-border-dark bg-background-dark text-primary focus:ring-primary h-4 w-4"
|
||||
/>
|
||||
<span className="text-sm text-white">Enable Deduplication</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Connected Clients Preview */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex justify-between items-end">
|
||||
<h3 className="text-base font-bold text-white">Top Active Clients</h3>
|
||||
<a className="text-sm text-primary hover:text-blue-400 font-medium cursor-pointer" href="#">
|
||||
View all clients
|
||||
</a>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border-dark overflow-hidden bg-surface-dark">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="bg-background-dark text-text-secondary font-medium border-b border-border-dark">
|
||||
<tr>
|
||||
<th className="px-4 py-3">IP Address</th>
|
||||
<th className="px-4 py-3">User</th>
|
||||
<th className="px-4 py-3">Protocol</th>
|
||||
<th className="px-4 py-3 text-right">Throughput</th>
|
||||
<th className="px-4 py-3 text-right">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-white divide-y divide-border-dark">
|
||||
<tr>
|
||||
<td className="px-4 py-3 font-mono">192.168.10.105</td>
|
||||
<td className="px-4 py-3">esxi-host-01</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="bg-primary/20 text-primary px-1.5 py-0.5 rounded text-xs font-bold">NFS</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-text-secondary">420 MB/s</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button className="text-text-secondary hover:text-red-400" title="Disconnect">
|
||||
<Ban size={18} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3 font-mono">192.168.10.106</td>
|
||||
<td className="px-4 py-3">esxi-host-02</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="bg-primary/20 text-primary px-1.5 py-0.5 rounded text-xs font-bold">NFS</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-text-secondary">105 MB/s</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button className="text-text-secondary hover:text-red-400" title="Disconnect">
|
||||
<Ban size={18} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'permissions' && (
|
||||
<div className="rounded-xl border border-border-dark bg-surface-dark p-8 text-center">
|
||||
<Lock className="mx-auto mb-4 text-text-secondary" size={48} />
|
||||
<p className="text-text-secondary text-sm">Permissions (ACL) configuration coming soon</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'clients' && (
|
||||
<div className="rounded-xl border border-border-dark bg-surface-dark overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-border-dark flex justify-between items-center bg-[#1c2a39]">
|
||||
<h3 className="text-sm font-bold text-white flex items-center gap-2">
|
||||
<Network className="text-primary" size={20} />
|
||||
Connected Clients
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<div className="rounded-lg border border-border-dark overflow-hidden bg-surface-dark">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="bg-background-dark text-text-secondary font-medium border-b border-border-dark">
|
||||
<tr>
|
||||
<th className="px-4 py-3">IP Address</th>
|
||||
<th className="px-4 py-3">User</th>
|
||||
<th className="px-4 py-3">Protocol</th>
|
||||
<th className="px-4 py-3 text-right">Throughput</th>
|
||||
<th className="px-4 py-3 text-right">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-white divide-y divide-border-dark">
|
||||
<tr>
|
||||
<td className="px-4 py-3 font-mono">192.168.10.105</td>
|
||||
<td className="px-4 py-3">esxi-host-01</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="bg-primary/20 text-primary px-1.5 py-0.5 rounded text-xs font-bold">NFS</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-text-secondary">420 MB/s</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button className="text-text-secondary hover:text-red-400" title="Disconnect">
|
||||
<Ban size={18} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3 font-mono">192.168.10.106</td>
|
||||
<td className="px-4 py-3">esxi-host-02</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="bg-primary/20 text-primary px-1.5 py-0.5 rounded text-xs font-bold">NFS</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-text-secondary">105 MB/s</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button className="text-text-secondary hover:text-red-400" title="Disconnect">
|
||||
<Ban size={18} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-text-secondary">
|
||||
<div className="text-center">
|
||||
<FolderOpen className="mx-auto mb-4 text-text-secondary" size={48} />
|
||||
<p className="text-sm">Select a share to view details</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Share Modal */}
|
||||
{showCreateForm && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
|
||||
onClick={() => setShowCreateForm(false)}
|
||||
>
|
||||
<div
|
||||
className="bg-surface-dark rounded-xl border border-border-dark max-w-2xl w-full max-h-[90vh] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-6 border-b border-border-dark flex justify-between items-center">
|
||||
<h3 className="text-lg font-bold text-white">Create New Share</h3>
|
||||
<button
|
||||
onClick={() => setShowCreateForm(false)}
|
||||
className="text-text-secondary hover:text-white"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-semibold text-white">Dataset</label>
|
||||
<select
|
||||
className="w-full bg-background-dark border border-border-dark rounded-lg px-3 py-2 text-sm text-white focus:border-primary focus:ring-1 focus:ring-primary"
|
||||
value={formData.dataset_id}
|
||||
onChange={(e) => setFormData({ ...formData, dataset_id: e.target.value })}
|
||||
>
|
||||
<option value="">Select a dataset</option>
|
||||
{datasets.map((ds) => (
|
||||
<option key={ds.id} value={ds.id}>
|
||||
{ds.name} {ds.mount_point && ds.mount_point !== 'none' ? `(${ds.mount_point})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between p-4 rounded-xl border border-border-dark bg-surface-dark/40">
|
||||
<div className="flex items-center gap-3">
|
||||
<Network className="text-text-secondary" size={20} />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-bold text-white">Enable NFS</span>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.nfs_enabled}
|
||||
onChange={(e) => setFormData({ ...formData, nfs_enabled: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-border-dark bg-background-dark text-primary focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-4 rounded-xl border border-border-dark bg-surface-dark/40">
|
||||
<div className="flex items-center gap-3">
|
||||
<FolderSymlink className="text-text-secondary" size={20} />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-bold text-white">Enable SMB</span>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.smb_enabled}
|
||||
onChange={(e) => setFormData({ ...formData, smb_enabled: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-border-dark bg-background-dark text-primary focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{formData.nfs_enabled && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-semibold text-white">NFS Options</label>
|
||||
<input
|
||||
className="w-full bg-background-dark border border-border-dark rounded-lg px-3 py-2 text-sm text-white focus:border-primary focus:ring-1 focus:ring-primary font-mono"
|
||||
type="text"
|
||||
value={formData.nfs_options}
|
||||
onChange={(e) => setFormData({ ...formData, nfs_options: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formData.smb_enabled && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-semibold text-white">SMB Share Name</label>
|
||||
<input
|
||||
className="w-full bg-background-dark border border-border-dark rounded-lg px-3 py-2 text-sm text-white focus:border-primary focus:ring-1 focus:ring-primary"
|
||||
type="text"
|
||||
value={formData.smb_share_name}
|
||||
onChange={(e) => setFormData({ ...formData, smb_share_name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-semibold text-white">Comment</label>
|
||||
<input
|
||||
className="w-full bg-background-dark border border-border-dark rounded-lg px-3 py-2 text-sm text-white focus:border-primary focus:ring-1 focus:ring-primary"
|
||||
type="text"
|
||||
value={formData.smb_comment}
|
||||
onChange={(e) => setFormData({ ...formData, smb_comment: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<Button
|
||||
onClick={() => setShowCreateForm(false)}
|
||||
variant="outline"
|
||||
className="px-4 h-10"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleCreateShare}
|
||||
disabled={createMutation.isPending}
|
||||
className="px-4 h-10 bg-primary hover:bg-blue-600"
|
||||
>
|
||||
{createMutation.isPending ? 'Creating...' : 'Create Share'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
872
frontend/src/pages/SnapshotReplication.tsx
Normal file
872
frontend/src/pages/SnapshotReplication.tsx
Normal file
@@ -0,0 +1,872 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { formatBytes } from '@/lib/format'
|
||||
import { zfsApi } from '@/api/storage'
|
||||
import {
|
||||
Camera,
|
||||
History,
|
||||
Plus,
|
||||
Search,
|
||||
Filter,
|
||||
Calendar,
|
||||
RotateCcw,
|
||||
Copy,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
Clock,
|
||||
TrendingUp,
|
||||
CloudSync,
|
||||
AlertCircle,
|
||||
MoreVertical,
|
||||
X,
|
||||
Save
|
||||
} from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
// Mock data - will be replaced with API calls
|
||||
const MOCK_SNAPSHOTS = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'auto-2023-10-27-0000',
|
||||
dataset: 'tank/home',
|
||||
created: '2023-10-27T00:00:00Z',
|
||||
referenced: 1.2 * 1024 * 1024, // 1.2 MB
|
||||
isLatest: true,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'manual-backup-pre-upgrade',
|
||||
dataset: 'tank/services/db',
|
||||
created: '2023-10-26T16:30:00Z',
|
||||
referenced: 4.5 * 1024 * 1024 * 1024, // 4.5 GB
|
||||
isLatest: false,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'auto-2023-10-26-0000',
|
||||
dataset: 'tank/home',
|
||||
created: '2023-10-26T00:00:00Z',
|
||||
referenced: 850 * 1024, // 850 KB
|
||||
isLatest: false,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'auto-2023-10-25-0000',
|
||||
dataset: 'tank/home',
|
||||
created: '2023-10-25T00:00:00Z',
|
||||
referenced: 1.1 * 1024 * 1024, // 1.1 MB
|
||||
isLatest: false,
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
name: 'auto-2023-10-24-0000',
|
||||
dataset: 'tank/home',
|
||||
created: '2023-10-24T00:00:00Z',
|
||||
referenced: 920 * 1024, // 920 KB
|
||||
isLatest: false,
|
||||
},
|
||||
]
|
||||
|
||||
const MOCK_REPLICATIONS = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Daily Offsite (tank/backup)',
|
||||
target: '192.168.20.5 (ssh)',
|
||||
status: 'idle',
|
||||
lastRun: '15m ago',
|
||||
progress: 0,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Hourly Sync (tank/projects)',
|
||||
target: '192.168.20.5 (ssh)',
|
||||
status: 'running',
|
||||
lastRun: 'Running...',
|
||||
progress: 45,
|
||||
},
|
||||
]
|
||||
|
||||
export default function SnapshotReplication() {
|
||||
const [activeTab, setActiveTab] = useState<'snapshots' | 'replication' | 'restore'>('snapshots')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [showCreateReplication, setShowCreateReplication] = useState(false)
|
||||
const [showCreateSnapshot, setShowCreateSnapshot] = useState(false)
|
||||
const itemsPerPage = 10
|
||||
|
||||
// Form state for replication
|
||||
const [replicationForm, setReplicationForm] = useState({
|
||||
name: '',
|
||||
sourceDataset: '',
|
||||
targetHost: '',
|
||||
targetDataset: '',
|
||||
targetPort: '22',
|
||||
targetUser: 'root',
|
||||
schedule: 'daily',
|
||||
scheduleTime: '00:00',
|
||||
compression: 'lz4',
|
||||
encryption: false,
|
||||
recursive: true,
|
||||
autoSnapshot: true,
|
||||
})
|
||||
|
||||
// Fetch pools and datasets for form
|
||||
const { data: pools = [] } = useQuery({
|
||||
queryKey: ['replication-pools'],
|
||||
queryFn: zfsApi.listPools,
|
||||
})
|
||||
|
||||
const [datasets, setDatasets] = useState<Array<{ pool: string; datasets: any[] }>>([])
|
||||
|
||||
// Fetch datasets for selected pool
|
||||
const fetchDatasets = async (poolName: string) => {
|
||||
try {
|
||||
const poolDatasets = await zfsApi.listDatasets(poolName)
|
||||
setDatasets((prev) => {
|
||||
const filtered = prev.filter((d) => d.pool !== poolName)
|
||||
return [...filtered, { pool: poolName, datasets: poolDatasets }]
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch datasets:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter snapshots
|
||||
const filteredSnapshots = MOCK_SNAPSHOTS.filter((snapshot) => {
|
||||
const matchesSearch =
|
||||
snapshot.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
snapshot.dataset.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
return matchesSearch
|
||||
})
|
||||
|
||||
// Pagination
|
||||
const totalPages = Math.ceil(filteredSnapshots.length / itemsPerPage)
|
||||
const paginatedSnapshots = filteredSnapshots.slice(
|
||||
(currentPage - 1) * itemsPerPage,
|
||||
currentPage * itemsPerPage
|
||||
)
|
||||
|
||||
// Calculate totals
|
||||
const totalSnapshots = MOCK_SNAPSHOTS.length
|
||||
const totalReclaimable = MOCK_SNAPSHOTS.reduce((sum, s) => sum + s.referenced, 0)
|
||||
|
||||
// Format date
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-hidden flex flex-col bg-background-dark">
|
||||
{/* Header */}
|
||||
<header className="flex items-center justify-between px-8 py-5 border-b border-[#233648] bg-background-dark shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link to="/storage" className="text-text-secondary hover:text-white text-sm font-medium transition-colors">
|
||||
Storage
|
||||
</Link>
|
||||
<ChevronRight className="text-[#536b85]" size={16} />
|
||||
<Link to="/storage" className="text-text-secondary hover:text-white text-sm font-medium transition-colors">
|
||||
Pools
|
||||
</Link>
|
||||
<ChevronRight className="text-[#536b85]" size={16} />
|
||||
<span className="text-white text-sm font-bold bg-[#1e2936] px-2 py-1 rounded">Data Protection</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<button className="flex items-center justify-center w-8 h-8 rounded-full hover:bg-[#1e2936] text-text-secondary transition-colors relative">
|
||||
<AlertCircle size={20} />
|
||||
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-red-500 rounded-full border border-background-dark"></span>
|
||||
</button>
|
||||
<button className="flex items-center justify-center w-8 h-8 rounded-full hover:bg-[#1e2936] text-text-secondary transition-colors">
|
||||
<MoreVertical size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Scrollable Page Content */}
|
||||
<div className="flex-1 overflow-y-auto p-8 custom-scrollbar">
|
||||
<div className="mx-auto max-w-[1200px] flex flex-col gap-8">
|
||||
{/* Page Heading */}
|
||||
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-white text-3xl font-extrabold tracking-tight">Snapshots & Replication</h1>
|
||||
<p className="text-text-secondary text-base font-normal max-w-2xl">
|
||||
Manage local ZFS snapshots and configure remote replication tasks to ensure data redundancy and disaster recovery.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button className="px-4 py-2 bg-[#1e2936] hover:bg-[#2a3b4d] text-white text-sm font-bold rounded-lg border border-[#324d67] transition-all flex items-center gap-2">
|
||||
<History size={18} />
|
||||
View Logs
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (activeTab === 'replication') {
|
||||
setShowCreateReplication(true)
|
||||
} else {
|
||||
setShowCreateSnapshot(true)
|
||||
}
|
||||
}}
|
||||
className="px-4 py-2 bg-primary hover:bg-blue-600 text-white text-sm font-bold rounded-lg shadow-[0_4px_12px_rgba(19,127,236,0.3)] transition-all flex items-center gap-2"
|
||||
>
|
||||
<Plus size={18} />
|
||||
{activeTab === 'replication' ? 'Create Replication' : 'Create Snapshot'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* Total Snapshots */}
|
||||
<div className="flex flex-col gap-1 rounded-xl p-5 bg-[#18232e] border border-[#2a3b4d] relative overflow-hidden group">
|
||||
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||
<Camera className="text-6xl text-primary" />
|
||||
</div>
|
||||
<p className="text-text-secondary text-sm font-medium uppercase tracking-wider">Total Snapshots</p>
|
||||
<div className="flex items-end gap-2">
|
||||
<p className="text-white text-3xl font-bold tracking-tight">{totalSnapshots.toLocaleString()}</p>
|
||||
<span className="text-emerald-400 text-sm font-medium mb-1 flex items-center">
|
||||
<TrendingUp size={14} className="mr-0.5" />
|
||||
+12 today
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[#536b85] text-xs font-medium mt-1">{formatBytes(totalReclaimable, 1)} Reclaimable Space</p>
|
||||
</div>
|
||||
|
||||
{/* Last Replication */}
|
||||
<div className="flex flex-col gap-1 rounded-xl p-5 bg-[#18232e] border border-[#2a3b4d] relative overflow-hidden group">
|
||||
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||
<RefreshCw className="text-6xl text-emerald-500" />
|
||||
</div>
|
||||
<p className="text-text-secondary text-sm font-medium uppercase tracking-wider">Last Replication</p>
|
||||
<div className="flex items-end gap-2">
|
||||
<p className="text-white text-3xl font-bold tracking-tight">Success</p>
|
||||
</div>
|
||||
<p className="text-[#536b85] text-xs font-medium mt-1">15 mins ago • tank/backup</p>
|
||||
</div>
|
||||
|
||||
{/* Next Scheduled */}
|
||||
<div className="flex flex-col gap-1 rounded-xl p-5 bg-[#18232e] border border-[#2a3b4d] relative overflow-hidden group">
|
||||
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||
<Clock className="text-6xl text-purple-500" />
|
||||
</div>
|
||||
<p className="text-text-secondary text-sm font-medium uppercase tracking-wider">Next Scheduled</p>
|
||||
<div className="flex items-end gap-2">
|
||||
<p className="text-white text-3xl font-bold tracking-tight">10:00 PM</p>
|
||||
</div>
|
||||
<p className="text-[#536b85] text-xs font-medium mt-1">Daily Offsite Backup</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Section */}
|
||||
<div className="flex flex-col bg-[#18232e] border border-[#2a3b4d] rounded-xl overflow-hidden shadow-sm">
|
||||
{/* Tabs Header */}
|
||||
<div className="flex border-b border-[#2a3b4d] bg-[#151f29]">
|
||||
<button
|
||||
onClick={() => setActiveTab('snapshots')}
|
||||
className={`px-6 py-4 text-sm font-bold flex items-center gap-2 transition-colors ${
|
||||
activeTab === 'snapshots'
|
||||
? 'text-white border-b-2 border-primary bg-[#18232e]'
|
||||
: 'text-text-secondary hover:text-white border-b-2 border-transparent hover:bg-[#18232e]'
|
||||
}`}
|
||||
>
|
||||
<Camera size={20} />
|
||||
Snapshots
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('replication')}
|
||||
className={`px-6 py-4 text-sm font-bold flex items-center gap-2 transition-colors ${
|
||||
activeTab === 'replication'
|
||||
? 'text-white border-b-2 border-primary bg-[#18232e]'
|
||||
: 'text-text-secondary hover:text-white border-b-2 border-transparent hover:bg-[#18232e]'
|
||||
}`}
|
||||
>
|
||||
<CloudSync size={20} />
|
||||
Replication Tasks
|
||||
<span className="ml-1 bg-[#2a3b4d] text-white text-[10px] px-1.5 py-0.5 rounded-full">
|
||||
{MOCK_REPLICATIONS.length}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('restore')}
|
||||
className={`px-6 py-4 text-sm font-bold flex items-center gap-2 transition-colors ${
|
||||
activeTab === 'restore'
|
||||
? 'text-white border-b-2 border-primary bg-[#18232e]'
|
||||
: 'text-text-secondary hover:text-white border-b-2 border-transparent hover:bg-[#18232e]'
|
||||
}`}
|
||||
>
|
||||
<RotateCcw size={20} />
|
||||
Restore Points
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="p-4 flex flex-col sm:flex-row gap-4 justify-between items-center border-b border-[#2a3b4d] bg-[#18232e]">
|
||||
<div className="relative w-full sm:w-96 group">
|
||||
<Search className="absolute left-3 top-2.5 text-[#536b85] group-focus-within:text-primary transition-colors" size={20} />
|
||||
<input
|
||||
className="w-full bg-[#111a22] border border-[#2a3b4d] text-white text-sm rounded-lg pl-10 pr-4 py-2.5 focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all placeholder-[#536b85]"
|
||||
placeholder="Search snapshot name or dataset..."
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 w-full sm:w-auto">
|
||||
<div className="relative group">
|
||||
<button className="flex items-center gap-2 px-4 py-2.5 bg-[#111a22] border border-[#2a3b4d] rounded-lg text-sm font-medium text-text-secondary hover:text-white hover:border-[#536b85] transition-all">
|
||||
<Filter size={18} />
|
||||
Dataset: All
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative group">
|
||||
<button className="flex items-center gap-2 px-4 py-2.5 bg-[#111a22] border border-[#2a3b4d] rounded-lg text-sm font-medium text-text-secondary hover:text-white hover:border-[#536b85] transition-all">
|
||||
<Calendar size={18} />
|
||||
Date Range
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'snapshots' && (
|
||||
<>
|
||||
{/* Data Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead className="bg-[#151f29] text-text-secondary">
|
||||
<tr>
|
||||
<th className="p-4 border-b border-[#2a3b4d] w-[50px]">
|
||||
<input
|
||||
className="w-4 h-4 rounded border-[#324d67] bg-[#111a22] text-primary focus:ring-offset-[#111a22]"
|
||||
type="checkbox"
|
||||
/>
|
||||
</th>
|
||||
<th className="p-4 border-b border-[#2a3b4d] text-xs font-bold uppercase tracking-wider">
|
||||
Snapshot Name
|
||||
</th>
|
||||
<th className="p-4 border-b border-[#2a3b4d] text-xs font-bold uppercase tracking-wider">Dataset</th>
|
||||
<th className="p-4 border-b border-[#2a3b4d] text-xs font-bold uppercase tracking-wider">Created</th>
|
||||
<th className="p-4 border-b border-[#2a3b4d] text-xs font-bold uppercase tracking-wider">Referenced</th>
|
||||
<th className="p-4 border-b border-[#2a3b4d] text-xs font-bold uppercase tracking-wider text-right">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-sm">
|
||||
{paginatedSnapshots.map((snapshot) => (
|
||||
<tr key={snapshot.id} className="group hover:bg-[#1e2936] transition-colors border-b border-[#2a3b4d]">
|
||||
<td className="p-4">
|
||||
<input
|
||||
className="w-4 h-4 rounded border-[#324d67] bg-[#111a22] text-primary focus:ring-offset-[#111a22]"
|
||||
type="checkbox"
|
||||
/>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Camera className="text-[#536b85]" size={20} />
|
||||
<span className="text-white font-medium">{snapshot.name}</span>
|
||||
{snapshot.isLatest && (
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-primary/20 text-primary border border-primary/20">
|
||||
LATEST
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<span className="text-text-secondary">{snapshot.dataset}</span>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<span className="text-text-secondary">{formatDate(snapshot.created)}</span>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<span className="text-white font-mono">{formatBytes(snapshot.referenced)}</span>
|
||||
</td>
|
||||
<td className="p-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 hover:bg-[#2a3b4d] rounded text-text-secondary hover:text-white"
|
||||
title="Rollback"
|
||||
>
|
||||
<RotateCcw size={20} />
|
||||
</button>
|
||||
<button
|
||||
className="p-1.5 hover:bg-[#2a3b4d] rounded text-text-secondary hover:text-white"
|
||||
title="Clone"
|
||||
>
|
||||
<Copy size={20} />
|
||||
</button>
|
||||
<button
|
||||
className="p-1.5 hover:bg-red-500/20 rounded text-text-secondary hover:text-red-500"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between p-4 bg-[#18232e]">
|
||||
<p className="text-text-secondary text-sm">
|
||||
Showing <span className="text-white font-bold">1-{paginatedSnapshots.length}</span> of{' '}
|
||||
<span className="text-white font-bold">{filteredSnapshots.length}</span>
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="px-3 py-1 text-sm rounded border border-[#2a3b4d] text-text-secondary hover:bg-[#2a3b4d] hover:text-white disabled:opacity-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
{Array.from({ length: Math.min(3, totalPages) }, (_, i) => i + 1).map((page) => (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => setCurrentPage(page)}
|
||||
className={`px-3 py-1 text-sm rounded border border-[#2a3b4d] ${
|
||||
currentPage === page
|
||||
? 'text-white bg-[#2a3b4d]'
|
||||
: 'text-text-secondary hover:bg-[#2a3b4d] hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
))}
|
||||
{totalPages > 3 && <span className="text-text-secondary">...</span>}
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-3 py-1 text-sm rounded border border-[#2a3b4d] text-text-secondary hover:bg-[#2a3b4d] hover:text-white disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'replication' && (
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-white text-lg font-bold">Replication Tasks</h3>
|
||||
<button
|
||||
onClick={() => setShowCreateReplication(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-blue-600 text-white text-sm font-bold rounded-lg transition-colors"
|
||||
>
|
||||
<Plus size={18} />
|
||||
Create Replication
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{MOCK_REPLICATIONS.map((replication) => (
|
||||
<div
|
||||
key={replication.id}
|
||||
className="flex items-center gap-4 bg-[#111a22] p-4 rounded-lg border border-[#2a3b4d] hover:bg-[#1e2936] transition-colors"
|
||||
>
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
replication.status === 'running'
|
||||
? 'bg-primary animate-pulse'
|
||||
: 'bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]'
|
||||
}`}
|
||||
></div>
|
||||
<div className="flex-1">
|
||||
<p className="text-white text-sm font-medium">{replication.name}</p>
|
||||
<p className="text-[#536b85] text-xs">Target: {replication.target}</p>
|
||||
{replication.status === 'running' && (
|
||||
<div className="w-full bg-[#2a3b4d] rounded-full h-1.5 mt-2">
|
||||
<div
|
||||
className="bg-primary h-1.5 rounded-full transition-all"
|
||||
style={{ width: `${replication.progress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p
|
||||
className={`text-sm ${
|
||||
replication.status === 'running' ? 'text-primary font-bold' : 'text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{replication.status === 'running' ? `${replication.progress}%` : 'Idle'}
|
||||
</p>
|
||||
<p className="text-[#536b85] text-xs">{replication.lastRun}</p>
|
||||
</div>
|
||||
<button className="p-2 hover:bg-[#2a3b4d] rounded text-text-secondary hover:text-white transition-colors">
|
||||
<MoreVertical size={18} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'restore' && (
|
||||
<div className="p-8 text-center">
|
||||
<RotateCcw className="mx-auto mb-4 text-text-secondary" size={48} />
|
||||
<p className="text-text-secondary text-sm">Restore points coming soon</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom Info / Replication Quick Status */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Replication Status */}
|
||||
<div className="bg-[#18232e] border border-[#2a3b4d] rounded-xl p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-white font-bold text-lg">Replication Status</h3>
|
||||
<a className="text-primary text-sm font-bold hover:underline" href="#">
|
||||
Manage All
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
{MOCK_REPLICATIONS.map((replication) => (
|
||||
<div
|
||||
key={replication.id}
|
||||
className="flex items-center gap-4 bg-[#111a22] p-3 rounded-lg border border-[#2a3b4d]"
|
||||
>
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
replication.status === 'running'
|
||||
? 'bg-primary animate-pulse'
|
||||
: 'bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]'
|
||||
}`}
|
||||
></div>
|
||||
<div className="flex-1">
|
||||
<p className="text-white text-sm font-medium">{replication.name}</p>
|
||||
<p className="text-[#536b85] text-xs">Target: {replication.target}</p>
|
||||
{replication.status === 'running' && (
|
||||
<div className="w-full bg-[#2a3b4d] rounded-full h-1.5 mt-2">
|
||||
<div
|
||||
className="bg-primary h-1.5 rounded-full transition-all"
|
||||
style={{ width: `${replication.progress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p
|
||||
className={`text-sm ${
|
||||
replication.status === 'running' ? 'text-primary font-bold' : 'text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{replication.status === 'running' ? `${replication.progress}%` : 'Idle'}
|
||||
</p>
|
||||
<p className="text-[#536b85] text-xs">{replication.lastRun}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Snapshot Retention */}
|
||||
<div className="bg-[#18232e] border border-[#2a3b4d] rounded-xl p-6 flex flex-col justify-center items-center text-center">
|
||||
<div className="w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center mb-3">
|
||||
<AlertCircle className="text-primary text-2xl" />
|
||||
</div>
|
||||
<h3 className="text-white font-bold text-lg">Snapshot Retention</h3>
|
||||
<p className="text-text-secondary text-sm mt-1 mb-4">
|
||||
You have 14 snapshots marked for expiration in the next 24 hours.
|
||||
</p>
|
||||
<button className="text-white bg-[#2a3b4d] hover:bg-[#324d67] px-4 py-2 rounded-lg text-sm font-medium transition-colors">
|
||||
Review Expiration Policy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Replication Modal */}
|
||||
{showCreateReplication && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
|
||||
onClick={() => setShowCreateReplication(false)}
|
||||
>
|
||||
<div
|
||||
className="bg-[#18232e] rounded-xl border border-[#2a3b4d] max-w-2xl w-full max-h-[90vh] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-6 border-b border-[#2a3b4d] flex justify-between items-center">
|
||||
<h3 className="text-lg font-bold text-white">Create Replication Task</h3>
|
||||
<button
|
||||
onClick={() => setShowCreateReplication(false)}
|
||||
className="text-text-secondary hover:text-white"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 flex flex-col gap-4">
|
||||
{/* Task Name */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-semibold text-white">Task Name</label>
|
||||
<input
|
||||
className="w-full bg-[#111a22] border border-[#2a3b4d] rounded-lg px-3 py-2 text-sm text-white focus:border-primary focus:ring-1 focus:ring-primary outline-none"
|
||||
type="text"
|
||||
placeholder="Daily Offsite Backup"
|
||||
value={replicationForm.name}
|
||||
onChange={(e) => setReplicationForm({ ...replicationForm, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Source Dataset */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-semibold text-white">Source Dataset</label>
|
||||
<select
|
||||
className="w-full bg-[#111a22] border border-[#2a3b4d] rounded-lg px-3 py-2 text-sm text-white focus:border-primary focus:ring-1 focus:ring-primary outline-none appearance-none"
|
||||
value={replicationForm.sourceDataset}
|
||||
onChange={(e) => {
|
||||
setReplicationForm({ ...replicationForm, sourceDataset: e.target.value })
|
||||
const poolName = e.target.value.split('/')[0]
|
||||
if (poolName) {
|
||||
fetchDatasets(poolName)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="">Select a dataset</option>
|
||||
{pools.map((pool) => (
|
||||
<optgroup key={pool.id} label={pool.name}>
|
||||
{datasets
|
||||
.find((d) => d.pool === pool.name)
|
||||
?.datasets.filter((ds) => ds.type === 'filesystem')
|
||||
.map((ds) => (
|
||||
<option key={ds.id} value={ds.name}>
|
||||
{ds.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Target Configuration */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-semibold text-white">Target Host</label>
|
||||
<input
|
||||
className="w-full bg-[#111a22] border border-[#2a3b4d] rounded-lg px-3 py-2 text-sm text-white focus:border-primary focus:ring-1 focus:ring-primary outline-none font-mono"
|
||||
type="text"
|
||||
placeholder="192.168.20.5"
|
||||
value={replicationForm.targetHost}
|
||||
onChange={(e) => setReplicationForm({ ...replicationForm, targetHost: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-semibold text-white">SSH Port</label>
|
||||
<input
|
||||
className="w-full bg-[#111a22] border border-[#2a3b4d] rounded-lg px-3 py-2 text-sm text-white focus:border-primary focus:ring-1 focus:ring-primary outline-none font-mono"
|
||||
type="number"
|
||||
placeholder="22"
|
||||
value={replicationForm.targetPort}
|
||||
onChange={(e) => setReplicationForm({ ...replicationForm, targetPort: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-semibold text-white">Target User</label>
|
||||
<input
|
||||
className="w-full bg-[#111a22] border border-[#2a3b4d] rounded-lg px-3 py-2 text-sm text-white focus:border-primary focus:ring-1 focus:ring-primary outline-none"
|
||||
type="text"
|
||||
placeholder="root"
|
||||
value={replicationForm.targetUser}
|
||||
onChange={(e) => setReplicationForm({ ...replicationForm, targetUser: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-semibold text-white">Target Dataset</label>
|
||||
<input
|
||||
className="w-full bg-[#111a22] border border-[#2a3b4d] rounded-lg px-3 py-2 text-sm text-white focus:border-primary focus:ring-1 focus:ring-primary outline-none font-mono"
|
||||
type="text"
|
||||
placeholder="tank/backup"
|
||||
value={replicationForm.targetDataset}
|
||||
onChange={(e) => setReplicationForm({ ...replicationForm, targetDataset: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Schedule */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-semibold text-white">Schedule</label>
|
||||
<select
|
||||
className="w-full bg-[#111a22] border border-[#2a3b4d] rounded-lg px-3 py-2 text-sm text-white focus:border-primary focus:ring-1 focus:ring-primary outline-none appearance-none"
|
||||
value={replicationForm.schedule}
|
||||
onChange={(e) => setReplicationForm({ ...replicationForm, schedule: e.target.value })}
|
||||
>
|
||||
<option value="hourly">Hourly</option>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="custom">Custom (Cron)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-semibold text-white">Time</label>
|
||||
<input
|
||||
className="w-full bg-[#111a22] border border-[#2a3b4d] rounded-lg px-3 py-2 text-sm text-white focus:border-primary focus:ring-1 focus:ring-primary outline-none font-mono"
|
||||
type="time"
|
||||
value={replicationForm.scheduleTime}
|
||||
onChange={(e) => setReplicationForm({ ...replicationForm, scheduleTime: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-semibold text-white">Compression</label>
|
||||
<select
|
||||
className="w-full bg-[#111a22] border border-[#2a3b4d] rounded-lg px-3 py-2 text-sm text-white focus:border-primary focus:ring-1 focus:ring-primary outline-none appearance-none"
|
||||
value={replicationForm.compression}
|
||||
onChange={(e) => setReplicationForm({ ...replicationForm, compression: e.target.value })}
|
||||
>
|
||||
<option value="off">Off</option>
|
||||
<option value="lz4">LZ4 (Fast)</option>
|
||||
<option value="gzip">GZIP</option>
|
||||
<option value="zstd">ZSTD</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Checkboxes */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={replicationForm.recursive}
|
||||
onChange={(e) => setReplicationForm({ ...replicationForm, recursive: e.target.checked })}
|
||||
className="rounded border-[#324d67] bg-[#111a22] text-primary focus:ring-primary h-4 w-4"
|
||||
/>
|
||||
<span className="text-sm text-white">Recursive (include child datasets)</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={replicationForm.autoSnapshot}
|
||||
onChange={(e) => setReplicationForm({ ...replicationForm, autoSnapshot: e.target.checked })}
|
||||
className="rounded border-[#324d67] bg-[#111a22] text-primary focus:ring-primary h-4 w-4"
|
||||
/>
|
||||
<span className="text-sm text-white">Auto-create snapshot before replication</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={replicationForm.encryption}
|
||||
onChange={(e) => setReplicationForm({ ...replicationForm, encryption: e.target.checked })}
|
||||
className="rounded border-[#324d67] bg-[#111a22] text-primary focus:ring-primary h-4 w-4"
|
||||
/>
|
||||
<span className="text-sm text-white">Enable encryption (SSH tunnel)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-[#2a3b4d]">
|
||||
<Button
|
||||
onClick={() => setShowCreateReplication(false)}
|
||||
variant="outline"
|
||||
className="px-4 h-10 border-[#2a3b4d] text-white hover:bg-[#2a3b4d]"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
// TODO: Implement API call
|
||||
console.log('Creating replication:', replicationForm)
|
||||
alert('Replication task created successfully!')
|
||||
setShowCreateReplication(false)
|
||||
setReplicationForm({
|
||||
name: '',
|
||||
sourceDataset: '',
|
||||
targetHost: '',
|
||||
targetDataset: '',
|
||||
targetPort: '22',
|
||||
targetUser: 'root',
|
||||
schedule: 'daily',
|
||||
scheduleTime: '00:00',
|
||||
compression: 'lz4',
|
||||
encryption: false,
|
||||
recursive: true,
|
||||
autoSnapshot: true,
|
||||
})
|
||||
}}
|
||||
className="px-4 h-10 bg-primary hover:bg-blue-600"
|
||||
>
|
||||
<Save size={18} className="mr-2" />
|
||||
Create Replication
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Snapshot Modal */}
|
||||
{showCreateSnapshot && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
|
||||
onClick={() => setShowCreateSnapshot(false)}
|
||||
>
|
||||
<div
|
||||
className="bg-[#18232e] rounded-xl border border-[#2a3b4d] max-w-lg w-full"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-6 border-b border-[#2a3b4d] flex justify-between items-center">
|
||||
<h3 className="text-lg font-bold text-white">Create Snapshot</h3>
|
||||
<button
|
||||
onClick={() => setShowCreateSnapshot(false)}
|
||||
className="text-text-secondary hover:text-white"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-semibold text-white">Snapshot Name</label>
|
||||
<input
|
||||
className="w-full bg-[#111a22] border border-[#2a3b4d] rounded-lg px-3 py-2 text-sm text-white focus:border-primary focus:ring-1 focus:ring-primary outline-none"
|
||||
type="text"
|
||||
placeholder="manual-backup-2024"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-semibold text-white">Dataset</label>
|
||||
<select className="w-full bg-[#111a22] border border-[#2a3b4d] rounded-lg px-3 py-2 text-sm text-white focus:border-primary focus:ring-1 focus:ring-primary outline-none">
|
||||
<option value="">Select a dataset</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<Button
|
||||
onClick={() => setShowCreateSnapshot(false)}
|
||||
variant="outline"
|
||||
className="px-4 h-10 border-[#2a3b4d] text-white hover:bg-[#2a3b4d]"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
alert('Snapshot created successfully!')
|
||||
setShowCreateSnapshot(false)
|
||||
}}
|
||||
className="px-4 h-10 bg-primary hover:bg-blue-600"
|
||||
>
|
||||
Create Snapshot
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ export default function System() {
|
||||
const [ntpServers, setNtpServers] = useState<string[]>(['pool.ntp.org', 'time.google.com'])
|
||||
const [showAddNtpServer, setShowAddNtpServer] = useState(false)
|
||||
const [newNtpServer, setNewNtpServer] = useState('')
|
||||
const [showLicenseModal, setShowLicenseModal] = useState(false)
|
||||
const [licenseKey, setLicenseKey] = useState('')
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
@@ -120,6 +122,93 @@ export default function System() {
|
||||
|
||||
{/* Grid Layout */}
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-2">
|
||||
{/* Feature License Card */}
|
||||
<div className="flex flex-col rounded-xl border border-border-dark bg-card-dark shadow-sm">
|
||||
<div className="flex items-center justify-between border-b border-border-dark px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="material-symbols-outlined text-primary">verified</span>
|
||||
<h2 className="text-lg font-bold text-white">Feature License</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="h-2 w-2 rounded-full bg-green-500"></span>
|
||||
<span className="text-xs text-text-secondary">Licensed</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 flex flex-col gap-4">
|
||||
{/* License Status */}
|
||||
<div className="flex items-center justify-between p-4 rounded-lg bg-[#111a22] border border-border-dark">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-bold text-white">License Status</p>
|
||||
<p className="text-xs text-text-secondary">Enterprise Edition</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-green-500/10 border border-green-500/20">
|
||||
<span className="material-symbols-outlined text-green-500 text-[16px]">check_circle</span>
|
||||
<span className="text-xs font-bold text-green-500">Active</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* License Details */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-text-secondary">License Key</span>
|
||||
<span className="text-xs font-mono text-white">CAL-****-****-****-****-****</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-text-secondary">Expires</span>
|
||||
<span className="text-xs font-bold text-white">Dec 31, 2025</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-text-secondary">Days Remaining</span>
|
||||
<span className="text-xs font-bold text-emerald-400">365 days</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Enabled Features */}
|
||||
<div className="border-t border-border-dark pt-4">
|
||||
<h3 className="text-sm font-bold text-white mb-3">Enabled Features</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
{[
|
||||
{ name: 'Advanced Replication', enabled: true },
|
||||
{ name: 'Encryption at Rest', enabled: true },
|
||||
{ name: 'Deduplication', enabled: true },
|
||||
{ name: 'Cloud Backup Integration', enabled: true },
|
||||
{ name: 'Multi-Site Sync', enabled: true },
|
||||
{ name: 'Advanced Monitoring', enabled: true },
|
||||
].map((feature) => (
|
||||
<div key={feature.name} className="flex items-center justify-between p-2 rounded bg-[#111a22]">
|
||||
<span className="text-xs text-white">{feature.name}</span>
|
||||
{feature.enabled ? (
|
||||
<span className="material-symbols-outlined text-green-500 text-[16px]">check_circle</span>
|
||||
) : (
|
||||
<span className="material-symbols-outlined text-text-secondary text-[16px]">cancel</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="border-t border-border-dark pt-4 flex flex-col gap-2">
|
||||
<button
|
||||
onClick={() => setShowLicenseModal(true)}
|
||||
className="w-full flex items-center justify-center gap-2 rounded-lg bg-primary px-4 py-2.5 text-sm font-bold text-white hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">key</span>
|
||||
Update License Key
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
// TODO: Implement download license info
|
||||
alert('Downloading license information...')
|
||||
}}
|
||||
className="w-full flex items-center justify-center gap-2 rounded-lg bg-border-dark px-4 py-2.5 text-sm font-bold text-white hover:bg-[#2f455a] transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">download</span>
|
||||
Download License Info
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Network Card */}
|
||||
<div className="flex flex-col rounded-xl border border-border-dark bg-card-dark shadow-sm">
|
||||
<div className="flex items-center justify-between border-b border-border-dark px-6 py-4">
|
||||
@@ -536,6 +625,83 @@ export default function System() {
|
||||
onClose={() => setViewingInterface(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* License Key Modal */}
|
||||
{showLicenseModal && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
|
||||
onClick={() => setShowLicenseModal(false)}
|
||||
>
|
||||
<div
|
||||
className="bg-card-dark rounded-xl border border-border-dark max-w-lg w-full"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-6 border-b border-border-dark flex justify-between items-center">
|
||||
<h3 className="text-lg font-bold text-white">Update License Key</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowLicenseModal(false)
|
||||
setLicenseKey('')
|
||||
}}
|
||||
className="text-text-secondary hover:text-white"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-semibold text-white">License Key</label>
|
||||
<textarea
|
||||
className="w-full bg-[#111a22] border border-border-dark rounded-lg px-3 py-2 text-sm text-white focus:border-primary focus:ring-1 focus:ring-primary outline-none font-mono resize-none"
|
||||
rows={4}
|
||||
placeholder="Paste your license key here..."
|
||||
value={licenseKey}
|
||||
onChange={(e) => setLicenseKey(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-text-secondary">
|
||||
Enter your license key to activate or update features. The key will be validated automatically.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/20">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-yellow-500 text-[18px]">info</span>
|
||||
<span className="text-xs font-bold text-yellow-500">Important</span>
|
||||
</div>
|
||||
<p className="text-xs text-text-secondary">
|
||||
Updating the license key will restart the system services. Make sure you have a valid license key before proceeding.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-border-dark">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowLicenseModal(false)
|
||||
setLicenseKey('')
|
||||
}}
|
||||
className="px-4 py-2 rounded-lg border border-border-dark text-white hover:bg-border-dark transition-colors text-sm font-bold"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!licenseKey.trim()) {
|
||||
alert('Please enter a license key')
|
||||
return
|
||||
}
|
||||
// TODO: Implement API call to update license
|
||||
console.log('Updating license key:', licenseKey)
|
||||
alert('License key updated successfully!')
|
||||
setShowLicenseModal(false)
|
||||
setLicenseKey('')
|
||||
}}
|
||||
className="px-4 py-2 rounded-lg bg-primary hover:bg-blue-600 text-white transition-colors text-sm font-bold"
|
||||
>
|
||||
Update License
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
323
frontend/src/pages/TerminalConsole.tsx
Normal file
323
frontend/src/pages/TerminalConsole.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { Terminal, RefreshCw, Trash2, Command } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import apiClient from '@/api/client'
|
||||
|
||||
interface CommandHistory {
|
||||
command: string
|
||||
output: string
|
||||
timestamp: Date
|
||||
error?: boolean
|
||||
service?: string
|
||||
}
|
||||
|
||||
export default function TerminalConsole() {
|
||||
const [commandHistory, setCommandHistory] = useState<CommandHistory[]>([])
|
||||
const [currentCommand, setCurrentCommand] = useState('')
|
||||
const [isExecuting, setIsExecuting] = useState(false)
|
||||
const [selectedService, setSelectedService] = useState<string>('system')
|
||||
const terminalRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Auto-scroll to bottom when new output is added
|
||||
useEffect(() => {
|
||||
if (terminalRef.current) {
|
||||
terminalRef.current.scrollTop = terminalRef.current.scrollHeight
|
||||
}
|
||||
}, [commandHistory])
|
||||
|
||||
// Focus input on mount
|
||||
useEffect(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const executeCommand = useMutation({
|
||||
mutationFn: async (cmd: string) => {
|
||||
const response = await apiClient.post('/system/execute', {
|
||||
command: cmd,
|
||||
service: selectedService,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
onSuccess: (data, command) => {
|
||||
setCommandHistory((prev) => [
|
||||
...prev,
|
||||
{
|
||||
command,
|
||||
output: data.output || data.error || 'Command executed',
|
||||
timestamp: new Date(),
|
||||
error: !!data.error,
|
||||
service: selectedService,
|
||||
},
|
||||
])
|
||||
setCurrentCommand('')
|
||||
setIsExecuting(false)
|
||||
setTimeout(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}, 100)
|
||||
},
|
||||
onError: (error: any) => {
|
||||
setCommandHistory((prev) => [
|
||||
...prev,
|
||||
{
|
||||
command: currentCommand,
|
||||
output: error?.response?.data?.error || error?.response?.data?.output || error.message || 'Error executing command',
|
||||
timestamp: new Date(),
|
||||
error: true,
|
||||
service: selectedService,
|
||||
},
|
||||
])
|
||||
setCurrentCommand('')
|
||||
setIsExecuting(false)
|
||||
setTimeout(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}, 100)
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const cmd = currentCommand.trim()
|
||||
if (!cmd || isExecuting) return
|
||||
|
||||
setIsExecuting(true)
|
||||
executeCommand.mutate(cmd)
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
// Handle Ctrl+L to clear
|
||||
if (e.ctrlKey && e.key === 'l') {
|
||||
e.preventDefault()
|
||||
if (confirm('Clear terminal history?')) {
|
||||
setCommandHistory([])
|
||||
}
|
||||
}
|
||||
// Handle Up arrow for command history (future enhancement)
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
// TODO: Implement command history navigation
|
||||
}
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
if (confirm('Clear terminal history?')) {
|
||||
setCommandHistory([])
|
||||
}
|
||||
}
|
||||
|
||||
const getServiceCommands = (service: string) => {
|
||||
const commands: Record<string, string[]> = {
|
||||
system: [
|
||||
'ls -la',
|
||||
'df -h',
|
||||
'free -h',
|
||||
'systemctl status scst',
|
||||
'scstadmin -list_target',
|
||||
'scstadmin -list_device',
|
||||
'ip addr show',
|
||||
'journalctl -u calypso-api -n 50',
|
||||
'ps aux | grep calypso',
|
||||
'netstat -tulpn | grep 8080',
|
||||
],
|
||||
scst: [
|
||||
'scstadmin -list_target',
|
||||
'scstadmin -list_device',
|
||||
'scstadmin -list_handler',
|
||||
'scstadmin -list_driver',
|
||||
'scstadmin -list_group',
|
||||
'scstadmin -list',
|
||||
'cat /etc/scst.conf',
|
||||
'systemctl status scst',
|
||||
'systemctl status iscsi-scst',
|
||||
],
|
||||
storage: [
|
||||
'zfs list',
|
||||
'zpool status',
|
||||
'zpool list',
|
||||
'lsblk',
|
||||
'df -h',
|
||||
'zfs get all',
|
||||
'zpool get all',
|
||||
],
|
||||
backup: [
|
||||
'bconsole -c "list jobs"',
|
||||
'bconsole -c "list clients"',
|
||||
'bconsole -c "list pools"',
|
||||
'systemctl status bacula-director',
|
||||
'systemctl status bacula-sd',
|
||||
'systemctl status bacula-fd',
|
||||
],
|
||||
tape: [
|
||||
'lsscsi -g',
|
||||
'mtx -f /dev/sgX status',
|
||||
'sg_inq /dev/sgX',
|
||||
'systemctl status mhvtl',
|
||||
],
|
||||
}
|
||||
return commands[service] || []
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto p-8">
|
||||
<div className="max-w-[1400px] mx-auto flex flex-col gap-6 h-full">
|
||||
{/* Header */}
|
||||
<div className="flex flex-wrap justify-between items-end gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-white text-3xl font-extrabold leading-tight tracking-tight flex items-center gap-3">
|
||||
<Terminal className="text-primary" size={32} />
|
||||
Terminal Console
|
||||
</h1>
|
||||
<p className="text-text-secondary text-base font-normal">
|
||||
Execute shell commands and manage all appliance services
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClear}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
<span>Clear</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Service Selector */}
|
||||
<div className="flex items-center gap-4 p-4 bg-card-dark border border-border-dark rounded-lg">
|
||||
<span className="text-text-secondary text-sm font-medium">Service:</span>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{['system', 'scst', 'storage', 'backup', 'tape'].map((service) => (
|
||||
<button
|
||||
key={service}
|
||||
onClick={() => setSelectedService(service)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
||||
selectedService === service
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-[#0f161d] text-text-secondary hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
{service.charAt(0).toUpperCase() + service.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Terminal */}
|
||||
<div className="flex-1 flex flex-col bg-[#0a0f14] border border-border-dark rounded-lg overflow-hidden min-h-[600px]">
|
||||
{/* Terminal Output */}
|
||||
<div
|
||||
ref={terminalRef}
|
||||
className="flex-1 overflow-y-auto p-4 custom-scrollbar"
|
||||
style={{ minHeight: '400px' }}
|
||||
>
|
||||
{commandHistory.length === 0 ? (
|
||||
<div className="text-text-secondary">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<Terminal className="text-primary" size={20} />
|
||||
<span className="text-white font-semibold">Terminal Console</span>
|
||||
<span className="text-xs px-2 py-0.5 bg-primary/20 text-primary rounded">
|
||||
{selectedService}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mb-2">Type commands below to execute shell commands</div>
|
||||
<div className="text-xs opacity-70 mt-4">
|
||||
<div className="font-semibold mb-2">Common commands for {selectedService}:</div>
|
||||
<div className="ml-4 space-y-1">
|
||||
{getServiceCommands(selectedService).map((cmd, idx) => (
|
||||
<div key={idx}>
|
||||
• <span className="text-primary font-mono">{cmd}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
commandHistory.map((item, idx) => (
|
||||
<div key={idx} className="mb-6">
|
||||
<div className="text-primary mb-2 font-mono text-sm flex items-center gap-2">
|
||||
<span className="text-text-secondary">$</span>
|
||||
<span className="text-white">{item.command}</span>
|
||||
{item.service && (
|
||||
<span className="text-xs px-1.5 py-0.5 bg-primary/20 text-primary rounded">
|
||||
{item.service}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-text-secondary text-xs ml-auto">
|
||||
{new Date(item.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={`font-mono text-xs leading-relaxed whitespace-pre overflow-x-auto ${
|
||||
item.error ? 'text-red-400' : 'text-green-400'
|
||||
}`}
|
||||
style={{
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
|
||||
lineHeight: '1.6',
|
||||
tabSize: 2,
|
||||
}}
|
||||
>
|
||||
{item.output}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{isExecuting && (
|
||||
<div className="text-text-secondary flex items-center gap-2">
|
||||
<RefreshCw size={16} className="animate-spin" />
|
||||
<span>Executing command...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Terminal Input */}
|
||||
<div className="flex-none border-t border-border-dark bg-[#161f29]">
|
||||
<form onSubmit={handleSubmit} className="flex items-center">
|
||||
<div className="px-4 flex items-center gap-2">
|
||||
<span className="text-primary font-mono text-sm">$</span>
|
||||
<span className="text-xs text-text-secondary px-1.5 py-0.5 bg-primary/20 text-primary rounded">
|
||||
{selectedService}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={currentCommand}
|
||||
onChange={(e) => setCurrentCommand(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={isExecuting}
|
||||
placeholder={`Enter ${selectedService} command...`}
|
||||
className="flex-1 bg-transparent text-white font-mono text-sm py-3 focus:outline-none disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!currentCommand.trim() || isExecuting}
|
||||
className="px-4 py-3 text-primary hover:text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Command size={16} className={isExecuting ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex-none px-6 py-3 border-t border-border-dark bg-[#141d26] flex items-center justify-between text-xs text-text-secondary">
|
||||
<div className="flex items-center gap-4">
|
||||
<span>Terminal Console - Command Execution</span>
|
||||
<span>• {commandHistory.length} commands executed</span>
|
||||
</div>
|
||||
<div>
|
||||
Press <kbd className="px-1.5 py-0.5 bg-[#0a0f14] border border-border-dark rounded text-xs">Ctrl+L</kbd> to clear
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user