1.30 k8s管理
This commit is contained in:
parent
81294bc1dc
commit
d81dfe9efa
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
105
frontend/src/pages/Resource/K8s/List/components/PodRow.tsx
Normal file
105
frontend/src/pages/Resource/K8s/List/components/PodRow.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
51
frontend/src/pages/Resource/K8s/List/hooks/useAutoRefresh.ts
Normal file
51
frontend/src/pages/Resource/K8s/List/hooks/useAutoRefresh.ts
Normal 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 };
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
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 { 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 { getExternalSystemList } from '@/pages/Resource/External/List/service';
|
||||
import { syncK8sNamespace, syncK8sDeployment, getK8sNamespaceList, getK8sDeploymentByNamespace } from './service';
|
||||
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 { getDeploymentHealth, HealthStatus } from './types';
|
||||
import { useAutoRefresh } from './hooks/useAutoRefresh';
|
||||
import { useDeploymentExpand } from './hooks/useDeploymentExpand';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const K8sManagement: React.FC = () => {
|
||||
const { toast } = useToast();
|
||||
@ -22,14 +30,17 @@ const K8sManagement: React.FC = () => {
|
||||
const [deployments, setDeployments] = useState<K8sDeploymentResponse[]>([]);
|
||||
const [syncingNamespace, setSyncingNamespace] = useState(false);
|
||||
const [syncingDeployment, setSyncingDeployment] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
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集群列表
|
||||
const loadK8sClusters = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const clusters = await getExternalSystemList({ type: SystemType.K8S });
|
||||
// 过滤出启用的集群
|
||||
const enabledClusters = (clusters || []).filter(c => c.enabled);
|
||||
@ -47,7 +58,7 @@ const K8sManagement: React.FC = () => {
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setInitialLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@ -79,6 +90,7 @@ const K8sManagement: React.FC = () => {
|
||||
try {
|
||||
const data = await getK8sDeploymentByNamespace(selectedClusterId, selectedNamespaceId);
|
||||
setDeployments(data || []);
|
||||
setLastUpdateTime(new Date());
|
||||
} catch (error) {
|
||||
console.error('加载Deployment失败:', error);
|
||||
toast({
|
||||
@ -89,6 +101,9 @@ const K8sManagement: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 30秒自动刷新
|
||||
useAutoRefresh(loadDeployments, 30000, !showHistory && !!selectedNamespaceId);
|
||||
|
||||
useEffect(() => {
|
||||
loadK8sClusters();
|
||||
}, []);
|
||||
@ -163,34 +178,84 @@ const K8sManagement: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 刷新页面数据
|
||||
// 手动刷新
|
||||
const handleRefresh = async () => {
|
||||
if (!selectedClusterId || !selectedNamespaceId) return;
|
||||
|
||||
try {
|
||||
setRefreshing(true);
|
||||
await loadDeployments();
|
||||
toast({
|
||||
title: '刷新成功',
|
||||
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 (
|
||||
<PageContainer>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<div className="mb-6">
|
||||
<h2 className="text-3xl font-bold tracking-tight">K8S管理</h2>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
管理Kubernetes集群资源,同步命名空间和Deployment
|
||||
</p>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@ -221,21 +286,15 @@ const K8sManagement: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 顶部控制栏 */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Cloud className="h-5 w-5" />
|
||||
K8S集群
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<div className="w-64">
|
||||
<Select
|
||||
value={selectedClusterId ? String(selectedClusterId) : undefined}
|
||||
onValueChange={(value) => setSelectedClusterId(Number(value))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder="选择K8S集群" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@ -246,102 +305,63 @@ const K8sManagement: React.FC = () => {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="w-64">
|
||||
<Select
|
||||
value={selectedNamespaceId ? String(selectedNamespaceId) : undefined}
|
||||
onValueChange={(value) => setSelectedNamespaceId(Number(value))}
|
||||
disabled={!selectedClusterId || namespaces.length === 0}
|
||||
>
|
||||
<SelectTrigger>
|
||||
{selectedNamespaceId ? (
|
||||
<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 className="w-48">
|
||||
<SelectValue placeholder="选择命名空间" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{namespaces.map((namespace) => (
|
||||
<SelectItem
|
||||
key={namespace.id}
|
||||
value={String(namespace.id)}
|
||||
className="data-[state=checked]:bg-primary/10 data-[state=checked]:text-primary"
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
<SelectItem key={namespace.id} value={String(namespace.id)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-4 w-4" />
|
||||
{namespace.namespaceName}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</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>
|
||||
|
||||
<Button
|
||||
onClick={handleSyncNamespace}
|
||||
disabled={!selectedClusterId || syncingNamespace}
|
||||
variant="outline"
|
||||
>
|
||||
{syncingNamespace ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Button onClick={handleRefresh} variant="outline" disabled={!selectedNamespaceId}>
|
||||
<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
|
||||
onClick={() => setShowHistory(!showHistory)}
|
||||
variant={showHistory ? "default" : "outline"}
|
||||
>
|
||||
<Button onClick={handleSyncNamespace} disabled={!selectedClusterId || syncingNamespace} variant="outline">
|
||||
{syncingNamespace && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
同步命名空间
|
||||
</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" />
|
||||
{showHistory ? '查看命名空间' : '同步历史'}
|
||||
{showHistory ? '返回' : '同步历史'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!showHistory && selectedNamespaceId && (
|
||||
<div className="mt-4 text-sm text-muted-foreground">
|
||||
最后更新: {dayjs(lastUpdateTime).format('HH:mm:ss')} ({dayjs(lastUpdateTime).fromNow()})
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@ -354,32 +374,31 @@ const K8sManagement: React.FC = () => {
|
||||
<SyncHistoryTab externalSystemId={selectedClusterId} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Deployment</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!selectedNamespaceId ? (
|
||||
) : !selectedNamespaceId ? (
|
||||
<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 className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{deployments.map((deployment) => (
|
||||
<DeploymentCard
|
||||
key={deployment.id}
|
||||
deployment={deployment}
|
||||
<>
|
||||
{/* 统计卡片 */}
|
||||
<StatsCards deployments={deployments} />
|
||||
|
||||
{/* 筛选标签 */}
|
||||
<FilterTabs
|
||||
activeFilter={activeFilter}
|
||||
onFilterChange={setActiveFilter}
|
||||
counts={stats}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Deployment表格 */}
|
||||
<DeploymentTable
|
||||
deployments={filteredDeployments}
|
||||
expandedIds={expandedIds}
|
||||
onToggleExpand={toggleExpand}
|
||||
onViewLogs={handleViewLogs}
|
||||
onRefresh={loadDeployments}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
|
||||
@ -164,3 +164,40 @@ export const getK8sPodDetail = async (
|
||||
): Promise<K8sPodResponse> => {
|
||||
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 });
|
||||
};
|
||||
|
||||
@ -277,3 +277,75 @@ export interface K8sPodResponse {
|
||||
/** YAML配置 */
|
||||
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' },
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user