fix mostly bugs on system management, and user roles and group assignment
This commit is contained in:
@@ -31,6 +31,28 @@ export interface NTPSettings {
|
||||
ntp_servers: string[]
|
||||
}
|
||||
|
||||
export interface ServiceStatus {
|
||||
name: string
|
||||
active_state: string // "active", "inactive", "activating", "deactivating", "failed"
|
||||
sub_state: string
|
||||
load_state: string
|
||||
description: string
|
||||
since?: string
|
||||
}
|
||||
|
||||
export interface SystemLogEntry {
|
||||
time: string
|
||||
level: string
|
||||
source: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface NetworkDataPoint {
|
||||
time: string
|
||||
inbound: number // Mbps
|
||||
outbound: number // Mbps
|
||||
}
|
||||
|
||||
export const systemAPI = {
|
||||
listNetworkInterfaces: async (): Promise<NetworkInterface[]> => {
|
||||
const response = await apiClient.get<{ interfaces: NetworkInterface[] | null }>('/system/interfaces')
|
||||
@@ -47,5 +69,20 @@ export const systemAPI = {
|
||||
saveNTPSettings: async (data: SaveNTPSettingsRequest): Promise<void> => {
|
||||
await apiClient.post('/system/ntp', data)
|
||||
},
|
||||
listServices: async (): Promise<ServiceStatus[]> => {
|
||||
const response = await apiClient.get<{ services: ServiceStatus[] }>('/system/services')
|
||||
return response.data.services || []
|
||||
},
|
||||
restartService: async (name: string): Promise<void> => {
|
||||
await apiClient.post(`/system/services/${name}/restart`)
|
||||
},
|
||||
getSystemLogs: async (limit: number = 30): Promise<SystemLogEntry[]> => {
|
||||
const response = await apiClient.get<{ logs: SystemLogEntry[] }>(`/system/logs?limit=${limit}`)
|
||||
return response.data.logs || []
|
||||
},
|
||||
getNetworkThroughput: async (duration: string = '5m'): Promise<NetworkDataPoint[]> => {
|
||||
const response = await apiClient.get<{ data: NetworkDataPoint[] }>(`/system/network/throughput?duration=${duration}`)
|
||||
return response.data.data || []
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useState, useMemo, useEffect } from 'react'
|
||||
import apiClient from '@/api/client'
|
||||
import { monitoringApi } from '@/api/monitoring'
|
||||
import { storageApi } from '@/api/storage'
|
||||
import { systemAPI } from '@/api/system'
|
||||
import { formatBytes } from '@/lib/format'
|
||||
import {
|
||||
Cpu,
|
||||
@@ -46,17 +47,18 @@ const MOCK_ACTIVE_JOBS = [
|
||||
},
|
||||
]
|
||||
|
||||
const MOCK_SYSTEM_LOGS = [
|
||||
{ time: '10:45:22', level: 'INFO', source: 'systemd', message: 'Started User Manager for UID 1000.' },
|
||||
{ time: '10:45:15', level: 'WARN', source: 'smartd', message: 'Device: /dev/ada5, SMART Usage Attribute: 194 Temperature_Celsius changed from 38 to 41' },
|
||||
{ time: '10:44:58', level: 'INFO', source: 'kernel', message: 'ix0: link state changed to UP' },
|
||||
{ time: '10:42:10', level: 'INFO', source: 'zfs', message: 'zfs_arc_reclaim_thread: reclaiming 157286400 bytes ...' },
|
||||
]
|
||||
|
||||
export default function Dashboard() {
|
||||
const [activeTab, setActiveTab] = useState<'jobs' | 'logs' | 'alerts'>('jobs')
|
||||
const [networkDataPoints, setNetworkDataPoints] = useState<Array<{ time: string; inbound: number; outbound: number }>>([])
|
||||
const refreshInterval = 5
|
||||
|
||||
// Fetch system logs with auto-refresh every 10 minutes
|
||||
const { data: systemLogs = [], isLoading: logsLoading, refetch: refetchLogs } = useQuery({
|
||||
queryKey: ['system-logs'],
|
||||
queryFn: () => systemAPI.getSystemLogs(30),
|
||||
refetchInterval: 10 * 60 * 1000, // 10 minutes
|
||||
})
|
||||
|
||||
const { data: health } = useQuery({
|
||||
queryKey: ['health'],
|
||||
@@ -143,51 +145,25 @@ export default function Dashboard() {
|
||||
return { totalStorage: total, usedStorage: used, storagePercent: percent }
|
||||
}, [repositories])
|
||||
|
||||
// Initialize network data
|
||||
// Fetch network throughput data from RRD
|
||||
const { data: networkThroughput = [] } = useQuery({
|
||||
queryKey: ['network-throughput'],
|
||||
queryFn: () => systemAPI.getNetworkThroughput('5m'),
|
||||
refetchInterval: 5 * 1000, // Refresh every 5 seconds
|
||||
})
|
||||
|
||||
// Update network data points when new data arrives
|
||||
useEffect(() => {
|
||||
// Generate initial 30 data points
|
||||
const initialData = []
|
||||
const now = Date.now()
|
||||
for (let i = 29; i >= 0; i--) {
|
||||
const time = new Date(now - i * 5000)
|
||||
const minutes = time.getMinutes().toString().padStart(2, '0')
|
||||
const seconds = time.getSeconds().toString().padStart(2, '0')
|
||||
|
||||
const baseInbound = 800 + Math.random() * 400
|
||||
const baseOutbound = 400 + Math.random() * 200
|
||||
|
||||
initialData.push({
|
||||
time: `${minutes}:${seconds}`,
|
||||
inbound: Math.round(baseInbound),
|
||||
outbound: Math.round(baseOutbound),
|
||||
})
|
||||
if (networkThroughput.length > 0) {
|
||||
// Take last 30 points
|
||||
const points = networkThroughput.slice(-30).map((point) => ({
|
||||
time: point.time,
|
||||
inbound: Math.round(point.inbound),
|
||||
outbound: Math.round(point.outbound),
|
||||
}))
|
||||
setNetworkDataPoints(points)
|
||||
}
|
||||
setNetworkDataPoints(initialData)
|
||||
|
||||
// Update data every 5 seconds
|
||||
const interval = setInterval(() => {
|
||||
setNetworkDataPoints((prev) => {
|
||||
const now = new Date()
|
||||
const minutes = now.getMinutes().toString().padStart(2, '0')
|
||||
const seconds = now.getSeconds().toString().padStart(2, '0')
|
||||
|
||||
const baseInbound = 800 + Math.random() * 400
|
||||
const baseOutbound = 400 + Math.random() * 200
|
||||
|
||||
const newPoint = {
|
||||
time: `${minutes}:${seconds}`,
|
||||
inbound: Math.round(baseInbound),
|
||||
outbound: Math.round(baseOutbound),
|
||||
}
|
||||
|
||||
// Keep only last 30 points
|
||||
const updated = [...prev.slice(1), newPoint]
|
||||
return updated
|
||||
})
|
||||
}, 5000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
}, [networkThroughput])
|
||||
|
||||
// Calculate current and peak throughput
|
||||
const currentThroughput = useMemo(() => {
|
||||
@@ -564,39 +540,59 @@ export default function Dashboard() {
|
||||
<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 className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => refetchLogs()}
|
||||
disabled={logsLoading}
|
||||
className="text-xs text-primary hover:text-white transition-colors flex items-center gap-1 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw size={14} className={logsLoading ? 'animate-spin' : ''} />
|
||||
Refresh
|
||||
</button>
|
||||
<button className="text-xs text-primary hover:text-white transition-colors">
|
||||
View All Logs
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
{MOCK_SYSTEM_LOGS.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">
|
||||
{log.time}
|
||||
</td>
|
||||
<td className="px-6 py-2 w-24">
|
||||
<span
|
||||
className={
|
||||
log.level === 'INFO'
|
||||
? 'text-emerald-500'
|
||||
: log.level === 'WARN'
|
||||
? 'text-yellow-500'
|
||||
: 'text-red-500'
|
||||
}
|
||||
>
|
||||
{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>
|
||||
{logsLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<span className="text-text-secondary">Loading logs...</span>
|
||||
</div>
|
||||
) : systemLogs.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<span className="text-text-secondary">No logs available</span>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-left border-collapse">
|
||||
<tbody className="text-sm font-mono divide-y divide-border-dark/50">
|
||||
{systemLogs.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">
|
||||
{log.time}
|
||||
</td>
|
||||
<td className="px-6 py-2 w-24">
|
||||
<span
|
||||
className={
|
||||
log.level === 'INFO' || log.level === 'NOTICE' || log.level === 'DEBUG'
|
||||
? 'text-emerald-500'
|
||||
: log.level === 'WARN'
|
||||
? 'text-yellow-500'
|
||||
: 'text-red-500'
|
||||
}
|
||||
>
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -696,10 +696,15 @@ function EditUserForm({ user, onClose, onSuccess }: EditUserFormProps) {
|
||||
iamApi.updateUser(user.id, data),
|
||||
onSuccess: async () => {
|
||||
onSuccess()
|
||||
// Invalidate all related queries to refresh counts
|
||||
queryClient.invalidateQueries({ queryKey: ['iam-users'] })
|
||||
await queryClient.refetchQueries({ queryKey: ['iam-users'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['iam-user', user.id] })
|
||||
queryClient.invalidateQueries({ queryKey: ['iam-roles'] }) // Refresh role user counts
|
||||
queryClient.invalidateQueries({ queryKey: ['iam-groups'] }) // Refresh group user counts
|
||||
await queryClient.refetchQueries({ queryKey: ['iam-users'] })
|
||||
await queryClient.refetchQueries({ queryKey: ['iam-user', user.id] })
|
||||
await queryClient.refetchQueries({ queryKey: ['iam-roles'] })
|
||||
await queryClient.refetchQueries({ queryKey: ['iam-groups'] })
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error('Failed to update user:', error)
|
||||
@@ -725,9 +730,11 @@ function EditUserForm({ user, onClose, onSuccess }: EditUserFormProps) {
|
||||
},
|
||||
onSuccess: async (_, roleName: string) => {
|
||||
// Don't overwrite state with server data - keep optimistic update
|
||||
// Just invalidate queries for other components
|
||||
// Invalidate queries to refresh counts
|
||||
queryClient.invalidateQueries({ queryKey: ['iam-users'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['iam-user', user.id] })
|
||||
queryClient.invalidateQueries({ queryKey: ['iam-roles'] }) // Refresh role user count
|
||||
await queryClient.refetchQueries({ queryKey: ['iam-roles'] })
|
||||
// Use functional update to get current state
|
||||
setUserRoles(current => {
|
||||
console.log('assignRoleMutation onSuccess - roleName:', roleName, 'current userRoles:', current)
|
||||
@@ -753,9 +760,11 @@ function EditUserForm({ user, onClose, onSuccess }: EditUserFormProps) {
|
||||
},
|
||||
onSuccess: async (_, roleName: string) => {
|
||||
// Don't overwrite state with server data - keep optimistic update
|
||||
// Just invalidate queries for other components
|
||||
// Invalidate queries to refresh counts
|
||||
queryClient.invalidateQueries({ queryKey: ['iam-users'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['iam-user', user.id] })
|
||||
queryClient.invalidateQueries({ queryKey: ['iam-roles'] }) // Refresh role user count
|
||||
await queryClient.refetchQueries({ queryKey: ['iam-roles'] })
|
||||
console.log('Role removed successfully:', roleName, 'Current userRoles:', userRoles)
|
||||
},
|
||||
onError: (error: any, _roleName: string, context: any) => {
|
||||
@@ -785,9 +794,11 @@ function EditUserForm({ user, onClose, onSuccess }: EditUserFormProps) {
|
||||
},
|
||||
onSuccess: async (_, groupName: string) => {
|
||||
// Don't overwrite state with server data - keep optimistic update
|
||||
// Just invalidate queries for other components
|
||||
// Invalidate queries to refresh counts
|
||||
queryClient.invalidateQueries({ queryKey: ['iam-users'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['iam-user', user.id] })
|
||||
queryClient.invalidateQueries({ queryKey: ['iam-groups'] }) // Refresh group user count
|
||||
await queryClient.refetchQueries({ queryKey: ['iam-groups'] })
|
||||
// Use functional update to get current state
|
||||
setUserGroups(current => {
|
||||
console.log('assignGroupMutation onSuccess - groupName:', groupName, 'current userGroups:', current)
|
||||
@@ -813,9 +824,11 @@ function EditUserForm({ user, onClose, onSuccess }: EditUserFormProps) {
|
||||
},
|
||||
onSuccess: async (_, groupName: string) => {
|
||||
// Don't overwrite state with server data - keep optimistic update
|
||||
// Just invalidate queries for other components
|
||||
// Invalidate queries to refresh counts
|
||||
queryClient.invalidateQueries({ queryKey: ['iam-users'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['iam-user', user.id] })
|
||||
queryClient.invalidateQueries({ queryKey: ['iam-groups'] }) // Refresh group user count
|
||||
await queryClient.refetchQueries({ queryKey: ['iam-groups'] })
|
||||
console.log('Group removed successfully:', groupName, 'Current userGroups:', userGroups)
|
||||
},
|
||||
onError: (error: any, _groupName: string, context: any) => {
|
||||
|
||||
@@ -7,8 +7,11 @@ export default function System() {
|
||||
const [snmpEnabled, setSnmpEnabled] = useState(false)
|
||||
const [openMenu, setOpenMenu] = useState<string | null>(null)
|
||||
const [editingInterface, setEditingInterface] = useState<NetworkInterface | null>(null)
|
||||
const [viewingInterface, setViewingInterface] = useState<NetworkInterface | null>(null)
|
||||
const [timezone, setTimezone] = useState('Etc/UTC')
|
||||
const [ntpServers, setNtpServers] = useState<string[]>(['pool.ntp.org', 'time.google.com'])
|
||||
const [showAddNtpServer, setShowAddNtpServer] = useState(false)
|
||||
const [newNtpServer, setNewNtpServer] = useState('')
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
@@ -34,6 +37,14 @@ export default function System() {
|
||||
refetchInterval: 5000, // Refresh every 5 seconds
|
||||
})
|
||||
|
||||
// Fetch services
|
||||
const { data: services = [], isLoading: servicesLoading } = useQuery({
|
||||
queryKey: ['system', 'services'],
|
||||
queryFn: () => systemAPI.listServices(),
|
||||
refetchInterval: 5000, // Refresh every 5 seconds
|
||||
})
|
||||
|
||||
|
||||
// Fetch NTP settings on mount
|
||||
const { data: ntpSettings } = useQuery({
|
||||
queryKey: ['system', 'ntp'],
|
||||
@@ -200,7 +211,7 @@ export default function System() {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
// TODO: Implement view details
|
||||
setViewingInterface(iface)
|
||||
setOpenMenu(null)
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-white hover:bg-border-dark transition-colors"
|
||||
@@ -245,145 +256,65 @@ export default function System() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 flex flex-col gap-1">
|
||||
{/* Service Row */}
|
||||
<div className="flex items-center justify-between rounded-lg bg-[#111a22] p-3 border border-transparent hover:border-border-dark transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded bg-border-dark/50 text-white">
|
||||
<span className="material-symbols-outlined text-[20px]">terminal</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-white">SSH Service</p>
|
||||
<p className="text-xs text-text-secondary">Remote command line access</p>
|
||||
</div>
|
||||
{servicesLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<span className="text-text-secondary">Loading services...</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-green-500/20 text-green-500 border border-green-500/20">RUNNING</span>
|
||||
<div className="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in">
|
||||
<input
|
||||
defaultChecked
|
||||
className="toggle-checkbox absolute block w-5 h-5 rounded-full bg-white border-4 appearance-none cursor-pointer checked:right-0 checked:border-primary transition-all duration-300"
|
||||
id="ssh-toggle"
|
||||
name="toggle"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label
|
||||
className="toggle-label block overflow-hidden h-5 rounded-full bg-border-dark cursor-pointer transition-colors duration-300"
|
||||
htmlFor="ssh-toggle"
|
||||
></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Service Row */}
|
||||
<div className="flex items-center justify-between rounded-lg bg-[#111a22] p-3 border border-transparent hover:border-border-dark transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded bg-border-dark/50 text-white">
|
||||
<span className="material-symbols-outlined text-[20px]">folder_shared</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-white">SMB / CIFS</p>
|
||||
<p className="text-xs text-text-secondary">Windows file sharing</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-green-500/20 text-green-500 border border-green-500/20">RUNNING</span>
|
||||
<div className="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in">
|
||||
<input
|
||||
defaultChecked
|
||||
className="toggle-checkbox absolute block w-5 h-5 rounded-full bg-white border-4 appearance-none cursor-pointer checked:right-0 checked:border-primary transition-all duration-300"
|
||||
id="smb-toggle"
|
||||
name="toggle"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label
|
||||
className="toggle-label block overflow-hidden h-5 rounded-full bg-border-dark cursor-pointer transition-colors duration-300"
|
||||
htmlFor="smb-toggle"
|
||||
></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Service Row */}
|
||||
<div className="flex items-center justify-between rounded-lg bg-[#111a22] p-3 border border-transparent hover:border-border-dark transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded bg-border-dark/50 text-white">
|
||||
<span className="material-symbols-outlined text-[20px]">storage</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-white">iSCSI Target</p>
|
||||
<p className="text-xs text-text-secondary">Block storage sharing</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-yellow-500/20 text-yellow-500 border border-yellow-500/20">STOPPED</span>
|
||||
<div className="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in">
|
||||
<input
|
||||
className="toggle-checkbox absolute block w-5 h-5 rounded-full bg-white border-4 appearance-none cursor-pointer checked:right-0 checked:border-primary transition-all duration-300"
|
||||
id="iscsi-toggle"
|
||||
name="toggle"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label
|
||||
className="toggle-label block overflow-hidden h-5 rounded-full bg-border-dark cursor-pointer transition-colors duration-300"
|
||||
htmlFor="iscsi-toggle"
|
||||
></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Service Row */}
|
||||
<div className="flex items-center justify-between rounded-lg bg-[#111a22] p-3 border border-transparent hover:border-border-dark transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded bg-border-dark/50 text-white">
|
||||
<span className="material-symbols-outlined text-[20px]">share</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-white">NFS Service</p>
|
||||
<p className="text-xs text-text-secondary">Unix file sharing</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-green-500/20 text-green-500 border border-green-500/20">RUNNING</span>
|
||||
<div className="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in">
|
||||
<input
|
||||
defaultChecked
|
||||
className="toggle-checkbox absolute block w-5 h-5 rounded-full bg-white border-4 appearance-none cursor-pointer checked:right-0 checked:border-primary transition-all duration-300"
|
||||
id="nfs-toggle"
|
||||
name="toggle"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label
|
||||
className="toggle-label block overflow-hidden h-5 rounded-full bg-border-dark cursor-pointer transition-colors duration-300"
|
||||
htmlFor="nfs-toggle"
|
||||
></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Service Row - VTL (MHVTL) */}
|
||||
<div className="flex items-center justify-between rounded-lg bg-[#111a22] p-3 border border-transparent hover:border-border-dark transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded bg-border-dark/50 text-white">
|
||||
<span className="material-symbols-outlined text-[20px]">album</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-white">VTL Service</p>
|
||||
<p className="text-xs text-text-secondary">Virtual tape library emulation</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-green-500/20 text-green-500 border border-green-500/20">RUNNING</span>
|
||||
<div className="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in">
|
||||
<input
|
||||
defaultChecked
|
||||
className="toggle-checkbox absolute block w-5 h-5 rounded-full bg-white border-4 appearance-none cursor-pointer checked:right-0 checked:border-primary transition-all duration-300"
|
||||
id="mhvtl-toggle"
|
||||
name="toggle"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label
|
||||
className="toggle-label block overflow-hidden h-5 rounded-full bg-border-dark cursor-pointer transition-colors duration-300"
|
||||
htmlFor="mhvtl-toggle"
|
||||
></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Service configs to display - map backend service names to display configs
|
||||
[
|
||||
{ key: 'ssh', serviceNames: ['ssh', 'sshd'], displayName: 'SSH Service', description: 'Remote command line access', icon: 'terminal' },
|
||||
{ key: 'smb', serviceNames: ['smbd', 'samba', 'smb'], displayName: 'SMB / CIFS', description: 'Windows file sharing', icon: 'folder_shared' },
|
||||
{ key: 'iscsi', serviceNames: ['iscsi-scst', 'iscsi', 'scst'], displayName: 'iSCSI Target', description: 'Block storage sharing', icon: 'storage' },
|
||||
{ key: 'nfs', serviceNames: ['nfs-server', 'nfs', 'nfsd'], displayName: 'NFS Service', description: 'Unix file sharing', icon: 'share' },
|
||||
{ key: 'vtl', serviceNames: ['mhvtl', 'vtl'], displayName: 'VTL Service', description: 'Virtual tape library emulation', icon: 'album' },
|
||||
].map((config) => {
|
||||
const service = services.find(s => {
|
||||
const serviceNameLower = s.name.toLowerCase()
|
||||
return config.serviceNames.some(name => serviceNameLower.includes(name.toLowerCase()) || name.toLowerCase().includes(serviceNameLower))
|
||||
})
|
||||
const isActive = service?.active_state === 'active'
|
||||
const status = isActive ? 'RUNNING' : 'STOPPED'
|
||||
const statusColor = isActive ? 'bg-green-500/20 text-green-500 border-green-500/20' : 'bg-yellow-500/20 text-yellow-500 border-yellow-500/20'
|
||||
|
||||
return (
|
||||
<div key={config.key} className="flex items-center justify-between rounded-lg bg-[#111a22] p-3 border border-transparent hover:border-border-dark transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded bg-border-dark/50 text-white">
|
||||
<span className="material-symbols-outlined text-[20px]">{config.icon}</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-white">{config.displayName}</p>
|
||||
<p className="text-xs text-text-secondary">{config.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className={`px-2 py-0.5 rounded text-[10px] font-bold border ${statusColor}`}>{status}</span>
|
||||
<label className="relative inline-block w-10 h-5 mr-2 align-middle select-none cursor-pointer">
|
||||
<input
|
||||
checked={isActive}
|
||||
onChange={() => {
|
||||
if (service) {
|
||||
systemAPI.restartService(service.name).then(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['system', 'services'] })
|
||||
}).catch((err) => {
|
||||
alert(`Failed to ${isActive ? 'stop' : 'start'} service: ${err.message || 'Unknown error'}`)
|
||||
})
|
||||
}
|
||||
}}
|
||||
className="sr-only peer"
|
||||
id={`${config.key}-toggle`}
|
||||
name="toggle"
|
||||
type="checkbox"
|
||||
/>
|
||||
<span className="absolute inset-0 rounded-full bg-border-dark transition-colors duration-300 peer-checked:bg-primary/20"></span>
|
||||
<span className="absolute left-0.5 top-0.5 h-4 w-4 rounded-full bg-white transition-transform duration-300 peer-checked:translate-x-5"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -440,11 +371,60 @@ export default function System() {
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="block text-xs font-medium text-text-secondary uppercase">NTP Servers</label>
|
||||
<button className="text-xs text-primary font-bold hover:text-white flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setShowAddNtpServer(true)}
|
||||
className="text-xs text-primary font-bold hover:text-white flex items-center gap-1"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">add</span> Add Server
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{showAddNtpServer && (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-[#111a22] p-3 border border-border-dark">
|
||||
<input
|
||||
type="text"
|
||||
value={newNtpServer}
|
||||
onChange={(e) => setNewNtpServer(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && newNtpServer.trim()) {
|
||||
if (!ntpServers.includes(newNtpServer.trim())) {
|
||||
setNtpServers([...ntpServers, newNtpServer.trim()])
|
||||
setNewNtpServer('')
|
||||
setShowAddNtpServer(false)
|
||||
}
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setNewNtpServer('')
|
||||
setShowAddNtpServer(false)
|
||||
}
|
||||
}}
|
||||
placeholder="Enter NTP server address (e.g., 0.pool.ntp.org)"
|
||||
className="flex-1 bg-transparent text-sm text-white placeholder-gray-500 focus:outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (newNtpServer.trim() && !ntpServers.includes(newNtpServer.trim())) {
|
||||
setNtpServers([...ntpServers, newNtpServer.trim()])
|
||||
setNewNtpServer('')
|
||||
setShowAddNtpServer(false)
|
||||
}
|
||||
}}
|
||||
className="text-green-500 hover:text-green-400"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">check</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setNewNtpServer('')
|
||||
setShowAddNtpServer(false)
|
||||
}}
|
||||
className="text-red-500 hover:text-red-400"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{ntpServers.map((server, index) => (
|
||||
<div key={index} className="flex items-center justify-between rounded-lg bg-[#111a22] p-3 border border-border-dark">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -484,20 +464,18 @@ export default function System() {
|
||||
<h3 className="text-sm font-bold text-white">SNMP Monitoring</h3>
|
||||
<p className="text-xs text-text-secondary">Enable Simple Network Management Protocol</p>
|
||||
</div>
|
||||
<div className="relative inline-block w-10 align-middle select-none transition duration-200 ease-in">
|
||||
<label className="relative inline-block w-10 h-5 align-middle select-none cursor-pointer">
|
||||
<input
|
||||
checked={snmpEnabled}
|
||||
onChange={(e) => setSnmpEnabled(e.target.checked)}
|
||||
className="toggle-checkbox absolute block w-5 h-5 rounded-full bg-white border-4 appearance-none cursor-pointer checked:right-0 checked:border-primary transition-all duration-300"
|
||||
className="sr-only peer"
|
||||
id="snmp-toggle"
|
||||
name="toggle"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label
|
||||
className="toggle-label block overflow-hidden h-5 rounded-full bg-border-dark cursor-pointer transition-colors duration-300"
|
||||
htmlFor="snmp-toggle"
|
||||
></label>
|
||||
</div>
|
||||
<span className="absolute inset-0 rounded-full bg-border-dark transition-colors duration-300 peer-checked:bg-primary/20"></span>
|
||||
<span className="absolute left-0.5 top-0.5 h-4 w-4 rounded-full bg-white transition-transform duration-300 peer-checked:translate-x-5"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div className={`grid grid-cols-1 gap-4 transition-opacity ${snmpEnabled ? 'opacity-100' : 'opacity-50 pointer-events-none'}`}>
|
||||
<div>
|
||||
@@ -550,6 +528,14 @@ export default function System() {
|
||||
onClose={() => setEditingInterface(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* View Details Modal */}
|
||||
{viewingInterface && (
|
||||
<ViewDetailsModal
|
||||
interface={viewingInterface}
|
||||
onClose={() => setViewingInterface(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -727,3 +713,128 @@ function EditConnectionModal({ interface: iface, onClose }: EditConnectionModalP
|
||||
)
|
||||
}
|
||||
|
||||
// View Details Modal Component
|
||||
interface ViewDetailsModalProps {
|
||||
interface: NetworkInterface
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function ViewDetailsModal({ interface: iface, onClose }: ViewDetailsModalProps) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="w-full max-w-2xl rounded-xl border border-border-dark bg-card-dark shadow-xl">
|
||||
<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">info</span>
|
||||
<h2 className="text-lg font-bold text-white">Interface Details - {iface.name}</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="h-8 w-8 rounded-full hover:bg-border-dark flex items-center justify-center text-text-secondary hover:text-white transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="space-y-4">
|
||||
{/* Status */}
|
||||
<div className="flex items-center justify-between p-4 rounded-lg bg-[#111a22] border border-border-dark">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`h-3 w-3 rounded-full ${iface.status === 'Connected' ? 'bg-green-500' : 'bg-red-500'}`}></div>
|
||||
<span className="text-sm font-medium text-text-secondary">Status</span>
|
||||
</div>
|
||||
<span className={`text-sm font-bold ${iface.status === 'Connected' ? 'text-green-500' : 'text-red-500'}`}>
|
||||
{iface.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Network Configuration Grid */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* IP Address */}
|
||||
<div className="p-4 rounded-lg bg-[#111a22] border border-border-dark">
|
||||
<label className="mb-2 block text-xs font-medium text-text-secondary uppercase">IP Address</label>
|
||||
<p className="text-sm font-mono text-white">{iface.ip_address || 'Not configured'}</p>
|
||||
</div>
|
||||
|
||||
{/* Subnet */}
|
||||
<div className="p-4 rounded-lg bg-[#111a22] border border-border-dark">
|
||||
<label className="mb-2 block text-xs font-medium text-text-secondary uppercase">Subnet Mask (CIDR)</label>
|
||||
<p className="text-sm font-mono text-white">/{iface.subnet || 'N/A'}</p>
|
||||
</div>
|
||||
|
||||
{/* Gateway */}
|
||||
<div className="p-4 rounded-lg bg-[#111a22] border border-border-dark">
|
||||
<label className="mb-2 block text-xs font-medium text-text-secondary uppercase">Default Gateway</label>
|
||||
<p className="text-sm font-mono text-white">{iface.gateway || 'Not configured'}</p>
|
||||
</div>
|
||||
|
||||
{/* Speed */}
|
||||
<div className="p-4 rounded-lg bg-[#111a22] border border-border-dark">
|
||||
<label className="mb-2 block text-xs font-medium text-text-secondary uppercase">Link Speed</label>
|
||||
<p className="text-sm font-mono text-white">{iface.speed || 'Unknown'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DNS Servers */}
|
||||
<div className="p-4 rounded-lg bg-[#111a22] border border-border-dark">
|
||||
<label className="mb-3 block text-xs font-medium text-text-secondary uppercase">DNS Servers</label>
|
||||
<div className="space-y-2">
|
||||
{iface.dns1 ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-text-secondary">Primary:</span>
|
||||
<span className="text-sm font-mono text-white">{iface.dns1}</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-text-secondary">Primary DNS: Not configured</p>
|
||||
)}
|
||||
{iface.dns2 ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-text-secondary">Secondary:</span>
|
||||
<span className="text-sm font-mono text-white">{iface.dns2}</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-text-secondary">Secondary DNS: Not configured</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Interface Role */}
|
||||
{iface.role && (
|
||||
<div className="p-4 rounded-lg bg-[#111a22] border border-border-dark">
|
||||
<label className="mb-2 block text-xs font-medium text-text-secondary uppercase">Interface Role</label>
|
||||
<span className={`inline-block px-3 py-1 rounded text-xs font-bold uppercase ${
|
||||
iface.role === 'ISCSI'
|
||||
? 'bg-purple-500/20 text-purple-400 border border-purple-500/20'
|
||||
: 'bg-primary/20 text-primary border border-primary/20'
|
||||
}`}>
|
||||
{iface.role}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Full Network Address */}
|
||||
{iface.ip_address && iface.subnet && (
|
||||
<div className="p-4 rounded-lg bg-[#111a22] border border-border-dark">
|
||||
<label className="mb-2 block text-xs font-medium text-text-secondary uppercase">Full Network Address</label>
|
||||
<p className="text-sm font-mono text-white">{iface.ip_address}/{iface.subnet}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Close Button */}
|
||||
<div className="mt-6 flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex items-center justify-center gap-2 rounded-lg bg-primary px-6 py-2.5 text-sm font-bold text-white hover:bg-blue-600 transition-all"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">close</span>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user