909 lines
31 KiB
TypeScript
909 lines
31 KiB
TypeScript
'use client'
|
|
|
|
import { use, useState } from 'react'
|
|
import Link from 'next/link'
|
|
import { useRouter } from 'next/navigation'
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from '@/components/ui/card'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Label } from '@/components/ui/label'
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select'
|
|
import {
|
|
useEnterpriseClient,
|
|
useUpdateEnterpriseClient,
|
|
useDeleteEnterpriseClient,
|
|
useClientServers,
|
|
useServerAction,
|
|
useErrorRules,
|
|
useCreateErrorRule,
|
|
useDetectedErrors,
|
|
useAcknowledgeError,
|
|
useAddServerToClient,
|
|
} from '@/hooks/use-enterprise-clients'
|
|
import { useNetcupServers } from '@/hooks/use-netcup'
|
|
import {
|
|
ArrowLeft,
|
|
Building2,
|
|
Server,
|
|
AlertTriangle,
|
|
Mail,
|
|
Phone,
|
|
FileText,
|
|
Edit,
|
|
Trash2,
|
|
Power,
|
|
RotateCcw,
|
|
Plus,
|
|
Check,
|
|
X,
|
|
Loader2,
|
|
AlertCircle,
|
|
RefreshCw,
|
|
Activity,
|
|
HardDrive,
|
|
Cpu,
|
|
MemoryStick,
|
|
Clock,
|
|
ShieldAlert,
|
|
Eye,
|
|
} from 'lucide-react'
|
|
import type { EnterpriseServerWithStatus, ErrorDetectionRule, DetectedError, ErrorSeverity } from '@/types/api'
|
|
|
|
// Overview stat card
|
|
function OverviewCard({
|
|
title,
|
|
value,
|
|
icon: Icon,
|
|
className = ''
|
|
}: {
|
|
title: string
|
|
value: string | number
|
|
icon: typeof Cpu
|
|
className?: string
|
|
}) {
|
|
return (
|
|
<Card className={className}>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 rounded-lg bg-muted">
|
|
<Icon className="h-4 w-4 text-muted-foreground" />
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">{title}</p>
|
|
<p className="text-lg font-semibold">{value}</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
// Server card component
|
|
function ServerCard({
|
|
server,
|
|
clientId,
|
|
onPowerAction
|
|
}: {
|
|
server: EnterpriseServerWithStatus
|
|
clientId: string
|
|
onPowerAction: (serverId: string, command: 'ON' | 'OFF' | 'POWERCYCLE') => void
|
|
}) {
|
|
const statusColors: Record<string, { bg: string; text: string; dot: string }> = {
|
|
running: { bg: 'bg-emerald-50 dark:bg-emerald-950/30', text: 'text-emerald-700 dark:text-emerald-400', dot: 'bg-emerald-500 animate-pulse' },
|
|
stopped: { bg: 'bg-slate-50 dark:bg-slate-950/30', text: 'text-slate-600 dark:text-slate-400', dot: 'bg-slate-400' },
|
|
error: { bg: 'bg-red-50 dark:bg-red-950/30', text: 'text-red-700 dark:text-red-400', dot: 'bg-red-500' },
|
|
unknown: { bg: 'bg-amber-50 dark:bg-amber-950/30', text: 'text-amber-700 dark:text-amber-400', dot: 'bg-amber-500' }
|
|
}
|
|
|
|
const status = server.netcupStatus?.toLowerCase() || 'unknown'
|
|
const colors = statusColors[status] || statusColors.unknown
|
|
|
|
return (
|
|
<Card className="hover:shadow-md transition-shadow">
|
|
<CardContent className="p-4">
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex items-start gap-3">
|
|
<div className="p-2 rounded-lg bg-muted">
|
|
<Server className="h-5 w-5 text-muted-foreground" />
|
|
</div>
|
|
<div>
|
|
<Link
|
|
href={`/admin/enterprise-clients/${clientId}/servers/${server.id}`}
|
|
className="font-medium hover:text-primary hover:underline"
|
|
>
|
|
{server.nickname || server.netcupServerId}
|
|
</Link>
|
|
{server.purpose && (
|
|
<p className="text-xs text-muted-foreground">{server.purpose}</p>
|
|
)}
|
|
<div className="flex items-center gap-2 mt-1">
|
|
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium ${colors.bg} ${colors.text}`}>
|
|
<span className={`h-1.5 w-1.5 rounded-full ${colors.dot}`} />
|
|
{status.charAt(0).toUpperCase() + status.slice(1)}
|
|
</span>
|
|
</div>
|
|
{server.netcupIps?.length > 0 && (
|
|
<p className="text-xs text-muted-foreground mt-1 font-mono">
|
|
{server.netcupIps[0]}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<Link href={`/admin/enterprise-clients/${clientId}/servers/${server.id}`}>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 w-8 p-0"
|
|
title="View Server"
|
|
>
|
|
<Eye className="h-4 w-4" />
|
|
</Button>
|
|
</Link>
|
|
{status === 'running' ? (
|
|
<>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => onPowerAction(server.id, 'POWERCYCLE')}
|
|
className="h-8 w-8 p-0"
|
|
title="Restart"
|
|
>
|
|
<RotateCcw className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => onPowerAction(server.id, 'OFF')}
|
|
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
|
|
title="Power Off"
|
|
>
|
|
<Power className="h-4 w-4" />
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => onPowerAction(server.id, 'ON')}
|
|
className="h-8 w-8 p-0 text-emerald-600"
|
|
title="Power On"
|
|
>
|
|
<Power className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
// Error rule row
|
|
function ErrorRuleRow({ rule }: { rule: ErrorDetectionRule }) {
|
|
const severityColors: Record<string, string> = {
|
|
INFO: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
|
|
WARNING: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
|
|
ERROR: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
|
|
CRITICAL: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
|
|
}
|
|
|
|
return (
|
|
<div className="flex items-center justify-between p-3 border rounded-lg">
|
|
<div className="flex items-center gap-3">
|
|
<div className={`p-1.5 rounded ${rule.isActive ? 'bg-emerald-100 dark:bg-emerald-900/30' : 'bg-slate-100 dark:bg-slate-900/30'}`}>
|
|
{rule.isActive ? (
|
|
<Check className="h-3.5 w-3.5 text-emerald-600" />
|
|
) : (
|
|
<X className="h-3.5 w-3.5 text-slate-400" />
|
|
)}
|
|
</div>
|
|
<div>
|
|
<p className="font-medium text-sm">{rule.name}</p>
|
|
<p className="text-xs text-muted-foreground font-mono truncate max-w-xs">
|
|
{rule.pattern}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className={`px-2 py-0.5 rounded text-xs font-medium ${severityColors[rule.severity]}`}>
|
|
{rule.severity}
|
|
</span>
|
|
<span className="text-xs text-muted-foreground">
|
|
{rule._count?.detectedErrors || 0} matches
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Detected error row
|
|
function DetectedErrorRow({
|
|
error,
|
|
onAcknowledge
|
|
}: {
|
|
error: DetectedError
|
|
onAcknowledge: () => void
|
|
}) {
|
|
const severityColors: Record<string, string> = {
|
|
INFO: 'border-l-blue-500',
|
|
WARNING: 'border-l-amber-500',
|
|
ERROR: 'border-l-red-500',
|
|
CRITICAL: 'border-l-purple-500'
|
|
}
|
|
|
|
const isAcknowledged = !!error.acknowledgedAt
|
|
|
|
return (
|
|
<div className={`p-3 border rounded-lg border-l-4 ${severityColors[error.rule?.severity || 'INFO']} ${isAcknowledged ? 'opacity-60' : ''}`}>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-medium text-sm">{error.rule?.name}</span>
|
|
{error.containerName && (
|
|
<span className="text-xs text-muted-foreground">
|
|
in {error.containerName}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground mt-1 font-mono truncate">
|
|
{error.logLine}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
{new Date(error.timestamp).toLocaleString()}
|
|
{error.server && ` • ${error.server.nickname || error.server.netcupServerId}`}
|
|
</p>
|
|
</div>
|
|
{!isAcknowledged && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={onAcknowledge}
|
|
className="h-8 px-2 text-xs"
|
|
>
|
|
<Check className="h-3.5 w-3.5 mr-1" />
|
|
Ack
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Add rule dialog
|
|
function AddRuleDialog({
|
|
open,
|
|
onOpenChange,
|
|
clientId
|
|
}: {
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
clientId: string
|
|
}) {
|
|
const [formData, setFormData] = useState({
|
|
name: '',
|
|
pattern: '',
|
|
severity: 'WARNING' as ErrorSeverity,
|
|
description: ''
|
|
})
|
|
|
|
const createRule = useCreateErrorRule()
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
try {
|
|
await createRule.mutateAsync({
|
|
clientId,
|
|
data: {
|
|
name: formData.name,
|
|
pattern: formData.pattern,
|
|
severity: formData.severity,
|
|
description: formData.description || undefined
|
|
}
|
|
})
|
|
setFormData({ name: '', pattern: '', severity: 'WARNING', description: '' })
|
|
onOpenChange(false)
|
|
} catch {
|
|
// Error handled by mutation
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="sm:max-w-[425px]">
|
|
<DialogHeader>
|
|
<DialogTitle>Add Error Detection Rule</DialogTitle>
|
|
<DialogDescription>
|
|
Create a regex pattern to detect errors in container logs.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<form onSubmit={handleSubmit}>
|
|
<div className="grid gap-4 py-4">
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="ruleName">Rule Name *</Label>
|
|
<Input
|
|
id="ruleName"
|
|
value={formData.name}
|
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
placeholder="Database Connection Failed"
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="pattern">Regex Pattern *</Label>
|
|
<Input
|
|
id="pattern"
|
|
value={formData.pattern}
|
|
onChange={(e) => setFormData({ ...formData, pattern: e.target.value })}
|
|
placeholder="error|ERROR|failed|FAILED"
|
|
className="font-mono text-sm"
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="severity">Severity</Label>
|
|
<Select
|
|
value={formData.severity}
|
|
onValueChange={(value) => setFormData({ ...formData, severity: value as ErrorSeverity })}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="INFO">Info</SelectItem>
|
|
<SelectItem value="WARNING">Warning</SelectItem>
|
|
<SelectItem value="ERROR">Error</SelectItem>
|
|
<SelectItem value="CRITICAL">Critical</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="description">Description</Label>
|
|
<Input
|
|
id="description"
|
|
value={formData.description}
|
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
|
placeholder="Detects database connection errors"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button type="submit" disabled={createRule.isPending}>
|
|
{createRule.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
Add Rule
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|
|
|
|
// Add server dialog
|
|
function AddServerDialog({
|
|
open,
|
|
onOpenChange,
|
|
clientId,
|
|
existingServerIds
|
|
}: {
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
clientId: string
|
|
existingServerIds: string[]
|
|
}) {
|
|
const [formData, setFormData] = useState({
|
|
netcupServerId: '',
|
|
nickname: '',
|
|
purpose: '',
|
|
portainerUrl: '',
|
|
portainerUsername: '',
|
|
portainerPassword: ''
|
|
})
|
|
|
|
const { data: netcupServers, isLoading: loadingServers } = useNetcupServers(false)
|
|
const addServer = useAddServerToClient()
|
|
|
|
// Filter out servers that are already linked
|
|
const availableServers = netcupServers?.servers?.filter(
|
|
(s: { id: string }) => !existingServerIds.includes(s.id)
|
|
) || []
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
if (!formData.netcupServerId) return
|
|
|
|
try {
|
|
await addServer.mutateAsync({
|
|
clientId,
|
|
data: {
|
|
netcupServerId: formData.netcupServerId,
|
|
nickname: formData.nickname || undefined,
|
|
purpose: formData.purpose || undefined,
|
|
portainerUrl: formData.portainerUrl || undefined,
|
|
portainerUsername: formData.portainerUsername || undefined,
|
|
portainerPassword: formData.portainerPassword || undefined
|
|
}
|
|
})
|
|
setFormData({
|
|
netcupServerId: '',
|
|
nickname: '',
|
|
purpose: '',
|
|
portainerUrl: '',
|
|
portainerUsername: '',
|
|
portainerPassword: ''
|
|
})
|
|
onOpenChange(false)
|
|
} catch {
|
|
// Error handled by mutation
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="sm:max-w-[500px]">
|
|
<DialogHeader>
|
|
<DialogTitle>Add Server to Client</DialogTitle>
|
|
<DialogDescription>
|
|
Link a Netcup server to this enterprise client.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<form onSubmit={handleSubmit}>
|
|
<div className="grid gap-4 py-4">
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="netcupServer">Netcup Server *</Label>
|
|
{loadingServers ? (
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground p-2 border rounded-md">
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
Loading servers...
|
|
</div>
|
|
) : availableServers.length === 0 ? (
|
|
<div className="text-sm text-muted-foreground p-2 border rounded-md">
|
|
No available servers. All Netcup servers are already linked.
|
|
</div>
|
|
) : (
|
|
<Select
|
|
value={formData.netcupServerId}
|
|
onValueChange={(value) => setFormData({ ...formData, netcupServerId: value })}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select a server..." />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{availableServers.map((server: { id: string; name: string; nickname?: string; hostname?: string; ips?: string[] }) => (
|
|
<SelectItem key={server.id} value={server.id}>
|
|
<div className="flex flex-col">
|
|
<span className="font-medium">{server.nickname || server.name}</span>
|
|
<span className="text-xs text-muted-foreground">
|
|
{server.ips?.[0] || 'No IP'}
|
|
{server.hostname && ` • ${server.hostname}`}
|
|
{server.nickname && ` • ${server.name}`}
|
|
</span>
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="nickname">Nickname</Label>
|
|
<Input
|
|
id="nickname"
|
|
value={formData.nickname}
|
|
onChange={(e) => setFormData({ ...formData, nickname: e.target.value })}
|
|
placeholder="Production Server"
|
|
/>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="purpose">Purpose</Label>
|
|
<Input
|
|
id="purpose"
|
|
value={formData.purpose}
|
|
onChange={(e) => setFormData({ ...formData, purpose: e.target.value })}
|
|
placeholder="Web hosting"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="border-t pt-4 mt-2">
|
|
<p className="text-sm font-medium mb-3">Portainer Credentials (Optional)</p>
|
|
<div className="grid gap-4">
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="portainerUrl">Portainer URL</Label>
|
|
<Input
|
|
id="portainerUrl"
|
|
value={formData.portainerUrl}
|
|
onChange={(e) => setFormData({ ...formData, portainerUrl: e.target.value })}
|
|
placeholder="https://portainer.example.com"
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="portainerUsername">Username</Label>
|
|
<Input
|
|
id="portainerUsername"
|
|
value={formData.portainerUsername}
|
|
onChange={(e) => setFormData({ ...formData, portainerUsername: e.target.value })}
|
|
placeholder="admin"
|
|
/>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="portainerPassword">Password</Label>
|
|
<Input
|
|
id="portainerPassword"
|
|
type="password"
|
|
value={formData.portainerPassword}
|
|
onChange={(e) => setFormData({ ...formData, portainerPassword: e.target.value })}
|
|
placeholder="••••••••"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button type="submit" disabled={addServer.isPending || !formData.netcupServerId}>
|
|
{addServer.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
Add Server
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|
|
|
|
export default function EnterpriseClientDetailPage({
|
|
params
|
|
}: {
|
|
params: Promise<{ id: string }>
|
|
}) {
|
|
const { id: clientId } = use(params)
|
|
const router = useRouter()
|
|
const [showAddRuleDialog, setShowAddRuleDialog] = useState(false)
|
|
const [showAddServerDialog, setShowAddServerDialog] = useState(false)
|
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
|
|
|
const { data: client, isLoading, isError, error, refetch } = useEnterpriseClient(clientId)
|
|
const { data: servers } = useClientServers(clientId)
|
|
const { data: errorRules } = useErrorRules(clientId)
|
|
const { data: detectedErrors } = useDetectedErrors(clientId, { acknowledged: false, limit: 50 })
|
|
|
|
const deleteClient = useDeleteEnterpriseClient()
|
|
const serverAction = useServerAction()
|
|
const acknowledgeError = useAcknowledgeError()
|
|
|
|
const handlePowerAction = async (serverId: string, command: 'ON' | 'OFF' | 'POWERCYCLE') => {
|
|
try {
|
|
await serverAction.mutateAsync({
|
|
clientId,
|
|
serverId,
|
|
action: { action: 'power', command }
|
|
})
|
|
} catch {
|
|
// Error handled by mutation
|
|
}
|
|
}
|
|
|
|
const handleAcknowledgeError = async (errorId: string) => {
|
|
try {
|
|
await acknowledgeError.mutateAsync({ clientId, errorId })
|
|
} catch {
|
|
// Error handled by mutation
|
|
}
|
|
}
|
|
|
|
const handleDeleteClient = async () => {
|
|
try {
|
|
await deleteClient.mutateAsync(clientId)
|
|
router.push('/admin/enterprise-clients')
|
|
} catch {
|
|
// Error handled by mutation
|
|
}
|
|
}
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center h-[50vh] gap-4">
|
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
|
<p className="text-sm text-muted-foreground">Loading client details...</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (isError || !client) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center h-[50vh] gap-4">
|
|
<AlertCircle className="h-8 w-8 text-destructive" />
|
|
<p className="font-medium text-destructive">Failed to load client</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
{error instanceof Error ? error.message : 'Client not found'}
|
|
</p>
|
|
<Button variant="outline" onClick={() => refetch()}>
|
|
<RefreshCw className="h-4 w-4 mr-2" />
|
|
Retry
|
|
</Button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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">
|
|
<Button variant="ghost" size="sm" className="gap-2">
|
|
<ArrowLeft className="h-4 w-4" />
|
|
Back
|
|
</Button>
|
|
</Link>
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-3 rounded-xl bg-gradient-to-br from-primary/10 to-primary/20">
|
|
<Building2 className="h-6 w-6 text-primary" />
|
|
</div>
|
|
<div>
|
|
<h1 className="text-2xl font-bold">{client.name}</h1>
|
|
{client.companyName && (
|
|
<p className="text-muted-foreground">{client.companyName}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button variant="outline" size="sm" className="gap-2">
|
|
<Edit className="h-4 w-4" />
|
|
Edit
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="gap-2 text-destructive hover:text-destructive"
|
|
onClick={() => setShowDeleteDialog(true)}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
Delete
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Client info cards */}
|
|
<div className="grid gap-4 md:grid-cols-3">
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center gap-3">
|
|
<Mail className="h-4 w-4 text-muted-foreground" />
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">Contact Email</p>
|
|
<p className="font-medium">{client.contactEmail}</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
{client.contactPhone && (
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center gap-3">
|
|
<Phone className="h-4 w-4 text-muted-foreground" />
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">Contact Phone</p>
|
|
<p className="font-medium">{client.contactPhone}</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
{client.notes && (
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center gap-3">
|
|
<FileText className="h-4 w-4 text-muted-foreground" />
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">Notes</p>
|
|
<p className="font-medium text-sm">{client.notes}</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
|
|
{/* Overview stats */}
|
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
<OverviewCard
|
|
title="Servers"
|
|
value={client.statsOverview?.totalServers || 0}
|
|
icon={Server}
|
|
/>
|
|
<OverviewCard
|
|
title="Avg CPU"
|
|
value={client.statsOverview?.avgCpuPercent != null ? `${client.statsOverview.avgCpuPercent}%` : '-'}
|
|
icon={Cpu}
|
|
/>
|
|
<OverviewCard
|
|
title="Containers"
|
|
value={`${client.statsOverview?.runningContainers || 0}/${client.statsOverview?.totalContainers || 0}`}
|
|
icon={HardDrive}
|
|
/>
|
|
<OverviewCard
|
|
title="Open Errors"
|
|
value={client.statsOverview?.unacknowledgedErrors || 0}
|
|
icon={AlertTriangle}
|
|
className={client.statsOverview?.unacknowledgedErrors ? 'border-red-200 dark:border-red-900' : ''}
|
|
/>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<Tabs defaultValue="servers" className="space-y-4">
|
|
<TabsList>
|
|
<TabsTrigger value="servers" className="gap-2">
|
|
<Server className="h-4 w-4" />
|
|
Servers ({servers?.length || 0})
|
|
</TabsTrigger>
|
|
<TabsTrigger value="errors" className="gap-2">
|
|
<AlertTriangle className="h-4 w-4" />
|
|
Errors ({detectedErrors?.length || 0})
|
|
</TabsTrigger>
|
|
<TabsTrigger value="rules" className="gap-2">
|
|
<ShieldAlert className="h-4 w-4" />
|
|
Rules ({errorRules?.length || 0})
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="servers" className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-sm text-muted-foreground">
|
|
{servers?.length || 0} server{servers?.length !== 1 ? 's' : ''} linked
|
|
</p>
|
|
<Button size="sm" className="gap-2" onClick={() => setShowAddServerDialog(true)}>
|
|
<Plus className="h-4 w-4" />
|
|
Add Server
|
|
</Button>
|
|
</div>
|
|
{servers && servers.length > 0 ? (
|
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
{servers.map((server) => (
|
|
<ServerCard
|
|
key={server.id}
|
|
server={server}
|
|
clientId={clientId}
|
|
onPowerAction={handlePowerAction}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<Card>
|
|
<CardContent className="py-12 text-center">
|
|
<Server className="h-12 w-12 text-muted-foreground/40 mx-auto mb-4" />
|
|
<p className="text-muted-foreground">No servers linked to this client yet.</p>
|
|
<Button className="mt-4 gap-2" onClick={() => setShowAddServerDialog(true)}>
|
|
<Plus className="h-4 w-4" />
|
|
Add Server
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</TabsContent>
|
|
|
|
<TabsContent value="errors" className="space-y-4">
|
|
{detectedErrors && detectedErrors.length > 0 ? (
|
|
<div className="space-y-2">
|
|
{detectedErrors.map((err) => (
|
|
<DetectedErrorRow
|
|
key={err.id}
|
|
error={err}
|
|
onAcknowledge={() => handleAcknowledgeError(err.id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<Card>
|
|
<CardContent className="py-12 text-center">
|
|
<Check className="h-12 w-12 text-emerald-500/40 mx-auto mb-4" />
|
|
<p className="text-muted-foreground">No unacknowledged errors.</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</TabsContent>
|
|
|
|
<TabsContent value="rules" className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-sm text-muted-foreground">
|
|
Define regex patterns to detect errors in container logs.
|
|
</p>
|
|
<Button size="sm" className="gap-2" onClick={() => setShowAddRuleDialog(true)}>
|
|
<Plus className="h-4 w-4" />
|
|
Add Rule
|
|
</Button>
|
|
</div>
|
|
{errorRules && errorRules.length > 0 ? (
|
|
<div className="space-y-2">
|
|
{errorRules.map((rule) => (
|
|
<ErrorRuleRow key={rule.id} rule={rule} />
|
|
))}
|
|
</div>
|
|
) : (
|
|
<Card>
|
|
<CardContent className="py-12 text-center">
|
|
<ShieldAlert className="h-12 w-12 text-muted-foreground/40 mx-auto mb-4" />
|
|
<p className="text-muted-foreground">No error detection rules configured.</p>
|
|
<Button className="mt-4 gap-2" onClick={() => setShowAddRuleDialog(true)}>
|
|
<Plus className="h-4 w-4" />
|
|
Add Rule
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</TabsContent>
|
|
</Tabs>
|
|
|
|
{/* Add Rule Dialog */}
|
|
<AddRuleDialog
|
|
open={showAddRuleDialog}
|
|
onOpenChange={setShowAddRuleDialog}
|
|
clientId={clientId}
|
|
/>
|
|
|
|
{/* Add Server Dialog */}
|
|
<AddServerDialog
|
|
open={showAddServerDialog}
|
|
onOpenChange={setShowAddServerDialog}
|
|
clientId={clientId}
|
|
existingServerIds={servers?.map(s => s.netcupServerId) || []}
|
|
/>
|
|
|
|
{/* Delete Confirmation Dialog */}
|
|
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Delete Enterprise Client</DialogTitle>
|
|
<DialogDescription>
|
|
Are you sure you want to delete {client.name}? This will remove all associated servers, error rules, and detected errors. This action cannot be undone.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setShowDeleteDialog(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
onClick={handleDeleteClient}
|
|
disabled={deleteClient.isPending}
|
|
>
|
|
{deleteClient.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
Delete Client
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
)
|
|
}
|