refactor(frontend): P3 — K8s 组件拆分、AlertCenter Query、permission utils
- 拆分 DeploymentView / DeploymentDetail 至子目录(<700 行红线) - AlertCenter 接入 useAlerts / useAgentAlertRules / useAlertMutations - 新增 utils/permission.ts,usePermission 复用纯函数 - useAlerts 增强分页统计与 Agent 规则 hooks
This commit is contained in:
1 parent
37c657987b
commit
521bfd356f
24 files changed
+1452
-1219
No files matched your search
@@ -1,51 +1,19 @@
|
|||||||
|
// DeploymentDetail.tsx — Deployment 详情页编排组件 // Logan.Li
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { FileText, Gauge, Loader2, XCircle } from 'lucide-react';
|
||||||
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 { kubernetesApi } from '@/api';
|
import { kubernetesApi } from '@/api';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||||
import { PodExecTerminal } from './PodExecTerminal';
|
import type { DeploymentDetailProps, PodWithContainers } from './deployment-detail/types';
|
||||||
import { PodLogsViewer } from './PodLogsViewer';
|
import { DeploymentOverview } from './deployment-detail/DeploymentOverview';
|
||||||
import { PodFileBrowser } from './PodFileBrowser';
|
import { DeploymentPodsTab } from './deployment-detail/DeploymentPodsTab';
|
||||||
|
import { DeploymentLogsTab } from './deployment-detail/DeploymentLogsTab';
|
||||||
interface DeploymentDetailProps {
|
import { DeploymentHpaTab } from './deployment-detail/DeploymentHpaTab';
|
||||||
clusterCode: string;
|
import { DeploymentPodModals } from './deployment-detail/DeploymentPodModals';
|
||||||
namespace: string;
|
|
||||||
deploymentName: string;
|
|
||||||
onBack: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBack }: DeploymentDetailProps) {
|
export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBack }: DeploymentDetailProps) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -53,39 +21,20 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac
|
|||||||
const [deployment, setDeployment] = useState<any>(null);
|
const [deployment, setDeployment] = useState<any>(null);
|
||||||
const [pods, setPods] = useState<any[]>([]);
|
const [pods, setPods] = useState<any[]>([]);
|
||||||
const [hpas, setHpas] = useState<any[]>([]);
|
const [hpas, setHpas] = useState<any[]>([]);
|
||||||
const [logs, setLogs] = useState<string>('');
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [actionLoading, setActionLoading] = useState('');
|
const [actionLoading, setActionLoading] = useState('');
|
||||||
|
|
||||||
// Scale
|
|
||||||
const [, setScaleValue] = useState(0);
|
const [, setScaleValue] = useState(0);
|
||||||
// Image update
|
|
||||||
const [newImage, setNewImage] = useState('');
|
const [newImage, setNewImage] = useState('');
|
||||||
const [showImageUpdate, setShowImageUpdate] = useState(false);
|
const [showImageUpdate, setShowImageUpdate] = useState(false);
|
||||||
// Logs
|
|
||||||
const [logPod, setLogPod] = useState('');
|
|
||||||
const [showLogs, setShowLogs] = useState(false);
|
|
||||||
const [logTail, setLogTail] = useState(200);
|
const [logTail, setLogTail] = useState(200);
|
||||||
|
const [execPod, setExecPod] = useState<PodWithContainers | null>(null);
|
||||||
// 🆕 Pod exec / logs / file browser (Logan <admin@attacker.club>)
|
|
||||||
const [execPod, setExecPod] = useState<{ name: string; containers: string[] } | null>(null);
|
|
||||||
const [execOpen, setExecOpen] = useState(false);
|
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 [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);
|
const [fileBrowserOpen, setFileBrowserOpen] = useState(false);
|
||||||
// Logs tab 内的 Pod 选择器(增强版查看器入口)
|
|
||||||
const [logsTabPod, setLogsTabPod] = useState<string>('');
|
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 () => {
|
const loadData = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -94,29 +43,19 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac
|
|||||||
kubernetesApi.getNamespacePods(clusterCode, namespace).catch(() => []),
|
kubernetesApi.getNamespacePods(clusterCode, namespace).catch(() => []),
|
||||||
kubernetesApi.getHPAs(clusterCode, namespace).catch(() => ({ items: [] })),
|
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 allDeps = toArray(depRes);
|
||||||
const allPods = toArray(podRes);
|
const allPods = toArray(podRes);
|
||||||
const allHpas = toArray(hpaRes);
|
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);
|
setDeployment(dep || null);
|
||||||
if (dep) {
|
if (dep) {
|
||||||
setScaleValue(dep.replicas || 0);
|
setScaleValue(dep.replicas || 0);
|
||||||
setNewImage(dep.image || '');
|
setNewImage(dep.image || '');
|
||||||
}
|
}
|
||||||
|
setPods(allPods.filter((p: any) => p.namespace === namespace && p.name.startsWith(deploymentName)));
|
||||||
// Filter pods by deployment label (prefix match on pod name)
|
setHpas(allHpas.filter((h: any) => h.target_name === deploymentName && h.namespace === namespace));
|
||||||
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);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('加载详情失败:', e);
|
console.error('加载详情失败:', e);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -162,20 +101,7 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac
|
|||||||
} finally { setActionLoading(''); }
|
} finally { setActionLoading(''); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const _handleLoadLogs = async (podName: string) => {
|
const handleHpaToggle = async (hpa: any) => {
|
||||||
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 ok = await confirm({
|
const ok = await confirm({
|
||||||
title: '删除 HPA',
|
title: '删除 HPA',
|
||||||
description: `确定删除 HPA "${hpa.name}"?`,
|
description: `确定删除 HPA "${hpa.name}"?`,
|
||||||
@@ -218,520 +144,81 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac
|
|||||||
const ready = deployment.ready_replicas || deployment.readyReplicas || 0;
|
const ready = deployment.ready_replicas || deployment.readyReplicas || 0;
|
||||||
const total = deployment.replicas || 0;
|
const total = deployment.replicas || 0;
|
||||||
const available = deployment.available_replicas || deployment.availableReplicas || 0;
|
const available = deployment.available_replicas || deployment.availableReplicas || 0;
|
||||||
const isHealthy = ready === total;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
<DeploymentOverview
|
||||||
<div className="flex items-center justify-between">
|
deploymentName={deploymentName}
|
||||||
<div className="flex items-center gap-3">
|
namespace={namespace}
|
||||||
<Button variant="ghost" size="sm" className="text-slate-400" onClick={onBack}>
|
deployment={deployment}
|
||||||
<ArrowLeft className="h-4 w-4 mr-1" />返回
|
hpas={hpas}
|
||||||
</Button>
|
clusterCode={clusterCode}
|
||||||
<div>
|
ready={ready}
|
||||||
<div className="flex items-center gap-2">
|
total={total}
|
||||||
<h1 className="text-xl font-bold text-slate-100">{deploymentName}</h1>
|
available={available}
|
||||||
<Badge variant="outline" className="border-slate-700 text-slate-400">{namespace}</Badge>
|
isHealthy={ready === total}
|
||||||
</div>
|
actionLoading={actionLoading}
|
||||||
<p className="text-sm text-slate-500 mt-0.5">镜像: {deployment.image || '-'}</p>
|
newImage={newImage}
|
||||||
</div>
|
showImageUpdate={showImageUpdate}
|
||||||
</div>
|
onBack={onBack}
|
||||||
<Button variant="outline" size="sm" className="border-slate-700 text-slate-400" onClick={loadData}>
|
onRefresh={loadData}
|
||||||
<RefreshCw className="h-4 w-4 mr-1" />刷新
|
onScale={handleScale}
|
||||||
</Button>
|
onRestart={handleRestart}
|
||||||
</div>
|
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">
|
<Tabs defaultValue="pods">
|
||||||
<TabsList className="bg-slate-800 border border-slate-700">
|
<TabsList className="bg-slate-800 border border-slate-700">
|
||||||
<TabsTrigger value="pods" className="data-[state=active]:bg-slate-700">
|
<TabsTrigger value="pods" className="data-[state=active]:bg-slate-700">
|
||||||
Pod 列表 ({pods.length})
|
Pod 列表 ({pods.length})
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="logs" className="data-[state=active]:bg-slate-700">
|
<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>
|
||||||
<TabsTrigger value="hpa" className="data-[state=active]:bg-slate-700">
|
<TabsTrigger value="hpa" className="data-[state=active]:bg-slate-700">
|
||||||
<Gauge className="h-4 w-4 mr-1" />HPA ({hpas.length})
|
<Gauge className="h-4 w-4 mr-1" />HPA ({hpas.length})
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* Pods Tab */}
|
<DeploymentPodsTab
|
||||||
<TabsContent value="pods">
|
pods={pods}
|
||||||
<Card className="bg-slate-900 border-slate-800">
|
onOpenLogsViewer={(pod) => { setLogsViewerPod(pod); setLogsViewerOpen(true); }}
|
||||||
<CardContent className="p-0">
|
onOpenExec={(pod) => { setExecPod(pod); setExecOpen(true); }}
|
||||||
<Table>
|
onOpenFileBrowser={(pod) => { setFileBrowserPod(pod); setFileBrowserOpen(true); }}
|
||||||
<TableHeader>
|
/>
|
||||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
<DeploymentLogsTab
|
||||||
<TableHead className="text-slate-400">Pod 名称</TableHead>
|
pods={pods}
|
||||||
<TableHead className="text-slate-400">状态</TableHead>
|
logTail={logTail}
|
||||||
<TableHead className="text-slate-400">节点</TableHead>
|
onLogTailChange={setLogTail}
|
||||||
<TableHead className="text-slate-400">IP</TableHead>
|
logsTabPod={logsTabPod}
|
||||||
<TableHead className="text-slate-400">重启</TableHead>
|
onLogsTabPodChange={setLogsTabPod}
|
||||||
<TableHead className="text-slate-400">操作</TableHead>
|
onOpenLogsViewer={(name, containers) => { setLogsViewerPod({ name, containers }); setLogsViewerOpen(true); }}
|
||||||
</TableRow>
|
/>
|
||||||
</TableHeader>
|
<DeploymentHpaTab
|
||||||
<TableBody>
|
hpas={hpas}
|
||||||
{pods.length === 0 ? (
|
clusterCode={clusterCode}
|
||||||
<TableRow>
|
namespace={namespace}
|
||||||
<TableCell colSpan={6} className="text-center text-slate-500 py-8">
|
deploymentName={deploymentName}
|
||||||
暂无 Pod
|
onCreated={loadData}
|
||||||
</TableCell>
|
onDeleteHpa={handleHpaToggle}
|
||||||
</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>
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{/* 🆕 Pod exec 终端 */}
|
<DeploymentPodModals
|
||||||
{execPod && (
|
clusterCode={clusterCode}
|
||||||
<PodExecTerminal
|
namespace={namespace}
|
||||||
open={execOpen}
|
execPod={execPod}
|
||||||
onOpenChange={(o) => {
|
execOpen={execOpen}
|
||||||
setExecOpen(o);
|
onExecOpenChange={(o) => { setExecOpen(o); if (!o) setExecPod(null); }}
|
||||||
if (!o) setExecPod(null);
|
logsViewerPod={logsViewerPod}
|
||||||
}}
|
logsViewerOpen={logsViewerOpen}
|
||||||
clusterId={clusterCode}
|
onLogsViewerOpenChange={(o) => { setLogsViewerOpen(o); if (!o) setLogsViewerPod(null); }}
|
||||||
namespace={namespace}
|
fileBrowserPod={fileBrowserPod}
|
||||||
podName={execPod.name}
|
fileBrowserOpen={fileBrowserOpen}
|
||||||
containers={execPod.containers}
|
onFileBrowserOpenChange={(o) => { setFileBrowserOpen(o); if (!o) setFileBrowserPod(null); }}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 🆕 增强版日志查看器 */}
|
|
||||||
{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}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</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: 容器组 | 访问方式 | 事件 | 容器伸缩 | 历史版本
|
// Tabs: 容器组 | 访问方式 | 事件 | 容器伸缩 | 历史版本
|
||||||
// 作者: Logan <admin@attacker.club>
|
import { useState, useEffect } from 'react';
|
||||||
import { useState, useEffect, Fragment } from 'react';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import {
|
|
||||||
Card, CardContent, CardHeader, CardTitle,
|
|
||||||
} from '@/components/ui/card';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import {
|
import {
|
||||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
Activity, Container, History, Network, Minus, Plus, Loader2,
|
||||||
} from '@/components/ui/table';
|
|
||||||
import {
|
|
||||||
|
|
||||||
Clock,
|
|
||||||
HardDrive,
|
|
||||||
Network,
|
|
||||||
Activity,
|
|
||||||
RotateCcw,
|
|
||||||
Container,
|
|
||||||
History,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
|
||||||
Terminal,
|
|
||||||
FileText,
|
|
||||||
ExternalLink,
|
|
||||||
Minus,
|
|
||||||
Plus,
|
|
||||||
Loader2,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { kubernetesApi } from '@/api';
|
import { kubernetesApi } from '@/api';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||||
import {
|
import { formatK8sTime, STATUS_COLORS } from './k8s-yaml-utils';
|
||||||
formatK8sTime, formatDuration, elapsedSeconds, statusColor, STATUS_COLORS,
|
|
||||||
} from './k8s-yaml-utils';
|
|
||||||
|
|
||||||
// Types
|
import { InfoItem, PodConditionsRow } from './deployment-view/helpers';
|
||||||
interface PodItem {
|
import { PodsTab } from './deployment-view/PodsTab';
|
||||||
name: string; namespace?: string; status?: string; node_name?: string; node?: string;
|
import { ServicesTab } from './deployment-view/ServicesTab';
|
||||||
ip?: string; pod_ip?: string; restart_count?: number; age?: string; created_at?: string;
|
import { EventsTab } from './deployment-view/EventsTab';
|
||||||
start_time?: string; containers?: any[]; image?: string; images?: string[];
|
import { HPACronHPATab } from './deployment-view/HPACronHPATab';
|
||||||
}
|
import { HistoryTab } from './deployment-view/HistoryTab';
|
||||||
interface ReplicaSetItem {
|
import type { DeploymentViewProps, SvcItem, IngItem, EventItem } from './deployment-view/types';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Helpers ───
|
export type { DeploymentViewProps } from './deployment-view/types';
|
||||||
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 function DeploymentView(props: DeploymentViewProps) {
|
export function DeploymentView(props: DeploymentViewProps) {
|
||||||
const { clusterCode, namespace, deployment, pods, revisions, onRefresh } = props;
|
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 [expandedPod, setExpandedPod] = useState<string | null>(null);
|
||||||
const [rollbackLoading, setRollbackLoading] = useState<number | null>(null);
|
const [rollbackLoading, setRollbackLoading] = useState<number | null>(null);
|
||||||
|
|
||||||
// Scale state
|
// Scale state // Logan.Li
|
||||||
const [scaleValue, setScaleValue] = useState(deployment?.replicas || 0);
|
const [scaleValue, setScaleValue] = useState(deployment?.replicas || 0);
|
||||||
const [scaleLoading, setScaleLoading] = useState(false);
|
const [scaleLoading, setScaleLoading] = useState(false);
|
||||||
|
|
||||||
// Service/Ingress/Events state
|
// Service/Ingress/Events state // Logan.Li
|
||||||
const [services, setServices] = useState<SvcItem[]>([]);
|
const [services, setServices] = useState<SvcItem[]>([]);
|
||||||
const [ingresses, setIngresses] = useState<IngItem[]>([]);
|
const [ingresses, setIngresses] = useState<IngItem[]>([]);
|
||||||
const [events, setEvents] = useState<EventItem[]>([]);
|
const [events, setEvents] = useState<EventItem[]>([]);
|
||||||
const [tabLoading, setTabLoading] = useState('');
|
const [tabLoading, setTabLoading] = useState('');
|
||||||
|
|
||||||
// HPA / CronHPA state
|
// HPA / CronHPA state // Logan.Li
|
||||||
const [hpas, setHpas] = useState<any[]>([]);
|
const [hpas, setHpas] = useState<any[]>([]);
|
||||||
const [cronHpas, setCronHpas] = useState<any[]>([]);
|
const [cronHpas, setCronHpas] = useState<any[]>([]);
|
||||||
|
|
||||||
@@ -100,7 +50,7 @@ export function DeploymentView(props: DeploymentViewProps) {
|
|||||||
const total = deployment?.replicas ?? 0;
|
const total = deployment?.replicas ?? 0;
|
||||||
const isHealthy = ready === total && total > 0;
|
const isHealthy = ready === total && total > 0;
|
||||||
|
|
||||||
// Load Services and Ingresses for "访问方式" tab
|
// 访问方式 Tab 数据加载 // Logan.Li
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tab !== 'services') return;
|
if (tab !== 'services') return;
|
||||||
setTabLoading('services');
|
setTabLoading('services');
|
||||||
@@ -108,39 +58,37 @@ export function DeploymentView(props: DeploymentViewProps) {
|
|||||||
kubernetesApi.getNamespaceServices(clusterCode, namespace).catch(() => []),
|
kubernetesApi.getNamespaceServices(clusterCode, namespace).catch(() => []),
|
||||||
kubernetesApi.getNamespaceIngresses(clusterCode, namespace).catch(() => []),
|
kubernetesApi.getNamespaceIngresses(clusterCode, namespace).catch(() => []),
|
||||||
]).then(([svcRes, ingRes]) => {
|
]).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 allSvcs = toArr(svcRes);
|
||||||
const allIngs = toArr(ingRes);
|
const allIngs = toArr(ingRes);
|
||||||
|
|
||||||
// Filter services matching deployment selector
|
|
||||||
const selector = deployment?.spec?.selector?.matchLabels || deployment?.spec?.template?.metadata?.labels || {};
|
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 || {};
|
const svcSel = s.spec?.selector || s.selector || {};
|
||||||
if (Object.keys(svcSel).length === 0) return false;
|
if (Object.keys(svcSel).length === 0) return false;
|
||||||
return Object.entries(svcSel).every(([k, v]) => selector[k] === v);
|
return Object.entries(svcSel).every(([k, v]) => selector[k] === v);
|
||||||
}).map(( s: any) => ({
|
}).map((s: any) => ({
|
||||||
name: s.name || s.metadata?.name,
|
name: s.name || s.metadata?.name,
|
||||||
type: s.spec?.type || s.type || 'ClusterIP',
|
type: s.spec?.type || s.type || 'ClusterIP',
|
||||||
clusterIP: s.spec?.clusterIP || s.cluster_ip || '<none>',
|
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 || {},
|
selector: s.spec?.selector || s.selector || {},
|
||||||
age: s.age || '',
|
age: s.age || '',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Filter ingresses pointing to matched services
|
const svcNames = new Set(matchedSvcs.map((s: any) => s.name));
|
||||||
const svcNames = new Set(matchedSvcs.map(( s: any) => s.name));
|
const matchedIngs = allIngs.filter((i: any) => {
|
||||||
const matchedIngs = allIngs.filter(( i: any) => {
|
|
||||||
const rules = i.spec?.rules || i.rules || [];
|
const rules = i.spec?.rules || i.rules || [];
|
||||||
return rules.some(( r: any) =>
|
return rules.some((r: any) =>
|
||||||
(r.http?.paths || r.paths || []).some(( p: any) => {
|
(r.http?.paths || r.paths || []).some((p: any) => {
|
||||||
const backendSvc = p.backend?.service?.name || p.backendService || '';
|
const backendSvc = p.backend?.service?.name || p.backendService || '';
|
||||||
return svcNames.has(backendSvc);
|
return svcNames.has(backendSvc);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}).map(( i: any) => ({
|
}).map((i: any) => ({
|
||||||
name: i.name || i.metadata?.name,
|
name: i.name || i.metadata?.name,
|
||||||
className: i.spec?.ingressClassName || i.ingressClass || '-',
|
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 || [],
|
rules: i.spec?.rules || i.rules || [],
|
||||||
age: i.age || '',
|
age: i.age || '',
|
||||||
}));
|
}));
|
||||||
@@ -150,17 +98,17 @@ export function DeploymentView(props: DeploymentViewProps) {
|
|||||||
}).catch(() => {}).finally(() => setTabLoading(''));
|
}).catch(() => {}).finally(() => setTabLoading(''));
|
||||||
}, [tab, clusterCode, namespace, deployment]);
|
}, [tab, clusterCode, namespace, deployment]);
|
||||||
|
|
||||||
// Load Events for "事件" tab
|
// 事件 Tab 数据加载 // Logan.Li
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tab !== 'events') return;
|
if (tab !== 'events') return;
|
||||||
setTabLoading('events');
|
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 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 involved = e.involvedObject || e.involved_object || {};
|
||||||
const objName = involved.name || '';
|
const objName = involved.name || '';
|
||||||
return objName === deployment?.name || objName.startsWith(deployment?.name + '-');
|
return objName === deployment?.name || objName.startsWith(deployment?.name + '-');
|
||||||
}).map(( e: any) => ({
|
}).map((e: any) => ({
|
||||||
type: e.type || 'Normal',
|
type: e.type || 'Normal',
|
||||||
reason: e.reason || '',
|
reason: e.reason || '',
|
||||||
message: e.message || '',
|
message: e.message || '',
|
||||||
@@ -173,7 +121,7 @@ export function DeploymentView(props: DeploymentViewProps) {
|
|||||||
}).catch(() => {}).finally(() => setTabLoading(''));
|
}).catch(() => {}).finally(() => setTabLoading(''));
|
||||||
}, [tab, clusterCode, namespace, deployment?.name]);
|
}, [tab, clusterCode, namespace, deployment?.name]);
|
||||||
|
|
||||||
// Load HPA and CronHPA for "伸缩" tab
|
// 伸缩 Tab 数据加载 // Logan.Li
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tab !== 'scale') return;
|
if (tab !== 'scale') return;
|
||||||
setTabLoading('scale');
|
setTabLoading('scale');
|
||||||
@@ -181,16 +129,16 @@ export function DeploymentView(props: DeploymentViewProps) {
|
|||||||
kubernetesApi.getHPAs(clusterCode, namespace).catch(() => []),
|
kubernetesApi.getHPAs(clusterCode, namespace).catch(() => []),
|
||||||
Promise.resolve([]), // CronHPA 待后端支持
|
Promise.resolve([]), // CronHPA 待后端支持
|
||||||
]).then(([hpaRes]) => {
|
]).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 allHpas = toArr(hpaRes);
|
||||||
const depName = deployment?.name || '';
|
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([]);
|
setCronHpas([]);
|
||||||
}).catch(() => {}).finally(() => setTabLoading(''));
|
}).catch(() => {}).finally(() => setTabLoading(''));
|
||||||
}, [tab, clusterCode, namespace, deployment?.name]);
|
}, [tab, clusterCode, namespace, deployment?.name]);
|
||||||
|
|
||||||
// Rollback
|
// 回滚 // Logan.Li
|
||||||
const handleRollback = async ( rev: any) => {
|
const handleRollback = async (rev: any) => {
|
||||||
const revision = rev.revision ?? rev.revision_number ?? rev.number;
|
const revision = rev.revision ?? rev.revision_number ?? rev.number;
|
||||||
const ok = await confirm({
|
const ok = await confirm({
|
||||||
title: '回滚版本',
|
title: '回滚版本',
|
||||||
@@ -209,7 +157,7 @@ export function DeploymentView(props: DeploymentViewProps) {
|
|||||||
} finally { setRollbackLoading(null); }
|
} finally { setRollbackLoading(null); }
|
||||||
};
|
};
|
||||||
|
|
||||||
// Scale
|
// 手动伸缩 // Logan.Li
|
||||||
const handleScale = async () => {
|
const handleScale = async () => {
|
||||||
setScaleLoading(true);
|
setScaleLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -231,7 +179,7 @@ export function DeploymentView(props: DeploymentViewProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* ============ Info Panel (ACK-style) ============ */}
|
{/* Info Panel (ACK-style) */}
|
||||||
<Card className="dg-card">
|
<Card className="dg-card">
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 text-sm">
|
<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)} />
|
<InfoItem label="创建时间" value={formatK8sTime(deployment?.created_at || deployment?.creationTimestamp)} />
|
||||||
</div>
|
</div>
|
||||||
{/* Pod conditions timeline */}
|
|
||||||
{pods[0] && (
|
{pods[0] && (
|
||||||
<div className="mt-3 pt-3 border-t border-[var(--dg-border)]">
|
<div className="mt-3 pt-3 border-t border-[var(--dg-border)]">
|
||||||
<PodConditionsRow pod={pods[0]} />
|
<PodConditionsRow pod={pods[0]} />
|
||||||
@@ -263,7 +210,7 @@ export function DeploymentView(props: DeploymentViewProps) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* ============ Tab Bar + 副本数 ============ */}
|
{/* Tab Bar + 副本数 */}
|
||||||
<div className="flex items-center gap-0.5 border-b border-[var(--dg-border)] overflow-x-auto">
|
<div className="flex items-center gap-0.5 border-b border-[var(--dg-border)] overflow-x-auto">
|
||||||
{TABS.map(t => (
|
{TABS.map(t => (
|
||||||
<button
|
<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>}
|
{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>
|
</button>
|
||||||
))}
|
))}
|
||||||
{/* 副本数控制 — Tab 行右侧 */}
|
|
||||||
<div className="ml-auto flex items-center gap-1 pr-2 shrink-0">
|
<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)]"
|
<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}
|
disabled={scaleLoading || scaleValue <= 0}
|
||||||
@@ -306,7 +252,7 @@ export function DeploymentView(props: DeploymentViewProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ============ Tab Content ============ */}
|
{/* Tab Content */}
|
||||||
{tab === 'pods' && <PodsTab {...props} expandedPod={expandedPod} setExpandedPod={setExpandedPod} />}
|
{tab === 'pods' && <PodsTab {...props} expandedPod={expandedPod} setExpandedPod={setExpandedPod} />}
|
||||||
{tab === 'services' && <ServicesTab services={services} ingresses={ingresses} loading={tabLoading} namespace={namespace} />}
|
{tab === 'services' && <ServicesTab services={services} ingresses={ingresses} loading={tabLoading} namespace={namespace} />}
|
||||||
{tab === 'events' && <EventsTab events={events} loading={tabLoading} />}
|
{tab === 'events' && <EventsTab events={events} loading={tabLoading} />}
|
||||||
@@ -322,414 +268,3 @@ export function DeploymentView(props: DeploymentViewProps) {
|
|||||||
</div>
|
</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
|
// 告警中心主组件 — TanStack Query 编排 // Logan.Li
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
AlertTriangle, CheckCircle, Search, RefreshCw,
|
AlertTriangle, CheckCircle, Search, RefreshCw,
|
||||||
Eye, Check, VolumeX, Plus, Rocket,
|
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 { Switch } from '@/components/ui/switch';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
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 {
|
import {
|
||||||
type Alert, type AlertRule, mapAlert, getSeverityBadge, getStatusBadge,
|
type Alert, type AlertRule, getSeverityBadge, getStatusBadge,
|
||||||
getChannelTypeLabel,
|
getChannelTypeLabel,
|
||||||
} from './alert-center-helpers';
|
} from './alert-center-helpers';
|
||||||
import { AlertDetailDialog, AlertSilenceDialog } from './AlertCenterDetail';
|
import { AlertDetailDialog, AlertSilenceDialog } from './AlertCenterDetail';
|
||||||
@@ -30,9 +31,6 @@ import type { Contact, ContactGroup } from './alert-center-helpers';
|
|||||||
|
|
||||||
const AlertCenter: React.FC = () => {
|
const AlertCenter: React.FC = () => {
|
||||||
const _confirm = useConfirm();
|
const _confirm = useConfirm();
|
||||||
const [alerts, setAlerts] = useState<Alert[]>([]);
|
|
||||||
const [rules, setRules] = useState<AlertRule[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [severityFilter, setSeverityFilter] = useState('all');
|
const [severityFilter, setSeverityFilter] = useState('all');
|
||||||
const [statusFilter, setStatusFilter] = useState('all');
|
const [statusFilter, setStatusFilter] = useState('all');
|
||||||
@@ -40,11 +38,9 @@ const AlertCenter: React.FC = () => {
|
|||||||
const [selectedAlert, setSelectedAlert] = useState<Alert | null>(null);
|
const [selectedAlert, setSelectedAlert] = useState<Alert | null>(null);
|
||||||
const [detailOpen, setDetailOpen] = useState(false);
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
const [silenceOpen, setSilenceOpen] = 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 [ruleDialogOpen, setRuleDialogOpen] = useState(false);
|
||||||
const [editingRule, setEditingRule] = useState<any>(null);
|
const [editingRule, setEditingRule] = useState<any>(null);
|
||||||
const [quickSetupLoading, setQuickSetupLoading] = useState(false);
|
|
||||||
// 联系人 & 联系组
|
// 联系人 & 联系组
|
||||||
const [contacts, setContacts] = useState<Contact[]>([]);
|
const [contacts, setContacts] = useState<Contact[]>([]);
|
||||||
const [contactGroups, setContactGroups] = useState<ContactGroup[]>([]);
|
const [contactGroups, setContactGroups] = useState<ContactGroup[]>([]);
|
||||||
@@ -55,67 +51,33 @@ const AlertCenter: React.FC = () => {
|
|||||||
// 分页
|
// 分页
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [pageSize] = useState(10);
|
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));
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData();
|
|
||||||
if (activeTab === 'contacts') fetchContacts();
|
if (activeTab === 'contacts') fetchContacts();
|
||||||
if (activeTab === 'groups') { fetchContacts(); fetchContactGroups(); }
|
if (activeTab === 'groups') { fetchContacts(); fetchContactGroups(); }
|
||||||
const interval = setInterval(fetchData, 30000);
|
}, [activeTab]);
|
||||||
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('获取告警规则失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchContacts = async () => {
|
const fetchContacts = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -133,9 +95,8 @@ const AlertCenter: React.FC = () => {
|
|||||||
|
|
||||||
const handleAcknowledge = async (id: string) => {
|
const handleAcknowledge = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await alertCenterApi.acknowledge(id);
|
await ackMutation.mutateAsync(id);
|
||||||
toast.success('告警已确认');
|
toast.success('告警已确认');
|
||||||
fetchAlerts();
|
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('操作失败');
|
toast.error('操作失败');
|
||||||
}
|
}
|
||||||
@@ -143,9 +104,8 @@ const AlertCenter: React.FC = () => {
|
|||||||
|
|
||||||
const handleResolve = async (id: string) => {
|
const handleResolve = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await alertCenterApi.resolve(id);
|
await resolveMutation.mutateAsync(id);
|
||||||
toast.success('告警已解决');
|
toast.success('告警已解决');
|
||||||
fetchAlerts();
|
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('操作失败');
|
toast.error('操作失败');
|
||||||
}
|
}
|
||||||
@@ -169,7 +129,7 @@ const AlertCenter: React.FC = () => {
|
|||||||
});
|
});
|
||||||
toast.success(`告警已静默 ${duration}`);
|
toast.success(`告警已静默 ${duration}`);
|
||||||
setSilenceOpen(false);
|
setSilenceOpen(false);
|
||||||
fetchAlerts();
|
refetchAlerts();
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('操作失败');
|
toast.error('操作失败');
|
||||||
}
|
}
|
||||||
@@ -177,8 +137,7 @@ const AlertCenter: React.FC = () => {
|
|||||||
|
|
||||||
const toggleRule = async (ruleId: string, enabled: boolean) => {
|
const toggleRule = async (ruleId: string, enabled: boolean) => {
|
||||||
try {
|
try {
|
||||||
await agentApi.updateRule(Number(ruleId), { enabled });
|
await toggleAgentRuleMutation.mutateAsync({ id: Number(ruleId), enabled });
|
||||||
setRules(rules.map(r => String(r.id) === ruleId ? { ...r, enabled } : r));
|
|
||||||
toast.success('规则状态已更新');
|
toast.success('规则状态已更新');
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('更新失败');
|
toast.error('更新失败');
|
||||||
@@ -196,20 +155,18 @@ const AlertCenter: React.FC = () => {
|
|||||||
confirmText: '确认创建',
|
confirmText: '确认创建',
|
||||||
});
|
});
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
setQuickSetupLoading(true);
|
|
||||||
try {
|
try {
|
||||||
const res = await alertCenterApi.quickSetup('ecs');
|
const res = await quickSetupMutation.mutateAsync('ecs');
|
||||||
toast.success(typeof res === 'string' ? res : (res?.message || '默认告警规则创建成功'));
|
toast.success(typeof res === 'string' ? res : ((res as { message?: string })?.message || '默认告警规则创建成功'));
|
||||||
fetchRules();
|
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('创建失败,请检查 Agent 主机是否在线');
|
toast.error('创建失败,请检查 Agent 主机是否在线');
|
||||||
} finally { setQuickSetupLoading(false); }
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteRule = async (rule: AlertRule) => {
|
const handleDeleteRule = async (rule: AlertRule) => {
|
||||||
const ok = await _confirm({ title: '删除规则', description: `确定删除规则「${rule.name}」?`, confirmText: '删除', variant: 'destructive' });
|
const ok = await _confirm({ title: '删除规则', description: `确定删除规则「${rule.name}」?`, confirmText: '删除', variant: 'destructive' });
|
||||||
if (!ok) return;
|
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) => {
|
const handleFilterChange = (type: 'severity' | 'status', value: string) => {
|
||||||
@@ -277,7 +234,7 @@ const AlertCenter: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
<span>共 {total} 条</span>
|
<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" />刷新
|
<RefreshCw className="w-3 h-3 mr-1" />刷新
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -402,12 +359,12 @@ const AlertCenter: React.FC = () => {
|
|||||||
<Button onClick={handleOpenAddRule}>
|
<Button onClick={handleOpenAddRule}>
|
||||||
<Plus className="w-4 h-4 mr-1" />添加规则
|
<Plus className="w-4 h-4 mr-1" />添加规则
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="secondary" onClick={handleQuickSetup} disabled={quickSetupLoading}>
|
<Button variant="secondary" onClick={handleQuickSetup} disabled={quickSetupMutation.isPending}>
|
||||||
<Rocket className="w-4 h-4 mr-1" />
|
<Rocket className="w-4 h-4 mr-1" />
|
||||||
{quickSetupLoading ? '创建中…' : '一键报警'}
|
{quickSetupMutation.isPending ? '创建中…' : '一键报警'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" onClick={fetchRules}>
|
<Button variant="outline" onClick={() => refetchRules()}>
|
||||||
<RefreshCw className="w-4 h-4 mr-2" />
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
刷新
|
刷新
|
||||||
</Button>
|
</Button>
|
||||||
@@ -615,7 +572,7 @@ const AlertCenter: React.FC = () => {
|
|||||||
onOpenChange={handleRuleDialogClose}
|
onOpenChange={handleRuleDialogClose}
|
||||||
editingRule={editingRule}
|
editingRule={editingRule}
|
||||||
notifyChannels={[]}
|
notifyChannels={[]}
|
||||||
onSaved={() => { handleRuleDialogClose(); fetchRules(); }}
|
onSaved={() => { handleRuleDialogClose(); refetchRules(); }}
|
||||||
/>
|
/>
|
||||||
<ContactDialog
|
<ContactDialog
|
||||||
open={contactDialogOpen}
|
open={contactDialogOpen}
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ export { useServers, useServerMutations, type ServerRecord } from './useServers'
|
|||||||
export { useCredentials, useCredentialDetail, useCredentialMutations, type CredentialRecord } from './useCredentials';
|
export { useCredentials, useCredentialDetail, useCredentialMutations, type CredentialRecord } from './useCredentials';
|
||||||
export { usePromDatasources, usePromDatasourceMutations, type PromDatasource } from './usePromDatasources';
|
export { usePromDatasources, usePromDatasourceMutations, type PromDatasource } from './usePromDatasources';
|
||||||
export { usePipelines, usePipelineRuns, usePipelineMutations } from './usePipelines';
|
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 { useAuditLogs } from './useAuditLogs';
|
||||||
export { useSessionCommands, type SessionCommand } from './useSessionCommands';
|
export { useSessionCommands, type SessionCommand } from './useSessionCommands';
|
||||||
@@ -13,6 +13,7 @@ export const queryKeys = {
|
|||||||
pipelineRuns: (code: string) => ['pipeline-runs', code] as const,
|
pipelineRuns: (code: string) => ['pipeline-runs', code] as const,
|
||||||
alerts: (params?: Record<string, unknown>) => ['alerts', params] as const,
|
alerts: (params?: Record<string, unknown>) => ['alerts', params] as const,
|
||||||
alertRules: () => ['alert-rules'] as const,
|
alertRules: () => ['alert-rules'] as const,
|
||||||
|
agentAlertRules: () => ['agent-alert-rules'] as const,
|
||||||
auditLogs: (params?: Record<string, unknown>) => ['audit-logs', params] as const,
|
auditLogs: (params?: Record<string, unknown>) => ['audit-logs', params] as const,
|
||||||
sessionCommands: (params?: Record<string, unknown>) => ['session-commands', params] as const,
|
sessionCommands: (params?: Record<string, unknown>) => ['session-commands', params] as const,
|
||||||
k8sClusters: () => ['k8s-clusters'] as const,
|
k8sClusters: () => ['k8s-clusters'] as const,
|
||||||
|
|||||||
@@ -1,9 +1,32 @@
|
|||||||
// 告警规则 / 告警事件 — TanStack Query // Logan.Li
|
// 告警规则 / 告警事件 — TanStack Query // Logan.Li
|
||||||
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { alertApi, alertCenterApi } from '@/api';
|
import { alertApi, alertCenterApi, agentApi } from '@/api';
|
||||||
import { queryKeys } from './queryKeys';
|
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() {
|
export function useAlertRules() {
|
||||||
return useQuery({
|
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({
|
return useQuery({
|
||||||
queryKey: queryKeys.alerts(params),
|
queryKey: queryKeys.alerts(params),
|
||||||
queryFn: async () => {
|
queryFn: async (): Promise<AlertListResult> => {
|
||||||
const resp = await alertCenterApi.getAlerts(params);
|
const resp = await alertCenterApi.getAlerts({
|
||||||
return unwrapList<Record<string, unknown>>(resp);
|
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,
|
staleTime: 15_000,
|
||||||
|
refetchInterval: 30_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAlertMutations() {
|
export function useAlertMutations() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const invalidateRules = () => qc.invalidateQueries({ queryKey: queryKeys.alertRules() });
|
const invalidateRules = () => qc.invalidateQueries({ queryKey: queryKeys.alertRules() });
|
||||||
|
const invalidateAgentRules = () => qc.invalidateQueries({ queryKey: queryKeys.agentAlertRules() });
|
||||||
const invalidateAlerts = () => qc.invalidateQueries({ queryKey: ['alerts'] });
|
const invalidateAlerts = () => qc.invalidateQueries({ queryKey: ['alerts'] });
|
||||||
|
|
||||||
const toggleRuleMutation = useMutation({
|
const toggleRuleMutation = useMutation({
|
||||||
@@ -42,10 +100,42 @@ export function useAlertMutations() {
|
|||||||
onSuccess: invalidateRules,
|
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({
|
const ackMutation = useMutation({
|
||||||
mutationFn: (id: number | string) => alertCenterApi.acknowledge(id),
|
mutationFn: (id: number | string) => alertCenterApi.acknowledge(id),
|
||||||
onSuccess: invalidateAlerts,
|
onSuccess: invalidateAlerts,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { toggleRuleMutation, deleteRuleMutation, ackMutation, invalidateRules, invalidateAlerts };
|
const resolveMutation = useMutation({
|
||||||
|
mutationFn: (id: number | string) => alertCenterApi.resolve(id),
|
||||||
|
onSuccess: invalidateAlerts,
|
||||||
|
});
|
||||||
|
|
||||||
|
const quickSetupMutation = useMutation({
|
||||||
|
mutationFn: (scope: string) => alertCenterApi.quickSetup(scope),
|
||||||
|
onSuccess: invalidateAgentRules,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
toggleRuleMutation,
|
||||||
|
deleteRuleMutation,
|
||||||
|
toggleAgentRuleMutation,
|
||||||
|
deleteAgentRuleMutation,
|
||||||
|
ackMutation,
|
||||||
|
resolveMutation,
|
||||||
|
quickSetupMutation,
|
||||||
|
invalidateRules,
|
||||||
|
invalidateAgentRules,
|
||||||
|
invalidateAlerts,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
@@ -17,45 +17,20 @@
|
|||||||
* </HasPermission>
|
* </HasPermission>
|
||||||
*/
|
*/
|
||||||
import { useUserStore } from '@/store';
|
import { useUserStore } from '@/store';
|
||||||
|
import {
|
||||||
// 权限通配符 — admin 用户拥有
|
checkPermission,
|
||||||
const WILDCARD = '*';
|
checkAnyPermission,
|
||||||
|
checkAllPermissions,
|
||||||
|
isWildcardAdmin,
|
||||||
|
} from '@/utils/permission';
|
||||||
|
|
||||||
export function usePermission() {
|
export function usePermission() {
|
||||||
const permissions = useUserStore((s) => s.permissions);
|
const permissions = useUserStore((s) => s.permissions);
|
||||||
|
const isAdmin = isWildcardAdmin(permissions);
|
||||||
|
|
||||||
// admin 通配符短路
|
const hasPermission = (code: string) => checkPermission(permissions, code);
|
||||||
const isAdmin = permissions?.includes(WILDCARD) ?? false;
|
const hasAnyPermission = (codes: string[]) => checkAnyPermission(permissions, codes);
|
||||||
|
const hasAllPermissions = (codes: string[]) => checkAllPermissions(permissions, codes);
|
||||||
/**
|
|
||||||
* 检查是否拥有指定权限
|
|
||||||
* @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));
|
|
||||||
};
|
|
||||||
|
|
||||||
return { hasPermission, hasAnyPermission, hasAllPermissions, isAdmin };
|
return { hasPermission, hasAnyPermission, hasAllPermissions, isAdmin };
|
||||||
}
|
}
|
||||||
@@ -44,3 +44,11 @@ export {
|
|||||||
isValidUrl,
|
isValidUrl,
|
||||||
parsePortList,
|
parsePortList,
|
||||||
} from './validate';
|
} from './validate';
|
||||||
|
|
||||||
|
export {
|
||||||
|
PERMISSION_WILDCARD,
|
||||||
|
isWildcardAdmin,
|
||||||
|
checkPermission,
|
||||||
|
checkAnyPermission,
|
||||||
|
checkAllPermissions,
|
||||||
|
} from './permission';
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// 权限码常量与纯函数 — 供 hooks/usePermission 复用 // Logan.Li
|
||||||
|
|
||||||
|
export const PERMISSION_WILDCARD = '*';
|
||||||
|
|
||||||
|
/** 是否拥有通配符(超级管理员) */
|
||||||
|
export function isWildcardAdmin(permissions: string[] | null | undefined): boolean {
|
||||||
|
return permissions?.includes(PERMISSION_WILDCARD) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 检查单个权限码 */
|
||||||
|
export function checkPermission(
|
||||||
|
permissions: string[] | null | undefined,
|
||||||
|
code: string,
|
||||||
|
): boolean {
|
||||||
|
if (permissions === null || permissions === undefined) return true;
|
||||||
|
if (isWildcardAdmin(permissions)) return true;
|
||||||
|
return permissions.includes(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** OR 逻辑 */
|
||||||
|
export function checkAnyPermission(
|
||||||
|
permissions: string[] | null | undefined,
|
||||||
|
codes: string[],
|
||||||
|
): boolean {
|
||||||
|
if (permissions === null || permissions === undefined) return true;
|
||||||
|
if (isWildcardAdmin(permissions)) return true;
|
||||||
|
return codes.some((c) => permissions.includes(c));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AND 逻辑 */
|
||||||
|
export function checkAllPermissions(
|
||||||
|
permissions: string[] | null | undefined,
|
||||||
|
codes: string[],
|
||||||
|
): boolean {
|
||||||
|
if (permissions === null || permissions === undefined) return true;
|
||||||
|
if (isWildcardAdmin(permissions)) return true;
|
||||||
|
return codes.every((c) => permissions.includes(c));
|
||||||
|
}
|
||||||
Reference in new issue
Block a user