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:
Logan.Li committed 2026-06-26 10:51:15 +08:00
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}
+7 -1
View File
@@ -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';
+1
View File
@@ -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,
+97 -7
View File
@@ -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,
};
}
+10 -35
View File
@@ -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 };
}
+9 -1
View File
@@ -43,4 +43,12 @@ export {
isValidHostname,
isValidUrl,
parsePortList,
} from './validate';
} from './validate';
export {
PERMISSION_WILDCARD,
isWildcardAdmin,
checkPermission,
checkAnyPermission,
checkAllPermissions,
} from './permission';
+38
View File
@@ -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));
}