From 521bfd356fc3badaf8c168ace363f919ce6ad7c9 Mon Sep 17 00:00:00 2001 From: "Logan.Li" Date: Fri, 26 Jun 2026 10:50:50 +0800 Subject: [PATCH] =?UTF-8?q?refactor(frontend):=20P3=20=E2=80=94=20K8s=20?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=E6=8B=86=E5=88=86=E3=80=81AlertCenter=20Quer?= =?UTF-8?q?y=E3=80=81permission=20utils?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 拆分 DeploymentView / DeploymentDetail 至子目录(<700 行红线) - AlertCenter 接入 useAlerts / useAgentAlertRules / useAlertMutations - 新增 utils/permission.ts,usePermission 复用纯函数 - useAlerts 增强分页统计与 Agent 规则 hooks --- .../kubernetes/DeploymentDetail.tsx | 663 ++---------------- .../components/kubernetes/DeploymentView.tsx | 547 ++------------- .../deployment-detail/CreateHPADialog.tsx | 117 ++++ .../deployment-detail/DeploymentHpaTab.tsx | 90 +++ .../deployment-detail/DeploymentLogsTab.tsx | 89 +++ .../deployment-detail/DeploymentOverview.tsx | 164 +++++ .../deployment-detail/DeploymentPodModals.tsx | 61 ++ .../deployment-detail/DeploymentPodsTab.tsx | 87 +++ .../kubernetes/deployment-detail/helpers.ts | 10 + .../kubernetes/deployment-detail/types.ts | 13 + .../kubernetes/deployment-view/EventsTab.tsx | 49 ++ .../deployment-view/HPACronHPATab.tsx | 126 ++++ .../kubernetes/deployment-view/HistoryTab.tsx | 53 ++ .../kubernetes/deployment-view/PodsTab.tsx | 88 +++ .../deployment-view/ServicesTab.tsx | 90 +++ .../kubernetes/deployment-view/helpers.tsx | 64 ++ .../kubernetes/deployment-view/types.ts | 35 + .../monitor/alert-center/AlertCenter.tsx | 123 ++-- frontend/src/hooks/api/index.ts | 8 +- frontend/src/hooks/api/queryKeys.ts | 1 + frontend/src/hooks/api/useAlerts.ts | 104 ++- frontend/src/hooks/usePermission.ts | 45 +- frontend/src/utils/index.ts | 10 +- frontend/src/utils/permission.ts | 38 + 24 files changed, 1454 insertions(+), 1221 deletions(-) create mode 100644 frontend/src/components/kubernetes/deployment-detail/CreateHPADialog.tsx create mode 100644 frontend/src/components/kubernetes/deployment-detail/DeploymentHpaTab.tsx create mode 100644 frontend/src/components/kubernetes/deployment-detail/DeploymentLogsTab.tsx create mode 100644 frontend/src/components/kubernetes/deployment-detail/DeploymentOverview.tsx create mode 100644 frontend/src/components/kubernetes/deployment-detail/DeploymentPodModals.tsx create mode 100644 frontend/src/components/kubernetes/deployment-detail/DeploymentPodsTab.tsx create mode 100644 frontend/src/components/kubernetes/deployment-detail/helpers.ts create mode 100644 frontend/src/components/kubernetes/deployment-detail/types.ts create mode 100644 frontend/src/components/kubernetes/deployment-view/EventsTab.tsx create mode 100644 frontend/src/components/kubernetes/deployment-view/HPACronHPATab.tsx create mode 100644 frontend/src/components/kubernetes/deployment-view/HistoryTab.tsx create mode 100644 frontend/src/components/kubernetes/deployment-view/PodsTab.tsx create mode 100644 frontend/src/components/kubernetes/deployment-view/ServicesTab.tsx create mode 100644 frontend/src/components/kubernetes/deployment-view/helpers.tsx create mode 100644 frontend/src/components/kubernetes/deployment-view/types.ts create mode 100644 frontend/src/utils/permission.ts diff --git a/frontend/src/components/kubernetes/DeploymentDetail.tsx b/frontend/src/components/kubernetes/DeploymentDetail.tsx index 16d1510..723332d 100644 --- a/frontend/src/components/kubernetes/DeploymentDetail.tsx +++ b/frontend/src/components/kubernetes/DeploymentDetail.tsx @@ -1,51 +1,19 @@ +// DeploymentDetail.tsx — Deployment 详情页编排组件 // Logan.Li import { useState, useEffect, useCallback } from 'react'; -import { Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card'; +import { Card, CardContent } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Badge } from '@/components/ui/badge'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Label } from '@/components/ui/label'; -import { - Table, TableBody, TableCell, TableHead, TableHeader, TableRow, -} from '@/components/ui/table'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { - - ArrowLeft, - Minus, - Plus, - RotateCw, - Upload, - FileText, - Terminal, - Gauge, - Trash2, - RefreshCw, - Loader2, - XCircle, - ExternalLink, - FolderOpen, -} from 'lucide-react'; +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { FileText, Gauge, Loader2, XCircle } from 'lucide-react'; import { kubernetesApi } from '@/api'; import { useToast } from '@/hooks/use-toast'; import { useConfirm } from '@/components/ui/confirm-dialog'; -import { PodExecTerminal } from './PodExecTerminal'; -import { PodLogsViewer } from './PodLogsViewer'; -import { PodFileBrowser } from './PodFileBrowser'; - -interface DeploymentDetailProps { - clusterCode: string; - namespace: string; - deploymentName: string; - onBack: () => void; -} +import type { DeploymentDetailProps, PodWithContainers } from './deployment-detail/types'; +import { DeploymentOverview } from './deployment-detail/DeploymentOverview'; +import { DeploymentPodsTab } from './deployment-detail/DeploymentPodsTab'; +import { DeploymentLogsTab } from './deployment-detail/DeploymentLogsTab'; +import { DeploymentHpaTab } from './deployment-detail/DeploymentHpaTab'; +import { DeploymentPodModals } from './deployment-detail/DeploymentPodModals'; export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBack }: DeploymentDetailProps) { const { toast } = useToast(); @@ -53,39 +21,20 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac const [deployment, setDeployment] = useState(null); const [pods, setPods] = useState([]); const [hpas, setHpas] = useState([]); - const [logs, setLogs] = useState(''); const [loading, setLoading] = useState(true); const [actionLoading, setActionLoading] = useState(''); - - // Scale const [, setScaleValue] = useState(0); - // Image update const [newImage, setNewImage] = useState(''); const [showImageUpdate, setShowImageUpdate] = useState(false); - // Logs - const [logPod, setLogPod] = useState(''); - const [showLogs, setShowLogs] = useState(false); const [logTail, setLogTail] = useState(200); - - // 🆕 Pod exec / logs / file browser (Logan ) - const [execPod, setExecPod] = useState<{ name: string; containers: string[] } | null>(null); + const [execPod, setExecPod] = useState(null); const [execOpen, setExecOpen] = useState(false); - const [logsViewerPod, setLogsViewerPod] = useState<{ name: string; containers: string[] } | null>(null); + const [logsViewerPod, setLogsViewerPod] = useState(null); const [logsViewerOpen, setLogsViewerOpen] = useState(false); - const [fileBrowserPod, setFileBrowserPod] = useState<{ name: string; containers: string[] } | null>(null); + const [fileBrowserPod, setFileBrowserPod] = useState(null); const [fileBrowserOpen, setFileBrowserOpen] = useState(false); - // Logs tab 内的 Pod 选择器(增强版查看器入口) const [logsTabPod, setLogsTabPod] = useState(''); - // 从 Pod 对象提取容器名列表(兼容多种字段) - const extractContainers = ( pod: any): string[] => { - const c = pod?.containers; - if (Array.isArray(c)) return c.map(( x: any) => typeof x === 'string' ? x : (x?.name || x?.Name)).filter(Boolean); - if (Array.isArray(pod?.container_statuses)) return pod.container_statuses.map(( x: any) => x?.name).filter(Boolean); - if (Array.isArray(pod?.containerNames)) return pod.containerNames.filter(Boolean); - return []; - }; - const loadData = useCallback(async () => { setLoading(true); try { @@ -94,29 +43,19 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac kubernetesApi.getNamespacePods(clusterCode, namespace).catch(() => []), kubernetesApi.getHPAs(clusterCode, namespace).catch(() => ({ items: [] })), ]); - const toArray = ( v: any) => (Array.isArray(v) ? v : (v?.list || v?.items || [])); + const toArray = (v: any) => (Array.isArray(v) ? v : (v?.list || v?.items || [])); const allDeps = toArray(depRes); const allPods = toArray(podRes); const allHpas = toArray(hpaRes); - const dep = allDeps.find(( d: any) => d.name === deploymentName && d.namespace === namespace); + const dep = allDeps.find((d: any) => d.name === deploymentName && d.namespace === namespace); setDeployment(dep || null); if (dep) { setScaleValue(dep.replicas || 0); setNewImage(dep.image || ''); } - - // Filter pods by deployment label (prefix match on pod name) - const depPods = allPods.filter(( p: any) => - p.namespace === namespace && p.name.startsWith(deploymentName) - ); - setPods(depPods); - - // Filter HPAs targeting this deployment (flattened data) - const depHpas = allHpas.filter(( h: any) => { - return h.target_name === deploymentName && h.namespace === namespace; - }); - setHpas(depHpas); + setPods(allPods.filter((p: any) => p.namespace === namespace && p.name.startsWith(deploymentName))); + setHpas(allHpas.filter((h: any) => h.target_name === deploymentName && h.namespace === namespace)); } catch (e) { console.error('加载详情失败:', e); } finally { @@ -162,20 +101,7 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac } finally { setActionLoading(''); } }; - const _handleLoadLogs = async (podName: string) => { - setLogPod(podName); - setShowLogs(true); - setLogs(''); - try { - const res: Record = await kubernetesApi.getPodLogs(clusterCode, namespace, podName, logTail); - setLogs(res?.data?.logs || res?.logs || '无日志'); - } catch { - setLogs('获取日志失败'); - } - }; - - const handleHpaToggle = async ( hpa: any) => { - // Not direct toggle — delete HPA + const handleHpaToggle = async (hpa: any) => { const ok = await confirm({ title: '删除 HPA', description: `确定删除 HPA "${hpa.name}"?`, @@ -218,520 +144,81 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac const ready = deployment.ready_replicas || deployment.readyReplicas || 0; const total = deployment.replicas || 0; const available = deployment.available_replicas || deployment.availableReplicas || 0; - const isHealthy = ready === total; return (
- {/* Header */} -
-
- -
-
-

{deploymentName}

- {namespace} -
-

镜像: {deployment.image || '-'}

-
-
- -
+ setShowImageUpdate((v) => !v)} + onUpdateImage={handleUpdateImage} + /> - {/* Status Bar */} -
- - -

副本状态

-

- {ready}/{total} -

-
-
- - -

可用副本

-

{available}

-
-
- - -

镜像

-

- {deployment.image?.split('/').pop() || '-'} -

-
-
- - -

HPA

- {hpas.length > 0 ? ( -

已启用

- ) : ( -
-

未配置

- -
- )} -
-
-
- - {/* Actions */} - - - 操作 - - -
- {/* Scale controls */} -
- - - {actionLoading === 'scale' ? : total} - - -
- - - - -
- - {/* Inline image update */} - {showImageUpdate && ( -
- 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" - /> - - -
- )} -
-
- - {/* Tabs: Pods / Logs / HPA */} Pod 列表 ({pods.length}) - 日志 {showLogs && `(${logPod})`} + 日志 HPA ({hpas.length}) - {/* Pods Tab */} - - - - - - - Pod 名称 - 状态 - 节点 - IP - 重启 - 操作 - - - - {pods.length === 0 ? ( - - - 暂无 Pod - - - ) : ( - pods.map(( pod: any) => ( - - {pod.name} - - {pod.status} - - {pod.node_name || '-'} - {pod.ip || '-'} - 0 ? 'text-yellow-400' : 'text-slate-500'}> - {pod.restart_count || 0} - - -
- - - -
-
-
- )) - )} -
-
-
-
-
- - {/* Logs Tab */} - - - -
- Pod 日志 -
- {showLogs && logPod && ( - - )} - 最近 - setLogTail(Number(e.target.value))} - className="w-20 h-7 bg-slate-800 border-slate-700 text-slate-200 text-xs" - /> - 行 -
-
-
- - {pods.length === 0 ? ( -
- -

暂无 Pod

-
- ) : ( -
- - 选择 Pod 打开增强版日志查看器 - - - - 支持实时跟踪、多容器切换、previous 日志、关键字过滤、导出 - -
- )} - {showLogs && logPod && ( -
-
-                    {logs || '无日志内容'}
-                  
-
- )} -
-
-
- - {/* HPA Tab */} - - - -
- HPA 自动扩缩容 - -
-
- - {hpas.length === 0 ? ( -
- -

未配置 HPA

-

点击右上角「创建 HPA」设置自动扩缩容

-
- ) : ( - - - - 名称 - 最小/最大 - 指标 - 当前/目标 - 操作 - - - - {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 ( - - {hpa.name} - {hpa.min_replicas} / {hpa.max_replicas} - {metrics} - {hpa.current_replicas || 0} - - - - - ); - })} - -
- )} -
-
-
+ { setLogsViewerPod(pod); setLogsViewerOpen(true); }} + onOpenExec={(pod) => { setExecPod(pod); setExecOpen(true); }} + onOpenFileBrowser={(pod) => { setFileBrowserPod(pod); setFileBrowserOpen(true); }} + /> + { setLogsViewerPod({ name, containers }); setLogsViewerOpen(true); }} + /> +
- {/* 🆕 Pod exec 终端 */} - {execPod && ( - { - setExecOpen(o); - if (!o) setExecPod(null); - }} - clusterId={clusterCode} - namespace={namespace} - podName={execPod.name} - containers={execPod.containers} - /> - )} - - {/* 🆕 增强版日志查看器 */} - {logsViewerPod && ( - { - setLogsViewerOpen(o); - if (!o) setLogsViewerPod(null); - }} - clusterCode={clusterCode} - namespace={namespace} - podName={logsViewerPod.name} - containers={logsViewerPod.containers} - /> - )} - - {/* 🆕 容器文件浏览器 */} - {fileBrowserPod && ( - { - setFileBrowserOpen(o); - if (!o) setFileBrowserPod(null); - }} - clusterCode={clusterCode} - namespace={namespace} - podName={fileBrowserPod.name} - containers={fileBrowserPod.containers} - /> - )} + { setExecOpen(o); if (!o) setExecPod(null); }} + logsViewerPod={logsViewerPod} + logsViewerOpen={logsViewerOpen} + onLogsViewerOpenChange={(o) => { setLogsViewerOpen(o); if (!o) setLogsViewerPod(null); }} + fileBrowserPod={fileBrowserPod} + fileBrowserOpen={fileBrowserOpen} + onFileBrowserOpenChange={(o) => { setFileBrowserOpen(o); if (!o) setFileBrowserPod(null); }} + />
); } - -// ============================================================================ -// 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 ( - <> - - {open && ( -
setOpen(false)}> -
e.stopPropagation()}> -

创建 HPA

-
-
- - setForm({ ...form, name: e.target.value })} - placeholder={`hpa-${deploymentName}`} - className="bg-slate-800 border-slate-700 text-slate-200 mt-1" /> -
-
- -

- Deployment: {deploymentName} · 命名空间: {namespace} -

-
-
-
- - setForm({ ...form, minReplicas: Number(e.target.value) })} - className="bg-slate-800 border-slate-700 text-slate-200 mt-1" /> -
-
- - setForm({ ...form, maxReplicas: Number(e.target.value) })} - className="bg-slate-800 border-slate-700 text-slate-200 mt-1" /> -
-
-
-
- - setForm({ ...form, cpuPercent: Number(e.target.value) })} - className="bg-slate-800 border-slate-700 text-slate-200 mt-1" /> -
-
- - setForm({ ...form, memPercent: Number(e.target.value) })} - className="bg-slate-800 border-slate-700 text-slate-200 mt-1" /> -
-
-
-
- - -
-
-
- )} - - ); -} diff --git a/frontend/src/components/kubernetes/DeploymentView.tsx b/frontend/src/components/kubernetes/DeploymentView.tsx index 6aeec7a..cb67b56 100644 --- a/frontend/src/components/kubernetes/DeploymentView.tsx +++ b/frontend/src/components/kubernetes/DeploymentView.tsx @@ -1,78 +1,28 @@ -// DeploymentView.tsx — ACK 风格 Tab 详情看板 +// DeploymentView.tsx — ACK 风格 Tab 详情看板(编排层) // Logan.Li // Tabs: 容器组 | 访问方式 | 事件 | 容器伸缩 | 历史版本 -// 作者: Logan -import { useState, useEffect, Fragment } from 'react'; -import { - Card, CardContent, CardHeader, CardTitle, -} from '@/components/ui/card'; +import { useState, useEffect } from 'react'; +import { Card, CardContent } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Input } from '@/components/ui/input'; import { - Table, TableBody, TableCell, TableHead, TableHeader, TableRow, -} from '@/components/ui/table'; -import { - - Clock, - HardDrive, - Network, - Activity, - RotateCcw, - Container, - History, - ChevronDown, - ChevronRight, - Terminal, - FileText, - ExternalLink, - Minus, - Plus, - Loader2, + Activity, Container, History, Network, Minus, Plus, Loader2, } from 'lucide-react'; import { kubernetesApi } from '@/api'; import { useToast } from '@/hooks/use-toast'; import { useConfirm } from '@/components/ui/confirm-dialog'; -import { - formatK8sTime, formatDuration, elapsedSeconds, statusColor, STATUS_COLORS, -} from './k8s-yaml-utils'; +import { formatK8sTime, STATUS_COLORS } from './k8s-yaml-utils'; -// Types -interface PodItem { - name: string; namespace?: string; status?: string; node_name?: string; node?: string; - ip?: string; pod_ip?: string; restart_count?: number; age?: string; created_at?: string; - start_time?: string; containers?: any[]; image?: string; images?: string[]; -} -interface ReplicaSetItem { - name: string; replicas?: number; ready_replicas?: number; available_replicas?: number; - image?: string; revision?: number; status?: string; created_at?: string; -} -interface SvcItem { name: string; type: string; clusterIP: string; ports: any[]; selector: Record; age: string; } -interface IngItem { name: string; className: string; hosts: string[]; rules: any[]; age: string; } -interface EventItem { type: string; reason: string; message: string; source: string; firstTimestamp: string; lastTimestamp: string; count: number; } -interface DeploymentViewProps { - clusterCode: string; namespace: string; deployment: any; - pods: PodItem[]; replicaSets: ReplicaSetItem[]; revisions: any[]; - onRefresh: () => void; - onShowPodLogs: (pod: PodItem) => void; - onShowPodTerminal: (pod: PodItem) => void; - onShowPodYaml: (pod: PodItem) => void; - onShowPodFileBrowser: (pod: PodItem) => void; -} +import { InfoItem, PodConditionsRow } from './deployment-view/helpers'; +import { PodsTab } from './deployment-view/PodsTab'; +import { ServicesTab } from './deployment-view/ServicesTab'; +import { EventsTab } from './deployment-view/EventsTab'; +import { HPACronHPATab } from './deployment-view/HPACronHPATab'; +import { HistoryTab } from './deployment-view/HistoryTab'; +import type { DeploymentViewProps, SvcItem, IngItem, EventItem } from './deployment-view/types'; -// ─── Helpers ─── -function getPodTimeline(pod: PodItem) { - const cond: Record[] = (pod as Record).conditions || (pod as Record).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).startTime, - }; -} +export type { DeploymentViewProps } from './deployment-view/types'; export function DeploymentView(props: DeploymentViewProps) { const { clusterCode, namespace, deployment, pods, revisions, onRefresh } = props; @@ -82,17 +32,17 @@ export function DeploymentView(props: DeploymentViewProps) { const [expandedPod, setExpandedPod] = useState(null); const [rollbackLoading, setRollbackLoading] = useState(null); - // Scale state + // Scale state // Logan.Li const [scaleValue, setScaleValue] = useState(deployment?.replicas || 0); const [scaleLoading, setScaleLoading] = useState(false); - // Service/Ingress/Events state + // Service/Ingress/Events state // Logan.Li const [services, setServices] = useState([]); const [ingresses, setIngresses] = useState([]); const [events, setEvents] = useState([]); const [tabLoading, setTabLoading] = useState(''); - // HPA / CronHPA state + // HPA / CronHPA state // Logan.Li const [hpas, setHpas] = useState([]); const [cronHpas, setCronHpas] = useState([]); @@ -100,7 +50,7 @@ export function DeploymentView(props: DeploymentViewProps) { const total = deployment?.replicas ?? 0; const isHealthy = ready === total && total > 0; - // Load Services and Ingresses for "访问方式" tab + // 访问方式 Tab 数据加载 // Logan.Li useEffect(() => { if (tab !== 'services') return; setTabLoading('services'); @@ -108,39 +58,37 @@ export function DeploymentView(props: DeploymentViewProps) { kubernetesApi.getNamespaceServices(clusterCode, namespace).catch(() => []), kubernetesApi.getNamespaceIngresses(clusterCode, namespace).catch(() => []), ]).then(([svcRes, ingRes]) => { - const toArr = ( v: any) => (Array.isArray(v) ? v : (v?.list || v?.items || v?.data || [])); + const toArr = (v: any) => (Array.isArray(v) ? v : (v?.list || v?.items || v?.data || [])); const allSvcs = toArr(svcRes); const allIngs = toArr(ingRes); - // Filter services matching deployment selector const selector = deployment?.spec?.selector?.matchLabels || deployment?.spec?.template?.metadata?.labels || {}; - const matchedSvcs = allSvcs.filter(( s: any) => { + const matchedSvcs = allSvcs.filter((s: any) => { const svcSel = s.spec?.selector || s.selector || {}; if (Object.keys(svcSel).length === 0) return false; return Object.entries(svcSel).every(([k, v]) => selector[k] === v); - }).map(( s: any) => ({ + }).map((s: any) => ({ name: s.name || s.metadata?.name, type: s.spec?.type || s.type || 'ClusterIP', clusterIP: s.spec?.clusterIP || s.cluster_ip || '', - ports: (s.spec?.ports || s.ports || []).map(( p: any) => `${p.port}${p.targetPort ? ':' + p.targetPort : ''}/${p.protocol || 'TCP'}`), + ports: (s.spec?.ports || s.ports || []).map((p: any) => `${p.port}${p.targetPort ? ':' + p.targetPort : ''}/${p.protocol || 'TCP'}`), selector: s.spec?.selector || s.selector || {}, age: s.age || '', })); - // Filter ingresses pointing to matched services - const svcNames = new Set(matchedSvcs.map(( s: any) => s.name)); - const matchedIngs = allIngs.filter(( i: any) => { + const svcNames = new Set(matchedSvcs.map((s: any) => s.name)); + const matchedIngs = allIngs.filter((i: any) => { const rules = i.spec?.rules || i.rules || []; - return rules.some(( r: any) => - (r.http?.paths || r.paths || []).some(( p: any) => { + return rules.some((r: any) => + (r.http?.paths || r.paths || []).some((p: any) => { const backendSvc = p.backend?.service?.name || p.backendService || ''; return svcNames.has(backendSvc); }) ); - }).map(( i: any) => ({ + }).map((i: any) => ({ name: i.name || i.metadata?.name, className: i.spec?.ingressClassName || i.ingressClass || '-', - hosts: (i.spec?.rules || i.rules || []).map(( r: any) => r.host).filter(Boolean), + hosts: (i.spec?.rules || i.rules || []).map((r: any) => r.host).filter(Boolean), rules: i.spec?.rules || i.rules || [], age: i.age || '', })); @@ -150,17 +98,17 @@ export function DeploymentView(props: DeploymentViewProps) { }).catch(() => {}).finally(() => setTabLoading('')); }, [tab, clusterCode, namespace, deployment]); - // Load Events for "事件" tab + // 事件 Tab 数据加载 // Logan.Li useEffect(() => { if (tab !== 'events') return; setTabLoading('events'); - kubernetesApi.getEvents(clusterCode, namespace, undefined).then(( res: any) => { + kubernetesApi.getEvents(clusterCode, namespace, undefined).then((res: any) => { const allEvents = Array.isArray(res) ? res : (res?.list || res?.items || res?.data || []); - const depEvents = allEvents.filter(( e: any) => { + const depEvents = allEvents.filter((e: any) => { const involved = e.involvedObject || e.involved_object || {}; const objName = involved.name || ''; return objName === deployment?.name || objName.startsWith(deployment?.name + '-'); - }).map(( e: any) => ({ + }).map((e: any) => ({ type: e.type || 'Normal', reason: e.reason || '', message: e.message || '', @@ -173,7 +121,7 @@ export function DeploymentView(props: DeploymentViewProps) { }).catch(() => {}).finally(() => setTabLoading('')); }, [tab, clusterCode, namespace, deployment?.name]); - // Load HPA and CronHPA for "伸缩" tab + // 伸缩 Tab 数据加载 // Logan.Li useEffect(() => { if (tab !== 'scale') return; setTabLoading('scale'); @@ -181,16 +129,16 @@ export function DeploymentView(props: DeploymentViewProps) { kubernetesApi.getHPAs(clusterCode, namespace).catch(() => []), Promise.resolve([]), // CronHPA 待后端支持 ]).then(([hpaRes]) => { - const toArr = ( v: any) => (Array.isArray(v) ? v : (v?.list || v?.items || v?.data || [])); + const toArr = (v: any) => (Array.isArray(v) ? v : (v?.list || v?.items || v?.data || [])); const allHpas = toArr(hpaRes); const depName = deployment?.name || ''; - setHpas(allHpas.filter(( h: any) => (h.scaleTargetRef?.name || h.targetRef?.name || '') === depName)); + setHpas(allHpas.filter((h: any) => (h.scaleTargetRef?.name || h.targetRef?.name || '') === depName)); setCronHpas([]); }).catch(() => {}).finally(() => setTabLoading('')); }, [tab, clusterCode, namespace, deployment?.name]); - // Rollback - const handleRollback = async ( rev: any) => { + // 回滚 // Logan.Li + const handleRollback = async (rev: any) => { const revision = rev.revision ?? rev.revision_number ?? rev.number; const ok = await confirm({ title: '回滚版本', @@ -209,7 +157,7 @@ export function DeploymentView(props: DeploymentViewProps) { } finally { setRollbackLoading(null); } }; - // Scale + // 手动伸缩 // Logan.Li const handleScale = async () => { setScaleLoading(true); try { @@ -231,7 +179,7 @@ export function DeploymentView(props: DeploymentViewProps) { return (
- {/* ============ Info Panel (ACK-style) ============ */} + {/* Info Panel (ACK-style) */}
@@ -254,7 +202,6 @@ export function DeploymentView(props: DeploymentViewProps) { )}
- {/* Pod conditions timeline */} {pods[0] && (
@@ -263,7 +210,7 @@ export function DeploymentView(props: DeploymentViewProps) { - {/* ============ Tab Bar + 副本数 ============ */} + {/* Tab Bar + 副本数 */}
{TABS.map(t => ( ))} - {/* 副本数控制 — Tab 行右侧 */}
- {/* ============ Tab Content ============ */} + {/* Tab Content */} {tab === 'pods' && } {tab === 'services' && } {tab === 'events' && } @@ -321,415 +267,4 @@ export function DeploymentView(props: DeploymentViewProps) { {tab === 'history' && }
); -} - -// ─── Info Item ─── -function InfoItem({ label, value }: { label: string; value: React.ReactNode }) { - return ( -
-

{label}

-
{value}
-
- ); -} - -// ─── 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 ( -
- {stages.map((s, i) => { - const prev = stages[i - 1]; - const dur = elapsedSeconds(prev?.ts, s.ts); - return ( - -
-
- {s.label} - {s.ts ? formatK8sTime(s.ts) : '-'} -
- {i < stages.length - 1 && ( -
-
- {dur != null ? formatDuration(dur) : ''} -
- )} - - ); - })} -
- ); -} - -// ═══════════════ TAB: 容器组 ═══════════════ -function PodsTab({ clusterCode, pods, onShowPodLogs, onShowPodTerminal, onShowPodFileBrowser, expandedPod, setExpandedPod }: DeploymentViewProps & { expandedPod: string | null; setExpandedPod: (v: string | null) => void }) { - if (pods.length === 0) { - return
暂无容器组
; - } - return ( -
- {pods.map(pod => { - const tl = getPodTimeline(pod); - const expanded = expandedPod === pod.name; - const nodeName = pod.node_name || pod.node; - return ( - - - {/* Pod header row */} -
- - {pod.name} - - {pod.status || 'Unknown'} - - - - - {pod.ip || pod.pod_ip || '-'} - 重启 {pod.restart_count || 0} 次 - {formatK8sTime(tl.startTime || pod.created_at)} -
- - - -
- {/* Expanded: timeline */} - {expanded && ( -
- - {/* Container details */} - {(pod as Record).containers && ((pod as Record).containers as any[]).length > 0 && ( -
- {((pod as Record).containers as any[]).map(( c: any, idx: number) => ( -
-
- {c.name} - {c.ready ? 'Ready' : 'Pending'} -
-
{c.image}
- {c.resources && ( -
- CPU: {c.resources?.requests?.cpu || '0'}/{c.resources?.limits?.cpu || '-'} m - 内存: {c.resources?.requests?.memory || '0'}/{c.resources?.limits?.memory || '-'} -
- )} -
- ))} -
- )} -
- )} - - - ); - })} -
- ); -} - -// ═══════════════ TAB: 访问方式 ═══════════════ -function ServicesTab({ services, ingresses, loading }: { services: SvcItem[]; ingresses: IngItem[]; loading: string; namespace: string }) { - if (loading === 'services') { - return
; - } - return ( -
- {/* Services */} -
-

- 关联 Service -

- {services.length === 0 ? ( -

暂无匹配的 Service(Deployment 选择器未关联任何 Service)

- ) : ( -
- - - - 名称 - 类型 - ClusterIP - 端口 - Selector - - - - {services.map(svc => ( - - {svc.name} - {svc.type} - {svc.clusterIP} - {svc.ports.join(', ')} - - {Object.entries(svc.selector).map(([k, v]) => ( - {k}={v} - ))} - - - ))} - -
-
- )} -
- - {/* Ingresses */} -
-

- 关联 Ingress -

- {ingresses.length === 0 ? ( -

暂无匹配的 Ingress

- ) : ( -
- - - - 名称 - Class - Host - 路径 - - - - {ingresses.map(ing => ( - - {ing.name} - {ing.className} - {ing.hosts.join(', ')} - - {ing.rules.flatMap(( r: any) => (r.http?.paths || r.paths || []).map(( p: any) => p.path || '/')).join(', ')} - - - ))} - -
-
- )} -
-
- ); -} - -// ═══════════════ TAB: 事件 ═══════════════ -function EventsTab({ events, loading }: { events: EventItem[]; loading: string }) { - if (loading === 'events') { - return
; - } - if (events.length === 0) { - return
暂无事件
; - } - return ( -
- - - - 类型 - 原因 - 消息 - 来源 - 次数 - 最后时间 - - - - {events.map((e, i) => ( - - - - {e.type} - - - {e.reason} - {e.message} - {e.source} - {e.count} - {formatK8sTime(e.lastTimestamp)} - - ))} - -
-
- ); -} - -// ═══════════════ TAB: 伸缩 (HPA + CronHPA) ═══════════════ -function HPACronHPATab({ hpas, cronHpas, loading, replicas }: { hpas: any[]; cronHpas: any[]; loading: string; replicas: number }) { - if (loading === 'scale') { - return
; - } - - return ( -
- {/* 当前副本数提示 */} -
- 当前副本: - {replicas} - (可通过 Tab 栏右侧 +/- 手动调整) -
- - {/* HPA 策略 */} - - HPA — 水平自动伸缩 - - {hpas.length === 0 ? ( -
- 未关联 HPA — -
- ) : ( -
- - - - 名称 - 最小/最大 - 指标 - 当前负载 - 状态 - - - - {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 ( - - {hpa.name || hpa.metadata?.name} - {minR} → {maxR} - - {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
{resource?.name || resource}: {target}{m.resource?.target?.averageUtilization ? '%' : ''}
; - })} -
- {curR} - - - {ok ? '正常' : '异常'} - - -
- ); - })} -
-
-
- )} -
-
- - {/* CronHPA 策略 */} - - CronHPA — 定时伸缩 - - {cronHpas.length === 0 ? ( -
- 未关联 CronHPA — -
- ) : ( -
- - - - 名称 - 目标副本 - 调度规则 - 时区 - 状态 - - - - {cronHpas.map(( cron: any, i: number) => { - const jobs = cron.spec?.jobs || cron.jobs || []; - const tz = cron.spec?.timezone || 'UTC'; - return ( - - {cron.name || cron.metadata?.name} - { - jobs.map(( j: any) => j.targetSize || j.targetReplicas || '?').join(', ') - } - {jobs.map(( j: any) => j.schedule || '?').join(', ')} - {tz} - - 已启用 - - - ); - })} - -
-
- )} -
-
-
- ); -} - -// ═══════════════ TAB: 历史版本 ═══════════════ -function HistoryTab({ revisions, rollbackLoading, onRollback }: { revisions: any[]; rollbackLoading: number | null; onRollback: ( rev: any) => void }) { - if (revisions.length === 0) { - return
暂无历史版本
; - } - return ( -
- - - - Revision - 镜像 - 状态 - 更新时间 - 操作 - - - - {revisions.map((rev, idx) => { - const revision = rev.revision ?? rev.revision_number ?? rev.number ?? idx + 1; - const isLatest = idx === 0; - return ( - - #{revision} - {rev.image || rev.images?.[0] || '-'} - - {isLatest - ? 当前 - : 历史} - - {formatK8sTime(rev.created_at || rev.time)} - - - - - ); - })} - -
-
- ); -} +} \ No newline at end of file diff --git a/frontend/src/components/kubernetes/deployment-detail/CreateHPADialog.tsx b/frontend/src/components/kubernetes/deployment-detail/CreateHPADialog.tsx new file mode 100644 index 0000000..ba23f91 --- /dev/null +++ b/frontend/src/components/kubernetes/deployment-detail/CreateHPADialog.tsx @@ -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 ( + <> + + {open && ( +
setOpen(false)}> +
e.stopPropagation()}> +

创建 HPA

+
+
+ + setForm({ ...form, name: e.target.value })} + placeholder={`hpa-${deploymentName}`} + className="bg-slate-800 border-slate-700 text-slate-200 mt-1" /> +
+
+ +

+ Deployment: {deploymentName} · 命名空间: {namespace} +

+
+
+
+ + setForm({ ...form, minReplicas: Number(e.target.value) })} + className="bg-slate-800 border-slate-700 text-slate-200 mt-1" /> +
+
+ + setForm({ ...form, maxReplicas: Number(e.target.value) })} + className="bg-slate-800 border-slate-700 text-slate-200 mt-1" /> +
+
+
+
+ + setForm({ ...form, cpuPercent: Number(e.target.value) })} + className="bg-slate-800 border-slate-700 text-slate-200 mt-1" /> +
+
+ + setForm({ ...form, memPercent: Number(e.target.value) })} + className="bg-slate-800 border-slate-700 text-slate-200 mt-1" /> +
+
+
+
+ + +
+
+
+ )} + + ); +} \ No newline at end of file diff --git a/frontend/src/components/kubernetes/deployment-detail/DeploymentHpaTab.tsx b/frontend/src/components/kubernetes/deployment-detail/DeploymentHpaTab.tsx new file mode 100644 index 0000000..ccceee7 --- /dev/null +++ b/frontend/src/components/kubernetes/deployment-detail/DeploymentHpaTab.tsx @@ -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 ( + + + +
+ HPA 自动扩缩容 + +
+
+ + {hpas.length === 0 ? ( +
+ +

未配置 HPA

+

点击右上角「创建 HPA」设置自动扩缩容

+
+ ) : ( + + + + 名称 + 最小/最大 + 指标 + 当前/目标 + 操作 + + + + {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 ( + + {hpa.name} + {hpa.min_replicas} / {hpa.max_replicas} + {metrics} + {hpa.current_replicas || 0} + + + + + ); + })} + +
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/kubernetes/deployment-detail/DeploymentLogsTab.tsx b/frontend/src/components/kubernetes/deployment-detail/DeploymentLogsTab.tsx new file mode 100644 index 0000000..eba2d49 --- /dev/null +++ b/frontend/src/components/kubernetes/deployment-detail/DeploymentLogsTab.tsx @@ -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 ( + + + +
+ Pod 日志 +
+ 最近 + onLogTailChange(Number(e.target.value))} + className="w-20 h-7 bg-slate-800 border-slate-700 text-slate-200 text-xs" + /> + 行 +
+
+
+ + {pods.length === 0 ? ( +
+ +

暂无 Pod

+
+ ) : ( +
+ + 选择 Pod 打开增强版日志查看器 + + + + 支持实时跟踪、多容器切换、previous 日志、关键字过滤、导出 + +
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/kubernetes/deployment-detail/DeploymentOverview.tsx b/frontend/src/components/kubernetes/deployment-detail/DeploymentOverview.tsx new file mode 100644 index 0000000..2001327 --- /dev/null +++ b/frontend/src/components/kubernetes/deployment-detail/DeploymentOverview.tsx @@ -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 ( + <> +
+
+ +
+
+

{deploymentName}

+ {namespace} +
+

镜像: {deployment.image || '-'}

+
+
+ +
+ +
+ + +

副本状态

+

+ {ready}/{total} +

+
+
+ + +

可用副本

+

{available}

+
+
+ + +

镜像

+

+ {deployment.image?.split('/').pop() || '-'} +

+
+
+ + +

HPA

+ {hpas.length > 0 ? ( +

已启用

+ ) : ( +
+

未配置

+ +
+ )} +
+
+
+ + + + 操作 + + +
+
+ + + {actionLoading === 'scale' ? : total} + + +
+ + +
+ {showImageUpdate && ( +
+ 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" + /> + + +
+ )} +
+
+ + ); +} \ No newline at end of file diff --git a/frontend/src/components/kubernetes/deployment-detail/DeploymentPodModals.tsx b/frontend/src/components/kubernetes/deployment-detail/DeploymentPodModals.tsx new file mode 100644 index 0000000..069c04f --- /dev/null +++ b/frontend/src/components/kubernetes/deployment-detail/DeploymentPodModals.tsx @@ -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 && ( + + )} + {logsViewerPod && ( + + )} + {fileBrowserPod && ( + + )} + + ); +} \ No newline at end of file diff --git a/frontend/src/components/kubernetes/deployment-detail/DeploymentPodsTab.tsx b/frontend/src/components/kubernetes/deployment-detail/DeploymentPodsTab.tsx new file mode 100644 index 0000000..816e246 --- /dev/null +++ b/frontend/src/components/kubernetes/deployment-detail/DeploymentPodsTab.tsx @@ -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 ( + + + + + + + Pod 名称 + 状态 + 节点 + IP + 重启 + 操作 + + + + {pods.length === 0 ? ( + + + 暂无 Pod + + + ) : ( + pods.map((pod: any) => ( + + {pod.name} + + {pod.status} + + {pod.node_name || '-'} + {pod.ip || '-'} + 0 ? 'text-yellow-400' : 'text-slate-500'}> + {pod.restart_count || 0} + + +
+ + + +
+
+
+ )) + )} +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/kubernetes/deployment-detail/helpers.ts b/frontend/src/components/kubernetes/deployment-detail/helpers.ts new file mode 100644 index 0000000..345a532 --- /dev/null +++ b/frontend/src/components/kubernetes/deployment-detail/helpers.ts @@ -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 []; +} \ No newline at end of file diff --git a/frontend/src/components/kubernetes/deployment-detail/types.ts b/frontend/src/components/kubernetes/deployment-detail/types.ts new file mode 100644 index 0000000..40611a4 --- /dev/null +++ b/frontend/src/components/kubernetes/deployment-detail/types.ts @@ -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[]; +} \ No newline at end of file diff --git a/frontend/src/components/kubernetes/deployment-view/EventsTab.tsx b/frontend/src/components/kubernetes/deployment-view/EventsTab.tsx new file mode 100644 index 0000000..82a7dd9 --- /dev/null +++ b/frontend/src/components/kubernetes/deployment-view/EventsTab.tsx @@ -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
; + } + if (events.length === 0) { + return
暂无事件
; + } + return ( +
+ + + + 类型 + 原因 + 消息 + 来源 + 次数 + 最后时间 + + + + {events.map((e, i) => ( + + + + {e.type} + + + {e.reason} + {e.message} + {e.source} + {e.count} + {formatK8sTime(e.lastTimestamp)} + + ))} + +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/kubernetes/deployment-view/HPACronHPATab.tsx b/frontend/src/components/kubernetes/deployment-view/HPACronHPATab.tsx new file mode 100644 index 0000000..790d095 --- /dev/null +++ b/frontend/src/components/kubernetes/deployment-view/HPACronHPATab.tsx @@ -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
; + } + + return ( +
+ {/* 当前副本数提示 */} +
+ 当前副本: + {replicas} + (可通过 Tab 栏右侧 +/- 手动调整) +
+ + {/* HPA 策略 */} + + HPA — 水平自动伸缩 + + {hpas.length === 0 ? ( +
+ 未关联 HPA — +
+ ) : ( +
+ + + + 名称 + 最小/最大 + 指标 + 当前负载 + 状态 + + + + {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 ( + + {hpa.name || hpa.metadata?.name} + {minR} → {maxR} + + {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
{resource?.name || resource}: {target}{m.resource?.target?.averageUtilization ? '%' : ''}
; + })} +
+ {curR} + + + {ok ? '正常' : '异常'} + + +
+ ); + })} +
+
+
+ )} +
+
+ + {/* CronHPA 策略 */} + + CronHPA — 定时伸缩 + + {cronHpas.length === 0 ? ( +
+ 未关联 CronHPA — +
+ ) : ( +
+ + + + 名称 + 目标副本 + 调度规则 + 时区 + 状态 + + + + {cronHpas.map((cron: any, i: number) => { + const jobs = cron.spec?.jobs || cron.jobs || []; + const tz = cron.spec?.timezone || 'UTC'; + return ( + + {cron.name || cron.metadata?.name} + { + jobs.map((j: any) => j.targetSize || j.targetReplicas || '?').join(', ') + } + {jobs.map((j: any) => j.schedule || '?').join(', ')} + {tz} + + 已启用 + + + ); + })} + +
+
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/kubernetes/deployment-view/HistoryTab.tsx b/frontend/src/components/kubernetes/deployment-view/HistoryTab.tsx new file mode 100644 index 0000000..45ecfce --- /dev/null +++ b/frontend/src/components/kubernetes/deployment-view/HistoryTab.tsx @@ -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
暂无历史版本
; + } + return ( +
+ + + + Revision + 镜像 + 状态 + 更新时间 + 操作 + + + + {revisions.map((rev, idx) => { + const revision = rev.revision ?? rev.revision_number ?? rev.number ?? idx + 1; + const isLatest = idx === 0; + return ( + + #{revision} + {rev.image || rev.images?.[0] || '-'} + + {isLatest + ? 当前 + : 历史} + + {formatK8sTime(rev.created_at || rev.time)} + + + + + ); + })} + +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/kubernetes/deployment-view/PodsTab.tsx b/frontend/src/components/kubernetes/deployment-view/PodsTab.tsx new file mode 100644 index 0000000..36d7533 --- /dev/null +++ b/frontend/src/components/kubernetes/deployment-view/PodsTab.tsx @@ -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
暂无容器组
; + } + return ( +
+ {pods.map(pod => { + const tl = getPodTimeline(pod); + const expanded = expandedPod === pod.name; + const nodeName = pod.node_name || pod.node; + return ( + + + {/* Pod header row */} +
+ + {pod.name} + + {pod.status || 'Unknown'} + + + + + {pod.ip || pod.pod_ip || '-'} + 重启 {pod.restart_count || 0} 次 + {formatK8sTime(tl.startTime || pod.created_at)} +
+ + + +
+ {/* Expanded: timeline */} + {expanded && ( +
+ + {/* Container details */} + {(pod as Record).containers && ((pod as Record).containers as any[]).length > 0 && ( +
+ {((pod as Record).containers as any[]).map((c: any, idx: number) => ( +
+
+ {c.name} + {c.ready ? 'Ready' : 'Pending'} +
+
{c.image}
+ {c.resources && ( +
+ CPU: {c.resources?.requests?.cpu || '0'}/{c.resources?.limits?.cpu || '-'} m + 内存: {c.resources?.requests?.memory || '0'}/{c.resources?.limits?.memory || '-'} +
+ )} +
+ ))} +
+ )} +
+ )} + + + ); + })} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/kubernetes/deployment-view/ServicesTab.tsx b/frontend/src/components/kubernetes/deployment-view/ServicesTab.tsx new file mode 100644 index 0000000..0244bc6 --- /dev/null +++ b/frontend/src/components/kubernetes/deployment-view/ServicesTab.tsx @@ -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
; + } + return ( +
+ {/* Services */} +
+

+ 关联 Service +

+ {services.length === 0 ? ( +

暂无匹配的 Service(Deployment 选择器未关联任何 Service)

+ ) : ( +
+ + + + 名称 + 类型 + ClusterIP + 端口 + Selector + + + + {services.map(svc => ( + + {svc.name} + {svc.type} + {svc.clusterIP} + {svc.ports.join(', ')} + + {Object.entries(svc.selector).map(([k, v]) => ( + {k}={v} + ))} + + + ))} + +
+
+ )} +
+ + {/* Ingresses */} +
+

+ 关联 Ingress +

+ {ingresses.length === 0 ? ( +

暂无匹配的 Ingress

+ ) : ( +
+ + + + 名称 + Class + Host + 路径 + + + + {ingresses.map(ing => ( + + {ing.name} + {ing.className} + {ing.hosts.join(', ')} + + {ing.rules.flatMap((r: any) => (r.http?.paths || r.paths || []).map((p: any) => p.path || '/')).join(', ')} + + + ))} + +
+
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/kubernetes/deployment-view/helpers.tsx b/frontend/src/components/kubernetes/deployment-view/helpers.tsx new file mode 100644 index 0000000..df2f765 --- /dev/null +++ b/frontend/src/components/kubernetes/deployment-view/helpers.tsx @@ -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[] = (pod as Record).conditions || (pod as Record).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).startTime, + }; +} + +// 信息面板单项 // Logan.Li +export function InfoItem({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+

{label}

+
{value}
+
+ ); +} + +// 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 ( +
+ {stages.map((s, i) => { + const prev = stages[i - 1]; + const dur = elapsedSeconds(prev?.ts, s.ts); + return ( + +
+
+ {s.label} + {s.ts ? formatK8sTime(s.ts) : '-'} +
+ {i < stages.length - 1 && ( +
+
+ {dur != null ? formatDuration(dur) : ''} +
+ )} + + ); + })} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/kubernetes/deployment-view/types.ts b/frontend/src/components/kubernetes/deployment-view/types.ts new file mode 100644 index 0000000..f8f0878 --- /dev/null +++ b/frontend/src/components/kubernetes/deployment-view/types.ts @@ -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; 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; +} \ No newline at end of file diff --git a/frontend/src/components/monitor/alert-center/AlertCenter.tsx b/frontend/src/components/monitor/alert-center/AlertCenter.tsx index 3ea0b60..e675775 100644 --- a/frontend/src/components/monitor/alert-center/AlertCenter.tsx +++ b/frontend/src/components/monitor/alert-center/AlertCenter.tsx @@ -1,5 +1,5 @@ -// 告警中心主组件 // Logan.Li -import React, { useState, useEffect } from 'react'; +// 告警中心主组件 — TanStack Query 编排 // Logan.Li +import React, { useState, useEffect, useMemo } from 'react'; import { AlertTriangle, CheckCircle, Search, RefreshCw, Eye, Check, VolumeX, Plus, Rocket, @@ -16,9 +16,10 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Switch } from '@/components/ui/switch'; import { toast } from 'sonner'; import { useConfirm } from '@/components/ui/confirm-dialog'; -import { alertCenterApi, agentApi, alertApi } from '@/api'; +import { alertCenterApi, alertApi } from '@/api'; +import { useAlerts, useAgentAlertRules, useAlertMutations } from '@/hooks/api'; import { - type Alert, type AlertRule, mapAlert, getSeverityBadge, getStatusBadge, + type Alert, type AlertRule, getSeverityBadge, getStatusBadge, getChannelTypeLabel, } from './alert-center-helpers'; import { AlertDetailDialog, AlertSilenceDialog } from './AlertCenterDetail'; @@ -30,9 +31,6 @@ import type { Contact, ContactGroup } from './alert-center-helpers'; const AlertCenter: React.FC = () => { const _confirm = useConfirm(); - const [alerts, setAlerts] = useState([]); - const [rules, setRules] = useState([]); - const [loading, setLoading] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [severityFilter, setSeverityFilter] = useState('all'); const [statusFilter, setStatusFilter] = useState('all'); @@ -40,11 +38,9 @@ const AlertCenter: React.FC = () => { const [selectedAlert, setSelectedAlert] = useState(null); const [detailOpen, setDetailOpen] = useState(false); const [silenceOpen, setSilenceOpen] = useState(false); - const [stats, setStats] = useState({ firing: 0, critical: 0, warning: 0, resolved: 0 }); // 规则弹框 const [ruleDialogOpen, setRuleDialogOpen] = useState(false); const [editingRule, setEditingRule] = useState(null); - const [quickSetupLoading, setQuickSetupLoading] = useState(false); // 联系人 & 联系组 const [contacts, setContacts] = useState([]); const [contactGroups, setContactGroups] = useState([]); @@ -55,67 +51,33 @@ const AlertCenter: React.FC = () => { // 分页 const [page, setPage] = useState(1); const [pageSize] = useState(10); - const [total, setTotal] = useState(0); + + const alertParams = useMemo(() => ({ + page, + page_size: pageSize, + severity: severityFilter, + status: statusFilter, + }), [page, pageSize, severityFilter, statusFilter]); + + const { data: alertData, isLoading: loading, refetch: refetchAlerts } = useAlerts(alertParams); + const { data: rules = [], refetch: refetchRules } = useAgentAlertRules(); + const { + ackMutation, + resolveMutation, + quickSetupMutation, + toggleAgentRuleMutation, + deleteAgentRuleMutation, + } = useAlertMutations(); + + const alerts = alertData?.list ?? []; + const total = alertData?.total ?? 0; + const stats = alertData?.stats ?? { firing: 0, critical: 0, warning: 0, resolved: 0 }; const totalPages = Math.max(1, Math.ceil(total / pageSize)); useEffect(() => { - fetchData(); if (activeTab === 'contacts') fetchContacts(); if (activeTab === 'groups') { fetchContacts(); fetchContactGroups(); } - const interval = setInterval(fetchData, 30000); - return () => clearInterval(interval); - }, [page, severityFilter, statusFilter, activeTab]); - - const fetchData = async () => { - await Promise.all([fetchAlerts(), fetchRules()]); - }; - - // Logan.Li — 获取告警列表(分页) - const fetchAlerts = async () => { - setLoading(true); - try { - const res: Record = await alertCenterApi.getAlerts({ - page, - page_size: pageSize, - severity: severityFilter !== 'all' ? severityFilter : undefined, - status: statusFilter !== 'all' ? statusFilter : undefined, - }); - const raw = (res.list || res.data?.list || []) as any[]; - const data: Alert[] = raw.map(mapAlert); - setAlerts(data); - setTotal(res.total || res.data?.total || data.length); - setStats({ - firing: data.filter(a => a.status === 'firing').length, - critical: data.filter(a => a.severity === 'critical' && a.status === 'firing').length, - warning: data.filter(a => a.severity === 'warning' && a.status === 'firing').length, - resolved: data.filter(a => a.status === 'resolved').length, - }); - } catch { - toast.error('获取告警失败'); - } finally { - setLoading(false); - } - }; - - const fetchRules = async () => { - try { - const res = await agentApi.listRules(); - // agentApi.listRules returns {code, data: AgentRule[]} — map to AlertRule for UI - const raw = (Array.isArray(res) ? res : (res.list || [])) as any[]; - setRules(raw.map(r => ({ - id: r.id, - name: r.name, - description: `${r.metric} ${r.operator} ${r.threshold}%`, - enabled: r.enabled, - severity: 'warning', - type: r.metric, - hostname: r.hostname || '全部主机', - threshold: r.threshold, - })) as AlertRule[]); - } catch { - toast.error('获取告警规则失败'); - } - }; + }, [activeTab]); const fetchContacts = async () => { try { @@ -133,9 +95,8 @@ const AlertCenter: React.FC = () => { const handleAcknowledge = async (id: string) => { try { - await alertCenterApi.acknowledge(id); + await ackMutation.mutateAsync(id); toast.success('告警已确认'); - fetchAlerts(); } catch { toast.error('操作失败'); } @@ -143,9 +104,8 @@ const AlertCenter: React.FC = () => { const handleResolve = async (id: string) => { try { - await alertCenterApi.resolve(id); + await resolveMutation.mutateAsync(id); toast.success('告警已解决'); - fetchAlerts(); } catch { toast.error('操作失败'); } @@ -169,7 +129,7 @@ const AlertCenter: React.FC = () => { }); toast.success(`告警已静默 ${duration}`); setSilenceOpen(false); - fetchAlerts(); + refetchAlerts(); } catch { toast.error('操作失败'); } @@ -177,8 +137,7 @@ const AlertCenter: React.FC = () => { const toggleRule = async (ruleId: string, enabled: boolean) => { try { - await agentApi.updateRule(Number(ruleId), { enabled }); - setRules(rules.map(r => String(r.id) === ruleId ? { ...r, enabled } : r)); + await toggleAgentRuleMutation.mutateAsync({ id: Number(ruleId), enabled }); toast.success('规则状态已更新'); } catch { toast.error('更新失败'); @@ -196,20 +155,18 @@ const AlertCenter: React.FC = () => { confirmText: '确认创建', }); if (!ok) return; - setQuickSetupLoading(true); try { - const res = await alertCenterApi.quickSetup('ecs'); - toast.success(typeof res === 'string' ? res : (res?.message || '默认告警规则创建成功')); - fetchRules(); + const res = await quickSetupMutation.mutateAsync('ecs'); + toast.success(typeof res === 'string' ? res : ((res as { message?: string })?.message || '默认告警规则创建成功')); } catch { toast.error('创建失败,请检查 Agent 主机是否在线'); - } finally { setQuickSetupLoading(false); } + } }; const handleDeleteRule = async (rule: AlertRule) => { const ok = await _confirm({ title: '删除规则', description: `确定删除规则「${rule.name}」?`, confirmText: '删除', variant: 'destructive' }); if (!ok) return; - try { await agentApi.deleteRule(Number(rule.id)); toast.success('已删除'); fetchRules(); } catch { toast.error('删除失败'); } + try { await deleteAgentRuleMutation.mutateAsync(Number(rule.id)); toast.success('已删除'); } catch { toast.error('删除失败'); } }; const handleFilterChange = (type: 'severity' | 'status', value: string) => { @@ -277,7 +234,7 @@ const AlertCenter: React.FC = () => {
共 {total} 条 -
@@ -402,12 +359,12 @@ const AlertCenter: React.FC = () => { -
- @@ -615,7 +572,7 @@ const AlertCenter: React.FC = () => { onOpenChange={handleRuleDialogClose} editingRule={editingRule} notifyChannels={[]} - onSaved={() => { handleRuleDialogClose(); fetchRules(); }} + onSaved={() => { handleRuleDialogClose(); refetchRules(); }} /> ['pipeline-runs', code] as const, alerts: (params?: Record) => ['alerts', params] as const, alertRules: () => ['alert-rules'] as const, + agentAlertRules: () => ['agent-alert-rules'] as const, auditLogs: (params?: Record) => ['audit-logs', params] as const, sessionCommands: (params?: Record) => ['session-commands', params] as const, k8sClusters: () => ['k8s-clusters'] as const, diff --git a/frontend/src/hooks/api/useAlerts.ts b/frontend/src/hooks/api/useAlerts.ts index 1f9e726..da864dc 100644 --- a/frontend/src/hooks/api/useAlerts.ts +++ b/frontend/src/hooks/api/useAlerts.ts @@ -1,9 +1,32 @@ // 告警规则 / 告警事件 — TanStack Query // Logan.Li import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { alertApi, alertCenterApi } from '@/api'; +import { alertApi, alertCenterApi, agentApi } from '@/api'; import { queryKeys } from './queryKeys'; -import { unwrapList } from './unwrap'; +import { unwrapList, unwrapTotal } from './unwrap'; +import { mapAlert, type Alert, type AlertRule } from '@/components/monitor/alert-center/alert-center-helpers'; + +type AlertListParams = { + page?: number; + page_size?: number; + status?: string; + severity?: string; +}; + +export type AlertListResult = { + list: Alert[]; + total: number; + stats: { firing: number; critical: number; warning: number; resolved: number }; +}; + +function buildAlertStats(list: Alert[]) { + return { + firing: list.filter((a) => a.status === 'firing').length, + critical: list.filter((a) => a.severity === 'critical' && a.status === 'firing').length, + warning: list.filter((a) => a.severity === 'warning' && a.status === 'firing').length, + resolved: list.filter((a) => a.status === 'resolved').length, + }; +} export function useAlertRules() { return useQuery({ @@ -16,20 +39,55 @@ export function useAlertRules() { }); } -export function useAlerts(params: { page?: number; page_size?: number; status?: string; severity?: string } = {}) { +/** Agent 主机告警规则(告警中心「规则」Tab) // Logan.Li */ +export function useAgentAlertRules() { + return useQuery({ + queryKey: queryKeys.agentAlertRules(), + queryFn: async () => { + const res = await agentApi.listRules(); + const raw = unwrapList>(res); + return raw.map((r) => ({ + id: r.id as number, + name: String(r.name || ''), + description: `${r.metric} ${r.operator} ${r.threshold}%`, + enabled: Boolean(r.enabled), + severity: 'warning' as const, + type: String(r.metric || ''), + hostname: String(r.hostname || '全部主机'), + threshold: Number(r.threshold || 0), + })) as AlertRule[]; + }, + staleTime: 30_000, + }); +} + +export function useAlerts(params: AlertListParams = {}) { return useQuery({ queryKey: queryKeys.alerts(params), - queryFn: async () => { - const resp = await alertCenterApi.getAlerts(params); - return unwrapList>(resp); + queryFn: async (): Promise => { + const resp = await alertCenterApi.getAlerts({ + page: params.page, + page_size: params.page_size, + severity: params.severity !== 'all' ? params.severity : undefined, + status: params.status !== 'all' ? params.status : undefined, + }); + const raw = unwrapList>(resp); + const list = raw.map((item) => mapAlert(item as Parameters[0])); + return { + list, + total: unwrapTotal(resp), + stats: buildAlertStats(list), + }; }, staleTime: 15_000, + refetchInterval: 30_000, }); } export function useAlertMutations() { const qc = useQueryClient(); const invalidateRules = () => qc.invalidateQueries({ queryKey: queryKeys.alertRules() }); + const invalidateAgentRules = () => qc.invalidateQueries({ queryKey: queryKeys.agentAlertRules() }); const invalidateAlerts = () => qc.invalidateQueries({ queryKey: ['alerts'] }); const toggleRuleMutation = useMutation({ @@ -42,10 +100,42 @@ export function useAlertMutations() { onSuccess: invalidateRules, }); + const toggleAgentRuleMutation = useMutation({ + mutationFn: ({ id, enabled }: { id: number; enabled: boolean }) => + agentApi.updateRule(id, { enabled }), + onSuccess: invalidateAgentRules, + }); + + const deleteAgentRuleMutation = useMutation({ + mutationFn: (id: number) => agentApi.deleteRule(id), + onSuccess: invalidateAgentRules, + }); + const ackMutation = useMutation({ mutationFn: (id: number | string) => alertCenterApi.acknowledge(id), onSuccess: invalidateAlerts, }); - return { toggleRuleMutation, deleteRuleMutation, ackMutation, invalidateRules, invalidateAlerts }; + const resolveMutation = useMutation({ + mutationFn: (id: number | string) => alertCenterApi.resolve(id), + onSuccess: invalidateAlerts, + }); + + const quickSetupMutation = useMutation({ + mutationFn: (scope: string) => alertCenterApi.quickSetup(scope), + onSuccess: invalidateAgentRules, + }); + + return { + toggleRuleMutation, + deleteRuleMutation, + toggleAgentRuleMutation, + deleteAgentRuleMutation, + ackMutation, + resolveMutation, + quickSetupMutation, + invalidateRules, + invalidateAgentRules, + invalidateAlerts, + }; } \ No newline at end of file diff --git a/frontend/src/hooks/usePermission.ts b/frontend/src/hooks/usePermission.ts index 60b3e02..429d581 100644 --- a/frontend/src/hooks/usePermission.ts +++ b/frontend/src/hooks/usePermission.ts @@ -17,45 +17,20 @@ * */ import { useUserStore } from '@/store'; - -// 权限通配符 — admin 用户拥有 -const WILDCARD = '*'; +import { + checkPermission, + checkAnyPermission, + checkAllPermissions, + isWildcardAdmin, +} from '@/utils/permission'; export function usePermission() { const permissions = useUserStore((s) => s.permissions); + const isAdmin = isWildcardAdmin(permissions); - // admin 通配符短路 - const isAdmin = permissions?.includes(WILDCARD) ?? false; - - /** - * 检查是否拥有指定权限 - * @param code 权限码,如 'server:create' - * @returns true=有权限;permissions 未加载时默认 true - */ - const hasPermission = (code: string): boolean => { - // 权限未加载(登录后未调用 setPermissions)→ 默认放行 - if (permissions === null || permissions === undefined) return true; - if (isAdmin) return true; - return permissions.includes(code); - }; - - /** - * 检查是否拥有任意一个权限(OR 逻辑) - */ - const hasAnyPermission = (codes: string[]): boolean => { - if (permissions === null || permissions === undefined) return true; - if (isAdmin) return true; - return codes.some((c) => permissions.includes(c)); - }; - - /** - * 检查是否拥有全部权限(AND 逻辑) - */ - const hasAllPermissions = (codes: string[]): boolean => { - if (permissions === null || permissions === undefined) return true; - if (isAdmin) return true; - return codes.every((c) => permissions.includes(c)); - }; + const hasPermission = (code: string) => checkPermission(permissions, code); + const hasAnyPermission = (codes: string[]) => checkAnyPermission(permissions, codes); + const hasAllPermissions = (codes: string[]) => checkAllPermissions(permissions, codes); return { hasPermission, hasAnyPermission, hasAllPermissions, isAdmin }; } diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts index de49db2..30a0663 100644 --- a/frontend/src/utils/index.ts +++ b/frontend/src/utils/index.ts @@ -43,4 +43,12 @@ export { isValidHostname, isValidUrl, parsePortList, -} from './validate'; \ No newline at end of file +} from './validate'; + +export { + PERMISSION_WILDCARD, + isWildcardAdmin, + checkPermission, + checkAnyPermission, + checkAllPermissions, +} from './permission'; \ No newline at end of file diff --git a/frontend/src/utils/permission.ts b/frontend/src/utils/permission.ts new file mode 100644 index 0000000..b2c2e8e --- /dev/null +++ b/frontend/src/utils/permission.ts @@ -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)); +} \ No newline at end of file