add feature license management

This commit is contained in:
Warp Agent
2026-01-04 12:54:25 +07:00
parent 7543b3a850
commit 2bb64620d4
29 changed files with 5447 additions and 22 deletions

View File

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

View File

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

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

View File

@@ -166,6 +166,7 @@ export const zfsApi = {
}
export interface ZFSDataset {
id: string
name: string
pool: string
type: string // filesystem, volume, snapshot

View File

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

View File

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

View 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: &gt;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>
)
}

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

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

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

View File

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

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