节点管理原表格只有"使用率"一列(CPU/内存双条),缺少 Request 占用。 - ListNodes 一次性拉取 Pod 并按节点聚合 CPU/内存 Request, 计算 cpu_alloc_pct / mem_alloc_pct(已申请 / 可分配), 新增 pod_count / cpu_request_m / mem_request_mi 字段 - 前端新增"Request 占用"双条列,阈值 60%/85% 变色, tooltip 展示"已申请 / 可分配"绝对值 - 不含磁盘/分区:K8s 原生 API 不提供磁盘用量, 后续接入 Prometheus 后由监控侧承担 服务器列表分页选项由 10/20/30 调整为 10/30/50/100。 // Logan.Li
209 lines
12 KiB
TypeScript
209 lines
12 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import {
|
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
|
} from '@/components/ui/select';
|
|
import {
|
|
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
|
} from '@/components/ui/table';
|
|
import { RefreshCw, Search, Server, Loader2 } from 'lucide-react';
|
|
import { kubernetesApi } from '@/api';
|
|
import { PageShell } from '@/components/layout/PageShell';
|
|
import { ContentPanel } from '@/components/layout/ContentPanel';
|
|
import type { JsonData } from '@/types/common';
|
|
import i18n from '@/i18n';
|
|
|
|
export function KubernetesNodes() {
|
|
const [clusters, setClusters] = useState<JsonData[]>([]);
|
|
const [selectedCluster, setSelectedCluster] = useState('');
|
|
const [nodes, setNodes] = useState<JsonData[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [search, setSearch] = useState('');
|
|
const [roleFilter, setRoleFilter] = useState('all');
|
|
|
|
const loadClusters = useCallback(async () => {
|
|
try {
|
|
const res: JsonData = await kubernetesApi.getClusters();
|
|
const list = res?.list || [];
|
|
setClusters(list);
|
|
if (list.length > 0 && !selectedCluster) setSelectedCluster(String(list[0].code || list[0].id));
|
|
} catch { setClusters([]); }
|
|
}, []);
|
|
|
|
useEffect(() => { loadClusters(); }, [loadClusters]);
|
|
|
|
const loadNodes = useCallback(async () => {
|
|
if (!selectedCluster) return;
|
|
setLoading(true);
|
|
try {
|
|
const res: JsonData = await kubernetesApi.getNodes(selectedCluster);
|
|
setNodes(Array.isArray(res) ? res : (res?.list || []));
|
|
} catch { setNodes([]); } finally { setLoading(false); }
|
|
}, [selectedCluster]);
|
|
|
|
useEffect(() => { loadNodes(); }, [loadNodes]);
|
|
|
|
const filtered = nodes.filter((n) =>
|
|
(n.name || '').toLowerCase().includes(search.toLowerCase()) &&
|
|
(roleFilter === 'all' || n.role === roleFilter),
|
|
);
|
|
|
|
const getRoleBadge = (role: string) =>
|
|
role === 'master'
|
|
? <Badge className="bg-[var(--dg-blue)]/20 text-[var(--dg-blue)] border-[var(--dg-blue)]/30">Master</Badge>
|
|
: <Badge className="bg-[var(--dg-green)]/20 text-[var(--dg-green)] border-[var(--dg-green)]/30">Worker</Badge>;
|
|
|
|
const getStatusBadge = (status: string) =>
|
|
status === 'Ready'
|
|
? <Badge className="bg-[var(--dg-green)]/20 text-[var(--dg-green)] border-[var(--dg-green)]/30">{status}</Badge>
|
|
: <Badge variant="destructive">{status || 'Unknown'}</Badge>;
|
|
|
|
const tableToolbar = (
|
|
<div className="flex items-center gap-3 flex-wrap">
|
|
<div className="relative w-56">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 dg-text-muted" />
|
|
<Input placeholder={i18n.t('k8s.topology.searchPlaceholder')} value={search} onChange={(e) => setSearch(e.target.value)} className="pl-9 h-9 dg-input" />
|
|
</div>
|
|
<Select value={roleFilter} onValueChange={setRoleFilter}>
|
|
<SelectTrigger className="w-32 h-9 dg-input">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all"> {i18n.t('kubernetes.kubernetesnodes.s5bcc10f8')} </SelectItem>
|
|
<SelectItem value="master">Master</SelectItem>
|
|
<SelectItem value="worker">Worker</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
);
|
|
|
|
const clusterLabel = clusters.find((c) => String(c.code || c.id) === selectedCluster)?.name;
|
|
|
|
return (
|
|
<PageShell
|
|
variant="list"
|
|
title={i18n.t('menu.nodeManagement')}
|
|
subtitle={clusterLabel ? `${clusterLabel} · ${i18n.t('k8s.capacity.nodeResources')}` : i18n.t('kubernetes.kubernetesnodes.s01b441ea')}
|
|
icon={<Server className="h-5 w-5" />}
|
|
actions={(
|
|
<>
|
|
<Select value={selectedCluster} onValueChange={setSelectedCluster}>
|
|
<SelectTrigger className="w-48 h-9 dg-input">
|
|
<SelectValue placeholder={i18n.t('kubernetes.topology.selectCluster')} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{clusters.map((c) => (
|
|
<SelectItem key={c.id} value={String(c.code || c.id)}>{c.name}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={loadNodes} disabled={loading}>
|
|
<RefreshCw className={`h-4 w-4 mr-1 ${loading ? 'animate-spin' : ''}`} />
|
|
{i18n.t('common.refresh')}
|
|
</Button>
|
|
</>
|
|
)}
|
|
>
|
|
<ContentPanel toolbar={tableToolbar}>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
|
|
<TableHead className="text-[var(--dg-text-secondary)]"> {i18n.t('kubernetes.node.nodeName')} </TableHead>
|
|
<TableHead className="text-[var(--dg-text-secondary)]">IP</TableHead>
|
|
<TableHead className="text-[var(--dg-text-secondary)]"> {i18n.t('system.userRole')} </TableHead>
|
|
<TableHead className="text-[var(--dg-text-secondary)]"> {i18n.t('system.terminalStatus')} </TableHead>
|
|
<TableHead className="text-[var(--dg-text-secondary)]"> {i18n.t('deploy.detailDialog.config')} </TableHead>
|
|
<TableHead className="text-[var(--dg-text-secondary)]"> {i18n.t('server.usage')} </TableHead>
|
|
{/* Request 占用:已申请 / 可分配,与"使用率"分列,避免一列塞四个条 // Logan.Li */}
|
|
<TableHead className="text-[var(--dg-text-secondary)]"> {i18n.t('k8s.node.requestUsage', { defaultValue: 'Request 占用' })} </TableHead>
|
|
<TableHead className="text-[var(--dg-text-secondary)]">Pods</TableHead>
|
|
<TableHead className="text-[var(--dg-text-secondary)]">Kubelet</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{loading ? (
|
|
<TableRow><TableCell colSpan={9} className="text-center text-[var(--dg-text-muted)] py-12"><Loader2 className="h-5 w-5 animate-spin mx-auto mb-2" /> {i18n.t('monitor.logPanel.logResults.loading')} </TableCell></TableRow>
|
|
) : filtered.length === 0 ? (
|
|
<TableRow><TableCell colSpan={9} className="text-center text-[var(--dg-text-muted)] py-12"><Server className="h-8 w-8 mx-auto mb-2 opacity-50" />{selectedCluster ? i18n.t('k8s.empty.nodes') : i18n.t('kubernetes.kubernetesnodes.sf8c38834')}</TableCell></TableRow>
|
|
) : (
|
|
filtered.map((n) => (
|
|
<TableRow key={n.id} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/30">
|
|
<TableCell className="font-medium text-[var(--dg-text-primary)]">{n.name}</TableCell>
|
|
<TableCell><code className="text-sm text-[var(--dg-text-secondary)] bg-[var(--dg-bg-tertiary)] px-1.5 py-0.5 rounded">{n.ip || '-'}</code></TableCell>
|
|
<TableCell>{getRoleBadge(n.role)}</TableCell>
|
|
<TableCell>{getStatusBadge(n.status)}</TableCell>
|
|
<TableCell className="text-sm text-[var(--dg-text-secondary)]">
|
|
{n.cpu ? i18n.t('k8s.capacity.cpuCores', { count: n.cpu }) : '-'} / {n.memory ? `${Math.round(n.memory / 1024)}GB` : '-'}
|
|
</TableCell>
|
|
<TableCell>
|
|
{(() => {
|
|
const cpu = n.cpu_usage || 0;
|
|
const mem = n.memory_usage || 0;
|
|
const bar = (val: number) => val >= 85 ? 'bg-[var(--dg-red)]' : val >= 60 ? 'bg-[var(--dg-orange)]' : 'bg-[var(--dg-green)]';
|
|
const txt = (val: number) => val >= 85 ? 'text-[var(--dg-red)]' : val >= 60 ? 'text-[var(--dg-orange)]' : 'text-[var(--dg-green)]';
|
|
return (
|
|
<div className="flex flex-col gap-1 min-w-[120px]">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs text-[var(--dg-text-muted)] w-7">CPU</span>
|
|
<div className="flex-1 h-1.5 rounded-full bg-[var(--dg-bg-tertiary)]/50 overflow-hidden">
|
|
<div className={`h-full rounded-full ${bar(cpu)}`} style={{ width: `${Math.min(cpu, 100)}%` }} />
|
|
</div>
|
|
<span className={`text-xs w-9 text-right ${txt(cpu)}`}>{cpu.toFixed(0)}%</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs text-[var(--dg-text-muted)] w-7">Mem</span>
|
|
<div className="flex-1 h-1.5 rounded-full bg-[var(--dg-bg-tertiary)]/50 overflow-hidden">
|
|
<div className={`h-full rounded-full ${bar(mem)}`} style={{ width: `${Math.min(mem, 100)}%` }} />
|
|
</div>
|
|
<span className={`text-xs w-9 text-right ${txt(mem)}`}>{mem.toFixed(0)}%</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
</TableCell>
|
|
<TableCell>
|
|
{(() => {
|
|
// Request 占用 = 该节点 Pod 已申请 / 可分配。
|
|
// 百分比给直观判断,tooltip 给绝对值(分子/分母一起看才有意义)。 // Logan.Li
|
|
const cpuPct = n.cpu_alloc_pct || 0;
|
|
const memPct = n.mem_alloc_pct || 0;
|
|
const cpuReq = n.cpu_request_m || 0;
|
|
const memReq = n.mem_request_mi || 0;
|
|
const bar = (val: number) => val >= 85 ? 'bg-[var(--dg-red)]' : val >= 60 ? 'bg-[var(--dg-orange)]' : 'bg-[var(--dg-blue)]';
|
|
const txt = (val: number) => val >= 85 ? 'text-[var(--dg-red)]' : val >= 60 ? 'text-[var(--dg-orange)]' : 'text-[var(--dg-text-secondary)]';
|
|
const cpuCap = (n.cpu || 0) * 1000;
|
|
const memCap = n.memory || 0;
|
|
return (
|
|
<div className="flex flex-col gap-1 min-w-[120px]">
|
|
<div className="flex items-center gap-2" title={`CPU 已申请 ${cpuReq}m / 可分配 ${cpuCap}m`}>
|
|
<span className="text-xs text-[var(--dg-text-muted)] w-7">CPU</span>
|
|
<div className="flex-1 h-1.5 rounded-full bg-[var(--dg-bg-tertiary)]/50 overflow-hidden">
|
|
<div className={`h-full rounded-full ${bar(cpuPct)}`} style={{ width: `${Math.min(cpuPct, 100)}%` }} />
|
|
</div>
|
|
<span className={`text-xs w-9 text-right ${txt(cpuPct)}`}>{cpuPct.toFixed(0)}%</span>
|
|
</div>
|
|
<div className="flex items-center gap-2" title={`内存 已申请 ${(memReq / 1024).toFixed(1)}Gi / 可分配 ${(memCap / 1024).toFixed(1)}Gi`}>
|
|
<span className="text-xs text-[var(--dg-text-muted)] w-7">Mem</span>
|
|
<div className="flex-1 h-1.5 rounded-full bg-[var(--dg-bg-tertiary)]/50 overflow-hidden">
|
|
<div className={`h-full rounded-full ${bar(memPct)}`} style={{ width: `${Math.min(memPct, 100)}%` }} />
|
|
</div>
|
|
<span className={`text-xs w-9 text-right ${txt(memPct)}`}>{memPct.toFixed(0)}%</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
</TableCell>
|
|
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{n.pod_count || 0} / {n.max_pods || '-'}</TableCell>
|
|
<TableCell className="text-xs text-[var(--dg-text-muted)]">{n.kubelet_version || '-'}</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</ContentPanel>
|
|
</PageShell>
|
|
);
|
|
}
|