1.30 k8s管理

This commit is contained in:
dengqichen 2025-12-13 17:40:16 +08:00
parent 81294bc1dc
commit d81dfe9efa
16 changed files with 1297 additions and 1042 deletions

View File

@ -1,273 +0,0 @@
import React, { useState } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Package, Tag, FileCode, Copy, Check, Box } from 'lucide-react';
import { useToast } from '@/components/ui/use-toast';
import { YamlViewerDialog } from './YamlViewerDialog';
import { PodListDialog } from './PodListDialog';
import type { K8sDeploymentResponse } from '../types';
import dayjs from 'dayjs';
interface DeploymentCardProps {
deployment: K8sDeploymentResponse;
}
export const DeploymentCard: React.FC<DeploymentCardProps> = ({ deployment }) => {
const { toast } = useToast();
const [labelDialogOpen, setLabelDialogOpen] = useState(false);
const [yamlDialogOpen, setYamlDialogOpen] = useState(false);
const [podListDialogOpen, setPodListDialogOpen] = useState(false);
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
const [copiedAll, setCopiedAll] = useState(false);
const [copiedImage, setCopiedImage] = useState(false);
const labelCount = deployment.labels ? Object.keys(deployment.labels).length : 0;
const getReplicaStatus = () => {
const ready = deployment.readyReplicas ?? 0;
const desired = deployment.replicas ?? 0;
if (desired === 0) {
return { variant: 'secondary' as const, text: '0/0' };
}
if (ready === desired) {
return { variant: 'default' as const, text: `${ready}/${desired}` };
} else if (ready > 0) {
return { variant: 'secondary' as const, text: `${ready}/${desired}` };
} else {
return { variant: 'destructive' as const, text: `${ready}/${desired}` };
}
};
const status = getReplicaStatus();
const handleCopyImage = (e: React.MouseEvent) => {
e.stopPropagation();
if (!deployment.image) return;
navigator.clipboard.writeText(deployment.image).then(() => {
setCopiedImage(true);
toast({
title: '已复制',
description: '已复制镜像地址到剪贴板',
});
setTimeout(() => setCopiedImage(false), 2000);
});
};
const handleCopyLabel = (key: string, value: string, index: number) => {
const text = `${key}:${value}`;
navigator.clipboard.writeText(text).then(() => {
setCopiedIndex(index);
toast({
title: '已复制',
description: `已复制标签到剪贴板`,
});
setTimeout(() => setCopiedIndex(null), 2000);
});
};
const handleCopyAll = () => {
if (!deployment.labels || Object.keys(deployment.labels).length === 0) return;
const allLabelsText = Object.entries(deployment.labels)
.map(([key, value]) => `${key}:${value}`)
.join('\n');
navigator.clipboard.writeText(allLabelsText).then(() => {
setCopiedAll(true);
toast({
title: '已复制',
description: `已复制全部${labelCount}个标签`,
});
setTimeout(() => setCopiedAll(false), 2000);
});
};
return (
<>
<Card className="hover:shadow-lg transition-all hover:border-primary/50 border-border">
<CardContent className="p-4">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="flex-shrink-0">
<Package className="h-5 w-5 text-primary" />
</div>
<h3 className="font-medium text-base truncate" title={deployment.deploymentName}>
{deployment.deploymentName}
</h3>
</div>
<Badge variant={status.variant} className="flex-shrink-0 ml-2">
{status.text}
</Badge>
</div>
<div className="space-y-2 text-sm text-muted-foreground">
{deployment.image && (
<div className="flex items-center justify-between gap-2 group">
<div className="truncate flex-1" title={deployment.image}>
<span className="font-medium">:</span> {deployment.image}
</div>
<Button
variant="ghost"
size="sm"
onClick={handleCopyImage}
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
>
{copiedImage ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
)}
<div className="flex items-center gap-4">
<div className="flex items-center gap-1.5">
<span className="font-medium text-foreground">{labelCount}</span>
<span></span>
{labelCount > 0 && <Tag className="h-3 w-3" />}
</div>
</div>
{deployment.k8sUpdateTime && (
<div className="text-xs">
: {dayjs(deployment.k8sUpdateTime).format('YYYY-MM-DD HH:mm')}
</div>
)}
</div>
<div className="mt-4 pt-3 border-t flex gap-2">
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
setLabelDialogOpen(true);
}}
className="h-8 w-8 p-0"
title="查看标签"
>
<Tag className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
setYamlDialogOpen(true);
}}
className="h-8 w-8 p-0"
title="查看YAML"
>
<FileCode className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
setPodListDialogOpen(true);
}}
className="h-8 flex-1"
title="查看Pod"
>
<Box className="h-4 w-4 mr-1" />
Pod
</Button>
</div>
</CardContent>
</Card>
{/* 标签Dialog */}
{labelDialogOpen && (
<div className="fixed inset-0 z-50 bg-black/50" onClick={() => setLabelDialogOpen(false)}>
<div className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-2xl bg-white rounded-lg shadow-lg p-6" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold flex items-center gap-2">
<Tag className="h-5 w-5" />
</h3>
<Button
variant="outline"
size="sm"
onClick={handleCopyAll}
className="flex items-center gap-2"
>
{copiedAll ? (
<>
<Check className="h-4 w-4 text-green-600" />
</>
) : (
<>
<Copy className="h-4 w-4" />
</>
)}
</Button>
</div>
<div className="max-h-[60vh] overflow-y-auto">
{deployment.labels && Object.keys(deployment.labels).length > 0 ? (
<div className="space-y-2">
{Object.entries(deployment.labels).map(([key, value], index) => (
<div
key={key}
className="flex items-center justify-between gap-4 p-3 rounded-lg border border-border hover:border-primary/50 transition-colors bg-muted/30"
>
<div className="flex-1 min-w-0 font-mono text-sm">
<span className="text-blue-600 dark:text-blue-400 font-semibold">{key}</span>
<span className="text-muted-foreground">:</span>
<span className="text-foreground">{value}</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleCopyLabel(key, value, index)}
className="flex-shrink-0"
>
{copiedIndex === index ? (
<>
<Check className="h-4 w-4 mr-1 text-green-600" />
</>
) : (
<>
<Copy className="h-4 w-4 mr-1" />
</>
)}
</Button>
</div>
))}
</div>
) : (
<div className="text-center text-muted-foreground py-8">
</div>
)}
</div>
</div>
</div>
)}
{/* YAML查看器 */}
<YamlViewerDialog
open={yamlDialogOpen}
onOpenChange={setYamlDialogOpen}
title={`Deployment: ${deployment.deploymentName} - YAML配置`}
yamlContent={deployment.yamlConfig}
/>
{/* Pod列表 */}
<PodListDialog
open={podListDialogOpen}
onOpenChange={setPodListDialogOpen}
deploymentId={deployment.id}
deploymentName={deployment.deploymentName}
/>
</>
);
};

View File

@ -0,0 +1,151 @@
import React, { useState, useEffect } from 'react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { ChevronDown, ChevronRight, Loader2, Layers } from 'lucide-react';
import { useToast } from '@/components/ui/use-toast';
import { HealthBar } from './HealthBar';
import { PodRow } from './PodRow';
import { OperationMenu } from './OperationMenu';
import type { K8sDeploymentResponse, K8sPodResponse } from '../types';
import { getDeploymentHealth, HealthStatusLabels } from '../types';
import { getK8sPodsByDeployment } from '../service';
import { useAutoRefresh } from '../hooks/useAutoRefresh';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import 'dayjs/locale/zh-cn';
dayjs.extend(relativeTime);
dayjs.locale('zh-cn');
interface DeploymentRowProps {
deployment: K8sDeploymentResponse;
isExpanded: boolean;
onToggleExpand: () => void;
onViewLogs: (pod: K8sPodResponse) => void;
onRefresh?: () => void;
}
export const DeploymentRow: React.FC<DeploymentRowProps> = ({
deployment,
isExpanded,
onToggleExpand,
onViewLogs,
onRefresh,
}) => {
const { toast } = useToast();
const [pods, setPods] = useState<K8sPodResponse[]>([]);
const [loading, setLoading] = useState(false);
const health = getDeploymentHealth(deployment);
const healthLabel = HealthStatusLabels[health];
const loadPods = async () => {
if (!isExpanded) return;
try {
setLoading(true);
const data = await getK8sPodsByDeployment(deployment.id);
setPods(data || []);
} catch (error) {
console.error('加载Pod失败:', error);
toast({
title: '加载失败',
description: '加载Pod列表失败',
variant: 'destructive',
});
} finally {
setLoading(false);
}
};
// 展开时加载Pod
useEffect(() => {
if (isExpanded) {
loadPods();
}
}, [isExpanded]);
// 展开时启动10秒轮询
useAutoRefresh(loadPods, 10000, isExpanded);
const ready = deployment.readyReplicas ?? 0;
const desired = deployment.replicas ?? 0;
return (
<>
<tr className={`border-b hover:bg-gray-50 cursor-pointer ${health === 'critical' ? 'border-l-4 border-l-red-500' : health === 'warning' ? 'border-l-4 border-l-yellow-500' : ''}`}>
<td className="px-6 py-4">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={onToggleExpand}
className="h-6 w-6 p-0"
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
<Layers className="h-4 w-4 text-blue-600" />
<span className="font-medium">{deployment.deploymentName}</span>
</div>
</td>
<td className="px-6 py-4">
<HealthBar deployment={deployment} />
</td>
<td className="px-6 py-4">
<Button
variant="ghost"
size="sm"
onClick={onToggleExpand}
className="h-7 font-mono"
>
{ready}/{desired}
</Button>
</td>
<td className="px-6 py-4">
<span className="text-sm text-gray-600 font-mono truncate block max-w-xs" title={deployment.image}>
{deployment.image || '-'}
</span>
</td>
<td className="px-6 py-4">
<span className="text-sm text-gray-600">0</span>
</td>
<td className="px-6 py-4">
<span className="text-sm text-gray-600">
{deployment.k8sUpdateTime ? dayjs(deployment.k8sUpdateTime).fromNow() : '-'}
</span>
</td>
<td className="px-6 py-4">
<OperationMenu deployment={deployment} onSuccess={onRefresh} />
</td>
</tr>
{isExpanded && (
<>
{loading ? (
<tr>
<td colSpan={7} className="px-6 py-4 text-center">
<Loader2 className="h-6 w-6 animate-spin mx-auto text-primary" />
</td>
</tr>
) : pods.length === 0 ? (
<tr>
<td colSpan={7} className="px-6 py-4 text-center text-gray-500">
Deployment下暂无Pod
</td>
</tr>
) : (
pods.map(pod => (
<PodRow
key={pod.name}
pod={pod}
onViewLogs={onViewLogs}
/>
))
)}
</>
)}
</>
);
};

View File

@ -0,0 +1,58 @@
import React from 'react';
import { Table, TableBody, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { DeploymentRow } from './DeploymentRow';
import type { K8sDeploymentResponse, K8sPodResponse } from '../types';
interface DeploymentTableProps {
deployments: K8sDeploymentResponse[];
expandedIds: Set<number>;
onToggleExpand: (id: number) => void;
onViewLogs: (pod: K8sPodResponse) => void;
onRefresh?: () => void;
}
export const DeploymentTable: React.FC<DeploymentTableProps> = ({
deployments,
expandedIds,
onToggleExpand,
onViewLogs,
onRefresh,
}) => {
if (deployments.length === 0) {
return (
<div className="flex items-center justify-center h-64 text-muted-foreground">
Deployment
</div>
);
}
return (
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-64"></TableHead>
<TableHead className="w-32"></TableHead>
<TableHead className="w-24"></TableHead>
<TableHead className="w-80"></TableHead>
<TableHead className="w-24"></TableHead>
<TableHead className="w-32"></TableHead>
<TableHead className="w-20"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{deployments.map(deployment => (
<DeploymentRow
key={deployment.id}
deployment={deployment}
isExpanded={expandedIds.has(deployment.id)}
onToggleExpand={() => onToggleExpand(deployment.id)}
onViewLogs={onViewLogs}
onRefresh={onRefresh}
/>
))}
</TableBody>
</Table>
</div>
);
};

View File

@ -0,0 +1,48 @@
import React from 'react';
import { Skeleton } from '@/components/ui/skeleton';
export const DeploymentTableSkeleton: React.FC = () => {
return (
<div className="border rounded-lg overflow-hidden">
<div className="bg-gray-50 border-b">
<div className="flex items-center px-6 py-3 gap-4">
<div className="w-64"><Skeleton className="h-4 w-16" /></div>
<div className="w-32"><Skeleton className="h-4 w-16" /></div>
<div className="w-24"><Skeleton className="h-4 w-12" /></div>
<div className="w-80"><Skeleton className="h-4 w-12" /></div>
<div className="w-24"><Skeleton className="h-4 w-12" /></div>
<div className="w-32"><Skeleton className="h-4 w-20" /></div>
<div className="w-20"><Skeleton className="h-4 w-12" /></div>
</div>
</div>
<div className="divide-y">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="px-6 py-4 flex items-center gap-4">
<div className="w-64 flex items-center gap-2">
<Skeleton className="h-4 w-4" />
<Skeleton className="h-4 w-40" />
</div>
<div className="w-32">
<Skeleton className="h-2 w-full rounded-full" />
</div>
<div className="w-24">
<Skeleton className="h-6 w-12" />
</div>
<div className="w-80">
<Skeleton className="h-4 w-64" />
</div>
<div className="w-24">
<Skeleton className="h-4 w-8" />
</div>
<div className="w-32">
<Skeleton className="h-4 w-20" />
</div>
<div className="w-20">
<Skeleton className="h-6 w-6 rounded" />
</div>
</div>
))}
</div>
</div>
);
};

View File

@ -0,0 +1,38 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { HealthStatus } from '../types';
interface FilterTabsProps {
activeFilter: HealthStatus | 'all';
onFilterChange: (filter: HealthStatus | 'all') => void;
counts: {
all: number;
healthy: number;
warning: number;
critical: number;
};
}
export const FilterTabs: React.FC<FilterTabsProps> = ({ activeFilter, onFilterChange, counts }) => {
const filters = [
{ key: 'all' as const, label: '全部', count: counts.all },
{ key: HealthStatus.HEALTHY, label: '健康', count: counts.healthy },
{ key: HealthStatus.WARNING, label: '警告', count: counts.warning },
{ key: HealthStatus.CRITICAL, label: '异常', count: counts.critical },
];
return (
<div className="flex gap-2 mb-4">
{filters.map(filter => (
<Button
key={filter.key}
variant={activeFilter === filter.key ? 'default' : 'outline'}
size="sm"
onClick={() => onFilterChange(filter.key)}
>
{filter.label} ({filter.count})
</Button>
))}
</div>
);
};

View File

@ -0,0 +1,38 @@
import React from 'react';
import type { K8sDeploymentResponse } from '../types';
import { getDeploymentHealth, HealthStatus } from '../types';
interface HealthBarProps {
deployment: K8sDeploymentResponse;
}
export const HealthBar: React.FC<HealthBarProps> = ({ deployment }) => {
const ready = deployment.readyReplicas ?? 0;
const desired = deployment.replicas ?? 0;
const percentage = desired > 0 ? (ready / desired) * 100 : 100;
const health = getDeploymentHealth(deployment);
const getBarColor = () => {
switch (health) {
case HealthStatus.HEALTHY:
return 'bg-green-500';
case HealthStatus.WARNING:
return 'bg-yellow-500';
case HealthStatus.CRITICAL:
return 'bg-red-500';
default:
return 'bg-gray-500';
}
};
return (
<div className="w-full">
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className={`h-full ${getBarColor()} transition-all duration-300`}
style={{ width: `${percentage}%` }}
/>
</div>
</div>
);
};

View File

@ -0,0 +1,383 @@
import React, { useState } from 'react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogBody,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useToast } from '@/components/ui/use-toast';
import {
MoreVertical,
RotateCw,
Maximize2,
FileCode,
Tag,
Trash2,
Loader2,
} from 'lucide-react';
import type { K8sDeploymentResponse } from '../types';
import { restartK8sDeployment, scaleK8sDeployment, deleteK8sDeployment } from '../service';
import { YamlViewerDialog } from './YamlViewerDialog';
interface OperationMenuProps {
deployment: K8sDeploymentResponse;
onSuccess?: () => void;
}
export const OperationMenu: React.FC<OperationMenuProps> = ({ deployment, onSuccess }) => {
const { toast } = useToast();
const [open, setOpen] = useState(false);
const [scaleDialogOpen, setScaleDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [yamlDialogOpen, setYamlDialogOpen] = useState(false);
const [labelDialogOpen, setLabelDialogOpen] = useState(false);
const [replicas, setReplicas] = useState(deployment.replicas?.toString() || '1');
const [loading, setLoading] = useState(false);
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
const [copiedAll, setCopiedAll] = useState(false);
// 重启Deployment
const handleRestart = async () => {
try {
setLoading(true);
await restartK8sDeployment(deployment.id);
toast({
title: '重启成功',
description: `Deployment ${deployment.deploymentName} 已重启`,
});
onSuccess?.();
} catch (error: any) {
toast({
title: '重启失败',
description: error.message || '重启Deployment失败',
variant: 'destructive',
});
} finally {
setLoading(false);
setOpen(false);
}
};
// 扩缩容
const handleScale = async () => {
const newReplicas = parseInt(replicas);
if (isNaN(newReplicas) || newReplicas < 0) {
toast({
title: '参数错误',
description: '副本数必须是非负整数',
variant: 'destructive',
});
return;
}
try {
setLoading(true);
await scaleK8sDeployment(deployment.id, newReplicas);
toast({
title: '扩缩容成功',
description: `Deployment ${deployment.deploymentName} 副本数已调整为 ${newReplicas}`,
});
onSuccess?.();
} catch (error: any) {
toast({
title: '扩缩容失败',
description: error.message || '扩缩容失败',
variant: 'destructive',
});
} finally {
setLoading(false);
setScaleDialogOpen(false);
}
};
// 删除Deployment
const handleDelete = async () => {
try {
setLoading(true);
await deleteK8sDeployment(deployment.id);
toast({
title: '删除成功',
description: `Deployment ${deployment.deploymentName} 已删除`,
});
onSuccess?.();
} catch (error: any) {
toast({
title: '删除失败',
description: error.message || '删除Deployment失败',
variant: 'destructive',
});
} finally {
setLoading(false);
setDeleteDialogOpen(false);
}
};
// 复制标签
const handleCopyLabel = (key: string, value: string, index: number) => {
const text = `${key}:${value}`;
navigator.clipboard.writeText(text).then(() => {
setCopiedIndex(index);
toast({
title: '已复制',
description: '已复制标签到剪贴板',
});
setTimeout(() => setCopiedIndex(null), 2000);
});
};
// 复制所有标签
const handleCopyAllLabels = () => {
if (!deployment.labels || Object.keys(deployment.labels).length === 0) return;
const allLabelsText = Object.entries(deployment.labels)
.map(([key, value]) => `${key}:${value}`)
.join('\n');
navigator.clipboard.writeText(allLabelsText).then(() => {
setCopiedAll(true);
const labelCount = Object.keys(deployment.labels!).length;
toast({
title: '已复制',
description: `已复制全部${labelCount}个标签`,
});
setTimeout(() => setCopiedAll(false), 2000);
});
};
return (
<>
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={handleRestart} disabled={loading}>
<RotateCw className="h-4 w-4 mr-2" />
Deployment
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setScaleDialogOpen(true);
setOpen(false);
}}
>
<Maximize2 className="h-4 w-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setYamlDialogOpen(true);
setOpen(false);
}}
>
<FileCode className="h-4 w-4 mr-2" />
YAML
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setLabelDialogOpen(true);
setOpen(false);
}}
>
<Tag className="h-4 w-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
setDeleteDialogOpen(true);
setOpen(false);
}}
className="text-red-600 focus:text-red-600"
>
<Trash2 className="h-4 w-4 mr-2" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* 扩缩容对话框 */}
<Dialog open={scaleDialogOpen} onOpenChange={setScaleDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
{deployment.deploymentName}
</DialogDescription>
</DialogHeader>
<DialogBody>
<div className="space-y-4">
<div>
<Label htmlFor="replicas"></Label>
<div className="flex items-center gap-2 mt-2">
<Button
type="button"
variant="outline"
size="icon"
onClick={() => {
const current = parseInt(replicas) || 0;
if (current > 0) {
setReplicas(String(current - 1));
}
}}
disabled={parseInt(replicas) <= 0}
>
-
</Button>
<Input
id="replicas"
type="number"
min="0"
value={replicas}
onChange={(e) => setReplicas(e.target.value)}
className="text-center"
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => {
const current = parseInt(replicas) || 0;
setReplicas(String(current + 1));
}}
>
+
</Button>
</div>
</div>
<p className="text-sm text-muted-foreground">
: {deployment.replicas || 0}
</p>
</div>
</DialogBody>
<DialogFooter>
<Button variant="outline" onClick={() => setScaleDialogOpen(false)}>
</Button>
<Button onClick={handleScale} disabled={loading}>
{loading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 删除确认对话框 */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
Deployment <strong>{deployment.deploymentName}</strong>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
</Button>
<Button variant="destructive" onClick={handleDelete} disabled={loading}>
{loading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* YAML查看器 */}
<YamlViewerDialog
open={yamlDialogOpen}
onOpenChange={setYamlDialogOpen}
title={`Deployment: ${deployment.deploymentName} - YAML配置`}
yamlContent={deployment.yamlConfig}
/>
{/* 标签查看对话框 */}
<Dialog open={labelDialogOpen} onOpenChange={setLabelDialogOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<div className="flex items-center justify-between">
<DialogTitle className="flex items-center gap-2">
<Tag className="h-5 w-5" />
</DialogTitle>
<Button
variant="outline"
size="sm"
onClick={handleCopyAllLabels}
className="flex items-center gap-2"
>
{copiedAll ? (
<>
<span className="text-green-600"></span>
</>
) : (
<>
<FileCode className="h-4 w-4" />
</>
)}
</Button>
</div>
</DialogHeader>
<div className="max-h-[60vh] overflow-y-auto">
{deployment.labels && Object.keys(deployment.labels).length > 0 ? (
<div className="space-y-2">
{Object.entries(deployment.labels).map(([key, value], index) => (
<div
key={key}
className="flex items-center justify-between gap-4 p-3 rounded-lg border border-border hover:border-primary/50 transition-colors bg-muted/30"
>
<div className="flex-1 min-w-0 font-mono text-sm">
<span className="text-blue-600 dark:text-blue-400 font-semibold">
{key}
</span>
<span className="text-muted-foreground">:</span>
<span className="text-foreground">{value}</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleCopyLabel(key, value, index)}
className="flex-shrink-0"
>
{copiedIndex === index ? (
<>
<span className="text-green-600 mr-1"></span>
</>
) : (
<>
<FileCode className="h-4 w-4 mr-1" />
</>
)}
</Button>
</div>
))}
</div>
) : (
<div className="text-center text-muted-foreground py-8"></div>
)}
</div>
</DialogContent>
</Dialog>
</>
);
};

View File

@ -1,408 +0,0 @@
import React, { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogBody } from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Box, Tag, FileCode, Copy, Check, Network, Server, Package } from 'lucide-react';
import { useToast } from '@/components/ui/use-toast';
import { YamlViewerDialog } from './YamlViewerDialog';
import type { K8sPodResponse } from '../types';
import { PodPhaseLabels, ContainerStateLabels } from '../types';
import dayjs from 'dayjs';
interface PodDetailDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
pod: K8sPodResponse;
deploymentId: number;
}
export const PodDetailDialog: React.FC<PodDetailDialogProps> = ({
open,
onOpenChange,
pod,
}) => {
const { toast } = useToast();
const [yamlDialogOpen, setYamlDialogOpen] = useState(false);
const [labelDialogOpen, setLabelDialogOpen] = useState(false);
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
const [copiedAll, setCopiedAll] = useState(false);
const [copiedField, setCopiedField] = useState<string | null>(null);
const phaseLabel = PodPhaseLabels[pod.phase];
const handleCopy = (text: string, field: string) => {
navigator.clipboard.writeText(text).then(() => {
setCopiedField(field);
toast({
title: '已复制',
description: `已复制${field}到剪贴板`,
});
setTimeout(() => setCopiedField(null), 2000);
});
};
const handleCopyLabel = (key: string, value: string, index: number) => {
const text = `${key}:${value}`;
navigator.clipboard.writeText(text).then(() => {
setCopiedIndex(index);
toast({
title: '已复制',
description: `已复制标签到剪贴板`,
});
setTimeout(() => setCopiedIndex(null), 2000);
});
};
const handleCopyAll = () => {
if (!pod.labels || Object.keys(pod.labels).length === 0) return;
const allLabelsText = Object.entries(pod.labels)
.map(([key, value]) => `${key}:${value}`)
.join('\n');
navigator.clipboard.writeText(allLabelsText).then(() => {
setCopiedAll(true);
const labelCount = Object.keys(pod.labels!).length;
toast({
title: '已复制',
description: `已复制全部${labelCount}个标签`,
});
setTimeout(() => setCopiedAll(false), 2000);
});
};
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-5xl max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Box className="h-5 w-5 text-primary" />
<span>Pod详情: {pod.name}</span>
</DialogTitle>
</DialogHeader>
<DialogBody className="flex-1 overflow-y-auto space-y-6">
{/* 概览信息 */}
<div className="border rounded-lg p-4">
<h3 className="font-semibold mb-3 flex items-center gap-2">
<Package className="h-4 w-4" />
</h3>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 text-sm">
<div>
<span className="text-muted-foreground">:</span>
<Badge variant={phaseLabel.variant} className={`ml-2 ${phaseLabel.color}`}>
{phaseLabel.label}
</Badge>
</div>
<div>
<span className="text-muted-foreground">:</span>
<Badge variant={pod.ready ? 'default' : 'destructive'} className="ml-2">
{pod.ready ? '是' : '否'}
</Badge>
</div>
<div>
<span className="text-muted-foreground">:</span>
<span className="ml-2 font-medium">{pod.restartCount}</span>
</div>
{pod.ownerKind && (
<div>
<span className="text-muted-foreground">:</span>
<span className="ml-2 font-medium">{pod.ownerKind}</span>
</div>
)}
{pod.ownerName && (
<div className="col-span-2">
<span className="text-muted-foreground">:</span>
<span className="ml-2 font-medium">{pod.ownerName}</span>
</div>
)}
</div>
</div>
{/* 网络信息 */}
<div className="border rounded-lg p-4">
<h3 className="font-semibold mb-3 flex items-center gap-2">
<Network className="h-4 w-4" />
</h3>
<div className="space-y-2 text-sm">
{pod.podIP && (
<div className="flex items-center justify-between group">
<span className="text-muted-foreground">Pod IP:</span>
<div className="flex items-center gap-2">
<span className="font-mono">{pod.podIP}</span>
<Button
variant="ghost"
size="sm"
onClick={() => handleCopy(pod.podIP!, 'Pod IP')}
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
>
{copiedField === 'Pod IP' ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
</div>
)}
{pod.hostIP && (
<div className="flex items-center justify-between group">
<span className="text-muted-foreground">宿IP:</span>
<div className="flex items-center gap-2">
<span className="font-mono">{pod.hostIP}</span>
<Button
variant="ghost"
size="sm"
onClick={() => handleCopy(pod.hostIP!, '宿主机IP')}
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
>
{copiedField === '宿主机IP' ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
</div>
)}
{pod.nodeName && (
<div className="flex items-center justify-between group">
<span className="text-muted-foreground flex items-center gap-1">
<Server className="h-3 w-3" />
:
</span>
<div className="flex items-center gap-2">
<span className="font-mono">{pod.nodeName}</span>
<Button
variant="ghost"
size="sm"
onClick={() => handleCopy(pod.nodeName!, '节点名称')}
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
>
{copiedField === '节点名称' ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
</div>
)}
</div>
</div>
{/* 容器列表 */}
<div className="border rounded-lg p-4">
<h3 className="font-semibold mb-3"> ({pod.containers.length})</h3>
<div className="space-y-3">
{pod.containers.map((container, index) => {
const stateLabel = ContainerStateLabels[container.state];
return (
<div key={index} className="border rounded p-3 bg-muted/30">
<div className="flex items-center justify-between mb-2">
<span className="font-medium">{container.name}</span>
<div className="flex items-center gap-2">
<Badge variant={container.ready ? 'default' : 'secondary'}>
{container.ready ? '就绪' : '未就绪'}
</Badge>
<span className={`text-xs ${stateLabel.color}`}>
{stateLabel.label}
</span>
</div>
</div>
<div className="space-y-1 text-xs text-muted-foreground">
<div className="flex items-center justify-between group">
<span>:</span>
<div className="flex items-center gap-1">
<span className="font-mono text-xs truncate max-w-xs" title={container.image}>
{container.image}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => handleCopy(container.image, `镜像-${container.name}`)}
className="h-5 w-5 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
>
{copiedField === `镜像-${container.name}` ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
</div>
<div className="flex justify-between">
<span>:</span>
<span>{container.restartCount}</span>
</div>
{(container.cpuRequest || container.cpuLimit) && (
<div className="flex justify-between">
<span>CPU:</span>
<span>{container.cpuRequest || '-'} / {container.cpuLimit || '-'}</span>
</div>
)}
{(container.memoryRequest || container.memoryLimit) && (
<div className="flex justify-between">
<span>:</span>
<span>{container.memoryRequest || '-'} / {container.memoryLimit || '-'}</span>
</div>
)}
{container.startedAt && (
<div className="flex justify-between">
<span>:</span>
<span>{dayjs(container.startedAt).format('YYYY-MM-DD HH:mm:ss')}</span>
</div>
)}
</div>
</div>
);
})}
</div>
</div>
{/* 时间信息 */}
<div className="border rounded-lg p-4">
<h3 className="font-semibold mb-3"></h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span>{dayjs(pod.creationTimestamp).format('YYYY-MM-DD HH:mm:ss')}</span>
</div>
{pod.startTime && (
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span>{dayjs(pod.startTime).format('YYYY-MM-DD HH:mm:ss')}</span>
</div>
)}
</div>
</div>
{/* 标签和注解 */}
{(pod.labels && Object.keys(pod.labels).length > 0) && (
<div className="border rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold flex items-center gap-2">
<Tag className="h-4 w-4" />
({Object.keys(pod.labels).length})
</h3>
<Button
variant="outline"
size="sm"
onClick={() => setLabelDialogOpen(true)}
>
</Button>
</div>
<div className="flex flex-wrap gap-2">
{Object.entries(pod.labels).slice(0, 5).map(([key, value]) => (
<Badge key={key} variant="secondary" className="font-mono text-xs">
{key}: {value}
</Badge>
))}
{Object.keys(pod.labels).length > 5 && (
<Badge variant="outline">+{Object.keys(pod.labels).length - 5} </Badge>
)}
</div>
</div>
)}
{/* 操作按钮 */}
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => setYamlDialogOpen(true)}
className="flex items-center gap-2"
>
<FileCode className="h-4 w-4" />
YAML
</Button>
</div>
</DialogBody>
</DialogContent>
</Dialog>
{/* 标签Dialog */}
<Dialog open={labelDialogOpen} onOpenChange={setLabelDialogOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<div className="flex items-center justify-between">
<DialogTitle className="flex items-center gap-2">
<Tag className="h-5 w-5" />
</DialogTitle>
<Button
variant="outline"
size="sm"
onClick={handleCopyAll}
className="flex items-center gap-2"
>
{copiedAll ? (
<>
<Check className="h-4 w-4 text-green-600" />
</>
) : (
<>
<Copy className="h-4 w-4" />
</>
)}
</Button>
</div>
</DialogHeader>
<DialogBody className="max-h-[60vh] overflow-y-auto">
{pod.labels && Object.keys(pod.labels).length > 0 ? (
<div className="space-y-2">
{Object.entries(pod.labels).map(([key, value], index) => (
<div
key={key}
className="flex items-center justify-between gap-4 p-3 rounded-lg border border-border hover:border-primary/50 transition-colors bg-muted/30"
>
<div className="flex-1 min-w-0 font-mono text-sm">
<span className="text-blue-600 dark:text-blue-400 font-semibold">{key}</span>
<span className="text-muted-foreground">:</span>
<span className="text-foreground">{value}</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleCopyLabel(key, value, index)}
className="flex-shrink-0"
>
{copiedIndex === index ? (
<>
<Check className="h-4 w-4 mr-1 text-green-600" />
</>
) : (
<>
<Copy className="h-4 w-4 mr-1" />
</>
)}
</Button>
</div>
))}
</div>
) : (
<div className="text-center text-muted-foreground py-8">
</div>
)}
</DialogBody>
</DialogContent>
</Dialog>
{/* YAML查看器 */}
<YamlViewerDialog
open={yamlDialogOpen}
onOpenChange={setYamlDialogOpen}
title={`Pod: ${pod.name} - YAML配置`}
yamlContent={pod.yamlConfig}
/>
</>
);
};

View File

@ -1,196 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogBody } from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Loader2, Box, Copy, Check, Network, Server } from 'lucide-react';
import { useToast } from '@/components/ui/use-toast';
import { PodDetailDialog } from './PodDetailDialog';
import { getK8sPodsByDeployment } from '../service';
import type { K8sPodResponse } from '../types';
import { PodPhaseLabels } from '../types';
import dayjs from 'dayjs';
interface PodListDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
deploymentId: number;
deploymentName: string;
}
export const PodListDialog: React.FC<PodListDialogProps> = ({
open,
onOpenChange,
deploymentId,
deploymentName,
}) => {
const { toast } = useToast();
const [pods, setPods] = useState<K8sPodResponse[]>([]);
const [loading, setLoading] = useState(false);
const [selectedPod, setSelectedPod] = useState<K8sPodResponse | null>(null);
const [podDetailOpen, setPodDetailOpen] = useState(false);
const [copiedField, setCopiedField] = useState<string | null>(null);
const loadPods = async () => {
setLoading(true);
try {
const data = await getK8sPodsByDeployment(deploymentId);
setPods(data || []);
} catch (error) {
console.error('加载Pod失败:', error);
toast({
title: '加载失败',
description: '加载Pod列表失败',
variant: 'destructive',
});
} finally {
setLoading(false);
}
};
useEffect(() => {
if (open && deploymentId) {
loadPods();
}
}, [open, deploymentId]);
const handlePodClick = (pod: K8sPodResponse) => {
setSelectedPod(pod);
setPodDetailOpen(true);
};
const handleCopy = (text: string, field: string, e: React.MouseEvent) => {
e.stopPropagation();
navigator.clipboard.writeText(text).then(() => {
setCopiedField(field);
toast({
title: '已复制',
description: `已复制${field}到剪贴板`,
});
setTimeout(() => setCopiedField(null), 2000);
});
};
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-6xl max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Box className="h-5 w-5 text-primary" />
<span>Deployment: {deploymentName} Pod</span>
</DialogTitle>
</DialogHeader>
<DialogBody className="flex-1 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : pods.length === 0 ? (
<div className="flex items-center justify-center h-64 text-muted-foreground">
Deployment下暂无Pod
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{pods.map((pod) => {
const phaseLabel = PodPhaseLabels[pod.phase];
return (
<div
key={pod.name}
className="border rounded-lg p-4 hover:shadow-md transition-all cursor-pointer hover:border-primary/50"
onClick={() => handlePodClick(pod)}
>
<div className="flex items-start justify-between mb-3">
<h4 className="font-semibold text-sm truncate flex-1" title={pod.name}>
{pod.name}
</h4>
<Badge variant={phaseLabel.variant} className={phaseLabel.color}>
{phaseLabel.label}
</Badge>
</div>
<div className="space-y-2 text-xs text-muted-foreground">
<div className="flex items-center justify-between">
<span>:</span>
<Badge variant={pod.ready ? 'default' : 'destructive'} className="text-xs">
{pod.ready ? '就绪' : '未就绪'}
</Badge>
</div>
{pod.restartCount > 0 && (
<div className="flex items-center justify-between">
<span>:</span>
<Badge variant="secondary" className="bg-yellow-100 text-yellow-800 text-xs">
{pod.restartCount}
</Badge>
</div>
)}
{pod.nodeName && (
<div className="flex items-center gap-1 group">
<Server className="h-3 w-3" />
<span className="truncate flex-1" title={pod.nodeName}>{pod.nodeName}</span>
<Button
variant="ghost"
size="sm"
onClick={(e) => handleCopy(pod.nodeName!, `节点-${pod.name}`, e)}
className="h-5 w-5 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
>
{copiedField === `节点-${pod.name}` ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
)}
{pod.podIP && (
<div className="flex items-center gap-1 group">
<Network className="h-3 w-3" />
<span className="flex-1">{pod.podIP}</span>
<Button
variant="ghost"
size="sm"
onClick={(e) => handleCopy(pod.podIP!, `IP-${pod.name}`, e)}
className="h-5 w-5 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
>
{copiedField === `IP-${pod.name}` ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
)}
<div className="pt-2 border-t text-xs">
: {pod.containers.length}
</div>
{pod.creationTimestamp && (
<div className="text-xs">
: {dayjs(pod.creationTimestamp).format('MM-DD HH:mm')}
</div>
)}
</div>
</div>
);
})}
</div>
)}
</DialogBody>
</DialogContent>
</Dialog>
{selectedPod && (
<PodDetailDialog
open={podDetailOpen}
onOpenChange={setPodDetailOpen}
pod={selectedPod}
deploymentId={deploymentId}
/>
)}
</>
);
};

View File

@ -0,0 +1,105 @@
import React from 'react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { FileText, Copy, Check, Box } from 'lucide-react';
import { useToast } from '@/components/ui/use-toast';
import type { K8sPodResponse } from '../types';
import { PodPhaseLabels } from '../types';
interface PodRowProps {
pod: K8sPodResponse;
onViewLogs: (pod: K8sPodResponse) => void;
}
export const PodRow: React.FC<PodRowProps> = ({ pod, onViewLogs }) => {
const { toast } = useToast();
const [copiedField, setCopiedField] = React.useState<string | null>(null);
const phaseLabel = PodPhaseLabels[pod.phase];
const handleCopy = (text: string, field: string) => {
navigator.clipboard.writeText(text).then(() => {
setCopiedField(field);
toast({
title: '已复制',
description: `已复制${field}到剪贴板`,
});
setTimeout(() => setCopiedField(null), 2000);
});
};
return (
<tr className="border-t border-gray-100 bg-gray-50/50 hover:bg-gray-100/50">
<td className="px-6 py-3" colSpan={7}>
<div className="flex items-center gap-4 pl-8">
{/* Pod名称 */}
<div className="flex-shrink-0 w-48 flex items-center gap-2">
<Box className="h-3.5 w-3.5 text-green-600" />
<span className="text-sm font-mono text-gray-700">{pod.name}</span>
</div>
{/* 状态 */}
<div className="flex-shrink-0">
<Badge variant={phaseLabel.variant} className={`${phaseLabel.color} text-xs`}>
{phaseLabel.label}
</Badge>
</div>
{/* 就绪状态 */}
<div className="flex-shrink-0">
<Badge variant={pod.ready ? 'default' : 'destructive'} className="text-xs">
{pod.ready ? '就绪' : '未就绪'}
</Badge>
</div>
{/* 重启次数 */}
<div className="flex-shrink-0 w-20">
{pod.restartCount > 0 ? (
<Badge variant="secondary" className="bg-yellow-100 text-yellow-800 text-xs">
{pod.restartCount}
</Badge>
) : (
<span className="text-sm text-gray-400">-</span>
)}
</div>
{/* Pod IP */}
<div className="flex items-center gap-1 group flex-shrink-0 w-36">
<span className="text-sm text-gray-600 font-mono">{pod.podIP || '-'}</span>
{pod.podIP && (
<Button
variant="ghost"
size="sm"
onClick={() => handleCopy(pod.podIP!, 'Pod IP')}
className="h-5 w-5 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
>
{copiedField === 'Pod IP' ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
)}
</div>
{/* 节点 */}
<div className="flex-1 min-w-0">
<span className="text-sm text-gray-600 truncate block">{pod.nodeName || '-'}</span>
</div>
{/* 日志按钮 */}
<div className="flex-shrink-0">
<Button
variant="ghost"
size="sm"
onClick={() => onViewLogs(pod)}
className="h-7"
>
<FileText className="h-3 w-3 mr-1" />
</Button>
</div>
</div>
</td>
</tr>
);
};

View File

@ -0,0 +1,92 @@
import React from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Package, CheckCircle, AlertTriangle, XCircle, RefreshCw } from 'lucide-react';
import type { K8sDeploymentResponse } from '../types';
import { getDeploymentHealth, HealthStatus } from '../types';
interface StatsCardsProps {
deployments: K8sDeploymentResponse[];
}
export const StatsCards: React.FC<StatsCardsProps> = ({ deployments }) => {
const stats = React.useMemo(() => {
const total = deployments.length;
let healthy = 0;
let warning = 0;
let critical = 0;
let totalRestarts = 0;
deployments.forEach(deployment => {
const health = getDeploymentHealth(deployment);
if (health === HealthStatus.HEALTHY) healthy++;
else if (health === HealthStatus.WARNING) warning++;
else if (health === HealthStatus.CRITICAL) critical++;
});
return { total, healthy, warning, critical, totalRestarts };
}, [deployments]);
return (
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<Package className="h-8 w-8 text-blue-600" />
<div>
<p className="text-sm text-muted-foreground"></p>
<p className="text-2xl font-bold">{stats.total}</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<CheckCircle className="h-8 w-8 text-green-600" />
<div>
<p className="text-sm text-muted-foreground"></p>
<p className="text-2xl font-bold text-green-600">{stats.healthy}</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<AlertTriangle className="h-8 w-8 text-yellow-600" />
<div>
<p className="text-sm text-muted-foreground"></p>
<p className="text-2xl font-bold text-yellow-600">{stats.warning}</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<XCircle className="h-8 w-8 text-red-600" />
<div>
<p className="text-sm text-muted-foreground"></p>
<p className="text-2xl font-bold text-red-600">{stats.critical}</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<RefreshCw className="h-8 w-8 text-gray-600" />
<div>
<p className="text-sm text-muted-foreground"></p>
<p className="text-2xl font-bold text-gray-600">{stats.totalRestarts}</p>
</div>
</div>
</CardContent>
</Card>
</div>
);
};

View File

@ -0,0 +1,51 @@
import { useEffect, useRef, useCallback } from 'react';
/**
* Hook
* @param callback
* @param interval
* @param enabled
*/
export const useAutoRefresh = (
callback: () => void | Promise<void>,
interval: number,
enabled: boolean = true
) => {
const savedCallback = useRef(callback);
const timerRef = useRef<NodeJS.Timeout>();
// 更新回调引用
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
// 启动/停止定时器
useEffect(() => {
if (!enabled) {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = undefined;
}
return;
}
const tick = () => {
savedCallback.current();
};
timerRef.current = setInterval(tick, interval);
return () => {
if (timerRef.current) {
clearInterval(timerRef.current);
}
};
}, [interval, enabled]);
// 手动触发刷新
const refresh = useCallback(() => {
savedCallback.current();
}, []);
return { refresh };
};

View File

@ -0,0 +1,40 @@
import { useState, useCallback } from 'react';
/**
* Deployment展开状态管理Hook
*/
export const useDeploymentExpand = () => {
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
const toggleExpand = useCallback((id: number) => {
setExpandedIds(prev => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return newSet;
});
}, []);
const isExpanded = useCallback((id: number) => {
return expandedIds.has(id);
}, [expandedIds]);
const expandAll = useCallback((ids: number[]) => {
setExpandedIds(new Set(ids));
}, []);
const collapseAll = useCallback(() => {
setExpandedIds(new Set());
}, []);
return {
expandedIds,
toggleExpand,
isExpanded,
expandAll,
collapseAll,
};
};

View File

@ -1,17 +1,25 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { PageContainer } from '@/components/ui/page-container'; import { PageContainer } from '@/components/ui/page-container';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { RefreshCw, Loader2, Cloud, History as HistoryIcon, Database } from 'lucide-react'; import { Input } from '@/components/ui/input';
import { RefreshCw, Loader2, Cloud, History as HistoryIcon, Database, Search } from 'lucide-react';
import { useToast } from '@/components/ui/use-toast'; import { useToast } from '@/components/ui/use-toast';
import { DeploymentCard } from './components/DeploymentCard'; import { StatsCards } from './components/StatsCards';
import { FilterTabs } from './components/FilterTabs';
import { DeploymentTable } from './components/DeploymentTable';
import { DeploymentTableSkeleton } from './components/DeploymentTableSkeleton';
import { SyncHistoryTab } from './components/SyncHistoryTab'; import { SyncHistoryTab } from './components/SyncHistoryTab';
import { getExternalSystemList } from '@/pages/Resource/External/List/service'; import { getExternalSystemList } from '@/pages/Resource/External/List/service';
import { syncK8sNamespace, syncK8sDeployment, getK8sNamespaceList, getK8sDeploymentByNamespace } from './service'; import { syncK8sNamespace, syncK8sDeployment, getK8sNamespaceList, getK8sDeploymentByNamespace } from './service';
import type { ExternalSystemResponse } from '@/pages/Resource/External/List/types'; import type { ExternalSystemResponse } from '@/pages/Resource/External/List/types';
import type { K8sNamespaceResponse, K8sDeploymentResponse } from './types'; import type { K8sNamespaceResponse, K8sDeploymentResponse, K8sPodResponse } from './types';
import { SystemType } from '@/pages/Resource/External/List/types'; import { SystemType } from '@/pages/Resource/External/List/types';
import { getDeploymentHealth, HealthStatus } from './types';
import { useAutoRefresh } from './hooks/useAutoRefresh';
import { useDeploymentExpand } from './hooks/useDeploymentExpand';
import dayjs from 'dayjs';
const K8sManagement: React.FC = () => { const K8sManagement: React.FC = () => {
const { toast } = useToast(); const { toast } = useToast();
@ -22,14 +30,17 @@ const K8sManagement: React.FC = () => {
const [deployments, setDeployments] = useState<K8sDeploymentResponse[]>([]); const [deployments, setDeployments] = useState<K8sDeploymentResponse[]>([]);
const [syncingNamespace, setSyncingNamespace] = useState(false); const [syncingNamespace, setSyncingNamespace] = useState(false);
const [syncingDeployment, setSyncingDeployment] = useState(false); const [syncingDeployment, setSyncingDeployment] = useState(false);
const [loading, setLoading] = useState(true); const [initialLoading, setInitialLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [showHistory, setShowHistory] = useState(false); const [showHistory, setShowHistory] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [activeFilter, setActiveFilter] = useState<HealthStatus | 'all'>('all');
const [lastUpdateTime, setLastUpdateTime] = useState<Date>(new Date());
const { expandedIds, toggleExpand } = useDeploymentExpand();
// 加载K8S集群列表 // 加载K8S集群列表
const loadK8sClusters = async () => { const loadK8sClusters = async () => {
try { try {
setLoading(true);
const clusters = await getExternalSystemList({ type: SystemType.K8S }); const clusters = await getExternalSystemList({ type: SystemType.K8S });
// 过滤出启用的集群 // 过滤出启用的集群
const enabledClusters = (clusters || []).filter(c => c.enabled); const enabledClusters = (clusters || []).filter(c => c.enabled);
@ -47,7 +58,7 @@ const K8sManagement: React.FC = () => {
variant: 'destructive', variant: 'destructive',
}); });
} finally { } finally {
setLoading(false); setInitialLoading(false);
} }
}; };
@ -79,6 +90,7 @@ const K8sManagement: React.FC = () => {
try { try {
const data = await getK8sDeploymentByNamespace(selectedClusterId, selectedNamespaceId); const data = await getK8sDeploymentByNamespace(selectedClusterId, selectedNamespaceId);
setDeployments(data || []); setDeployments(data || []);
setLastUpdateTime(new Date());
} catch (error) { } catch (error) {
console.error('加载Deployment失败:', error); console.error('加载Deployment失败:', error);
toast({ toast({
@ -89,6 +101,9 @@ const K8sManagement: React.FC = () => {
} }
}; };
// 30秒自动刷新
useAutoRefresh(loadDeployments, 30000, !showHistory && !!selectedNamespaceId);
useEffect(() => { useEffect(() => {
loadK8sClusters(); loadK8sClusters();
}, []); }, []);
@ -163,34 +178,84 @@ const K8sManagement: React.FC = () => {
} }
}; };
// 刷新页面数据 // 手动刷新
const handleRefresh = async () => { const handleRefresh = async () => {
if (!selectedClusterId || !selectedNamespaceId) return;
try {
setRefreshing(true);
await loadDeployments(); await loadDeployments();
toast({ toast({
title: '刷新成功', title: '刷新成功',
description: 'Deployment列表已更新', description: 'Deployment列表已更新',
}); });
} catch (error) {
toast({
title: '刷新失败',
description: '刷新Deployment列表失败',
variant: 'destructive',
});
} finally {
setRefreshing(false);
}
}; };
if (loading) { // 筛选和搜索
const filteredDeployments = useMemo(() => {
let filtered = deployments;
// 按健康状态筛选
if (activeFilter !== 'all') {
filtered = filtered.filter(d => getDeploymentHealth(d) === activeFilter);
}
// 按搜索关键词筛选
if (searchQuery) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(d =>
d.deploymentName.toLowerCase().includes(query) ||
d.image?.toLowerCase().includes(query)
);
}
// 排序:异常 -> 警告 -> 健康
return filtered.sort((a, b) => {
const healthOrder = { critical: 0, warning: 1, healthy: 2 };
const healthA = getDeploymentHealth(a);
const healthB = getDeploymentHealth(b);
return healthOrder[healthA] - healthOrder[healthB];
});
}, [deployments, activeFilter, searchQuery]);
// 统计数据
const stats = useMemo(() => {
const all = deployments.length;
let healthy = 0;
let warning = 0;
let critical = 0;
deployments.forEach(d => {
const health = getDeploymentHealth(d);
if (health === HealthStatus.HEALTHY) healthy++;
else if (health === HealthStatus.WARNING) warning++;
else if (health === HealthStatus.CRITICAL) critical++;
});
return { all, healthy, warning, critical };
}, [deployments]);
// 处理日志查看
const handleViewLogs = (pod: K8sPodResponse) => {
// TODO: 打开日志抽屉
console.log('查看日志:', pod.name);
};
if (initialLoading) {
return ( return (
<PageContainer> <PageContainer>
<div className="flex items-center justify-center h-64"> <div className="mb-6">
<Loader2 className="h-8 w-8 animate-spin text-primary" /> <h2 className="text-3xl font-bold tracking-tight">K8S管理</h2>
<p className="text-muted-foreground mt-1">
Kubernetes集群资源Deployment
</p>
</div> </div>
<Card className="mb-6">
<CardContent className="pt-6">
<div className="flex items-center gap-4">
<div className="h-10 w-48 bg-gray-200 animate-pulse rounded" />
<div className="h-10 w-48 bg-gray-200 animate-pulse rounded" />
<div className="h-10 flex-1 max-w-sm bg-gray-200 animate-pulse rounded" />
</div>
</CardContent>
</Card>
<DeploymentTableSkeleton />
</PageContainer> </PageContainer>
); );
} }
@ -221,21 +286,15 @@ const K8sManagement: React.FC = () => {
</p> </p>
</div> </div>
{/* 顶部控制栏 */}
<Card className="mb-6"> <Card className="mb-6">
<CardHeader> <CardContent className="pt-6">
<CardTitle className="flex items-center gap-2">
<Cloud className="h-5 w-5" />
K8S集群
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-4 flex-wrap"> <div className="flex items-center gap-4 flex-wrap">
<div className="w-64">
<Select <Select
value={selectedClusterId ? String(selectedClusterId) : undefined} value={selectedClusterId ? String(selectedClusterId) : undefined}
onValueChange={(value) => setSelectedClusterId(Number(value))} onValueChange={(value) => setSelectedClusterId(Number(value))}
> >
<SelectTrigger> <SelectTrigger className="w-48">
<SelectValue placeholder="选择K8S集群" /> <SelectValue placeholder="选择K8S集群" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -246,102 +305,63 @@ const K8sManagement: React.FC = () => {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div>
<div className="w-64">
<Select <Select
value={selectedNamespaceId ? String(selectedNamespaceId) : undefined} value={selectedNamespaceId ? String(selectedNamespaceId) : undefined}
onValueChange={(value) => setSelectedNamespaceId(Number(value))} onValueChange={(value) => setSelectedNamespaceId(Number(value))}
disabled={!selectedClusterId || namespaces.length === 0} disabled={!selectedClusterId || namespaces.length === 0}
> >
<SelectTrigger> <SelectTrigger className="w-48">
{selectedNamespaceId ? ( <SelectValue placeholder="选择命名空间" />
<div className="flex items-center justify-between w-full gap-2">
<div className="flex items-center gap-2 min-w-0">
<Database className="h-4 w-4 flex-shrink-0" />
<span className="truncate">
{namespaces.find(n => n.id === selectedNamespaceId)?.namespaceName}
</span>
</div>
{namespaces.find(n => n.id === selectedNamespaceId)?.status && (
<span className={`text-xs px-2 py-0.5 rounded flex-shrink-0 ${namespaces.find(n => n.id === selectedNamespaceId)?.status === 'Active' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-700'}`}>
{namespaces.find(n => n.id === selectedNamespaceId)?.status}
</span>
)}
</div>
) : (
<span className="text-muted-foreground"></span>
)}
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{namespaces.map((namespace) => ( {namespaces.map((namespace) => (
<SelectItem <SelectItem key={namespace.id} value={String(namespace.id)}>
key={namespace.id} <div className="flex items-center gap-2">
value={String(namespace.id)} <Database className="h-4 w-4" />
className="data-[state=checked]:bg-primary/10 data-[state=checked]:text-primary" {namespace.namespaceName}
>
<div className="flex items-center gap-2 w-full">
<Database className="h-4 w-4 flex-shrink-0" />
<span className="inline-block w-40 truncate">{namespace.namespaceName}</span>
{namespace.status && (
<span className={`text-xs px-2 py-0.5 rounded flex-shrink-0 ml-auto ${namespace.status === 'Active' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-700'}`}>
{namespace.status}
</span>
)}
</div> </div>
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder="搜索Deployment或镜像..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div> </div>
<Button <Button onClick={handleRefresh} variant="outline" disabled={!selectedNamespaceId}>
onClick={handleSyncNamespace}
disabled={!selectedClusterId || syncingNamespace}
variant="outline"
>
{syncingNamespace ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<RefreshCw className="h-4 w-4 mr-2" /> <RefreshCw className="h-4 w-4 mr-2" />
)}
</Button>
<Button
onClick={handleSyncDeployment}
disabled={!selectedClusterId || syncingDeployment}
variant="outline"
>
{syncingDeployment ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<RefreshCw className="h-4 w-4 mr-2" />
)}
Deployment
</Button>
<Button
onClick={handleRefresh}
disabled={!selectedClusterId || !selectedNamespaceId || refreshing}
variant="outline"
>
{refreshing ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<RefreshCw className="h-4 w-4 mr-2" />
)}
</Button> </Button>
<Button <Button onClick={handleSyncNamespace} disabled={!selectedClusterId || syncingNamespace} variant="outline">
onClick={() => setShowHistory(!showHistory)} {syncingNamespace && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
variant={showHistory ? "default" : "outline"}
> </Button>
<Button onClick={handleSyncDeployment} disabled={!selectedClusterId || syncingDeployment} variant="outline">
{syncingDeployment && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Deployment
</Button>
<Button onClick={() => setShowHistory(!showHistory)} variant={showHistory ? "default" : "outline"}>
<HistoryIcon className="h-4 w-4 mr-2" /> <HistoryIcon className="h-4 w-4 mr-2" />
{showHistory ? '查看命名空间' : '同步历史'} {showHistory ? '返回' : '同步历史'}
</Button> </Button>
</div> </div>
{!showHistory && selectedNamespaceId && (
<div className="mt-4 text-sm text-muted-foreground">
: {dayjs(lastUpdateTime).format('HH:mm:ss')} ({dayjs(lastUpdateTime).fromNow()})
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
@ -354,32 +374,31 @@ const K8sManagement: React.FC = () => {
<SyncHistoryTab externalSystemId={selectedClusterId} /> <SyncHistoryTab externalSystemId={selectedClusterId} />
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : !selectedNamespaceId ? (
<Card>
<CardHeader>
<CardTitle>Deployment</CardTitle>
</CardHeader>
<CardContent>
{!selectedNamespaceId ? (
<div className="flex items-center justify-center h-64 text-muted-foreground"> <div className="flex items-center justify-center h-64 text-muted-foreground">
</div>
) : deployments.length === 0 ? (
<div className="flex items-center justify-center h-64 text-muted-foreground">
Deployment
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"> <>
{deployments.map((deployment) => ( {/* 统计卡片 */}
<DeploymentCard <StatsCards deployments={deployments} />
key={deployment.id}
deployment={deployment} {/* 筛选标签 */}
<FilterTabs
activeFilter={activeFilter}
onFilterChange={setActiveFilter}
counts={stats}
/> />
))}
</div> {/* Deployment表格 */}
)} <DeploymentTable
</CardContent> deployments={filteredDeployments}
</Card> expandedIds={expandedIds}
onToggleExpand={toggleExpand}
onViewLogs={handleViewLogs}
onRefresh={loadDeployments}
/>
</>
)} )}
</PageContainer> </PageContainer>
); );

View File

@ -164,3 +164,40 @@ export const getK8sPodDetail = async (
): Promise<K8sPodResponse> => { ): Promise<K8sPodResponse> => {
return request.get(`/api/v1/k8s-deployment/${deploymentId}/pods/${podName}`); return request.get(`/api/v1/k8s-deployment/${deploymentId}/pods/${podName}`);
}; };
// ==================== K8S日志接口 ====================
/**
* Pod日志
*/
export const getK8sPodLogs = async (
deploymentId: number,
podName: string,
params?: {
container?: string;
tail?: number;
since?: string;
follow?: boolean;
}
): Promise<string> => {
return request.get(`/api/v1/k8s-deployment/${deploymentId}/pods/${podName}/logs`, { params });
};
// ==================== K8S操作接口 ====================
/**
* Deployment
*/
export const restartK8sDeployment = async (id: number): Promise<{ success: boolean; message: string }> => {
return request.post(`/api/v1/k8s-deployment/${id}/restart`);
};
/**
* Deployment
*/
export const scaleK8sDeployment = async (
id: number,
replicas: number
): Promise<{ success: boolean; message: string }> => {
return request.post(`/api/v1/k8s-deployment/${id}/scale`, { replicas });
};

View File

@ -277,3 +277,75 @@ export interface K8sPodResponse {
/** YAML配置 */ /** YAML配置 */
yamlConfig?: string; yamlConfig?: string;
} }
// ==================== 日志相关 ====================
/**
* Pod日志查询参数
*/
export interface PodLogsQuery {
/** 容器名称 */
container?: string;
/** 显示最后N行 */
tail?: number;
/** 时间戳,显示此时间之后的日志 */
since?: string;
/** 是否实时跟踪 */
follow?: boolean;
}
// ==================== 操作相关 ====================
/**
*
*/
export interface ScaleRequest {
/** 副本数 */
replicas: number;
}
/**
*
*/
export interface OperationResponse {
/** 是否成功 */
success: boolean;
/** 消息 */
message: string;
}
// ==================== 健康状态 ====================
/**
*
*/
export enum HealthStatus {
/** 健康 */
HEALTHY = 'healthy',
/** 警告 */
WARNING = 'warning',
/** 异常 */
CRITICAL = 'critical',
}
/**
* Deployment健康状态
*/
export const getDeploymentHealth = (deployment: K8sDeploymentResponse): HealthStatus => {
const ready = deployment.readyReplicas ?? 0;
const desired = deployment.replicas ?? 0;
if (desired === 0) return HealthStatus.HEALTHY;
if (ready === 0) return HealthStatus.CRITICAL;
if (ready < desired) return HealthStatus.WARNING;
return HealthStatus.HEALTHY;
};
/**
*
*/
export const HealthStatusLabels: Record<HealthStatus, { label: string; color: string; bgColor: string }> = {
[HealthStatus.HEALTHY]: { label: '健康', color: 'text-green-600', bgColor: 'bg-green-100' },
[HealthStatus.WARNING]: { label: '警告', color: 'text-yellow-600', bgColor: 'bg-yellow-100' },
[HealthStatus.CRITICAL]: { label: '异常', color: 'text-red-600', bgColor: 'bg-red-100' },
};