814 lines
27 KiB
TypeScript
814 lines
27 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useMemo, useEffect, useCallback } from 'react'
|
|
import Link from 'next/link'
|
|
import { useParams, useRouter } from 'next/navigation'
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from '@/components/ui/card'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Label } from '@/components/ui/label'
|
|
import { useOrder, useUpdateOrder, useTriggerProvisioning } from '@/hooks/use-orders'
|
|
import { useProvisioningLogs, StreamedLog } from '@/hooks/use-provisioning-logs'
|
|
import { OrderStatus, SubscriptionTier, LogLevel } from '@/types/api'
|
|
import {
|
|
ArrowLeft,
|
|
Globe,
|
|
User,
|
|
Server,
|
|
Clock,
|
|
CheckCircle,
|
|
AlertCircle,
|
|
Loader2,
|
|
Eye,
|
|
EyeOff,
|
|
RefreshCw,
|
|
ExternalLink,
|
|
Terminal,
|
|
Zap,
|
|
Wifi,
|
|
WifiOff,
|
|
} from 'lucide-react'
|
|
|
|
// Status badge component
|
|
function StatusBadge({ status }: { status: OrderStatus }) {
|
|
const statusConfig: Record<OrderStatus, { label: string; className: string }> = {
|
|
PAYMENT_CONFIRMED: { label: 'Payment Confirmed', className: 'bg-blue-100 text-blue-800' },
|
|
AWAITING_SERVER: { label: 'Awaiting Server', className: 'bg-yellow-100 text-yellow-800' },
|
|
SERVER_READY: { label: 'Server Ready', className: 'bg-purple-100 text-purple-800' },
|
|
DNS_PENDING: { label: 'DNS Pending', className: 'bg-orange-100 text-orange-800' },
|
|
DNS_READY: { label: 'DNS Ready', className: 'bg-cyan-100 text-cyan-800' },
|
|
PROVISIONING: { label: 'Provisioning', className: 'bg-indigo-100 text-indigo-800' },
|
|
FULFILLED: { label: 'Fulfilled', className: 'bg-green-100 text-green-800' },
|
|
EMAIL_CONFIGURED: { label: 'Complete', className: 'bg-emerald-100 text-emerald-800' },
|
|
FAILED: { label: 'Failed', className: 'bg-red-100 text-red-800' },
|
|
}
|
|
|
|
const config = statusConfig[status]
|
|
|
|
return (
|
|
<span className={`inline-flex items-center rounded-full px-3 py-1 text-sm font-medium ${config.className}`}>
|
|
{config.label}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
// Server credentials form component
|
|
function ServerCredentialsForm({
|
|
initialIp,
|
|
initialPort,
|
|
hasCredentials,
|
|
onSubmit,
|
|
isLoading,
|
|
}: {
|
|
initialIp?: string
|
|
initialPort?: number
|
|
hasCredentials: boolean
|
|
onSubmit: (ip: string, password: string, port: number) => void
|
|
isLoading: boolean
|
|
}) {
|
|
const [ip, setIp] = useState(initialIp || '')
|
|
const [password, setPassword] = useState('')
|
|
const [port, setPort] = useState(String(initialPort || 22))
|
|
const [showPassword, setShowPassword] = useState(false)
|
|
const [testing, setTesting] = useState(false)
|
|
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null)
|
|
|
|
const handleTestConnection = async () => {
|
|
setTesting(true)
|
|
setTestResult(null)
|
|
// TODO: Call API to test SSH connection
|
|
await new Promise(resolve => setTimeout(resolve, 2000))
|
|
setTestResult({ success: true, message: 'Connection successful! SSH version: OpenSSH_8.4' })
|
|
setTesting(false)
|
|
}
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
onSubmit(ip, password, parseInt(port))
|
|
}
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Server className="h-5 w-5" />
|
|
Server Credentials
|
|
</CardTitle>
|
|
<CardDescription>
|
|
{hasCredentials
|
|
? 'Server credentials have been entered. You can update them if needed.'
|
|
: 'Enter the server credentials received from the hosting provider.'}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="ip">Server IP Address</Label>
|
|
<Input
|
|
id="ip"
|
|
type="text"
|
|
placeholder="123.45.67.89"
|
|
value={ip}
|
|
onChange={(e) => setIp(e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="port">SSH Port</Label>
|
|
<Input
|
|
id="port"
|
|
type="number"
|
|
placeholder="22"
|
|
value={port}
|
|
onChange={(e) => setPort(e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="password">Root Password</Label>
|
|
<div className="relative">
|
|
<Input
|
|
id="password"
|
|
type={showPassword ? 'text' : 'password'}
|
|
placeholder="Enter root password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
required={!hasCredentials}
|
|
className="pr-10"
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
|
|
onClick={() => setShowPassword(!showPassword)}
|
|
>
|
|
{showPassword ? (
|
|
<EyeOff className="h-4 w-4 text-muted-foreground" />
|
|
) : (
|
|
<Eye className="h-4 w-4 text-muted-foreground" />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
Password will be encrypted and deleted after provisioning completes.
|
|
</p>
|
|
</div>
|
|
|
|
{testResult && (
|
|
<div
|
|
className={`flex items-center gap-2 rounded-md p-3 ${
|
|
testResult.success ? 'bg-green-50 text-green-800' : 'bg-red-50 text-red-800'
|
|
}`}
|
|
>
|
|
{testResult.success ? (
|
|
<CheckCircle className="h-4 w-4" />
|
|
) : (
|
|
<AlertCircle className="h-4 w-4" />
|
|
)}
|
|
<span className="text-sm">{testResult.message}</span>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex gap-3">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={handleTestConnection}
|
|
disabled={!ip || !password || testing}
|
|
>
|
|
{testing ? (
|
|
<>
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
Testing...
|
|
</>
|
|
) : (
|
|
<>
|
|
<RefreshCw className="mr-2 h-4 w-4" />
|
|
Test Connection
|
|
</>
|
|
)}
|
|
</Button>
|
|
<Button type="submit" disabled={isLoading || !ip}>
|
|
{isLoading ? (
|
|
<>
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
Saving...
|
|
</>
|
|
) : (
|
|
'Save & Mark Ready'
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
// Provisioning logs component
|
|
function ProvisioningLogs({ logs, isLive, isConnected, onReconnect }: {
|
|
logs: Array<{
|
|
id: string
|
|
timestamp: Date
|
|
level: LogLevel
|
|
step: string | null
|
|
message: string
|
|
}>
|
|
isLive: boolean
|
|
isConnected?: boolean
|
|
onReconnect?: () => void
|
|
}) {
|
|
const levelColors: Record<LogLevel, string> = {
|
|
INFO: 'text-blue-400',
|
|
WARN: 'text-yellow-400',
|
|
ERROR: 'text-red-400',
|
|
DEBUG: 'text-gray-400',
|
|
}
|
|
|
|
const formatTime = (date: Date) => {
|
|
const d = new Date(date)
|
|
return d.toLocaleTimeString('en-US', { hour12: false })
|
|
}
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Terminal className="h-5 w-5" />
|
|
Provisioning Logs
|
|
</CardTitle>
|
|
<CardDescription>Real-time output from the provisioning process</CardDescription>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
{isLive && (
|
|
<>
|
|
{isConnected ? (
|
|
<div className="flex items-center gap-2">
|
|
<span className="relative flex h-3 w-3">
|
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75"></span>
|
|
<span className="relative inline-flex h-3 w-3 rounded-full bg-green-500"></span>
|
|
</span>
|
|
<span className="text-sm text-muted-foreground">Live</span>
|
|
<Wifi className="h-4 w-4 text-green-500" />
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center gap-2">
|
|
<WifiOff className="h-4 w-4 text-red-500" />
|
|
<span className="text-sm text-red-500">Disconnected</span>
|
|
{onReconnect && (
|
|
<Button variant="ghost" size="sm" onClick={onReconnect}>
|
|
<RefreshCw className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
{!isLive && logs.length > 0 && (
|
|
<span className="text-sm text-muted-foreground">
|
|
{logs.length} log{logs.length !== 1 ? 's' : ''}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="h-96 overflow-y-auto rounded-lg bg-gray-900 p-4 font-mono text-sm" id="log-container">
|
|
{logs.length === 0 ? (
|
|
<p className="text-gray-500">
|
|
{isLive && isConnected ? 'Waiting for logs...' : 'No logs available yet.'}
|
|
</p>
|
|
) : (
|
|
logs.map((log) => (
|
|
<div key={log.id} className="mb-2">
|
|
<span className="text-gray-500">[{formatTime(log.timestamp)}]</span>{' '}
|
|
<span className={levelColors[log.level]}>[{log.level}]</span>{' '}
|
|
{log.step && <span className="text-purple-400">[{log.step}]</span>}{' '}
|
|
<span className="text-gray-300">{log.message}</span>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
// Order timeline component
|
|
function OrderTimeline({
|
|
status,
|
|
timestamps,
|
|
}: {
|
|
status: OrderStatus
|
|
timestamps: {
|
|
createdAt?: Date | null
|
|
serverReadyAt?: Date | null
|
|
provisioningStartedAt?: Date | null
|
|
completedAt?: Date | null
|
|
}
|
|
}) {
|
|
const stages = [
|
|
{ key: 'payment_confirmed', label: 'Payment Confirmed', status: 'PAYMENT_CONFIRMED' },
|
|
{ key: 'awaiting_server', label: 'Server Ordered', status: 'AWAITING_SERVER' },
|
|
{ key: 'server_ready', label: 'Server Ready', status: 'SERVER_READY' },
|
|
{ key: 'dns_ready', label: 'DNS Configured', status: 'DNS_READY' },
|
|
{ key: 'provisioning', label: 'Provisioning', status: 'PROVISIONING' },
|
|
{ key: 'fulfilled', label: 'Fulfilled', status: 'FULFILLED' },
|
|
]
|
|
|
|
const statusOrder = stages.map((s) => s.status)
|
|
const currentIndex = statusOrder.indexOf(status)
|
|
|
|
const formatDate = (date: Date | null | undefined) => {
|
|
if (!date) return null
|
|
return new Date(date).toLocaleString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
hour12: true,
|
|
})
|
|
}
|
|
|
|
const getTimestamp = (key: string): string | null => {
|
|
switch (key) {
|
|
case 'payment_confirmed':
|
|
return formatDate(timestamps.createdAt)
|
|
case 'server_ready':
|
|
return formatDate(timestamps.serverReadyAt)
|
|
case 'provisioning':
|
|
return formatDate(timestamps.provisioningStartedAt)
|
|
case 'fulfilled':
|
|
return formatDate(timestamps.completedAt)
|
|
default:
|
|
return null
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Clock className="h-5 w-5" />
|
|
Order Timeline
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="relative">
|
|
{stages.map((stage, index) => {
|
|
const isComplete = index < currentIndex || (index === currentIndex && status !== 'FAILED')
|
|
const isCurrent = index === currentIndex
|
|
const timestamp = getTimestamp(stage.key)
|
|
|
|
return (
|
|
<div key={stage.key} className="flex gap-4 pb-8 last:pb-0">
|
|
<div className="relative flex flex-col items-center">
|
|
<div
|
|
className={`flex h-8 w-8 items-center justify-center rounded-full border-2 ${
|
|
isComplete
|
|
? 'border-green-500 bg-green-500 text-white'
|
|
: isCurrent
|
|
? 'border-blue-500 bg-blue-50'
|
|
: 'border-gray-200 bg-white'
|
|
}`}
|
|
>
|
|
{isComplete ? (
|
|
<CheckCircle className="h-4 w-4" />
|
|
) : (
|
|
<span className="text-sm font-medium">{index + 1}</span>
|
|
)}
|
|
</div>
|
|
{index < stages.length - 1 && (
|
|
<div
|
|
className={`absolute top-8 h-full w-0.5 ${
|
|
isComplete ? 'bg-green-500' : 'bg-gray-200'
|
|
}`}
|
|
/>
|
|
)}
|
|
</div>
|
|
<div className="flex-1 pt-1">
|
|
<p className={`font-medium ${isCurrent ? 'text-blue-600' : ''}`}>
|
|
{stage.label}
|
|
</p>
|
|
{timestamp && (
|
|
<p className="text-sm text-muted-foreground">{timestamp}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
export default function OrderDetailPage() {
|
|
const params = useParams()
|
|
const router = useRouter()
|
|
const orderId = params.id as string
|
|
|
|
// Fetch order data
|
|
const {
|
|
data: order,
|
|
isLoading,
|
|
isError,
|
|
error,
|
|
refetch,
|
|
isFetching,
|
|
} = useOrder(orderId)
|
|
|
|
// Mutations
|
|
const updateOrder = useUpdateOrder()
|
|
const triggerProvision = useTriggerProvisioning()
|
|
|
|
// Check if we should enable SSE streaming
|
|
const isProvisioning = order?.status === OrderStatus.PROVISIONING
|
|
|
|
// SSE for live log streaming
|
|
const {
|
|
logs: streamedLogs,
|
|
isConnected,
|
|
isComplete,
|
|
reconnect,
|
|
} = useProvisioningLogs({
|
|
orderId,
|
|
enabled: isProvisioning,
|
|
onStatusChange: useCallback((newStatus: OrderStatus) => {
|
|
// Refetch order data when status changes
|
|
refetch()
|
|
}, [refetch]),
|
|
onComplete: useCallback((success: boolean) => {
|
|
// Refetch order data when provisioning completes
|
|
refetch()
|
|
}, [refetch]),
|
|
})
|
|
|
|
// Computed values
|
|
const tierLabel = useMemo(() => {
|
|
if (!order) return ''
|
|
return order.tier === SubscriptionTier.HUB_DASHBOARD ? 'Hub Dashboard' : 'Control Panel'
|
|
}, [order?.tier])
|
|
|
|
// Merge historical logs with streamed logs, avoiding duplicates
|
|
const allLogs = useMemo(() => {
|
|
const historicalLogs = order?.provisioningLogs || []
|
|
const historicalLogIds = new Set(historicalLogs.map(l => l.id))
|
|
|
|
// Add only new streamed logs that aren't in historical
|
|
const newStreamedLogs = streamedLogs.filter(l => !historicalLogIds.has(l.id))
|
|
|
|
// Combine and sort by timestamp
|
|
const combined = [
|
|
...historicalLogs.map(l => ({
|
|
id: l.id,
|
|
level: l.level,
|
|
step: l.step,
|
|
message: l.message,
|
|
timestamp: new Date(l.timestamp),
|
|
})),
|
|
...newStreamedLogs,
|
|
]
|
|
|
|
return combined.sort(
|
|
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
|
)
|
|
}, [order?.provisioningLogs, streamedLogs])
|
|
|
|
// Auto-scroll logs to bottom when new logs come in
|
|
useEffect(() => {
|
|
if (isProvisioning && allLogs.length > 0) {
|
|
const container = document.getElementById('log-container')
|
|
if (container) {
|
|
container.scrollTop = container.scrollHeight
|
|
}
|
|
}
|
|
}, [allLogs.length, isProvisioning])
|
|
|
|
const handleCredentialsSubmit = async (ip: string, password: string, port: number) => {
|
|
await updateOrder.mutateAsync({
|
|
id: orderId,
|
|
data: {
|
|
serverIp: ip,
|
|
serverPassword: password,
|
|
sshPort: port,
|
|
},
|
|
})
|
|
}
|
|
|
|
const handleTriggerProvisioning = async () => {
|
|
try {
|
|
await triggerProvision.mutateAsync(orderId)
|
|
} catch (err) {
|
|
console.error('Failed to trigger provisioning:', err)
|
|
}
|
|
}
|
|
|
|
// Loading state
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center h-[50vh]">
|
|
<div className="flex flex-col items-center gap-4">
|
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
<p className="text-muted-foreground">Loading order details...</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Error state
|
|
if (isError || !order) {
|
|
return (
|
|
<div className="flex items-center justify-center h-[50vh]">
|
|
<div className="flex flex-col items-center gap-4 text-center">
|
|
<AlertCircle className="h-8 w-8 text-destructive" />
|
|
<div>
|
|
<p className="font-medium text-destructive">Failed to load order</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
{error instanceof Error ? error.message : 'Order not found'}
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" onClick={() => router.back()}>
|
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
|
Go Back
|
|
</Button>
|
|
<Button variant="outline" onClick={() => refetch()}>
|
|
<RefreshCw className="h-4 w-4 mr-2" />
|
|
Retry
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const showCredentialsForm = order.status === OrderStatus.AWAITING_SERVER || order.status === OrderStatus.SERVER_READY
|
|
const showProvisionButton = order.status === OrderStatus.DNS_READY || order.status === OrderStatus.FAILED
|
|
const showLogs = order.status === OrderStatus.PROVISIONING ||
|
|
order.status === OrderStatus.FULFILLED ||
|
|
order.status === OrderStatus.EMAIL_CONFIGURED ||
|
|
order.status === OrderStatus.FAILED
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center gap-4">
|
|
<Link href="/admin/orders">
|
|
<Button variant="ghost" size="icon">
|
|
<ArrowLeft className="h-5 w-5" />
|
|
</Button>
|
|
</Link>
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-3">
|
|
<h1 className="text-2xl font-bold">{order.domain}</h1>
|
|
<StatusBadge status={order.status} />
|
|
</div>
|
|
<p className="text-muted-foreground">Order #{orderId.slice(0, 8)}...</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => refetch()}
|
|
disabled={isFetching}
|
|
>
|
|
<RefreshCw className={`h-4 w-4 mr-2 ${isFetching ? 'animate-spin' : ''}`} />
|
|
Refresh
|
|
</Button>
|
|
{showProvisionButton && (
|
|
<Button
|
|
onClick={handleTriggerProvisioning}
|
|
disabled={triggerProvision.isPending}
|
|
>
|
|
{triggerProvision.isPending ? (
|
|
<>
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
Starting...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Zap className="mr-2 h-4 w-4" />
|
|
{order.status === OrderStatus.FAILED ? 'Retry Provisioning' : 'Start Provisioning'}
|
|
</>
|
|
)}
|
|
</Button>
|
|
)}
|
|
{order.portainerUrl && (
|
|
<Button variant="outline" asChild>
|
|
<a href={order.portainerUrl} target="_blank" rel="noopener noreferrer">
|
|
<ExternalLink className="mr-2 h-4 w-4" />
|
|
Portainer
|
|
</a>
|
|
</Button>
|
|
)}
|
|
{order.dashboardUrl && (
|
|
<Button variant="outline" asChild>
|
|
<a href={order.dashboardUrl} target="_blank" rel="noopener noreferrer">
|
|
<ExternalLink className="mr-2 h-4 w-4" />
|
|
Dashboard
|
|
</a>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Failure reason banner */}
|
|
{order.status === OrderStatus.FAILED && order.failureReason && (
|
|
<div className="flex items-start gap-3 rounded-lg border border-red-200 bg-red-50 p-4">
|
|
<AlertCircle className="h-5 w-5 text-red-500 mt-0.5" />
|
|
<div>
|
|
<p className="font-medium text-red-800">Provisioning Failed</p>
|
|
<p className="text-sm text-red-700">{order.failureReason}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Order info cards */}
|
|
<div className="grid gap-6 lg:grid-cols-3">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<User className="h-5 w-5" />
|
|
Customer
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-2">
|
|
<p className="font-medium">{order.user.name || order.user.company || 'N/A'}</p>
|
|
<p className="text-sm text-muted-foreground">{order.user.email}</p>
|
|
{order.user.company && order.user.name && (
|
|
<p className="text-sm text-muted-foreground">{order.user.company}</p>
|
|
)}
|
|
<Link href={`/admin/customers/${order.user.id}`}>
|
|
<Button variant="link" className="px-0 h-auto">
|
|
View Customer Profile
|
|
</Button>
|
|
</Link>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Globe className="h-5 w-5" />
|
|
Domain & Tier
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-2">
|
|
<p className="font-medium">{order.domain}</p>
|
|
<span className="inline-flex items-center rounded-full bg-primary/10 px-2 py-1 text-xs font-medium text-primary">
|
|
{tierLabel}
|
|
</span>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Server className="h-5 w-5" />
|
|
Server
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-2">
|
|
{order.serverIp ? (
|
|
<>
|
|
<p className="font-mono font-medium">{order.serverIp}</p>
|
|
<p className="text-sm text-muted-foreground">SSH Port: {order.sshPort || 22}</p>
|
|
</>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">Not configured</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Tools list */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Selected Tools</CardTitle>
|
|
<CardDescription>Tools to be deployed on this server</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="flex flex-wrap gap-2">
|
|
{order.tools.map((tool) => (
|
|
<span
|
|
key={tool}
|
|
className="rounded-full bg-gray-100 px-3 py-1 text-sm font-medium capitalize"
|
|
>
|
|
{tool}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Server credentials form (show for AWAITING_SERVER or SERVER_READY status) */}
|
|
{showCredentialsForm && (
|
|
<ServerCredentialsForm
|
|
initialIp={order.serverIp || undefined}
|
|
initialPort={order.sshPort}
|
|
hasCredentials={!!order.serverIp}
|
|
onSubmit={handleCredentialsSubmit}
|
|
isLoading={updateOrder.isPending}
|
|
/>
|
|
)}
|
|
|
|
{/* Two column layout for timeline and logs */}
|
|
<div className="grid gap-6 lg:grid-cols-2">
|
|
<OrderTimeline
|
|
status={order.status}
|
|
timestamps={{
|
|
createdAt: order.createdAt,
|
|
serverReadyAt: order.serverReadyAt,
|
|
provisioningStartedAt: order.provisioningStartedAt,
|
|
completedAt: order.completedAt,
|
|
}}
|
|
/>
|
|
|
|
{/* Show logs for provisioning/completed/failed status */}
|
|
{showLogs && (
|
|
<ProvisioningLogs
|
|
logs={allLogs}
|
|
isLive={isProvisioning}
|
|
isConnected={isConnected}
|
|
onReconnect={reconnect}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* Jobs history */}
|
|
{order.jobs && order.jobs.length > 0 && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Job History</CardTitle>
|
|
<CardDescription>Recent provisioning job attempts</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-3">
|
|
{order.jobs.map((job) => (
|
|
<div
|
|
key={job.id}
|
|
className="flex items-center justify-between rounded-lg border p-3"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<div
|
|
className={`h-2 w-2 rounded-full ${
|
|
job.status === 'COMPLETED'
|
|
? 'bg-green-500'
|
|
: job.status === 'FAILED'
|
|
? 'bg-red-500'
|
|
: job.status === 'RUNNING'
|
|
? 'bg-blue-500'
|
|
: 'bg-gray-400'
|
|
}`}
|
|
/>
|
|
<div>
|
|
<p className="text-sm font-medium">
|
|
Attempt {job.attempt} of {job.maxAttempts}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{new Date(job.createdAt).toLocaleString()}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="text-right">
|
|
<span
|
|
className={`text-xs font-medium ${
|
|
job.status === 'COMPLETED'
|
|
? 'text-green-600'
|
|
: job.status === 'FAILED'
|
|
? 'text-red-600'
|
|
: job.status === 'RUNNING'
|
|
? 'text-blue-600'
|
|
: 'text-gray-600'
|
|
}`}
|
|
>
|
|
{job.status}
|
|
</span>
|
|
{job.error && (
|
|
<p className="text-xs text-red-500 max-w-xs truncate">{job.error}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|