350 lines
12 KiB
TypeScript
350 lines
12 KiB
TypeScript
|
|
'use client'
|
||
|
|
|
||
|
|
import { useState, useCallback } from 'react'
|
||
|
|
import { useParams, useRouter } from 'next/navigation'
|
||
|
|
import Link from 'next/link'
|
||
|
|
import {
|
||
|
|
ArrowLeft,
|
||
|
|
Server,
|
||
|
|
Power,
|
||
|
|
RefreshCw,
|
||
|
|
AlertTriangle,
|
||
|
|
Cpu,
|
||
|
|
HardDrive,
|
||
|
|
Network,
|
||
|
|
MemoryStick,
|
||
|
|
Activity,
|
||
|
|
Loader2,
|
||
|
|
Box,
|
||
|
|
Unlink,
|
||
|
|
Settings
|
||
|
|
} from 'lucide-react'
|
||
|
|
import {
|
||
|
|
useEnterpriseClient,
|
||
|
|
useClientServer,
|
||
|
|
useServerStatsHistory,
|
||
|
|
useCollectServerStats,
|
||
|
|
useServerAction,
|
||
|
|
useRemoveServerFromClient
|
||
|
|
} from '@/hooks/use-enterprise-clients'
|
||
|
|
import {
|
||
|
|
RangeSelector,
|
||
|
|
StatsCard,
|
||
|
|
CpuUsageChart,
|
||
|
|
MemoryUsageChart,
|
||
|
|
DiskIOChart,
|
||
|
|
NetworkChart
|
||
|
|
} from '@/components/admin/enterprise-stats-charts'
|
||
|
|
import { LiveStatsPanel } from '@/components/admin/live-stats-panel'
|
||
|
|
import { EnterpriseContainerList } from '@/components/admin/enterprise-container-list'
|
||
|
|
import type { StatsRange } from '@/lib/api/admin'
|
||
|
|
import type { StatsDataPoint } from '@/lib/services/stats-collection-service'
|
||
|
|
|
||
|
|
export default function ServerDetailPage() {
|
||
|
|
const params = useParams()
|
||
|
|
const router = useRouter()
|
||
|
|
const clientId = params.id as string
|
||
|
|
const serverId = params.serverId as string
|
||
|
|
|
||
|
|
const [range, setRange] = useState<StatsRange>('24h')
|
||
|
|
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
||
|
|
|
||
|
|
const { data: client, isLoading: clientLoading } = useEnterpriseClient(clientId)
|
||
|
|
const { data: server, isLoading: serverLoading, refetch: refetchServer } = useClientServer(clientId, serverId)
|
||
|
|
const { data: statsData, isLoading: statsLoading } = useServerStatsHistory(clientId, serverId, range)
|
||
|
|
const collectStats = useCollectServerStats()
|
||
|
|
const serverAction = useServerAction()
|
||
|
|
const removeServer = useRemoveServerFromClient()
|
||
|
|
|
||
|
|
// Stable callback for refreshing stats (used by LiveStatsPanel)
|
||
|
|
const handleRefreshStats = useCallback(() => {
|
||
|
|
collectStats.mutate({ clientId, serverId })
|
||
|
|
}, [collectStats, clientId, serverId])
|
||
|
|
|
||
|
|
const isLoading = clientLoading || serverLoading
|
||
|
|
|
||
|
|
if (isLoading) {
|
||
|
|
return (
|
||
|
|
<div className="flex items-center justify-center h-64">
|
||
|
|
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!client || !server) {
|
||
|
|
return (
|
||
|
|
<div className="text-center py-12">
|
||
|
|
<h2 className="text-lg font-medium text-gray-900">Server not found</h2>
|
||
|
|
<p className="mt-1 text-sm text-gray-500">
|
||
|
|
The server you're looking for doesn't exist or has been removed.
|
||
|
|
</p>
|
||
|
|
<Link
|
||
|
|
href={`/admin/enterprise-clients/${clientId}`}
|
||
|
|
className="mt-4 inline-flex items-center text-blue-600 hover:underline"
|
||
|
|
>
|
||
|
|
<ArrowLeft className="h-4 w-4 mr-1" />
|
||
|
|
Back to client
|
||
|
|
</Link>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Convert API response to StatsDataPoint[] format for charts
|
||
|
|
const history: StatsDataPoint[] = statsData?.history?.map(h => ({
|
||
|
|
...h,
|
||
|
|
timestamp: new Date(h.timestamp)
|
||
|
|
})) || []
|
||
|
|
|
||
|
|
const latest = statsData?.latest
|
||
|
|
|
||
|
|
const handlePowerAction = async (command: 'ON' | 'OFF' | 'POWERCYCLE' | 'RESET') => {
|
||
|
|
setActionLoading(command)
|
||
|
|
try {
|
||
|
|
await serverAction.mutateAsync({
|
||
|
|
clientId,
|
||
|
|
serverId,
|
||
|
|
action: { action: 'power', command }
|
||
|
|
})
|
||
|
|
// Refresh server data after action
|
||
|
|
setTimeout(() => refetchServer(), 2000)
|
||
|
|
} catch (error) {
|
||
|
|
console.error('Power action failed:', error)
|
||
|
|
} finally {
|
||
|
|
setActionLoading(null)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleUnlinkServer = async () => {
|
||
|
|
if (!confirm(`Are you sure you want to unlink "${server?.nickname || server?.netcupServerId}" from this client?\n\nThis will remove the server from this enterprise client. The server itself will not be affected.`)) {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
await removeServer.mutateAsync({ clientId, serverId })
|
||
|
|
router.push(`/admin/enterprise-clients/${clientId}`)
|
||
|
|
} catch (error) {
|
||
|
|
console.error('Failed to unlink server:', error)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const statusColor = server.netcupStatus === 'RUNNING' || server.netcupStatus === 'running'
|
||
|
|
? 'bg-green-100 text-green-800'
|
||
|
|
: server.netcupStatus === 'SHUTOFF' || server.netcupStatus === 'stopped'
|
||
|
|
? 'bg-red-100 text-red-800'
|
||
|
|
: 'bg-yellow-100 text-yellow-800'
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="space-y-6">
|
||
|
|
{/* Header */}
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<div className="flex items-center gap-4">
|
||
|
|
<Link
|
||
|
|
href={`/admin/enterprise-clients/${clientId}`}
|
||
|
|
className="text-gray-500 hover:text-gray-700"
|
||
|
|
>
|
||
|
|
<ArrowLeft className="h-5 w-5" />
|
||
|
|
</Link>
|
||
|
|
<div>
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<Server className="h-5 w-5 text-gray-400" />
|
||
|
|
<h1 className="text-2xl font-bold text-gray-900">
|
||
|
|
{server.nickname || server.netcupServerId}
|
||
|
|
</h1>
|
||
|
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${statusColor}`}>
|
||
|
|
{server.netcupStatus}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
<p className="text-sm text-gray-500 mt-1">
|
||
|
|
{server.purpose && `${server.purpose} • `}
|
||
|
|
{client.name} • Netcup ID: {server.netcupServerId}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Power Controls */}
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<button
|
||
|
|
onClick={() => handlePowerAction('ON')}
|
||
|
|
disabled={actionLoading !== null}
|
||
|
|
className="inline-flex items-center px-3 py-1.5 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
|
||
|
|
>
|
||
|
|
{actionLoading === 'ON' ? (
|
||
|
|
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||
|
|
) : (
|
||
|
|
<Power className="h-4 w-4 mr-1 text-green-600" />
|
||
|
|
)}
|
||
|
|
Start
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
onClick={() => handlePowerAction('OFF')}
|
||
|
|
disabled={actionLoading !== null}
|
||
|
|
className="inline-flex items-center px-3 py-1.5 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
|
||
|
|
>
|
||
|
|
{actionLoading === 'OFF' ? (
|
||
|
|
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||
|
|
) : (
|
||
|
|
<Power className="h-4 w-4 mr-1 text-red-600" />
|
||
|
|
)}
|
||
|
|
Stop
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
onClick={() => handlePowerAction('POWERCYCLE')}
|
||
|
|
disabled={actionLoading !== null}
|
||
|
|
className="inline-flex items-center px-3 py-1.5 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
|
||
|
|
>
|
||
|
|
{actionLoading === 'POWERCYCLE' ? (
|
||
|
|
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||
|
|
) : (
|
||
|
|
<RefreshCw className="h-4 w-4 mr-1" />
|
||
|
|
)}
|
||
|
|
Restart
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
onClick={() => router.push(`/admin/enterprise-clients/${clientId}/servers/${serverId}/settings`)}
|
||
|
|
className="inline-flex items-center px-3 py-1.5 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50"
|
||
|
|
>
|
||
|
|
<Settings className="h-4 w-4 mr-1" />
|
||
|
|
Settings
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
onClick={() => router.push(`/admin/enterprise-clients/${clientId}/servers/${serverId}/danger`)}
|
||
|
|
className="inline-flex items-center px-3 py-1.5 border border-red-300 rounded-md text-sm font-medium text-red-700 bg-white hover:bg-red-50"
|
||
|
|
>
|
||
|
|
<AlertTriangle className="h-4 w-4 mr-1" />
|
||
|
|
Danger Zone
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
onClick={handleUnlinkServer}
|
||
|
|
disabled={removeServer.isPending}
|
||
|
|
className="inline-flex items-center px-3 py-1.5 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
|
||
|
|
>
|
||
|
|
{removeServer.isPending ? (
|
||
|
|
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||
|
|
) : (
|
||
|
|
<Unlink className="h-4 w-4 mr-1" />
|
||
|
|
)}
|
||
|
|
Unlink
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Server Info */}
|
||
|
|
{server.netcupIps && server.netcupIps.length > 0 && (
|
||
|
|
<div className="bg-gray-50 rounded-lg p-4">
|
||
|
|
<div className="flex items-center gap-6 text-sm">
|
||
|
|
<div>
|
||
|
|
<span className="text-gray-500">IP Address:</span>{' '}
|
||
|
|
<code className="bg-gray-200 px-2 py-0.5 rounded text-gray-800">
|
||
|
|
{server.netcupIps[0]}
|
||
|
|
</code>
|
||
|
|
</div>
|
||
|
|
{server.netcupHostname && (
|
||
|
|
<div>
|
||
|
|
<span className="text-gray-500">Hostname:</span>{' '}
|
||
|
|
<span className="font-medium">{server.netcupHostname}</span>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Live Stats Panel with Auto-Refresh */}
|
||
|
|
<LiveStatsPanel
|
||
|
|
data={latest}
|
||
|
|
isRefreshing={collectStats.isPending}
|
||
|
|
onRefresh={handleRefreshStats}
|
||
|
|
autoRefreshInterval={15000}
|
||
|
|
/>
|
||
|
|
|
||
|
|
{/* Historical Charts */}
|
||
|
|
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||
|
|
<div className="flex items-center justify-between mb-4">
|
||
|
|
<h2 className="text-lg font-medium text-gray-900">Historical Metrics</h2>
|
||
|
|
<RangeSelector value={range} onChange={setRange} />
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{statsLoading ? (
|
||
|
|
<div className="flex items-center justify-center h-64">
|
||
|
|
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<div className="space-y-8">
|
||
|
|
{/* CPU Chart */}
|
||
|
|
<div>
|
||
|
|
<div className="flex items-center gap-2 mb-2">
|
||
|
|
<Cpu className="h-4 w-4 text-blue-500" />
|
||
|
|
<h3 className="text-sm font-medium text-gray-700">CPU Usage</h3>
|
||
|
|
</div>
|
||
|
|
<CpuUsageChart data={history} height={200} />
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Memory Chart */}
|
||
|
|
<div>
|
||
|
|
<div className="flex items-center gap-2 mb-2">
|
||
|
|
<MemoryStick className="h-4 w-4 text-green-500" />
|
||
|
|
<h3 className="text-sm font-medium text-gray-700">Memory Usage</h3>
|
||
|
|
</div>
|
||
|
|
<MemoryUsageChart data={history} height={200} />
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Disk I/O Chart */}
|
||
|
|
<div>
|
||
|
|
<div className="flex items-center gap-2 mb-2">
|
||
|
|
<HardDrive className="h-4 w-4 text-purple-500" />
|
||
|
|
<h3 className="text-sm font-medium text-gray-700">Disk I/O</h3>
|
||
|
|
</div>
|
||
|
|
<DiskIOChart data={history} height={200} />
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Network Chart */}
|
||
|
|
<div>
|
||
|
|
<div className="flex items-center gap-2 mb-2">
|
||
|
|
<Network className="h-4 w-4 text-cyan-500" />
|
||
|
|
<h3 className="text-sm font-medium text-gray-700">Network Traffic</h3>
|
||
|
|
</div>
|
||
|
|
<NetworkChart data={history} height={200} />
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Quick Stats Cards */}
|
||
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||
|
|
<StatsCard
|
||
|
|
title="CPU Average"
|
||
|
|
value={latest?.cpuPercent?.toFixed(1) ?? null}
|
||
|
|
unit="%"
|
||
|
|
icon={<Cpu className="h-5 w-5" />}
|
||
|
|
/>
|
||
|
|
<StatsCard
|
||
|
|
title="Memory Used"
|
||
|
|
value={latest?.memoryUsedMb?.toFixed(0) ?? null}
|
||
|
|
unit="MB"
|
||
|
|
icon={<MemoryStick className="h-5 w-5" />}
|
||
|
|
/>
|
||
|
|
<StatsCard
|
||
|
|
title="Disk Read"
|
||
|
|
value={latest?.diskReadMbps?.toFixed(2) ?? null}
|
||
|
|
unit="MB/s"
|
||
|
|
icon={<HardDrive className="h-5 w-5" />}
|
||
|
|
/>
|
||
|
|
<StatsCard
|
||
|
|
title="Network In"
|
||
|
|
value={latest?.networkInMbps?.toFixed(2) ?? null}
|
||
|
|
unit="Mbps"
|
||
|
|
icon={<Activity className="h-5 w-5" />}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Containers */}
|
||
|
|
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||
|
|
<div className="flex items-center gap-2 mb-4">
|
||
|
|
<Box className="h-5 w-5 text-gray-400" />
|
||
|
|
<h2 className="text-lg font-medium text-gray-900">Containers</h2>
|
||
|
|
</div>
|
||
|
|
<EnterpriseContainerList clientId={clientId} serverId={serverId} />
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|