letsbe-hub/src/app/admin/orders/[id]/page.tsx

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