refactor(frontend): P3 — K8s 组件拆分、AlertCenter Query、permission utils
- 拆分 DeploymentView / DeploymentDetail 至子目录(<700 行红线) - AlertCenter 接入 useAlerts / useAgentAlertRules / useAlertMutations - 新增 utils/permission.ts,usePermission 复用纯函数 - useAlerts 增强分页统计与 Agent 规则 hooks
This commit is contained in:
1 parent
37c657987b
commit
521bfd356f
24 files changed
+1454
-1221
No files matched your search
@@ -1,51 +1,19 @@
|
||||
// DeploymentDetail.tsx — Deployment 详情页编排组件 // Logan.Li
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
|
||||
ArrowLeft,
|
||||
Minus,
|
||||
Plus,
|
||||
RotateCw,
|
||||
Upload,
|
||||
FileText,
|
||||
Terminal,
|
||||
Gauge,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
Loader2,
|
||||
XCircle,
|
||||
ExternalLink,
|
||||
FolderOpen,
|
||||
} from 'lucide-react';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { FileText, Gauge, Loader2, XCircle } from 'lucide-react';
|
||||
|
||||
import { kubernetesApi } from '@/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { PodExecTerminal } from './PodExecTerminal';
|
||||
import { PodLogsViewer } from './PodLogsViewer';
|
||||
import { PodFileBrowser } from './PodFileBrowser';
|
||||
|
||||
interface DeploymentDetailProps {
|
||||
clusterCode: string;
|
||||
namespace: string;
|
||||
deploymentName: string;
|
||||
onBack: () => void;
|
||||
}
|
||||
import type { DeploymentDetailProps, PodWithContainers } from './deployment-detail/types';
|
||||
import { DeploymentOverview } from './deployment-detail/DeploymentOverview';
|
||||
import { DeploymentPodsTab } from './deployment-detail/DeploymentPodsTab';
|
||||
import { DeploymentLogsTab } from './deployment-detail/DeploymentLogsTab';
|
||||
import { DeploymentHpaTab } from './deployment-detail/DeploymentHpaTab';
|
||||
import { DeploymentPodModals } from './deployment-detail/DeploymentPodModals';
|
||||
|
||||
export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBack }: DeploymentDetailProps) {
|
||||
const { toast } = useToast();
|
||||
@@ -53,39 +21,20 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac
|
||||
const [deployment, setDeployment] = useState<any>(null);
|
||||
const [pods, setPods] = useState<any[]>([]);
|
||||
const [hpas, setHpas] = useState<any[]>([]);
|
||||
const [logs, setLogs] = useState<string>('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionLoading, setActionLoading] = useState('');
|
||||
|
||||
// Scale
|
||||
const [, setScaleValue] = useState(0);
|
||||
// Image update
|
||||
const [newImage, setNewImage] = useState('');
|
||||
const [showImageUpdate, setShowImageUpdate] = useState(false);
|
||||
// Logs
|
||||
const [logPod, setLogPod] = useState('');
|
||||
const [showLogs, setShowLogs] = useState(false);
|
||||
const [logTail, setLogTail] = useState(200);
|
||||
|
||||
// 🆕 Pod exec / logs / file browser (Logan <admin@attacker.club>)
|
||||
const [execPod, setExecPod] = useState<{ name: string; containers: string[] } | null>(null);
|
||||
const [execPod, setExecPod] = useState<PodWithContainers | null>(null);
|
||||
const [execOpen, setExecOpen] = useState(false);
|
||||
const [logsViewerPod, setLogsViewerPod] = useState<{ name: string; containers: string[] } | null>(null);
|
||||
const [logsViewerPod, setLogsViewerPod] = useState<PodWithContainers | null>(null);
|
||||
const [logsViewerOpen, setLogsViewerOpen] = useState(false);
|
||||
const [fileBrowserPod, setFileBrowserPod] = useState<{ name: string; containers: string[] } | null>(null);
|
||||
const [fileBrowserPod, setFileBrowserPod] = useState<PodWithContainers | null>(null);
|
||||
const [fileBrowserOpen, setFileBrowserOpen] = useState(false);
|
||||
// Logs tab 内的 Pod 选择器(增强版查看器入口)
|
||||
const [logsTabPod, setLogsTabPod] = useState<string>('');
|
||||
|
||||
// 从 Pod 对象提取容器名列表(兼容多种字段)
|
||||
const extractContainers = ( pod: any): string[] => {
|
||||
const c = pod?.containers;
|
||||
if (Array.isArray(c)) return c.map(( x: any) => typeof x === 'string' ? x : (x?.name || x?.Name)).filter(Boolean);
|
||||
if (Array.isArray(pod?.container_statuses)) return pod.container_statuses.map(( x: any) => x?.name).filter(Boolean);
|
||||
if (Array.isArray(pod?.containerNames)) return pod.containerNames.filter(Boolean);
|
||||
return [];
|
||||
};
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -94,29 +43,19 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac
|
||||
kubernetesApi.getNamespacePods(clusterCode, namespace).catch(() => []),
|
||||
kubernetesApi.getHPAs(clusterCode, namespace).catch(() => ({ items: [] })),
|
||||
]);
|
||||
const toArray = ( v: any) => (Array.isArray(v) ? v : (v?.list || v?.items || []));
|
||||
const toArray = (v: any) => (Array.isArray(v) ? v : (v?.list || v?.items || []));
|
||||
const allDeps = toArray(depRes);
|
||||
const allPods = toArray(podRes);
|
||||
const allHpas = toArray(hpaRes);
|
||||
|
||||
const dep = allDeps.find(( d: any) => d.name === deploymentName && d.namespace === namespace);
|
||||
const dep = allDeps.find((d: any) => d.name === deploymentName && d.namespace === namespace);
|
||||
setDeployment(dep || null);
|
||||
if (dep) {
|
||||
setScaleValue(dep.replicas || 0);
|
||||
setNewImage(dep.image || '');
|
||||
}
|
||||
|
||||
// Filter pods by deployment label (prefix match on pod name)
|
||||
const depPods = allPods.filter(( p: any) =>
|
||||
p.namespace === namespace && p.name.startsWith(deploymentName)
|
||||
);
|
||||
setPods(depPods);
|
||||
|
||||
// Filter HPAs targeting this deployment (flattened data)
|
||||
const depHpas = allHpas.filter(( h: any) => {
|
||||
return h.target_name === deploymentName && h.namespace === namespace;
|
||||
});
|
||||
setHpas(depHpas);
|
||||
setPods(allPods.filter((p: any) => p.namespace === namespace && p.name.startsWith(deploymentName)));
|
||||
setHpas(allHpas.filter((h: any) => h.target_name === deploymentName && h.namespace === namespace));
|
||||
} catch (e) {
|
||||
console.error('加载详情失败:', e);
|
||||
} finally {
|
||||
@@ -162,20 +101,7 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac
|
||||
} finally { setActionLoading(''); }
|
||||
};
|
||||
|
||||
const _handleLoadLogs = async (podName: string) => {
|
||||
setLogPod(podName);
|
||||
setShowLogs(true);
|
||||
setLogs('');
|
||||
try {
|
||||
const res: Record<string, any> = await kubernetesApi.getPodLogs(clusterCode, namespace, podName, logTail);
|
||||
setLogs(res?.data?.logs || res?.logs || '无日志');
|
||||
} catch {
|
||||
setLogs('获取日志失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleHpaToggle = async ( hpa: any) => {
|
||||
// Not direct toggle — delete HPA
|
||||
const handleHpaToggle = async (hpa: any) => {
|
||||
const ok = await confirm({
|
||||
title: '删除 HPA',
|
||||
description: `确定删除 HPA "${hpa.name}"?`,
|
||||
@@ -218,520 +144,81 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac
|
||||
const ready = deployment.ready_replicas || deployment.readyReplicas || 0;
|
||||
const total = deployment.replicas || 0;
|
||||
const available = deployment.available_replicas || deployment.availableReplicas || 0;
|
||||
const isHealthy = ready === total;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="sm" className="text-slate-400" onClick={onBack}>
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />返回
|
||||
</Button>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-xl font-bold text-slate-100">{deploymentName}</h1>
|
||||
<Badge variant="outline" className="border-slate-700 text-slate-400">{namespace}</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 mt-0.5">镜像: {deployment.image || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="border-slate-700 text-slate-400" onClick={loadData}>
|
||||
<RefreshCw className="h-4 w-4 mr-1" />刷新
|
||||
</Button>
|
||||
</div>
|
||||
<DeploymentOverview
|
||||
deploymentName={deploymentName}
|
||||
namespace={namespace}
|
||||
deployment={deployment}
|
||||
hpas={hpas}
|
||||
clusterCode={clusterCode}
|
||||
ready={ready}
|
||||
total={total}
|
||||
available={available}
|
||||
isHealthy={ready === total}
|
||||
actionLoading={actionLoading}
|
||||
newImage={newImage}
|
||||
showImageUpdate={showImageUpdate}
|
||||
onBack={onBack}
|
||||
onRefresh={loadData}
|
||||
onScale={handleScale}
|
||||
onRestart={handleRestart}
|
||||
onNewImageChange={setNewImage}
|
||||
onToggleImageUpdate={() => setShowImageUpdate((v) => !v)}
|
||||
onUpdateImage={handleUpdateImage}
|
||||
/>
|
||||
|
||||
{/* Status Bar */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-3">
|
||||
<p className="text-xs text-slate-500">副本状态</p>
|
||||
<p className={`text-lg font-bold ${isHealthy ? 'text-emerald-400' : 'text-amber-400'}`}>
|
||||
{ready}/{total}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-3">
|
||||
<p className="text-xs text-slate-500">可用副本</p>
|
||||
<p className="text-lg font-bold text-blue-400">{available}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-3">
|
||||
<p className="text-xs text-slate-500">镜像</p>
|
||||
<p className="text-sm font-mono text-slate-300 truncate max-w-[200px]" title={deployment.image}>
|
||||
{deployment.image?.split('/').pop() || '-'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-3">
|
||||
<p className="text-xs text-slate-500">HPA</p>
|
||||
{hpas.length > 0 ? (
|
||||
<p className="text-lg font-bold text-purple-400">已启用</p>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-lg font-bold text-slate-500">未配置</p>
|
||||
<CreateHPADialog
|
||||
clusterCode={clusterCode}
|
||||
namespace={namespace}
|
||||
deploymentName={deploymentName}
|
||||
onCreated={loadData}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base text-slate-200">操作</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* Scale controls */}
|
||||
<div className="flex items-center gap-1 bg-slate-800 rounded-lg p-1">
|
||||
<Button
|
||||
variant="ghost" size="icon" className="h-8 w-8 text-slate-300 hover:text-slate-100"
|
||||
disabled={actionLoading === 'scale' || total <= 0}
|
||||
onClick={() => handleScale(-1)}
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-sm font-mono text-slate-200 w-8 text-center">
|
||||
{actionLoading === 'scale' ? <Loader2 className="h-4 w-4 animate-spin inline" /> : total}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost" size="icon" className="h-8 w-8 text-slate-300 hover:text-slate-100"
|
||||
disabled={actionLoading === 'scale'}
|
||||
onClick={() => handleScale(1)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline" size="sm" className="border-slate-700 text-slate-300 hover:text-slate-100"
|
||||
disabled={actionLoading === 'restart'}
|
||||
onClick={handleRestart}
|
||||
>
|
||||
{actionLoading === 'restart' ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <RotateCw className="h-4 w-4 mr-1" />}
|
||||
滚动重启
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline" size="sm" className="border-slate-700 text-slate-300 hover:text-slate-100"
|
||||
onClick={() => setShowImageUpdate(!showImageUpdate)}
|
||||
>
|
||||
<Upload className="h-4 w-4 mr-1" />
|
||||
更新镜像
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Inline image update */}
|
||||
{showImageUpdate && (
|
||||
<div className="mt-3 flex items-center gap-2 p-3 bg-slate-800/50 rounded-lg border border-slate-700">
|
||||
<Input
|
||||
value={newImage}
|
||||
onChange={(e) => setNewImage(e.target.value)}
|
||||
placeholder="输入新镜像地址,如 harbor.example.com/app:v2.0"
|
||||
className="flex-1 h-9 bg-slate-900 border-slate-700 text-slate-200 text-sm font-mono"
|
||||
/>
|
||||
<Button size="sm" className="bg-blue-600 hover:bg-blue-700"
|
||||
disabled={actionLoading === 'image' || !newImage.trim()}
|
||||
onClick={handleUpdateImage}
|
||||
>
|
||||
{actionLoading === 'image' ? <Loader2 className="h-4 w-4 animate-spin" /> : '更新'}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="text-slate-400" onClick={() => setShowImageUpdate(false)}>取消</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tabs: Pods / Logs / HPA */}
|
||||
<Tabs defaultValue="pods">
|
||||
<TabsList className="bg-slate-800 border border-slate-700">
|
||||
<TabsTrigger value="pods" className="data-[state=active]:bg-slate-700">
|
||||
Pod 列表 ({pods.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="logs" className="data-[state=active]:bg-slate-700">
|
||||
<FileText className="h-4 w-4 mr-1" />日志 {showLogs && `(${logPod})`}
|
||||
<FileText className="h-4 w-4 mr-1" />日志
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="hpa" className="data-[state=active]:bg-slate-700">
|
||||
<Gauge className="h-4 w-4 mr-1" />HPA ({hpas.length})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Pods Tab */}
|
||||
<TabsContent value="pods">
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||
<TableHead className="text-slate-400">Pod 名称</TableHead>
|
||||
<TableHead className="text-slate-400">状态</TableHead>
|
||||
<TableHead className="text-slate-400">节点</TableHead>
|
||||
<TableHead className="text-slate-400">IP</TableHead>
|
||||
<TableHead className="text-slate-400">重启</TableHead>
|
||||
<TableHead className="text-slate-400">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pods.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-slate-500 py-8">
|
||||
暂无 Pod
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
pods.map(( pod: any) => (
|
||||
<TableRow key={pod.name} className="border-slate-800 hover:bg-slate-800/30">
|
||||
<TableCell className="font-medium text-slate-200">{pod.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={
|
||||
pod.status === 'Running' ? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30' :
|
||||
pod.status === 'Pending' ? 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' :
|
||||
'bg-red-500/20 text-red-400 border-red-500/30'
|
||||
}>{pod.status}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-slate-400">{pod.node_name || '-'}</TableCell>
|
||||
<TableCell className="text-sm text-slate-500 font-mono">{pod.ip || '-'}</TableCell>
|
||||
<TableCell className={pod.restart_count > 0 ? 'text-yellow-400' : 'text-slate-500'}>
|
||||
{pod.restart_count || 0}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400"
|
||||
title="查看日志"
|
||||
onClick={() => {
|
||||
setLogsViewerPod({ name: pod.name, containers: extractContainers(pod) });
|
||||
setLogsViewerOpen(true);
|
||||
}}>
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-emerald-400"
|
||||
title="exec 终端"
|
||||
onClick={() => {
|
||||
setExecPod({ name: pod.name, containers: extractContainers(pod) });
|
||||
setExecOpen(true);
|
||||
}}>
|
||||
<Terminal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-amber-400"
|
||||
title="文件浏览器"
|
||||
onClick={() => {
|
||||
setFileBrowserPod({ name: pod.name, containers: extractContainers(pod) });
|
||||
setFileBrowserOpen(true);
|
||||
}}>
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Logs Tab */}
|
||||
<TabsContent value="logs">
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm text-slate-400">Pod 日志</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{showLogs && logPod && (
|
||||
<Button
|
||||
variant="outline" size="sm"
|
||||
className="border-blue-700 text-blue-400 text-xs h-7"
|
||||
onClick={() => {
|
||||
const pod = pods.find(( p: any) => p.name === logPod);
|
||||
setLogsViewerPod({ name: logPod, containers: pod ? extractContainers(pod) : [] });
|
||||
setLogsViewerOpen(true);
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="h-3 w-3 mr-1" />增强版日志
|
||||
</Button>
|
||||
)}
|
||||
<span className="text-xs text-slate-500">最近</span>
|
||||
<Input
|
||||
type="number" value={logTail} onChange={(e) => setLogTail(Number(e.target.value))}
|
||||
className="w-20 h-7 bg-slate-800 border-slate-700 text-slate-200 text-xs"
|
||||
/>
|
||||
<span className="text-xs text-slate-500">行</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{pods.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
<FileText className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p>暂无 Pod</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-3 py-10 justify-center flex-wrap">
|
||||
<FileText className="h-5 w-5 text-blue-400" />
|
||||
<span className="text-sm text-slate-400">选择 Pod 打开增强版日志查看器</span>
|
||||
<Select value={logsTabPod} onValueChange={setLogsTabPod}>
|
||||
<SelectTrigger className="w-[280px] h-8 bg-slate-800 border-slate-700 text-slate-200 text-xs">
|
||||
<SelectValue placeholder="选择 Pod…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{pods.map(( p: any) => (
|
||||
<SelectItem key={p.name} value={p.name}>{p.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-blue-600 hover:bg-blue-700 h-8"
|
||||
disabled={!logsTabPod}
|
||||
onClick={() => {
|
||||
const pod = pods.find(( p: any) => p.name === logsTabPod);
|
||||
setLogsViewerPod({ name: logsTabPod, containers: pod ? extractContainers(pod) : [] });
|
||||
setLogsViewerOpen(true);
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="h-3 w-3 mr-1" />打开日志
|
||||
</Button>
|
||||
<span className="text-xs text-slate-600 block w-full text-center mt-2">
|
||||
支持实时跟踪、多容器切换、previous 日志、关键字过滤、导出
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{showLogs && logPod && (
|
||||
<div className="mt-2">
|
||||
<pre className="bg-slate-950 rounded-lg p-4 text-xs text-slate-300 font-mono overflow-auto max-h-[300px] whitespace-pre-wrap">
|
||||
{logs || '无日志内容'}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* HPA Tab */}
|
||||
<TabsContent value="hpa">
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm text-slate-400">HPA 自动扩缩容</CardTitle>
|
||||
<CreateHPADialog
|
||||
clusterCode={clusterCode}
|
||||
namespace={namespace}
|
||||
deploymentName={deploymentName}
|
||||
onCreated={loadData}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{hpas.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
<Gauge className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p>未配置 HPA</p>
|
||||
<p className="text-xs mt-1">点击右上角「创建 HPA」设置自动扩缩容</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||
<TableHead className="text-slate-400">名称</TableHead>
|
||||
<TableHead className="text-slate-400">最小/最大</TableHead>
|
||||
<TableHead className="text-slate-400">指标</TableHead>
|
||||
<TableHead className="text-slate-400">当前/目标</TableHead>
|
||||
<TableHead className="text-slate-400">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{hpas.map(( hpa: any) => {
|
||||
const cpu = hpa.cpu_percent || 0;
|
||||
const mem = hpa.mem_percent || 0;
|
||||
const metricsParts = [];
|
||||
if (cpu) metricsParts.push(`CPU: ${cpu}%`);
|
||||
if (mem) metricsParts.push(`MEM: ${mem}%`);
|
||||
const metrics = metricsParts.join(', ') || '-';
|
||||
return (
|
||||
<TableRow key={hpa.name} className="border-slate-800 hover:bg-slate-800/30">
|
||||
<TableCell className="font-medium text-slate-200">{hpa.name}</TableCell>
|
||||
<TableCell className="text-slate-300">{hpa.min_replicas} / {hpa.max_replicas}</TableCell>
|
||||
<TableCell className="text-sm text-slate-400">{metrics}</TableCell>
|
||||
<TableCell className="text-slate-300">{hpa.current_replicas || 0}</TableCell>
|
||||
<TableCell>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-400 hover:text-red-300"
|
||||
onClick={() => handleHpaToggle(hpa)} title="删除 HPA">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<DeploymentPodsTab
|
||||
pods={pods}
|
||||
onOpenLogsViewer={(pod) => { setLogsViewerPod(pod); setLogsViewerOpen(true); }}
|
||||
onOpenExec={(pod) => { setExecPod(pod); setExecOpen(true); }}
|
||||
onOpenFileBrowser={(pod) => { setFileBrowserPod(pod); setFileBrowserOpen(true); }}
|
||||
/>
|
||||
<DeploymentLogsTab
|
||||
pods={pods}
|
||||
logTail={logTail}
|
||||
onLogTailChange={setLogTail}
|
||||
logsTabPod={logsTabPod}
|
||||
onLogsTabPodChange={setLogsTabPod}
|
||||
onOpenLogsViewer={(name, containers) => { setLogsViewerPod({ name, containers }); setLogsViewerOpen(true); }}
|
||||
/>
|
||||
<DeploymentHpaTab
|
||||
hpas={hpas}
|
||||
clusterCode={clusterCode}
|
||||
namespace={namespace}
|
||||
deploymentName={deploymentName}
|
||||
onCreated={loadData}
|
||||
onDeleteHpa={handleHpaToggle}
|
||||
/>
|
||||
</Tabs>
|
||||
|
||||
{/* 🆕 Pod exec 终端 */}
|
||||
{execPod && (
|
||||
<PodExecTerminal
|
||||
open={execOpen}
|
||||
onOpenChange={(o) => {
|
||||
setExecOpen(o);
|
||||
if (!o) setExecPod(null);
|
||||
}}
|
||||
clusterId={clusterCode}
|
||||
namespace={namespace}
|
||||
podName={execPod.name}
|
||||
containers={execPod.containers}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 🆕 增强版日志查看器 */}
|
||||
{logsViewerPod && (
|
||||
<PodLogsViewer
|
||||
open={logsViewerOpen}
|
||||
onOpenChange={(o) => {
|
||||
setLogsViewerOpen(o);
|
||||
if (!o) setLogsViewerPod(null);
|
||||
}}
|
||||
clusterCode={clusterCode}
|
||||
namespace={namespace}
|
||||
podName={logsViewerPod.name}
|
||||
containers={logsViewerPod.containers}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 🆕 容器文件浏览器 */}
|
||||
{fileBrowserPod && (
|
||||
<PodFileBrowser
|
||||
open={fileBrowserOpen}
|
||||
onOpenChange={(o) => {
|
||||
setFileBrowserOpen(o);
|
||||
if (!o) setFileBrowserPod(null);
|
||||
}}
|
||||
clusterCode={clusterCode}
|
||||
namespace={namespace}
|
||||
podName={fileBrowserPod.name}
|
||||
containers={fileBrowserPod.containers}
|
||||
/>
|
||||
)}
|
||||
<DeploymentPodModals
|
||||
clusterCode={clusterCode}
|
||||
namespace={namespace}
|
||||
execPod={execPod}
|
||||
execOpen={execOpen}
|
||||
onExecOpenChange={(o) => { setExecOpen(o); if (!o) setExecPod(null); }}
|
||||
logsViewerPod={logsViewerPod}
|
||||
logsViewerOpen={logsViewerOpen}
|
||||
onLogsViewerOpenChange={(o) => { setLogsViewerOpen(o); if (!o) setLogsViewerPod(null); }}
|
||||
fileBrowserPod={fileBrowserPod}
|
||||
fileBrowserOpen={fileBrowserOpen}
|
||||
onFileBrowserOpenChange={(o) => { setFileBrowserOpen(o); if (!o) setFileBrowserPod(null); }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Create HPA Dialog (inline)
|
||||
// ============================================================================
|
||||
function CreateHPADialog({ clusterCode, namespace, deploymentName, onCreated }: {
|
||||
clusterCode: string; namespace: string; deploymentName: string; onCreated: () => void;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
minReplicas: 1,
|
||||
maxReplicas: 10,
|
||||
cpuPercent: 80,
|
||||
memPercent: 0,
|
||||
});
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!form.name.trim()) {
|
||||
toast({ title: '名称不能为空', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await kubernetesApi.createHPA(clusterCode, {
|
||||
name: form.name,
|
||||
namespace,
|
||||
targetKind: 'Deployment',
|
||||
targetName: deploymentName,
|
||||
minReplicas: form.minReplicas,
|
||||
maxReplicas: form.maxReplicas,
|
||||
cpuPercent: form.cpuPercent,
|
||||
memPercent: form.memPercent,
|
||||
});
|
||||
toast({ title: 'HPA 创建成功' });
|
||||
setOpen(false);
|
||||
onCreated();
|
||||
setForm({ name: '', minReplicas: 1, maxReplicas: 10, cpuPercent: 80, memPercent: 0 });
|
||||
} catch (e) {
|
||||
toast({ title: '创建失败', description: e?.message || String(e), variant: 'destructive' });
|
||||
} finally { setLoading(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" size="sm" className="border-slate-700 text-slate-300" onClick={() => setOpen(true)}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />创建 HPA
|
||||
</Button>
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={() => setOpen(false)}>
|
||||
<div className="bg-slate-900 border border-slate-700 rounded-xl p-6 w-[480px] max-h-[80vh] overflow-auto" onClick={(e) => e.stopPropagation()}>
|
||||
<h2 className="text-lg font-bold text-slate-100 mb-4">创建 HPA</h2>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-slate-400 text-sm">名称</Label>
|
||||
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder={`hpa-${deploymentName}`}
|
||||
className="bg-slate-800 border-slate-700 text-slate-200 mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-slate-400 text-sm">目标</Label>
|
||||
<p className="text-slate-300 text-sm mt-1 bg-slate-800 rounded px-3 py-2">
|
||||
Deployment: {deploymentName} · 命名空间: {namespace}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-slate-400 text-sm">最小副本数</Label>
|
||||
<Input type="number" value={form.minReplicas} min={1}
|
||||
onChange={(e) => setForm({ ...form, minReplicas: Number(e.target.value) })}
|
||||
className="bg-slate-800 border-slate-700 text-slate-200 mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-slate-400 text-sm">最大副本数</Label>
|
||||
<Input type="number" value={form.maxReplicas} min={1}
|
||||
onChange={(e) => setForm({ ...form, maxReplicas: Number(e.target.value) })}
|
||||
className="bg-slate-800 border-slate-700 text-slate-200 mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-slate-400 text-sm">CPU 目标 (%)</Label>
|
||||
<Input type="number" value={form.cpuPercent} min={0} max={100}
|
||||
onChange={(e) => setForm({ ...form, cpuPercent: Number(e.target.value) })}
|
||||
className="bg-slate-800 border-slate-700 text-slate-200 mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-slate-400 text-sm">内存目标 (%) <span className="text-slate-600">可选</span></Label>
|
||||
<Input type="number" value={form.memPercent} min={0} max={100}
|
||||
onChange={(e) => setForm({ ...form, memPercent: Number(e.target.value) })}
|
||||
className="bg-slate-800 border-slate-700 text-slate-200 mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 mt-6">
|
||||
<Button variant="outline" className="border-slate-700 text-slate-400" onClick={() => setOpen(false)}>取消</Button>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700" disabled={loading} onClick={handleCreate}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : '创建'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,78 +1,28 @@
|
||||
// DeploymentView.tsx — ACK 风格 Tab 详情看板
|
||||
// DeploymentView.tsx — ACK 风格 Tab 详情看板(编排层) // Logan.Li
|
||||
// Tabs: 容器组 | 访问方式 | 事件 | 容器伸缩 | 历史版本
|
||||
// 作者: Logan <admin@attacker.club>
|
||||
import { useState, useEffect, Fragment } from 'react';
|
||||
import {
|
||||
Card, CardContent, CardHeader, CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
|
||||
Clock,
|
||||
HardDrive,
|
||||
Network,
|
||||
Activity,
|
||||
RotateCcw,
|
||||
Container,
|
||||
History,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Terminal,
|
||||
FileText,
|
||||
ExternalLink,
|
||||
Minus,
|
||||
Plus,
|
||||
Loader2,
|
||||
Activity, Container, History, Network, Minus, Plus, Loader2,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { kubernetesApi } from '@/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import {
|
||||
formatK8sTime, formatDuration, elapsedSeconds, statusColor, STATUS_COLORS,
|
||||
} from './k8s-yaml-utils';
|
||||
import { formatK8sTime, STATUS_COLORS } from './k8s-yaml-utils';
|
||||
|
||||
// Types
|
||||
interface PodItem {
|
||||
name: string; namespace?: string; status?: string; node_name?: string; node?: string;
|
||||
ip?: string; pod_ip?: string; restart_count?: number; age?: string; created_at?: string;
|
||||
start_time?: string; containers?: any[]; image?: string; images?: string[];
|
||||
}
|
||||
interface ReplicaSetItem {
|
||||
name: string; replicas?: number; ready_replicas?: number; available_replicas?: number;
|
||||
image?: string; revision?: number; status?: string; created_at?: string;
|
||||
}
|
||||
interface SvcItem { name: string; type: string; clusterIP: string; ports: any[]; selector: Record<string, string>; age: string; }
|
||||
interface IngItem { name: string; className: string; hosts: string[]; rules: any[]; age: string; }
|
||||
interface EventItem { type: string; reason: string; message: string; source: string; firstTimestamp: string; lastTimestamp: string; count: number; }
|
||||
interface DeploymentViewProps {
|
||||
clusterCode: string; namespace: string; deployment: any;
|
||||
pods: PodItem[]; replicaSets: ReplicaSetItem[]; revisions: any[];
|
||||
onRefresh: () => void;
|
||||
onShowPodLogs: (pod: PodItem) => void;
|
||||
onShowPodTerminal: (pod: PodItem) => void;
|
||||
onShowPodYaml: (pod: PodItem) => void;
|
||||
onShowPodFileBrowser: (pod: PodItem) => void;
|
||||
}
|
||||
import { InfoItem, PodConditionsRow } from './deployment-view/helpers';
|
||||
import { PodsTab } from './deployment-view/PodsTab';
|
||||
import { ServicesTab } from './deployment-view/ServicesTab';
|
||||
import { EventsTab } from './deployment-view/EventsTab';
|
||||
import { HPACronHPATab } from './deployment-view/HPACronHPATab';
|
||||
import { HistoryTab } from './deployment-view/HistoryTab';
|
||||
import type { DeploymentViewProps, SvcItem, IngItem, EventItem } from './deployment-view/types';
|
||||
|
||||
// ─── Helpers ───
|
||||
function getPodTimeline(pod: PodItem) {
|
||||
const cond: Record<string, any>[] = (pod as Record<string, any>).conditions || (pod as Record<string, any>).pod_conditions || [];
|
||||
const find = (t: string) => cond.find(( c: any) => (c.type || c.Type) === t);
|
||||
const ts = ( c: any) => c?.lastTransitionTime || c?.LastTransitionTime || c?.last_transition_time;
|
||||
return {
|
||||
scheduled: ts(find('PodScheduled') || {}),
|
||||
initialized: ts(find('Initialized') || {}),
|
||||
containersReady: ts(find('ContainersReady') || {}),
|
||||
ready: ts(find('Ready') || {}),
|
||||
startTime: pod.start_time || (pod as Record<string, any>).startTime,
|
||||
};
|
||||
}
|
||||
export type { DeploymentViewProps } from './deployment-view/types';
|
||||
|
||||
export function DeploymentView(props: DeploymentViewProps) {
|
||||
const { clusterCode, namespace, deployment, pods, revisions, onRefresh } = props;
|
||||
@@ -82,17 +32,17 @@ export function DeploymentView(props: DeploymentViewProps) {
|
||||
const [expandedPod, setExpandedPod] = useState<string | null>(null);
|
||||
const [rollbackLoading, setRollbackLoading] = useState<number | null>(null);
|
||||
|
||||
// Scale state
|
||||
// Scale state // Logan.Li
|
||||
const [scaleValue, setScaleValue] = useState(deployment?.replicas || 0);
|
||||
const [scaleLoading, setScaleLoading] = useState(false);
|
||||
|
||||
// Service/Ingress/Events state
|
||||
// Service/Ingress/Events state // Logan.Li
|
||||
const [services, setServices] = useState<SvcItem[]>([]);
|
||||
const [ingresses, setIngresses] = useState<IngItem[]>([]);
|
||||
const [events, setEvents] = useState<EventItem[]>([]);
|
||||
const [tabLoading, setTabLoading] = useState('');
|
||||
|
||||
// HPA / CronHPA state
|
||||
// HPA / CronHPA state // Logan.Li
|
||||
const [hpas, setHpas] = useState<any[]>([]);
|
||||
const [cronHpas, setCronHpas] = useState<any[]>([]);
|
||||
|
||||
@@ -100,7 +50,7 @@ export function DeploymentView(props: DeploymentViewProps) {
|
||||
const total = deployment?.replicas ?? 0;
|
||||
const isHealthy = ready === total && total > 0;
|
||||
|
||||
// Load Services and Ingresses for "访问方式" tab
|
||||
// 访问方式 Tab 数据加载 // Logan.Li
|
||||
useEffect(() => {
|
||||
if (tab !== 'services') return;
|
||||
setTabLoading('services');
|
||||
@@ -108,39 +58,37 @@ export function DeploymentView(props: DeploymentViewProps) {
|
||||
kubernetesApi.getNamespaceServices(clusterCode, namespace).catch(() => []),
|
||||
kubernetesApi.getNamespaceIngresses(clusterCode, namespace).catch(() => []),
|
||||
]).then(([svcRes, ingRes]) => {
|
||||
const toArr = ( v: any) => (Array.isArray(v) ? v : (v?.list || v?.items || v?.data || []));
|
||||
const toArr = (v: any) => (Array.isArray(v) ? v : (v?.list || v?.items || v?.data || []));
|
||||
const allSvcs = toArr(svcRes);
|
||||
const allIngs = toArr(ingRes);
|
||||
|
||||
// Filter services matching deployment selector
|
||||
const selector = deployment?.spec?.selector?.matchLabels || deployment?.spec?.template?.metadata?.labels || {};
|
||||
const matchedSvcs = allSvcs.filter(( s: any) => {
|
||||
const matchedSvcs = allSvcs.filter((s: any) => {
|
||||
const svcSel = s.spec?.selector || s.selector || {};
|
||||
if (Object.keys(svcSel).length === 0) return false;
|
||||
return Object.entries(svcSel).every(([k, v]) => selector[k] === v);
|
||||
}).map(( s: any) => ({
|
||||
}).map((s: any) => ({
|
||||
name: s.name || s.metadata?.name,
|
||||
type: s.spec?.type || s.type || 'ClusterIP',
|
||||
clusterIP: s.spec?.clusterIP || s.cluster_ip || '<none>',
|
||||
ports: (s.spec?.ports || s.ports || []).map(( p: any) => `${p.port}${p.targetPort ? ':' + p.targetPort : ''}/${p.protocol || 'TCP'}`),
|
||||
ports: (s.spec?.ports || s.ports || []).map((p: any) => `${p.port}${p.targetPort ? ':' + p.targetPort : ''}/${p.protocol || 'TCP'}`),
|
||||
selector: s.spec?.selector || s.selector || {},
|
||||
age: s.age || '',
|
||||
}));
|
||||
|
||||
// Filter ingresses pointing to matched services
|
||||
const svcNames = new Set(matchedSvcs.map(( s: any) => s.name));
|
||||
const matchedIngs = allIngs.filter(( i: any) => {
|
||||
const svcNames = new Set(matchedSvcs.map((s: any) => s.name));
|
||||
const matchedIngs = allIngs.filter((i: any) => {
|
||||
const rules = i.spec?.rules || i.rules || [];
|
||||
return rules.some(( r: any) =>
|
||||
(r.http?.paths || r.paths || []).some(( p: any) => {
|
||||
return rules.some((r: any) =>
|
||||
(r.http?.paths || r.paths || []).some((p: any) => {
|
||||
const backendSvc = p.backend?.service?.name || p.backendService || '';
|
||||
return svcNames.has(backendSvc);
|
||||
})
|
||||
);
|
||||
}).map(( i: any) => ({
|
||||
}).map((i: any) => ({
|
||||
name: i.name || i.metadata?.name,
|
||||
className: i.spec?.ingressClassName || i.ingressClass || '-',
|
||||
hosts: (i.spec?.rules || i.rules || []).map(( r: any) => r.host).filter(Boolean),
|
||||
hosts: (i.spec?.rules || i.rules || []).map((r: any) => r.host).filter(Boolean),
|
||||
rules: i.spec?.rules || i.rules || [],
|
||||
age: i.age || '',
|
||||
}));
|
||||
@@ -150,17 +98,17 @@ export function DeploymentView(props: DeploymentViewProps) {
|
||||
}).catch(() => {}).finally(() => setTabLoading(''));
|
||||
}, [tab, clusterCode, namespace, deployment]);
|
||||
|
||||
// Load Events for "事件" tab
|
||||
// 事件 Tab 数据加载 // Logan.Li
|
||||
useEffect(() => {
|
||||
if (tab !== 'events') return;
|
||||
setTabLoading('events');
|
||||
kubernetesApi.getEvents(clusterCode, namespace, undefined).then(( res: any) => {
|
||||
kubernetesApi.getEvents(clusterCode, namespace, undefined).then((res: any) => {
|
||||
const allEvents = Array.isArray(res) ? res : (res?.list || res?.items || res?.data || []);
|
||||
const depEvents = allEvents.filter(( e: any) => {
|
||||
const depEvents = allEvents.filter((e: any) => {
|
||||
const involved = e.involvedObject || e.involved_object || {};
|
||||
const objName = involved.name || '';
|
||||
return objName === deployment?.name || objName.startsWith(deployment?.name + '-');
|
||||
}).map(( e: any) => ({
|
||||
}).map((e: any) => ({
|
||||
type: e.type || 'Normal',
|
||||
reason: e.reason || '',
|
||||
message: e.message || '',
|
||||
@@ -173,7 +121,7 @@ export function DeploymentView(props: DeploymentViewProps) {
|
||||
}).catch(() => {}).finally(() => setTabLoading(''));
|
||||
}, [tab, clusterCode, namespace, deployment?.name]);
|
||||
|
||||
// Load HPA and CronHPA for "伸缩" tab
|
||||
// 伸缩 Tab 数据加载 // Logan.Li
|
||||
useEffect(() => {
|
||||
if (tab !== 'scale') return;
|
||||
setTabLoading('scale');
|
||||
@@ -181,16 +129,16 @@ export function DeploymentView(props: DeploymentViewProps) {
|
||||
kubernetesApi.getHPAs(clusterCode, namespace).catch(() => []),
|
||||
Promise.resolve([]), // CronHPA 待后端支持
|
||||
]).then(([hpaRes]) => {
|
||||
const toArr = ( v: any) => (Array.isArray(v) ? v : (v?.list || v?.items || v?.data || []));
|
||||
const toArr = (v: any) => (Array.isArray(v) ? v : (v?.list || v?.items || v?.data || []));
|
||||
const allHpas = toArr(hpaRes);
|
||||
const depName = deployment?.name || '';
|
||||
setHpas(allHpas.filter(( h: any) => (h.scaleTargetRef?.name || h.targetRef?.name || '') === depName));
|
||||
setHpas(allHpas.filter((h: any) => (h.scaleTargetRef?.name || h.targetRef?.name || '') === depName));
|
||||
setCronHpas([]);
|
||||
}).catch(() => {}).finally(() => setTabLoading(''));
|
||||
}, [tab, clusterCode, namespace, deployment?.name]);
|
||||
|
||||
// Rollback
|
||||
const handleRollback = async ( rev: any) => {
|
||||
// 回滚 // Logan.Li
|
||||
const handleRollback = async (rev: any) => {
|
||||
const revision = rev.revision ?? rev.revision_number ?? rev.number;
|
||||
const ok = await confirm({
|
||||
title: '回滚版本',
|
||||
@@ -209,7 +157,7 @@ export function DeploymentView(props: DeploymentViewProps) {
|
||||
} finally { setRollbackLoading(null); }
|
||||
};
|
||||
|
||||
// Scale
|
||||
// 手动伸缩 // Logan.Li
|
||||
const handleScale = async () => {
|
||||
setScaleLoading(true);
|
||||
try {
|
||||
@@ -231,7 +179,7 @@ export function DeploymentView(props: DeploymentViewProps) {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* ============ Info Panel (ACK-style) ============ */}
|
||||
{/* Info Panel (ACK-style) */}
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 text-sm">
|
||||
@@ -254,7 +202,6 @@ export function DeploymentView(props: DeploymentViewProps) {
|
||||
)}
|
||||
<InfoItem label="创建时间" value={formatK8sTime(deployment?.created_at || deployment?.creationTimestamp)} />
|
||||
</div>
|
||||
{/* Pod conditions timeline */}
|
||||
{pods[0] && (
|
||||
<div className="mt-3 pt-3 border-t border-[var(--dg-border)]">
|
||||
<PodConditionsRow pod={pods[0]} />
|
||||
@@ -263,7 +210,7 @@ export function DeploymentView(props: DeploymentViewProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ============ Tab Bar + 副本数 ============ */}
|
||||
{/* Tab Bar + 副本数 */}
|
||||
<div className="flex items-center gap-0.5 border-b border-[var(--dg-border)] overflow-x-auto">
|
||||
{TABS.map(t => (
|
||||
<button
|
||||
@@ -280,7 +227,6 @@ export function DeploymentView(props: DeploymentViewProps) {
|
||||
{t.key === 'pods' && <Badge className="ml-1 text-[10px] px-1.5 py-0 bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]">{t.count}</Badge>}
|
||||
</button>
|
||||
))}
|
||||
{/* 副本数控制 — Tab 行右侧 */}
|
||||
<div className="ml-auto flex items-center gap-1 pr-2 shrink-0">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"
|
||||
disabled={scaleLoading || scaleValue <= 0}
|
||||
@@ -306,7 +252,7 @@ export function DeploymentView(props: DeploymentViewProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ============ Tab Content ============ */}
|
||||
{/* Tab Content */}
|
||||
{tab === 'pods' && <PodsTab {...props} expandedPod={expandedPod} setExpandedPod={setExpandedPod} />}
|
||||
{tab === 'services' && <ServicesTab services={services} ingresses={ingresses} loading={tabLoading} namespace={namespace} />}
|
||||
{tab === 'events' && <EventsTab events={events} loading={tabLoading} />}
|
||||
@@ -321,415 +267,4 @@ export function DeploymentView(props: DeploymentViewProps) {
|
||||
{tab === 'history' && <HistoryTab revisions={revisions} rollbackLoading={rollbackLoading} onRollback={handleRollback} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Info Item ───
|
||||
function InfoItem({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mb-1">{label}</p>
|
||||
<div className="text-sm text-[var(--dg-text-primary)]">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Pod Conditions Row ───
|
||||
function PodConditionsRow({ pod }: { pod: PodItem }) {
|
||||
const tl = getPodTimeline(pod);
|
||||
const stages = [
|
||||
{ label: '已调度', ts: tl.scheduled, color: STATUS_COLORS.blue },
|
||||
{ label: '已初始化', ts: tl.initialized, color: STATUS_COLORS.orange },
|
||||
{ label: '容器已就绪', ts: tl.containersReady, color: STATUS_COLORS.green },
|
||||
{ label: '容器组已就绪', ts: tl.ready, color: STATUS_COLORS.green },
|
||||
];
|
||||
return (
|
||||
<div className="flex items-center gap-2 overflow-x-auto">
|
||||
{stages.map((s, i) => {
|
||||
const prev = stages[i - 1];
|
||||
const dur = elapsedSeconds(prev?.ts, s.ts);
|
||||
return (
|
||||
<Fragment key={s.label}>
|
||||
<div className="flex flex-col items-center min-w-[80px]">
|
||||
<div className="h-2 w-2 rounded-full" style={{ background: s.ts ? s.color : '#334155' }} />
|
||||
<span className="text-[10px] mt-1 font-medium" style={{ color: s.ts ? s.color : '#475569' }}>{s.label}</span>
|
||||
<span className="text-[9px] text-[var(--dg-text-muted)]">{s.ts ? formatK8sTime(s.ts) : '-'}</span>
|
||||
</div>
|
||||
{i < stages.length - 1 && (
|
||||
<div className="flex flex-col items-center mx-0.5 min-w-[30px]">
|
||||
<div className="h-0.5 w-8 bg-[var(--dg-border)]" />
|
||||
<span className="text-[9px] text-[var(--dg-text-muted)] mt-0.5">{dur != null ? formatDuration(dur) : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════ TAB: 容器组 ═══════════════
|
||||
function PodsTab({ clusterCode, pods, onShowPodLogs, onShowPodTerminal, onShowPodFileBrowser, expandedPod, setExpandedPod }: DeploymentViewProps & { expandedPod: string | null; setExpandedPod: (v: string | null) => void }) {
|
||||
if (pods.length === 0) {
|
||||
return <div className="text-center text-[var(--dg-text-muted)] py-12">暂无容器组</div>;
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{pods.map(pod => {
|
||||
const tl = getPodTimeline(pod);
|
||||
const expanded = expandedPod === pod.name;
|
||||
const nodeName = pod.node_name || pod.node;
|
||||
return (
|
||||
<Card key={pod.name} className="dg-card">
|
||||
<CardContent className="p-4">
|
||||
{/* Pod header row */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 text-[var(--dg-text-muted)]" onClick={() => setExpandedPod(expanded ? null : pod.name)}>
|
||||
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</Button>
|
||||
<span className="font-mono text-sm text-[var(--dg-text-primary)]">{pod.name}</span>
|
||||
<Badge style={{ background: `${statusColor(pod.status)}22`, color: statusColor(pod.status), border: `1px solid ${statusColor(pod.status)}44` }}>
|
||||
{pod.status || 'Unknown'}
|
||||
</Badge>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">
|
||||
<button className="text-[var(--dg-blue)] hover:underline mr-2" onClick={() => window.open(`/kubernetes/${clusterCode}/nodes/${nodeName}`, '_blank')}>
|
||||
{nodeName || '-'}
|
||||
</button>
|
||||
</span>
|
||||
<span className="text-xs font-mono text-[var(--dg-text-muted)]">{pod.ip || pod.pod_ip || '-'}</span>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">重启 {pod.restart_count || 0} 次</span>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">{formatK8sTime(tl.startTime || pod.created_at)}</span>
|
||||
<div className="flex-1" />
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs text-[var(--dg-blue)]" onClick={() => onShowPodLogs(pod)}>
|
||||
<FileText className="h-3.5 w-3.5 mr-1" />日志
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs text-emerald-400" onClick={() => onShowPodTerminal(pod)}>
|
||||
<Terminal className="h-3.5 w-3.5 mr-1" />终端
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs text-amber-400" onClick={() => onShowPodFileBrowser(pod)}>
|
||||
<HardDrive className="h-3.5 w-3.5 mr-1" />文件
|
||||
</Button>
|
||||
</div>
|
||||
{/* Expanded: timeline */}
|
||||
{expanded && (
|
||||
<div className="mt-3 pt-3 border-t border-[var(--dg-border)]">
|
||||
<PodConditionsRow pod={pod} />
|
||||
{/* Container details */}
|
||||
{(pod as Record<string, any>).containers && ((pod as Record<string, any>).containers as any[]).length > 0 && (
|
||||
<div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{((pod as Record<string, any>).containers as any[]).map(( c: any, idx: number) => (
|
||||
<div key={idx} className="p-2 rounded bg-[var(--dg-bg-tertiary)] text-xs">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-[var(--dg-text-primary)]">{c.name}</span>
|
||||
<span className={c.ready ? 'text-emerald-400' : 'text-[var(--dg-text-muted)]'}>{c.ready ? 'Ready' : 'Pending'}</span>
|
||||
</div>
|
||||
<div className="text-[var(--dg-text-muted)] mt-1">{c.image}</div>
|
||||
{c.resources && (
|
||||
<div className="mt-1 flex gap-3">
|
||||
<span>CPU: {c.resources?.requests?.cpu || '0'}/{c.resources?.limits?.cpu || '-'} m</span>
|
||||
<span>内存: {c.resources?.requests?.memory || '0'}/{c.resources?.limits?.memory || '-'}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════ TAB: 访问方式 ═══════════════
|
||||
function ServicesTab({ services, ingresses, loading }: { services: SvcItem[]; ingresses: IngItem[]; loading: string; namespace: string }) {
|
||||
if (loading === 'services') {
|
||||
return <div className="flex items-center justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-[var(--dg-text-muted)]" /></div>;
|
||||
}
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Services */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-[var(--dg-text-primary)] mb-3 flex items-center gap-2">
|
||||
<Network className="h-4 w-4 text-[var(--dg-blue)]" />关联 Service
|
||||
</h3>
|
||||
{services.length === 0 ? (
|
||||
<p className="text-sm text-[var(--dg-text-muted)] py-4">暂无匹配的 Service(Deployment 选择器未关联任何 Service)</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-[var(--dg-border)]">
|
||||
<TableHead className="text-[var(--dg-text-muted)]">名称</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">类型</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">ClusterIP</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">端口</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">Selector</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{services.map(svc => (
|
||||
<TableRow key={svc.name} className="border-[var(--dg-border)]">
|
||||
<TableCell className="font-medium text-[var(--dg-text-primary)]">{svc.name}</TableCell>
|
||||
<TableCell><Badge variant="outline" className="text-xs">{svc.type}</Badge></TableCell>
|
||||
<TableCell className="font-mono text-sm text-[var(--dg-text-muted)]">{svc.clusterIP}</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-muted)]">{svc.ports.join(', ')}</TableCell>
|
||||
<TableCell className="text-xs text-[var(--dg-text-muted)]">
|
||||
{Object.entries(svc.selector).map(([k, v]) => (
|
||||
<code key={k} className="mr-1 bg-[var(--dg-bg-tertiary)] px-1 rounded">{k}={v}</code>
|
||||
))}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Ingresses */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-[var(--dg-text-primary)] mb-3 flex items-center gap-2">
|
||||
<ExternalLink className="h-4 w-4 text-[var(--dg-purple)]" />关联 Ingress
|
||||
</h3>
|
||||
{ingresses.length === 0 ? (
|
||||
<p className="text-sm text-[var(--dg-text-muted)] py-4">暂无匹配的 Ingress</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-[var(--dg-border)]">
|
||||
<TableHead className="text-[var(--dg-text-muted)]">名称</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">Class</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">Host</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">路径</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{ingresses.map(ing => (
|
||||
<TableRow key={ing.name} className="border-[var(--dg-border)]">
|
||||
<TableCell className="font-medium text-[var(--dg-text-primary)]">{ing.name}</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-muted)]">{ing.className}</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-muted)]">{ing.hosts.join(', ')}</TableCell>
|
||||
<TableCell className="text-xs text-[var(--dg-text-muted)]">
|
||||
{ing.rules.flatMap(( r: any) => (r.http?.paths || r.paths || []).map(( p: any) => p.path || '/')).join(', ')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════ TAB: 事件 ═══════════════
|
||||
function EventsTab({ events, loading }: { events: EventItem[]; loading: string }) {
|
||||
if (loading === 'events') {
|
||||
return <div className="flex items-center justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-[var(--dg-text-muted)]" /></div>;
|
||||
}
|
||||
if (events.length === 0) {
|
||||
return <div className="text-center text-[var(--dg-text-muted)] py-12">暂无事件</div>;
|
||||
}
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-[var(--dg-border)]">
|
||||
<TableHead className="text-[var(--dg-text-muted)] w-16">类型</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)] w-24">原因</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">消息</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)] w-20">来源</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)] w-10">次数</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)] w-40">最后时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{events.map((e, i) => (
|
||||
<TableRow key={i} className="border-[var(--dg-border)]">
|
||||
<TableCell>
|
||||
<Badge variant="outline" className={e.type === 'Warning' ? 'text-[var(--dg-yellow)] border-[var(--dg-yellow)]/30' : 'text-[var(--dg-text-muted)] border-[var(--dg-border)]'}>
|
||||
{e.type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{e.reason}</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-primary)] max-w-md truncate" title={e.message}>{e.message}</TableCell>
|
||||
<TableCell className="text-xs text-[var(--dg-text-muted)]">{e.source}</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-muted)]">{e.count}</TableCell>
|
||||
<TableCell className="text-xs text-[var(--dg-text-muted)]">{formatK8sTime(e.lastTimestamp)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════ TAB: 伸缩 (HPA + CronHPA) ═══════════════
|
||||
function HPACronHPATab({ hpas, cronHpas, loading, replicas }: { hpas: any[]; cronHpas: any[]; loading: string; replicas: number }) {
|
||||
if (loading === 'scale') {
|
||||
return <div className="flex items-center justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-[var(--dg-text-muted)]" /></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 当前副本数提示 */}
|
||||
<div className="flex items-center gap-2 px-2">
|
||||
<span className="text-sm text-[var(--dg-text-muted)]">当前副本:</span>
|
||||
<span className="text-sm font-bold text-[var(--dg-text-primary)]">{replicas}</span>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">(可通过 Tab 栏右侧 +/- 手动调整)</span>
|
||||
</div>
|
||||
|
||||
{/* HPA 策略 */}
|
||||
<Card className="dg-card">
|
||||
<CardHeader className="pb-2"><CardTitle className="text-sm flex items-center gap-2"><Activity className="h-4 w-4 text-[var(--dg-orange)]" />HPA — 水平自动伸缩</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
{hpas.length === 0 ? (
|
||||
<div className="text-sm text-[var(--dg-text-muted)] py-4">
|
||||
未关联 HPA — <Button variant="link" className="h-auto p-0 text-[var(--dg-blue)] text-sm">创建 HPA</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-[var(--dg-border)]">
|
||||
<TableHead className="text-[var(--dg-text-muted)]">名称</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">最小/最大</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">指标</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">当前负载</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">状态</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{hpas.map(( hpa: any, i: number) => {
|
||||
const minR = hpa.spec?.minReplicas ?? hpa.minReplicas ?? 1;
|
||||
const maxR = hpa.spec?.maxReplicas ?? hpa.maxReplicas ?? 10;
|
||||
const metrics = hpa.spec?.metrics || hpa.metrics || [];
|
||||
const curR = hpa.status?.currentReplicas ?? hpa.currentReplicas ?? 0;
|
||||
const conditions = hpa.status?.conditions || hpa.conditions || [];
|
||||
const ableToScale = conditions.find(( c: any) => c.type === 'AbleToScale');
|
||||
const ok = ableToScale?.status === 'True';
|
||||
return (
|
||||
<TableRow key={hpa.name || i} className="border-[var(--dg-border)]">
|
||||
<TableCell className="font-medium text-[var(--dg-text-primary)]">{hpa.name || hpa.metadata?.name}</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-secondary)] font-mono">{minR} → {maxR}</TableCell>
|
||||
<TableCell className="text-xs text-[var(--dg-text-muted)]">
|
||||
{metrics.map(( m: any, j: number) => {
|
||||
const resource = m.resource || m.object?.metric?.name || '';
|
||||
const target = m.resource?.target?.averageUtilization || m.resource?.target?.averageValue || m.resource?.target?.type || '-';
|
||||
return <div key={j}>{resource?.name || resource}: {target}{m.resource?.target?.averageUtilization ? '%' : ''}</div>;
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm font-mono text-[var(--dg-text-secondary)]">{curR}</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={ok ? 'bg-[var(--dg-green)]/20 text-[var(--dg-green)]' : 'bg-[var(--dg-red)]/20 text-[var(--dg-red)]'}>
|
||||
{ok ? '正常' : '异常'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* CronHPA 策略 */}
|
||||
<Card className="dg-card">
|
||||
<CardHeader className="pb-2"><CardTitle className="text-sm flex items-center gap-2"><Clock className="h-4 w-4 text-[var(--dg-blue)]" />CronHPA — 定时伸缩</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
{cronHpas.length === 0 ? (
|
||||
<div className="text-sm text-[var(--dg-text-muted)] py-4">
|
||||
未关联 CronHPA — <Button variant="link" className="h-auto p-0 text-[var(--dg-blue)] text-sm">创建 CronHPA</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-[var(--dg-border)]">
|
||||
<TableHead className="text-[var(--dg-text-muted)]">名称</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">目标副本</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">调度规则</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">时区</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">状态</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{cronHpas.map(( cron: any, i: number) => {
|
||||
const jobs = cron.spec?.jobs || cron.jobs || [];
|
||||
const tz = cron.spec?.timezone || 'UTC';
|
||||
return (
|
||||
<TableRow key={cron.name || i} className="border-[var(--dg-border)]">
|
||||
<TableCell className="font-medium text-[var(--dg-text-primary)]">{cron.name || cron.metadata?.name}</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{
|
||||
jobs.map(( j: any) => j.targetSize || j.targetReplicas || '?').join(', ')
|
||||
}</TableCell>
|
||||
<TableCell className="text-xs font-mono text-[var(--dg-text-muted)]">{jobs.map(( j: any) => j.schedule || '?').join(', ')}</TableCell>
|
||||
<TableCell className="text-xs text-[var(--dg-text-muted)]">{tz}</TableCell>
|
||||
<TableCell>
|
||||
<Badge className="bg-[var(--dg-green)]/20 text-[var(--dg-green)]">已启用</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════ TAB: 历史版本 ═══════════════
|
||||
function HistoryTab({ revisions, rollbackLoading, onRollback }: { revisions: any[]; rollbackLoading: number | null; onRollback: ( rev: any) => void }) {
|
||||
if (revisions.length === 0) {
|
||||
return <div className="text-center text-[var(--dg-text-muted)] py-12">暂无历史版本</div>;
|
||||
}
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-[var(--dg-border)]">
|
||||
<TableHead className="text-[var(--dg-text-muted)] w-24">Revision</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">镜像</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)] w-24">状态</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)] w-40">更新时间</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)] w-24">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{revisions.map((rev, idx) => {
|
||||
const revision = rev.revision ?? rev.revision_number ?? rev.number ?? idx + 1;
|
||||
const isLatest = idx === 0;
|
||||
return (
|
||||
<TableRow key={revision} className="border-[var(--dg-border)]">
|
||||
<TableCell className="font-mono text-[var(--dg-text-primary)]">#{revision}</TableCell>
|
||||
<TableCell className="text-xs font-mono text-[var(--dg-text-muted)] max-w-[300px] truncate">{rev.image || rev.images?.[0] || '-'}</TableCell>
|
||||
<TableCell>
|
||||
{isLatest
|
||||
? <Badge className="bg-[var(--dg-green)]/20 text-[var(--dg-green)] border-[var(--dg-green)]/30">当前</Badge>
|
||||
: <Badge variant="outline" className="text-[var(--dg-text-muted)]">历史</Badge>}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-[var(--dg-text-muted)]">{formatK8sTime(rev.created_at || rev.time)}</TableCell>
|
||||
<TableCell>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-amber-400" disabled={isLatest || rollbackLoading !== null} onClick={() => onRollback(rev)}>
|
||||
{rollbackLoading === revision ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : <RotateCcw className="h-3 w-3 mr-1" />}
|
||||
回滚
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// CreateHPADialog.tsx — 创建 HPA 弹窗 // Logan.Li
|
||||
import { useState } from 'react';
|
||||
import { Plus, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { kubernetesApi } from '@/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
export interface CreateHPADialogProps {
|
||||
clusterCode: string;
|
||||
namespace: string;
|
||||
deploymentName: string;
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
export function CreateHPADialog({ clusterCode, namespace, deploymentName, onCreated }: CreateHPADialogProps) {
|
||||
const { toast } = useToast();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
minReplicas: 1,
|
||||
maxReplicas: 10,
|
||||
cpuPercent: 80,
|
||||
memPercent: 0,
|
||||
});
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!form.name.trim()) {
|
||||
toast({ title: '名称不能为空', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await kubernetesApi.createHPA(clusterCode, {
|
||||
name: form.name,
|
||||
namespace,
|
||||
targetKind: 'Deployment',
|
||||
targetName: deploymentName,
|
||||
minReplicas: form.minReplicas,
|
||||
maxReplicas: form.maxReplicas,
|
||||
cpuPercent: form.cpuPercent,
|
||||
memPercent: form.memPercent,
|
||||
});
|
||||
toast({ title: 'HPA 创建成功' });
|
||||
setOpen(false);
|
||||
onCreated();
|
||||
setForm({ name: '', minReplicas: 1, maxReplicas: 10, cpuPercent: 80, memPercent: 0 });
|
||||
} catch (e: any) {
|
||||
toast({ title: '创建失败', description: e?.message || String(e), variant: 'destructive' });
|
||||
} finally { setLoading(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" size="sm" className="border-slate-700 text-slate-300" onClick={() => setOpen(true)}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />创建 HPA
|
||||
</Button>
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={() => setOpen(false)}>
|
||||
<div className="bg-slate-900 border border-slate-700 rounded-xl p-6 w-[480px] max-h-[80vh] overflow-auto" onClick={(e) => e.stopPropagation()}>
|
||||
<h2 className="text-lg font-bold text-slate-100 mb-4">创建 HPA</h2>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-slate-400 text-sm">名称</Label>
|
||||
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder={`hpa-${deploymentName}`}
|
||||
className="bg-slate-800 border-slate-700 text-slate-200 mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-slate-400 text-sm">目标</Label>
|
||||
<p className="text-slate-300 text-sm mt-1 bg-slate-800 rounded px-3 py-2">
|
||||
Deployment: {deploymentName} · 命名空间: {namespace}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-slate-400 text-sm">最小副本数</Label>
|
||||
<Input type="number" value={form.minReplicas} min={1}
|
||||
onChange={(e) => setForm({ ...form, minReplicas: Number(e.target.value) })}
|
||||
className="bg-slate-800 border-slate-700 text-slate-200 mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-slate-400 text-sm">最大副本数</Label>
|
||||
<Input type="number" value={form.maxReplicas} min={1}
|
||||
onChange={(e) => setForm({ ...form, maxReplicas: Number(e.target.value) })}
|
||||
className="bg-slate-800 border-slate-700 text-slate-200 mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-slate-400 text-sm">CPU 目标 (%)</Label>
|
||||
<Input type="number" value={form.cpuPercent} min={0} max={100}
|
||||
onChange={(e) => setForm({ ...form, cpuPercent: Number(e.target.value) })}
|
||||
className="bg-slate-800 border-slate-700 text-slate-200 mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-slate-400 text-sm">内存目标 (%) <span className="text-slate-600">可选</span></Label>
|
||||
<Input type="number" value={form.memPercent} min={0} max={100}
|
||||
onChange={(e) => setForm({ ...form, memPercent: Number(e.target.value) })}
|
||||
className="bg-slate-800 border-slate-700 text-slate-200 mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 mt-6">
|
||||
<Button variant="outline" className="border-slate-700 text-slate-400" onClick={() => setOpen(false)}>取消</Button>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700" disabled={loading} onClick={handleCreate}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : '创建'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// DeploymentHpaTab.tsx — Deployment 详情 HPA Tab // Logan.Li
|
||||
import { Gauge, Trash2 } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { TabsContent } from '@/components/ui/tabs';
|
||||
import { CreateHPADialog } from './CreateHPADialog';
|
||||
|
||||
export interface DeploymentHpaTabProps {
|
||||
hpas: any[];
|
||||
clusterCode: string;
|
||||
namespace: string;
|
||||
deploymentName: string;
|
||||
onCreated: () => void;
|
||||
onDeleteHpa: (hpa: any) => void;
|
||||
}
|
||||
|
||||
export function DeploymentHpaTab({
|
||||
hpas,
|
||||
clusterCode,
|
||||
namespace,
|
||||
deploymentName,
|
||||
onCreated,
|
||||
onDeleteHpa,
|
||||
}: DeploymentHpaTabProps) {
|
||||
return (
|
||||
<TabsContent value="hpa">
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm text-slate-400">HPA 自动扩缩容</CardTitle>
|
||||
<CreateHPADialog
|
||||
clusterCode={clusterCode}
|
||||
namespace={namespace}
|
||||
deploymentName={deploymentName}
|
||||
onCreated={onCreated}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{hpas.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
<Gauge className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p>未配置 HPA</p>
|
||||
<p className="text-xs mt-1">点击右上角「创建 HPA」设置自动扩缩容</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||
<TableHead className="text-slate-400">名称</TableHead>
|
||||
<TableHead className="text-slate-400">最小/最大</TableHead>
|
||||
<TableHead className="text-slate-400">指标</TableHead>
|
||||
<TableHead className="text-slate-400">当前/目标</TableHead>
|
||||
<TableHead className="text-slate-400">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{hpas.map((hpa: any) => {
|
||||
const cpu = hpa.cpu_percent || 0;
|
||||
const mem = hpa.mem_percent || 0;
|
||||
const metricsParts = [];
|
||||
if (cpu) metricsParts.push(`CPU: ${cpu}%`);
|
||||
if (mem) metricsParts.push(`MEM: ${mem}%`);
|
||||
const metrics = metricsParts.join(', ') || '-';
|
||||
return (
|
||||
<TableRow key={hpa.name} className="border-slate-800 hover:bg-slate-800/30">
|
||||
<TableCell className="font-medium text-slate-200">{hpa.name}</TableCell>
|
||||
<TableCell className="text-slate-300">{hpa.min_replicas} / {hpa.max_replicas}</TableCell>
|
||||
<TableCell className="text-sm text-slate-400">{metrics}</TableCell>
|
||||
<TableCell className="text-slate-300">{hpa.current_replicas || 0}</TableCell>
|
||||
<TableCell>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-400 hover:text-red-300"
|
||||
onClick={() => onDeleteHpa(hpa)} title="删除 HPA">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// DeploymentLogsTab.tsx — Deployment 详情日志 Tab // Logan.Li
|
||||
import { FileText, ExternalLink } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { TabsContent } from '@/components/ui/tabs';
|
||||
import { extractContainers } from './helpers';
|
||||
|
||||
export interface DeploymentLogsTabProps {
|
||||
pods: any[];
|
||||
logTail: number;
|
||||
onLogTailChange: (value: number) => void;
|
||||
logsTabPod: string;
|
||||
onLogsTabPodChange: (value: string) => void;
|
||||
onOpenLogsViewer: (podName: string, containers: string[]) => void;
|
||||
}
|
||||
|
||||
export function DeploymentLogsTab({
|
||||
pods,
|
||||
logTail,
|
||||
onLogTailChange,
|
||||
logsTabPod,
|
||||
onLogsTabPodChange,
|
||||
onOpenLogsViewer,
|
||||
}: DeploymentLogsTabProps) {
|
||||
return (
|
||||
<TabsContent value="logs">
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm text-slate-400">Pod 日志</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-500">最近</span>
|
||||
<Input
|
||||
type="number" value={logTail} onChange={(e) => onLogTailChange(Number(e.target.value))}
|
||||
className="w-20 h-7 bg-slate-800 border-slate-700 text-slate-200 text-xs"
|
||||
/>
|
||||
<span className="text-xs text-slate-500">行</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{pods.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
<FileText className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p>暂无 Pod</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-3 py-10 justify-center flex-wrap">
|
||||
<FileText className="h-5 w-5 text-blue-400" />
|
||||
<span className="text-sm text-slate-400">选择 Pod 打开增强版日志查看器</span>
|
||||
<Select value={logsTabPod} onValueChange={onLogsTabPodChange}>
|
||||
<SelectTrigger className="w-[280px] h-8 bg-slate-800 border-slate-700 text-slate-200 text-xs">
|
||||
<SelectValue placeholder="选择 Pod…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{pods.map((p: any) => (
|
||||
<SelectItem key={p.name} value={p.name}>{p.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-blue-600 hover:bg-blue-700 h-8"
|
||||
disabled={!logsTabPod}
|
||||
onClick={() => {
|
||||
const pod = pods.find((p: any) => p.name === logsTabPod);
|
||||
onOpenLogsViewer(logsTabPod, pod ? extractContainers(pod) : []);
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="h-3 w-3 mr-1" />打开日志
|
||||
</Button>
|
||||
<span className="text-xs text-slate-600 block w-full text-center mt-2">
|
||||
支持实时跟踪、多容器切换、previous 日志、关键字过滤、导出
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
// DeploymentOverview.tsx — Deployment 详情头部、状态与操作区 // Logan.Li
|
||||
import {
|
||||
ArrowLeft, Minus, Plus, RotateCw, Upload, RefreshCw, Loader2,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { CreateHPADialog } from './CreateHPADialog';
|
||||
|
||||
export interface DeploymentOverviewProps {
|
||||
deploymentName: string;
|
||||
namespace: string;
|
||||
deployment: any;
|
||||
hpas: any[];
|
||||
clusterCode: string;
|
||||
ready: number;
|
||||
total: number;
|
||||
available: number;
|
||||
isHealthy: boolean;
|
||||
actionLoading: string;
|
||||
newImage: string;
|
||||
showImageUpdate: boolean;
|
||||
onBack: () => void;
|
||||
onRefresh: () => void;
|
||||
onScale: (delta: number) => void;
|
||||
onRestart: () => void;
|
||||
onNewImageChange: (value: string) => void;
|
||||
onToggleImageUpdate: () => void;
|
||||
onUpdateImage: () => void;
|
||||
}
|
||||
|
||||
export function DeploymentOverview({
|
||||
deploymentName, namespace, deployment, hpas, clusterCode,
|
||||
ready, total, available, isHealthy, actionLoading,
|
||||
newImage, showImageUpdate, onBack, onRefresh, onScale, onRestart,
|
||||
onNewImageChange, onToggleImageUpdate, onUpdateImage,
|
||||
}: DeploymentOverviewProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="sm" className="text-slate-400" onClick={onBack}>
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />返回
|
||||
</Button>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-xl font-bold text-slate-100">{deploymentName}</h1>
|
||||
<Badge variant="outline" className="border-slate-700 text-slate-400">{namespace}</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 mt-0.5">镜像: {deployment.image || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="border-slate-700 text-slate-400" onClick={onRefresh}>
|
||||
<RefreshCw className="h-4 w-4 mr-1" />刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-3">
|
||||
<p className="text-xs text-slate-500">副本状态</p>
|
||||
<p className={`text-lg font-bold ${isHealthy ? 'text-emerald-400' : 'text-amber-400'}`}>
|
||||
{ready}/{total}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-3">
|
||||
<p className="text-xs text-slate-500">可用副本</p>
|
||||
<p className="text-lg font-bold text-blue-400">{available}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-3">
|
||||
<p className="text-xs text-slate-500">镜像</p>
|
||||
<p className="text-sm font-mono text-slate-300 truncate max-w-[200px]" title={deployment.image}>
|
||||
{deployment.image?.split('/').pop() || '-'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-3">
|
||||
<p className="text-xs text-slate-500">HPA</p>
|
||||
{hpas.length > 0 ? (
|
||||
<p className="text-lg font-bold text-purple-400">已启用</p>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-lg font-bold text-slate-500">未配置</p>
|
||||
<CreateHPADialog
|
||||
clusterCode={clusterCode}
|
||||
namespace={namespace}
|
||||
deploymentName={deploymentName}
|
||||
onCreated={onRefresh}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base text-slate-200">操作</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex items-center gap-1 bg-slate-800 rounded-lg p-1">
|
||||
<Button
|
||||
variant="ghost" size="icon" className="h-8 w-8 text-slate-300 hover:text-slate-100"
|
||||
disabled={actionLoading === 'scale' || total <= 0}
|
||||
onClick={() => onScale(-1)}
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-sm font-mono text-slate-200 w-8 text-center">
|
||||
{actionLoading === 'scale' ? <Loader2 className="h-4 w-4 animate-spin inline" /> : total}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost" size="icon" className="h-8 w-8 text-slate-300 hover:text-slate-100"
|
||||
disabled={actionLoading === 'scale'}
|
||||
onClick={() => onScale(1)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline" size="sm" className="border-slate-700 text-slate-300 hover:text-slate-100"
|
||||
disabled={actionLoading === 'restart'}
|
||||
onClick={onRestart}
|
||||
>
|
||||
{actionLoading === 'restart' ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <RotateCw className="h-4 w-4 mr-1" />}
|
||||
滚动重启
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline" size="sm" className="border-slate-700 text-slate-300 hover:text-slate-100"
|
||||
onClick={onToggleImageUpdate}
|
||||
>
|
||||
<Upload className="h-4 w-4 mr-1" />
|
||||
更新镜像
|
||||
</Button>
|
||||
</div>
|
||||
{showImageUpdate && (
|
||||
<div className="mt-3 flex items-center gap-2 p-3 bg-slate-800/50 rounded-lg border border-slate-700">
|
||||
<Input
|
||||
value={newImage}
|
||||
onChange={(e) => onNewImageChange(e.target.value)}
|
||||
placeholder="输入新镜像地址,如 harbor.example.com/app:v2.0"
|
||||
className="flex-1 h-9 bg-slate-900 border-slate-700 text-slate-200 text-sm font-mono"
|
||||
/>
|
||||
<Button size="sm" className="bg-blue-600 hover:bg-blue-700"
|
||||
disabled={actionLoading === 'image' || !newImage.trim()}
|
||||
onClick={onUpdateImage}
|
||||
>
|
||||
{actionLoading === 'image' ? <Loader2 className="h-4 w-4 animate-spin" /> : '更新'}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="text-slate-400" onClick={onToggleImageUpdate}>取消</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// DeploymentPodModals.tsx — Pod exec / 日志 / 文件浏览器弹窗 // Logan.Li
|
||||
import { PodExecTerminal } from '../PodExecTerminal';
|
||||
import { PodLogsViewer } from '../PodLogsViewer';
|
||||
import { PodFileBrowser } from '../PodFileBrowser';
|
||||
import type { PodWithContainers } from './types';
|
||||
|
||||
export interface DeploymentPodModalsProps {
|
||||
clusterCode: string;
|
||||
namespace: string;
|
||||
execPod: PodWithContainers | null;
|
||||
execOpen: boolean;
|
||||
onExecOpenChange: (open: boolean) => void;
|
||||
logsViewerPod: PodWithContainers | null;
|
||||
logsViewerOpen: boolean;
|
||||
onLogsViewerOpenChange: (open: boolean) => void;
|
||||
fileBrowserPod: PodWithContainers | null;
|
||||
fileBrowserOpen: boolean;
|
||||
onFileBrowserOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function DeploymentPodModals({
|
||||
clusterCode, namespace,
|
||||
execPod, execOpen, onExecOpenChange,
|
||||
logsViewerPod, logsViewerOpen, onLogsViewerOpenChange,
|
||||
fileBrowserPod, fileBrowserOpen, onFileBrowserOpenChange,
|
||||
}: DeploymentPodModalsProps) {
|
||||
return (
|
||||
<>
|
||||
{execPod && (
|
||||
<PodExecTerminal
|
||||
open={execOpen}
|
||||
onOpenChange={onExecOpenChange}
|
||||
clusterId={clusterCode}
|
||||
namespace={namespace}
|
||||
podName={execPod.name}
|
||||
containers={execPod.containers}
|
||||
/>
|
||||
)}
|
||||
{logsViewerPod && (
|
||||
<PodLogsViewer
|
||||
open={logsViewerOpen}
|
||||
onOpenChange={onLogsViewerOpenChange}
|
||||
clusterCode={clusterCode}
|
||||
namespace={namespace}
|
||||
podName={logsViewerPod.name}
|
||||
containers={logsViewerPod.containers}
|
||||
/>
|
||||
)}
|
||||
{fileBrowserPod && (
|
||||
<PodFileBrowser
|
||||
open={fileBrowserOpen}
|
||||
onOpenChange={onFileBrowserOpenChange}
|
||||
clusterCode={clusterCode}
|
||||
namespace={namespace}
|
||||
podName={fileBrowserPod.name}
|
||||
containers={fileBrowserPod.containers}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// DeploymentPodsTab.tsx — Deployment 详情 Pod 列表 Tab // Logan.Li
|
||||
import { FileText, Terminal, FolderOpen } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { TabsContent } from '@/components/ui/tabs';
|
||||
import { extractContainers } from './helpers';
|
||||
import type { PodWithContainers } from './types';
|
||||
|
||||
export interface DeploymentPodsTabProps {
|
||||
pods: any[];
|
||||
onOpenLogsViewer: (pod: PodWithContainers) => void;
|
||||
onOpenExec: (pod: PodWithContainers) => void;
|
||||
onOpenFileBrowser: (pod: PodWithContainers) => void;
|
||||
}
|
||||
|
||||
export function DeploymentPodsTab({ pods, onOpenLogsViewer, onOpenExec, onOpenFileBrowser }: DeploymentPodsTabProps) {
|
||||
return (
|
||||
<TabsContent value="pods">
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||
<TableHead className="text-slate-400">Pod 名称</TableHead>
|
||||
<TableHead className="text-slate-400">状态</TableHead>
|
||||
<TableHead className="text-slate-400">节点</TableHead>
|
||||
<TableHead className="text-slate-400">IP</TableHead>
|
||||
<TableHead className="text-slate-400">重启</TableHead>
|
||||
<TableHead className="text-slate-400">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pods.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-slate-500 py-8">
|
||||
暂无 Pod
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
pods.map((pod: any) => (
|
||||
<TableRow key={pod.name} className="border-slate-800 hover:bg-slate-800/30">
|
||||
<TableCell className="font-medium text-slate-200">{pod.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={
|
||||
pod.status === 'Running' ? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30' :
|
||||
pod.status === 'Pending' ? 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' :
|
||||
'bg-red-500/20 text-red-400 border-red-500/30'
|
||||
}>{pod.status}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-slate-400">{pod.node_name || '-'}</TableCell>
|
||||
<TableCell className="text-sm text-slate-500 font-mono">{pod.ip || '-'}</TableCell>
|
||||
<TableCell className={pod.restart_count > 0 ? 'text-yellow-400' : 'text-slate-500'}>
|
||||
{pod.restart_count || 0}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400"
|
||||
title="查看日志"
|
||||
onClick={() => onOpenLogsViewer({ name: pod.name, containers: extractContainers(pod) })}>
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-emerald-400"
|
||||
title="exec 终端"
|
||||
onClick={() => onOpenExec({ name: pod.name, containers: extractContainers(pod) })}>
|
||||
<Terminal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-amber-400"
|
||||
title="文件浏览器"
|
||||
onClick={() => onOpenFileBrowser({ name: pod.name, containers: extractContainers(pod) })}>
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// helpers.ts — DeploymentDetail 工具函数 // Logan.Li
|
||||
|
||||
/** 从 Pod 对象提取容器名列表(兼容多种字段) */
|
||||
export function extractContainers(pod: any): string[] {
|
||||
const c = pod?.containers;
|
||||
if (Array.isArray(c)) return c.map((x: any) => typeof x === 'string' ? x : (x?.name || x?.Name)).filter(Boolean);
|
||||
if (Array.isArray(pod?.container_statuses)) return pod.container_statuses.map((x: any) => x?.name).filter(Boolean);
|
||||
if (Array.isArray(pod?.containerNames)) return pod.containerNames.filter(Boolean);
|
||||
return [];
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// types.ts — DeploymentDetail 组件类型定义 // Logan.Li
|
||||
|
||||
export interface DeploymentDetailProps {
|
||||
clusterCode: string;
|
||||
namespace: string;
|
||||
deploymentName: string;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export interface PodWithContainers {
|
||||
name: string;
|
||||
containers: string[];
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// deployment-view/EventsTab.tsx — 事件 Tab // Logan.Li
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { formatK8sTime } from '../k8s-yaml-utils';
|
||||
import type { EventItem } from './types';
|
||||
|
||||
export function EventsTab({ events, loading }: { events: EventItem[]; loading: string }) {
|
||||
if (loading === 'events') {
|
||||
return <div className="flex items-center justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-[var(--dg-text-muted)]" /></div>;
|
||||
}
|
||||
if (events.length === 0) {
|
||||
return <div className="text-center text-[var(--dg-text-muted)] py-12">暂无事件</div>;
|
||||
}
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-[var(--dg-border)]">
|
||||
<TableHead className="text-[var(--dg-text-muted)] w-16">类型</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)] w-24">原因</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">消息</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)] w-20">来源</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)] w-10">次数</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)] w-40">最后时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{events.map((e, i) => (
|
||||
<TableRow key={i} className="border-[var(--dg-border)]">
|
||||
<TableCell>
|
||||
<Badge variant="outline" className={e.type === 'Warning' ? 'text-[var(--dg-yellow)] border-[var(--dg-yellow)]/30' : 'text-[var(--dg-text-muted)] border-[var(--dg-border)]'}>
|
||||
{e.type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{e.reason}</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-primary)] max-w-md truncate" title={e.message}>{e.message}</TableCell>
|
||||
<TableCell className="text-xs text-[var(--dg-text-muted)]">{e.source}</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-muted)]">{e.count}</TableCell>
|
||||
<TableCell className="text-xs text-[var(--dg-text-muted)]">{formatK8sTime(e.lastTimestamp)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// deployment-view/HPACronHPATab.tsx — 容器伸缩 Tab (HPA + CronHPA) // Logan.Li
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Activity, Clock, Loader2 } from 'lucide-react';
|
||||
|
||||
export function HPACronHPATab({ hpas, cronHpas, loading, replicas }: { hpas: any[]; cronHpas: any[]; loading: string; replicas: number }) {
|
||||
if (loading === 'scale') {
|
||||
return <div className="flex items-center justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-[var(--dg-text-muted)]" /></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 当前副本数提示 */}
|
||||
<div className="flex items-center gap-2 px-2">
|
||||
<span className="text-sm text-[var(--dg-text-muted)]">当前副本:</span>
|
||||
<span className="text-sm font-bold text-[var(--dg-text-primary)]">{replicas}</span>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">(可通过 Tab 栏右侧 +/- 手动调整)</span>
|
||||
</div>
|
||||
|
||||
{/* HPA 策略 */}
|
||||
<Card className="dg-card">
|
||||
<CardHeader className="pb-2"><CardTitle className="text-sm flex items-center gap-2"><Activity className="h-4 w-4 text-[var(--dg-orange)]" />HPA — 水平自动伸缩</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
{hpas.length === 0 ? (
|
||||
<div className="text-sm text-[var(--dg-text-muted)] py-4">
|
||||
未关联 HPA — <Button variant="link" className="h-auto p-0 text-[var(--dg-blue)] text-sm">创建 HPA</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-[var(--dg-border)]">
|
||||
<TableHead className="text-[var(--dg-text-muted)]">名称</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">最小/最大</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">指标</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">当前负载</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">状态</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{hpas.map((hpa: any, i: number) => {
|
||||
const minR = hpa.spec?.minReplicas ?? hpa.minReplicas ?? 1;
|
||||
const maxR = hpa.spec?.maxReplicas ?? hpa.maxReplicas ?? 10;
|
||||
const metrics = hpa.spec?.metrics || hpa.metrics || [];
|
||||
const curR = hpa.status?.currentReplicas ?? hpa.currentReplicas ?? 0;
|
||||
const conditions = hpa.status?.conditions || hpa.conditions || [];
|
||||
const ableToScale = conditions.find((c: any) => c.type === 'AbleToScale');
|
||||
const ok = ableToScale?.status === 'True';
|
||||
return (
|
||||
<TableRow key={hpa.name || i} className="border-[var(--dg-border)]">
|
||||
<TableCell className="font-medium text-[var(--dg-text-primary)]">{hpa.name || hpa.metadata?.name}</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-secondary)] font-mono">{minR} → {maxR}</TableCell>
|
||||
<TableCell className="text-xs text-[var(--dg-text-muted)]">
|
||||
{metrics.map((m: any, j: number) => {
|
||||
const resource = m.resource || m.object?.metric?.name || '';
|
||||
const target = m.resource?.target?.averageUtilization || m.resource?.target?.averageValue || m.resource?.target?.type || '-';
|
||||
return <div key={j}>{resource?.name || resource}: {target}{m.resource?.target?.averageUtilization ? '%' : ''}</div>;
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm font-mono text-[var(--dg-text-secondary)]">{curR}</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={ok ? 'bg-[var(--dg-green)]/20 text-[var(--dg-green)]' : 'bg-[var(--dg-red)]/20 text-[var(--dg-red)]'}>
|
||||
{ok ? '正常' : '异常'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* CronHPA 策略 */}
|
||||
<Card className="dg-card">
|
||||
<CardHeader className="pb-2"><CardTitle className="text-sm flex items-center gap-2"><Clock className="h-4 w-4 text-[var(--dg-blue)]" />CronHPA — 定时伸缩</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
{cronHpas.length === 0 ? (
|
||||
<div className="text-sm text-[var(--dg-text-muted)] py-4">
|
||||
未关联 CronHPA — <Button variant="link" className="h-auto p-0 text-[var(--dg-blue)] text-sm">创建 CronHPA</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-[var(--dg-border)]">
|
||||
<TableHead className="text-[var(--dg-text-muted)]">名称</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">目标副本</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">调度规则</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">时区</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">状态</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{cronHpas.map((cron: any, i: number) => {
|
||||
const jobs = cron.spec?.jobs || cron.jobs || [];
|
||||
const tz = cron.spec?.timezone || 'UTC';
|
||||
return (
|
||||
<TableRow key={cron.name || i} className="border-[var(--dg-border)]">
|
||||
<TableCell className="font-medium text-[var(--dg-text-primary)]">{cron.name || cron.metadata?.name}</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{
|
||||
jobs.map((j: any) => j.targetSize || j.targetReplicas || '?').join(', ')
|
||||
}</TableCell>
|
||||
<TableCell className="text-xs font-mono text-[var(--dg-text-muted)]">{jobs.map((j: any) => j.schedule || '?').join(', ')}</TableCell>
|
||||
<TableCell className="text-xs text-[var(--dg-text-muted)]">{tz}</TableCell>
|
||||
<TableCell>
|
||||
<Badge className="bg-[var(--dg-green)]/20 text-[var(--dg-green)]">已启用</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// deployment-view/HistoryTab.tsx — 历史版本 Tab // Logan.Li
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { RotateCcw, Loader2 } from 'lucide-react';
|
||||
import { formatK8sTime } from '../k8s-yaml-utils';
|
||||
|
||||
export function HistoryTab({ revisions, rollbackLoading, onRollback }: { revisions: any[]; rollbackLoading: number | null; onRollback: (rev: any) => void }) {
|
||||
if (revisions.length === 0) {
|
||||
return <div className="text-center text-[var(--dg-text-muted)] py-12">暂无历史版本</div>;
|
||||
}
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-[var(--dg-border)]">
|
||||
<TableHead className="text-[var(--dg-text-muted)] w-24">Revision</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">镜像</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)] w-24">状态</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)] w-40">更新时间</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)] w-24">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{revisions.map((rev, idx) => {
|
||||
const revision = rev.revision ?? rev.revision_number ?? rev.number ?? idx + 1;
|
||||
const isLatest = idx === 0;
|
||||
return (
|
||||
<TableRow key={revision} className="border-[var(--dg-border)]">
|
||||
<TableCell className="font-mono text-[var(--dg-text-primary)]">#{revision}</TableCell>
|
||||
<TableCell className="text-xs font-mono text-[var(--dg-text-muted)] max-w-[300px] truncate">{rev.image || rev.images?.[0] || '-'}</TableCell>
|
||||
<TableCell>
|
||||
{isLatest
|
||||
? <Badge className="bg-[var(--dg-green)]/20 text-[var(--dg-green)] border-[var(--dg-green)]/30">当前</Badge>
|
||||
: <Badge variant="outline" className="text-[var(--dg-text-muted)]">历史</Badge>}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-[var(--dg-text-muted)]">{formatK8sTime(rev.created_at || rev.time)}</TableCell>
|
||||
<TableCell>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-amber-400" disabled={isLatest || rollbackLoading !== null} onClick={() => onRollback(rev)}>
|
||||
{rollbackLoading === revision ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : <RotateCcw className="h-3 w-3 mr-1" />}
|
||||
回滚
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// deployment-view/PodsTab.tsx — 容器组 Tab // Logan.Li
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
ChevronDown, ChevronRight, Terminal, FileText, HardDrive,
|
||||
} from 'lucide-react';
|
||||
import { formatK8sTime, statusColor } from '../k8s-yaml-utils';
|
||||
import { getPodTimeline, PodConditionsRow } from './helpers';
|
||||
import type { DeploymentViewProps } from './types';
|
||||
|
||||
export function PodsTab({
|
||||
clusterCode, pods, onShowPodLogs, onShowPodTerminal, onShowPodFileBrowser,
|
||||
expandedPod, setExpandedPod,
|
||||
}: DeploymentViewProps & { expandedPod: string | null; setExpandedPod: (v: string | null) => void }) {
|
||||
if (pods.length === 0) {
|
||||
return <div className="text-center text-[var(--dg-text-muted)] py-12">暂无容器组</div>;
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{pods.map(pod => {
|
||||
const tl = getPodTimeline(pod);
|
||||
const expanded = expandedPod === pod.name;
|
||||
const nodeName = pod.node_name || pod.node;
|
||||
return (
|
||||
<Card key={pod.name} className="dg-card">
|
||||
<CardContent className="p-4">
|
||||
{/* Pod header row */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 text-[var(--dg-text-muted)]" onClick={() => setExpandedPod(expanded ? null : pod.name)}>
|
||||
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</Button>
|
||||
<span className="font-mono text-sm text-[var(--dg-text-primary)]">{pod.name}</span>
|
||||
<Badge style={{ background: `${statusColor(pod.status)}22`, color: statusColor(pod.status), border: `1px solid ${statusColor(pod.status)}44` }}>
|
||||
{pod.status || 'Unknown'}
|
||||
</Badge>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">
|
||||
<button className="text-[var(--dg-blue)] hover:underline mr-2" onClick={() => window.open(`/kubernetes/${clusterCode}/nodes/${nodeName}`, '_blank')}>
|
||||
{nodeName || '-'}
|
||||
</button>
|
||||
</span>
|
||||
<span className="text-xs font-mono text-[var(--dg-text-muted)]">{pod.ip || pod.pod_ip || '-'}</span>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">重启 {pod.restart_count || 0} 次</span>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">{formatK8sTime(tl.startTime || pod.created_at)}</span>
|
||||
<div className="flex-1" />
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs text-[var(--dg-blue)]" onClick={() => onShowPodLogs(pod)}>
|
||||
<FileText className="h-3.5 w-3.5 mr-1" />日志
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs text-emerald-400" onClick={() => onShowPodTerminal(pod)}>
|
||||
<Terminal className="h-3.5 w-3.5 mr-1" />终端
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs text-amber-400" onClick={() => onShowPodFileBrowser(pod)}>
|
||||
<HardDrive className="h-3.5 w-3.5 mr-1" />文件
|
||||
</Button>
|
||||
</div>
|
||||
{/* Expanded: timeline */}
|
||||
{expanded && (
|
||||
<div className="mt-3 pt-3 border-t border-[var(--dg-border)]">
|
||||
<PodConditionsRow pod={pod} />
|
||||
{/* Container details */}
|
||||
{(pod as Record<string, any>).containers && ((pod as Record<string, any>).containers as any[]).length > 0 && (
|
||||
<div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{((pod as Record<string, any>).containers as any[]).map((c: any, idx: number) => (
|
||||
<div key={idx} className="p-2 rounded bg-[var(--dg-bg-tertiary)] text-xs">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-[var(--dg-text-primary)]">{c.name}</span>
|
||||
<span className={c.ready ? 'text-emerald-400' : 'text-[var(--dg-text-muted)]'}>{c.ready ? 'Ready' : 'Pending'}</span>
|
||||
</div>
|
||||
<div className="text-[var(--dg-text-muted)] mt-1">{c.image}</div>
|
||||
{c.resources && (
|
||||
<div className="mt-1 flex gap-3">
|
||||
<span>CPU: {c.resources?.requests?.cpu || '0'}/{c.resources?.limits?.cpu || '-'} m</span>
|
||||
<span>内存: {c.resources?.requests?.memory || '0'}/{c.resources?.limits?.memory || '-'}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// deployment-view/ServicesTab.tsx — 访问方式 Tab // Logan.Li
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Network, ExternalLink, Loader2 } from 'lucide-react';
|
||||
import type { SvcItem, IngItem } from './types';
|
||||
|
||||
export function ServicesTab({ services, ingresses, loading }: { services: SvcItem[]; ingresses: IngItem[]; loading: string; namespace: string }) {
|
||||
if (loading === 'services') {
|
||||
return <div className="flex items-center justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-[var(--dg-text-muted)]" /></div>;
|
||||
}
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Services */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-[var(--dg-text-primary)] mb-3 flex items-center gap-2">
|
||||
<Network className="h-4 w-4 text-[var(--dg-blue)]" />关联 Service
|
||||
</h3>
|
||||
{services.length === 0 ? (
|
||||
<p className="text-sm text-[var(--dg-text-muted)] py-4">暂无匹配的 Service(Deployment 选择器未关联任何 Service)</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-[var(--dg-border)]">
|
||||
<TableHead className="text-[var(--dg-text-muted)]">名称</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">类型</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">ClusterIP</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">端口</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">Selector</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{services.map(svc => (
|
||||
<TableRow key={svc.name} className="border-[var(--dg-border)]">
|
||||
<TableCell className="font-medium text-[var(--dg-text-primary)]">{svc.name}</TableCell>
|
||||
<TableCell><Badge variant="outline" className="text-xs">{svc.type}</Badge></TableCell>
|
||||
<TableCell className="font-mono text-sm text-[var(--dg-text-muted)]">{svc.clusterIP}</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-muted)]">{svc.ports.join(', ')}</TableCell>
|
||||
<TableCell className="text-xs text-[var(--dg-text-muted)]">
|
||||
{Object.entries(svc.selector).map(([k, v]) => (
|
||||
<code key={k} className="mr-1 bg-[var(--dg-bg-tertiary)] px-1 rounded">{k}={v}</code>
|
||||
))}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Ingresses */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-[var(--dg-text-primary)] mb-3 flex items-center gap-2">
|
||||
<ExternalLink className="h-4 w-4 text-[var(--dg-purple)]" />关联 Ingress
|
||||
</h3>
|
||||
{ingresses.length === 0 ? (
|
||||
<p className="text-sm text-[var(--dg-text-muted)] py-4">暂无匹配的 Ingress</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-[var(--dg-border)]">
|
||||
<TableHead className="text-[var(--dg-text-muted)]">名称</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">Class</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">Host</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">路径</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{ingresses.map(ing => (
|
||||
<TableRow key={ing.name} className="border-[var(--dg-border)]">
|
||||
<TableCell className="font-medium text-[var(--dg-text-primary)]">{ing.name}</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-muted)]">{ing.className}</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-muted)]">{ing.hosts.join(', ')}</TableCell>
|
||||
<TableCell className="text-xs text-[var(--dg-text-muted)]">
|
||||
{ing.rules.flatMap((r: any) => (r.http?.paths || r.paths || []).map((p: any) => p.path || '/')).join(', ')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// deployment-view/helpers.tsx — 时间线 / 信息面板辅助组件 // Logan.Li
|
||||
import { Fragment } from 'react';
|
||||
import {
|
||||
formatK8sTime, formatDuration, elapsedSeconds, STATUS_COLORS,
|
||||
} from '../k8s-yaml-utils';
|
||||
import type { PodItem } from './types';
|
||||
|
||||
// Pod 条件时间线提取 // Logan.Li
|
||||
export function getPodTimeline(pod: PodItem) {
|
||||
const cond: Record<string, any>[] = (pod as Record<string, any>).conditions || (pod as Record<string, any>).pod_conditions || [];
|
||||
const find = (t: string) => cond.find((c: any) => (c.type || c.Type) === t);
|
||||
const ts = (c: any) => c?.lastTransitionTime || c?.LastTransitionTime || c?.last_transition_time;
|
||||
return {
|
||||
scheduled: ts(find('PodScheduled') || {}),
|
||||
initialized: ts(find('Initialized') || {}),
|
||||
containersReady: ts(find('ContainersReady') || {}),
|
||||
ready: ts(find('Ready') || {}),
|
||||
startTime: pod.start_time || (pod as Record<string, any>).startTime,
|
||||
};
|
||||
}
|
||||
|
||||
// 信息面板单项 // Logan.Li
|
||||
export function InfoItem({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mb-1">{label}</p>
|
||||
<div className="text-sm text-[var(--dg-text-primary)]">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Pod 条件进度条 // Logan.Li
|
||||
export function PodConditionsRow({ pod }: { pod: PodItem }) {
|
||||
const tl = getPodTimeline(pod);
|
||||
const stages = [
|
||||
{ label: '已调度', ts: tl.scheduled, color: STATUS_COLORS.blue },
|
||||
{ label: '已初始化', ts: tl.initialized, color: STATUS_COLORS.orange },
|
||||
{ label: '容器已就绪', ts: tl.containersReady, color: STATUS_COLORS.green },
|
||||
{ label: '容器组已就绪', ts: tl.ready, color: STATUS_COLORS.green },
|
||||
];
|
||||
return (
|
||||
<div className="flex items-center gap-2 overflow-x-auto">
|
||||
{stages.map((s, i) => {
|
||||
const prev = stages[i - 1];
|
||||
const dur = elapsedSeconds(prev?.ts, s.ts);
|
||||
return (
|
||||
<Fragment key={s.label}>
|
||||
<div className="flex flex-col items-center min-w-[80px]">
|
||||
<div className="h-2 w-2 rounded-full" style={{ background: s.ts ? s.color : '#334155' }} />
|
||||
<span className="text-[10px] mt-1 font-medium" style={{ color: s.ts ? s.color : '#475569' }}>{s.label}</span>
|
||||
<span className="text-[9px] text-[var(--dg-text-muted)]">{s.ts ? formatK8sTime(s.ts) : '-'}</span>
|
||||
</div>
|
||||
{i < stages.length - 1 && (
|
||||
<div className="flex flex-col items-center mx-0.5 min-w-[30px]">
|
||||
<div className="h-0.5 w-8 bg-[var(--dg-border)]" />
|
||||
<span className="text-[9px] text-[var(--dg-text-muted)] mt-0.5">{dur != null ? formatDuration(dur) : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// deployment-view/types.ts — Deployment 详情看板类型定义 // Logan.Li
|
||||
|
||||
export interface PodItem {
|
||||
name: string; namespace?: string; status?: string; node_name?: string; node?: string;
|
||||
ip?: string; pod_ip?: string; restart_count?: number; age?: string; created_at?: string;
|
||||
start_time?: string; containers?: any[]; image?: string; images?: string[];
|
||||
}
|
||||
|
||||
export interface ReplicaSetItem {
|
||||
name: string; replicas?: number; ready_replicas?: number; available_replicas?: number;
|
||||
image?: string; revision?: number; status?: string; created_at?: string;
|
||||
}
|
||||
|
||||
export interface SvcItem {
|
||||
name: string; type: string; clusterIP: string; ports: any[]; selector: Record<string, string>; age: string;
|
||||
}
|
||||
|
||||
export interface IngItem {
|
||||
name: string; className: string; hosts: string[]; rules: any[]; age: string;
|
||||
}
|
||||
|
||||
export interface EventItem {
|
||||
type: string; reason: string; message: string; source: string;
|
||||
firstTimestamp: string; lastTimestamp: string; count: number;
|
||||
}
|
||||
|
||||
export interface DeploymentViewProps {
|
||||
clusterCode: string; namespace: string; deployment: any;
|
||||
pods: PodItem[]; replicaSets: ReplicaSetItem[]; revisions: any[];
|
||||
onRefresh: () => void;
|
||||
onShowPodLogs: (pod: PodItem) => void;
|
||||
onShowPodTerminal: (pod: PodItem) => void;
|
||||
onShowPodYaml: (pod: PodItem) => void;
|
||||
onShowPodFileBrowser: (pod: PodItem) => void;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// 告警中心主组件 // Logan.Li
|
||||
import React, { useState, useEffect } from 'react';
|
||||
// 告警中心主组件 — TanStack Query 编排 // Logan.Li
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
AlertTriangle, CheckCircle, Search, RefreshCw,
|
||||
Eye, Check, VolumeX, Plus, Rocket,
|
||||
@@ -16,9 +16,10 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { toast } from 'sonner';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { alertCenterApi, agentApi, alertApi } from '@/api';
|
||||
import { alertCenterApi, alertApi } from '@/api';
|
||||
import { useAlerts, useAgentAlertRules, useAlertMutations } from '@/hooks/api';
|
||||
import {
|
||||
type Alert, type AlertRule, mapAlert, getSeverityBadge, getStatusBadge,
|
||||
type Alert, type AlertRule, getSeverityBadge, getStatusBadge,
|
||||
getChannelTypeLabel,
|
||||
} from './alert-center-helpers';
|
||||
import { AlertDetailDialog, AlertSilenceDialog } from './AlertCenterDetail';
|
||||
@@ -30,9 +31,6 @@ import type { Contact, ContactGroup } from './alert-center-helpers';
|
||||
|
||||
const AlertCenter: React.FC = () => {
|
||||
const _confirm = useConfirm();
|
||||
const [alerts, setAlerts] = useState<Alert[]>([]);
|
||||
const [rules, setRules] = useState<AlertRule[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [severityFilter, setSeverityFilter] = useState('all');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
@@ -40,11 +38,9 @@ const AlertCenter: React.FC = () => {
|
||||
const [selectedAlert, setSelectedAlert] = useState<Alert | null>(null);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [silenceOpen, setSilenceOpen] = useState(false);
|
||||
const [stats, setStats] = useState({ firing: 0, critical: 0, warning: 0, resolved: 0 });
|
||||
// 规则弹框
|
||||
const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
|
||||
const [editingRule, setEditingRule] = useState<any>(null);
|
||||
const [quickSetupLoading, setQuickSetupLoading] = useState(false);
|
||||
// 联系人 & 联系组
|
||||
const [contacts, setContacts] = useState<Contact[]>([]);
|
||||
const [contactGroups, setContactGroups] = useState<ContactGroup[]>([]);
|
||||
@@ -55,67 +51,33 @@ const AlertCenter: React.FC = () => {
|
||||
// 分页
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(10);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
const alertParams = useMemo(() => ({
|
||||
page,
|
||||
page_size: pageSize,
|
||||
severity: severityFilter,
|
||||
status: statusFilter,
|
||||
}), [page, pageSize, severityFilter, statusFilter]);
|
||||
|
||||
const { data: alertData, isLoading: loading, refetch: refetchAlerts } = useAlerts(alertParams);
|
||||
const { data: rules = [], refetch: refetchRules } = useAgentAlertRules();
|
||||
const {
|
||||
ackMutation,
|
||||
resolveMutation,
|
||||
quickSetupMutation,
|
||||
toggleAgentRuleMutation,
|
||||
deleteAgentRuleMutation,
|
||||
} = useAlertMutations();
|
||||
|
||||
const alerts = alertData?.list ?? [];
|
||||
const total = alertData?.total ?? 0;
|
||||
const stats = alertData?.stats ?? { firing: 0, critical: 0, warning: 0, resolved: 0 };
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
if (activeTab === 'contacts') fetchContacts();
|
||||
if (activeTab === 'groups') { fetchContacts(); fetchContactGroups(); }
|
||||
const interval = setInterval(fetchData, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [page, severityFilter, statusFilter, activeTab]);
|
||||
|
||||
const fetchData = async () => {
|
||||
await Promise.all([fetchAlerts(), fetchRules()]);
|
||||
};
|
||||
|
||||
// Logan.Li — 获取告警列表(分页)
|
||||
const fetchAlerts = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res: Record<string, any> = await alertCenterApi.getAlerts({
|
||||
page,
|
||||
page_size: pageSize,
|
||||
severity: severityFilter !== 'all' ? severityFilter : undefined,
|
||||
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||
});
|
||||
const raw = (res.list || res.data?.list || []) as any[];
|
||||
const data: Alert[] = raw.map(mapAlert);
|
||||
setAlerts(data);
|
||||
setTotal(res.total || res.data?.total || data.length);
|
||||
setStats({
|
||||
firing: data.filter(a => a.status === 'firing').length,
|
||||
critical: data.filter(a => a.severity === 'critical' && a.status === 'firing').length,
|
||||
warning: data.filter(a => a.severity === 'warning' && a.status === 'firing').length,
|
||||
resolved: data.filter(a => a.status === 'resolved').length,
|
||||
});
|
||||
} catch {
|
||||
toast.error('获取告警失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchRules = async () => {
|
||||
try {
|
||||
const res = await agentApi.listRules();
|
||||
// agentApi.listRules returns {code, data: AgentRule[]} — map to AlertRule for UI
|
||||
const raw = (Array.isArray(res) ? res : (res.list || [])) as any[];
|
||||
setRules(raw.map(r => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
description: `${r.metric} ${r.operator} ${r.threshold}%`,
|
||||
enabled: r.enabled,
|
||||
severity: 'warning',
|
||||
type: r.metric,
|
||||
hostname: r.hostname || '全部主机',
|
||||
threshold: r.threshold,
|
||||
})) as AlertRule[]);
|
||||
} catch {
|
||||
toast.error('获取告警规则失败');
|
||||
}
|
||||
};
|
||||
}, [activeTab]);
|
||||
|
||||
const fetchContacts = async () => {
|
||||
try {
|
||||
@@ -133,9 +95,8 @@ const AlertCenter: React.FC = () => {
|
||||
|
||||
const handleAcknowledge = async (id: string) => {
|
||||
try {
|
||||
await alertCenterApi.acknowledge(id);
|
||||
await ackMutation.mutateAsync(id);
|
||||
toast.success('告警已确认');
|
||||
fetchAlerts();
|
||||
} catch {
|
||||
toast.error('操作失败');
|
||||
}
|
||||
@@ -143,9 +104,8 @@ const AlertCenter: React.FC = () => {
|
||||
|
||||
const handleResolve = async (id: string) => {
|
||||
try {
|
||||
await alertCenterApi.resolve(id);
|
||||
await resolveMutation.mutateAsync(id);
|
||||
toast.success('告警已解决');
|
||||
fetchAlerts();
|
||||
} catch {
|
||||
toast.error('操作失败');
|
||||
}
|
||||
@@ -169,7 +129,7 @@ const AlertCenter: React.FC = () => {
|
||||
});
|
||||
toast.success(`告警已静默 ${duration}`);
|
||||
setSilenceOpen(false);
|
||||
fetchAlerts();
|
||||
refetchAlerts();
|
||||
} catch {
|
||||
toast.error('操作失败');
|
||||
}
|
||||
@@ -177,8 +137,7 @@ const AlertCenter: React.FC = () => {
|
||||
|
||||
const toggleRule = async (ruleId: string, enabled: boolean) => {
|
||||
try {
|
||||
await agentApi.updateRule(Number(ruleId), { enabled });
|
||||
setRules(rules.map(r => String(r.id) === ruleId ? { ...r, enabled } : r));
|
||||
await toggleAgentRuleMutation.mutateAsync({ id: Number(ruleId), enabled });
|
||||
toast.success('规则状态已更新');
|
||||
} catch {
|
||||
toast.error('更新失败');
|
||||
@@ -196,20 +155,18 @@ const AlertCenter: React.FC = () => {
|
||||
confirmText: '确认创建',
|
||||
});
|
||||
if (!ok) return;
|
||||
setQuickSetupLoading(true);
|
||||
try {
|
||||
const res = await alertCenterApi.quickSetup('ecs');
|
||||
toast.success(typeof res === 'string' ? res : (res?.message || '默认告警规则创建成功'));
|
||||
fetchRules();
|
||||
const res = await quickSetupMutation.mutateAsync('ecs');
|
||||
toast.success(typeof res === 'string' ? res : ((res as { message?: string })?.message || '默认告警规则创建成功'));
|
||||
} catch {
|
||||
toast.error('创建失败,请检查 Agent 主机是否在线');
|
||||
} finally { setQuickSetupLoading(false); }
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRule = async (rule: AlertRule) => {
|
||||
const ok = await _confirm({ title: '删除规则', description: `确定删除规则「${rule.name}」?`, confirmText: '删除', variant: 'destructive' });
|
||||
if (!ok) return;
|
||||
try { await agentApi.deleteRule(Number(rule.id)); toast.success('已删除'); fetchRules(); } catch { toast.error('删除失败'); }
|
||||
try { await deleteAgentRuleMutation.mutateAsync(Number(rule.id)); toast.success('已删除'); } catch { toast.error('删除失败'); }
|
||||
};
|
||||
|
||||
const handleFilterChange = (type: 'severity' | 'status', value: string) => {
|
||||
@@ -277,7 +234,7 @@ const AlertCenter: React.FC = () => {
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<span>共 {total} 条</span>
|
||||
<Button variant="outline" size="sm" onClick={fetchAlerts} className="h-7 text-xs px-2">
|
||||
<Button variant="outline" size="sm" onClick={() => refetchAlerts()} className="h-7 text-xs px-2">
|
||||
<RefreshCw className="w-3 h-3 mr-1" />刷新
|
||||
</Button>
|
||||
</div>
|
||||
@@ -402,12 +359,12 @@ const AlertCenter: React.FC = () => {
|
||||
<Button onClick={handleOpenAddRule}>
|
||||
<Plus className="w-4 h-4 mr-1" />添加规则
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={handleQuickSetup} disabled={quickSetupLoading}>
|
||||
<Button variant="secondary" onClick={handleQuickSetup} disabled={quickSetupMutation.isPending}>
|
||||
<Rocket className="w-4 h-4 mr-1" />
|
||||
{quickSetupLoading ? '创建中…' : '一键报警'}
|
||||
{quickSetupMutation.isPending ? '创建中…' : '一键报警'}
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="outline" onClick={fetchRules}>
|
||||
<Button variant="outline" onClick={() => refetchRules()}>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
刷新
|
||||
</Button>
|
||||
@@ -615,7 +572,7 @@ const AlertCenter: React.FC = () => {
|
||||
onOpenChange={handleRuleDialogClose}
|
||||
editingRule={editingRule}
|
||||
notifyChannels={[]}
|
||||
onSaved={() => { handleRuleDialogClose(); fetchRules(); }}
|
||||
onSaved={() => { handleRuleDialogClose(); refetchRules(); }}
|
||||
/>
|
||||
<ContactDialog
|
||||
open={contactDialogOpen}
|
||||
|
||||
@@ -8,6 +8,12 @@ export { useServers, useServerMutations, type ServerRecord } from './useServers'
|
||||
export { useCredentials, useCredentialDetail, useCredentialMutations, type CredentialRecord } from './useCredentials';
|
||||
export { usePromDatasources, usePromDatasourceMutations, type PromDatasource } from './usePromDatasources';
|
||||
export { usePipelines, usePipelineRuns, usePipelineMutations } from './usePipelines';
|
||||
export { useAlertRules, useAlerts, useAlertMutations } from './useAlerts';
|
||||
export {
|
||||
useAlertRules,
|
||||
useAgentAlertRules,
|
||||
useAlerts,
|
||||
useAlertMutations,
|
||||
type AlertListResult,
|
||||
} from './useAlerts';
|
||||
export { useAuditLogs } from './useAuditLogs';
|
||||
export { useSessionCommands, type SessionCommand } from './useSessionCommands';
|
||||
@@ -13,6 +13,7 @@ export const queryKeys = {
|
||||
pipelineRuns: (code: string) => ['pipeline-runs', code] as const,
|
||||
alerts: (params?: Record<string, unknown>) => ['alerts', params] as const,
|
||||
alertRules: () => ['alert-rules'] as const,
|
||||
agentAlertRules: () => ['agent-alert-rules'] as const,
|
||||
auditLogs: (params?: Record<string, unknown>) => ['audit-logs', params] as const,
|
||||
sessionCommands: (params?: Record<string, unknown>) => ['session-commands', params] as const,
|
||||
k8sClusters: () => ['k8s-clusters'] as const,
|
||||
|
||||
@@ -1,9 +1,32 @@
|
||||
// 告警规则 / 告警事件 — TanStack Query // Logan.Li
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { alertApi, alertCenterApi } from '@/api';
|
||||
import { alertApi, alertCenterApi, agentApi } from '@/api';
|
||||
import { queryKeys } from './queryKeys';
|
||||
import { unwrapList } from './unwrap';
|
||||
import { unwrapList, unwrapTotal } from './unwrap';
|
||||
import { mapAlert, type Alert, type AlertRule } from '@/components/monitor/alert-center/alert-center-helpers';
|
||||
|
||||
type AlertListParams = {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
status?: string;
|
||||
severity?: string;
|
||||
};
|
||||
|
||||
export type AlertListResult = {
|
||||
list: Alert[];
|
||||
total: number;
|
||||
stats: { firing: number; critical: number; warning: number; resolved: number };
|
||||
};
|
||||
|
||||
function buildAlertStats(list: Alert[]) {
|
||||
return {
|
||||
firing: list.filter((a) => a.status === 'firing').length,
|
||||
critical: list.filter((a) => a.severity === 'critical' && a.status === 'firing').length,
|
||||
warning: list.filter((a) => a.severity === 'warning' && a.status === 'firing').length,
|
||||
resolved: list.filter((a) => a.status === 'resolved').length,
|
||||
};
|
||||
}
|
||||
|
||||
export function useAlertRules() {
|
||||
return useQuery({
|
||||
@@ -16,20 +39,55 @@ export function useAlertRules() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useAlerts(params: { page?: number; page_size?: number; status?: string; severity?: string } = {}) {
|
||||
/** Agent 主机告警规则(告警中心「规则」Tab) // Logan.Li */
|
||||
export function useAgentAlertRules() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.agentAlertRules(),
|
||||
queryFn: async () => {
|
||||
const res = await agentApi.listRules();
|
||||
const raw = unwrapList<Record<string, unknown>>(res);
|
||||
return raw.map((r) => ({
|
||||
id: r.id as number,
|
||||
name: String(r.name || ''),
|
||||
description: `${r.metric} ${r.operator} ${r.threshold}%`,
|
||||
enabled: Boolean(r.enabled),
|
||||
severity: 'warning' as const,
|
||||
type: String(r.metric || ''),
|
||||
hostname: String(r.hostname || '全部主机'),
|
||||
threshold: Number(r.threshold || 0),
|
||||
})) as AlertRule[];
|
||||
},
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAlerts(params: AlertListParams = {}) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.alerts(params),
|
||||
queryFn: async () => {
|
||||
const resp = await alertCenterApi.getAlerts(params);
|
||||
return unwrapList<Record<string, unknown>>(resp);
|
||||
queryFn: async (): Promise<AlertListResult> => {
|
||||
const resp = await alertCenterApi.getAlerts({
|
||||
page: params.page,
|
||||
page_size: params.page_size,
|
||||
severity: params.severity !== 'all' ? params.severity : undefined,
|
||||
status: params.status !== 'all' ? params.status : undefined,
|
||||
});
|
||||
const raw = unwrapList<Record<string, unknown>>(resp);
|
||||
const list = raw.map((item) => mapAlert(item as Parameters<typeof mapAlert>[0]));
|
||||
return {
|
||||
list,
|
||||
total: unwrapTotal(resp),
|
||||
stats: buildAlertStats(list),
|
||||
};
|
||||
},
|
||||
staleTime: 15_000,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAlertMutations() {
|
||||
const qc = useQueryClient();
|
||||
const invalidateRules = () => qc.invalidateQueries({ queryKey: queryKeys.alertRules() });
|
||||
const invalidateAgentRules = () => qc.invalidateQueries({ queryKey: queryKeys.agentAlertRules() });
|
||||
const invalidateAlerts = () => qc.invalidateQueries({ queryKey: ['alerts'] });
|
||||
|
||||
const toggleRuleMutation = useMutation({
|
||||
@@ -42,10 +100,42 @@ export function useAlertMutations() {
|
||||
onSuccess: invalidateRules,
|
||||
});
|
||||
|
||||
const toggleAgentRuleMutation = useMutation({
|
||||
mutationFn: ({ id, enabled }: { id: number; enabled: boolean }) =>
|
||||
agentApi.updateRule(id, { enabled }),
|
||||
onSuccess: invalidateAgentRules,
|
||||
});
|
||||
|
||||
const deleteAgentRuleMutation = useMutation({
|
||||
mutationFn: (id: number) => agentApi.deleteRule(id),
|
||||
onSuccess: invalidateAgentRules,
|
||||
});
|
||||
|
||||
const ackMutation = useMutation({
|
||||
mutationFn: (id: number | string) => alertCenterApi.acknowledge(id),
|
||||
onSuccess: invalidateAlerts,
|
||||
});
|
||||
|
||||
return { toggleRuleMutation, deleteRuleMutation, ackMutation, invalidateRules, invalidateAlerts };
|
||||
const resolveMutation = useMutation({
|
||||
mutationFn: (id: number | string) => alertCenterApi.resolve(id),
|
||||
onSuccess: invalidateAlerts,
|
||||
});
|
||||
|
||||
const quickSetupMutation = useMutation({
|
||||
mutationFn: (scope: string) => alertCenterApi.quickSetup(scope),
|
||||
onSuccess: invalidateAgentRules,
|
||||
});
|
||||
|
||||
return {
|
||||
toggleRuleMutation,
|
||||
deleteRuleMutation,
|
||||
toggleAgentRuleMutation,
|
||||
deleteAgentRuleMutation,
|
||||
ackMutation,
|
||||
resolveMutation,
|
||||
quickSetupMutation,
|
||||
invalidateRules,
|
||||
invalidateAgentRules,
|
||||
invalidateAlerts,
|
||||
};
|
||||
}
|
||||
@@ -17,45 +17,20 @@
|
||||
* </HasPermission>
|
||||
*/
|
||||
import { useUserStore } from '@/store';
|
||||
|
||||
// 权限通配符 — admin 用户拥有
|
||||
const WILDCARD = '*';
|
||||
import {
|
||||
checkPermission,
|
||||
checkAnyPermission,
|
||||
checkAllPermissions,
|
||||
isWildcardAdmin,
|
||||
} from '@/utils/permission';
|
||||
|
||||
export function usePermission() {
|
||||
const permissions = useUserStore((s) => s.permissions);
|
||||
const isAdmin = isWildcardAdmin(permissions);
|
||||
|
||||
// admin 通配符短路
|
||||
const isAdmin = permissions?.includes(WILDCARD) ?? false;
|
||||
|
||||
/**
|
||||
* 检查是否拥有指定权限
|
||||
* @param code 权限码,如 'server:create'
|
||||
* @returns true=有权限;permissions 未加载时默认 true
|
||||
*/
|
||||
const hasPermission = (code: string): boolean => {
|
||||
// 权限未加载(登录后未调用 setPermissions)→ 默认放行
|
||||
if (permissions === null || permissions === undefined) return true;
|
||||
if (isAdmin) return true;
|
||||
return permissions.includes(code);
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查是否拥有任意一个权限(OR 逻辑)
|
||||
*/
|
||||
const hasAnyPermission = (codes: string[]): boolean => {
|
||||
if (permissions === null || permissions === undefined) return true;
|
||||
if (isAdmin) return true;
|
||||
return codes.some((c) => permissions.includes(c));
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查是否拥有全部权限(AND 逻辑)
|
||||
*/
|
||||
const hasAllPermissions = (codes: string[]): boolean => {
|
||||
if (permissions === null || permissions === undefined) return true;
|
||||
if (isAdmin) return true;
|
||||
return codes.every((c) => permissions.includes(c));
|
||||
};
|
||||
const hasPermission = (code: string) => checkPermission(permissions, code);
|
||||
const hasAnyPermission = (codes: string[]) => checkAnyPermission(permissions, codes);
|
||||
const hasAllPermissions = (codes: string[]) => checkAllPermissions(permissions, codes);
|
||||
|
||||
return { hasPermission, hasAnyPermission, hasAllPermissions, isAdmin };
|
||||
}
|
||||
@@ -43,4 +43,12 @@ export {
|
||||
isValidHostname,
|
||||
isValidUrl,
|
||||
parsePortList,
|
||||
} from './validate';
|
||||
} from './validate';
|
||||
|
||||
export {
|
||||
PERMISSION_WILDCARD,
|
||||
isWildcardAdmin,
|
||||
checkPermission,
|
||||
checkAnyPermission,
|
||||
checkAllPermissions,
|
||||
} from './permission';
|
||||
@@ -0,0 +1,38 @@
|
||||
// 权限码常量与纯函数 — 供 hooks/usePermission 复用 // Logan.Li
|
||||
|
||||
export const PERMISSION_WILDCARD = '*';
|
||||
|
||||
/** 是否拥有通配符(超级管理员) */
|
||||
export function isWildcardAdmin(permissions: string[] | null | undefined): boolean {
|
||||
return permissions?.includes(PERMISSION_WILDCARD) ?? false;
|
||||
}
|
||||
|
||||
/** 检查单个权限码 */
|
||||
export function checkPermission(
|
||||
permissions: string[] | null | undefined,
|
||||
code: string,
|
||||
): boolean {
|
||||
if (permissions === null || permissions === undefined) return true;
|
||||
if (isWildcardAdmin(permissions)) return true;
|
||||
return permissions.includes(code);
|
||||
}
|
||||
|
||||
/** OR 逻辑 */
|
||||
export function checkAnyPermission(
|
||||
permissions: string[] | null | undefined,
|
||||
codes: string[],
|
||||
): boolean {
|
||||
if (permissions === null || permissions === undefined) return true;
|
||||
if (isWildcardAdmin(permissions)) return true;
|
||||
return codes.some((c) => permissions.includes(c));
|
||||
}
|
||||
|
||||
/** AND 逻辑 */
|
||||
export function checkAllPermissions(
|
||||
permissions: string[] | null | undefined,
|
||||
codes: string[],
|
||||
): boolean {
|
||||
if (permissions === null || permissions === undefined) return true;
|
||||
if (isWildcardAdmin(permissions)) return true;
|
||||
return codes.every((c) => permissions.includes(c));
|
||||
}
|
||||
Reference in new issue
Block a user