feat(k8s): M14 Namespace 管理看板(资源配额 + 命名空间 CRUD)
- 后端: ListNamespacesDetailed/CreateNamespace/DeleteNamespace/ListResourceQuotas/ListLimitRanges - 后端: GetNamespacesDetail handler (6 API calls aggregate, not N×3) - 后端: CreateNamespaceHandler + DeleteNamespaceHandler (system NS protection) - 前端: NamespaceManagementPage.tsx (~280行) — 表格+概览卡片+创建/删除弹框 - 路由: GET/POST/DELETE /:clusterCode/namespaces + GET /namespace/:ns/detail - 验证: 9/9 通过 (urllib), TS 零错误, Vite 构建成功
This commit is contained in:
1 parent
c7b61968bc
commit
8240a88a84
33 files changed
+2029
-71
No files matched your search
@@ -0,0 +1,336 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
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 { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Table, TableHeader, TableBody, TableRow, TableHead, TableCell,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
RefreshCw, Loader2, ChevronRight, Plus, Trash2, Boxes, Container, Network,
|
||||
Shield, Activity, AlertTriangle, CheckCircle2,
|
||||
} from 'lucide-react';
|
||||
import { kubernetesApi } from '@/services/api';
|
||||
|
||||
interface NamespaceDetail {
|
||||
name: string;
|
||||
status: string;
|
||||
age: string;
|
||||
labels: Record<string, string>;
|
||||
resource_counts: Record<string, number>;
|
||||
has_resource_quota: boolean;
|
||||
has_limit_range: boolean;
|
||||
resource_quotas?: Array<{
|
||||
metadata: { name: string };
|
||||
status: { hard: Record<string, string>; used: Record<string, string> };
|
||||
}>;
|
||||
}
|
||||
|
||||
const SYSTEM_NAMESPACES = new Set([
|
||||
'kube-system', 'kube-public', 'kube-node-lease', 'default', 'devops',
|
||||
]);
|
||||
|
||||
export default function NamespaceManagementPage() {
|
||||
const { clusterCode } = useParams<{ clusterCode: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [namespaces, setNamespaces] = useState<NamespaceDetail[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// Create dialog state
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
// Delete dialog state
|
||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!clusterCode) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await kubernetesApi.getNamespacesDetail(clusterCode);
|
||||
setNamespaces(Array.isArray(data) ? data : []);
|
||||
} catch {
|
||||
setNamespaces([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [clusterCode]);
|
||||
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!clusterCode || !newName.trim()) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
await kubernetesApi.createNamespace(clusterCode, newName.trim());
|
||||
setNewName('');
|
||||
setCreateOpen(false);
|
||||
await loadData();
|
||||
} catch (err: any) {
|
||||
alert(err?.message || '创建失败');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!clusterCode || !deleteTarget) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await kubernetesApi.deleteNamespace(clusterCode, deleteTarget);
|
||||
setDeleteTarget(null);
|
||||
await loadData();
|
||||
} catch (err: any) {
|
||||
alert(err?.message || '删除失败');
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filtered = namespaces.filter(ns =>
|
||||
!searchQuery || ns.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const totalDeploys = namespaces.reduce((s, n) => s + (n.resource_counts?.deployments || 0), 0);
|
||||
const totalPods = namespaces.reduce((s, n) => s + (n.resource_counts?.pods || 0), 0);
|
||||
const totalSvcs = namespaces.reduce((s, n) => s + (n.resource_counts?.services || 0), 0);
|
||||
const quotaCount = namespaces.filter(n => n.has_resource_quota).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 text-sm text-slate-500">
|
||||
<Link to="/kubernetes" className="hover:text-slate-300 transition-colors">集群</Link>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<Link to={`/kubernetes/${clusterCode}`} className="hover:text-slate-300 transition-colors">{clusterCode}</Link>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<span className="text-slate-300">命名空间管理</span>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-100 flex items-center gap-2">
|
||||
<Boxes className="h-6 w-6 text-emerald-400" />
|
||||
命名空间管理
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">命名空间资源配额与生命周期管理</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="icon" className="h-9 w-9 border-slate-700 text-slate-400"
|
||||
onClick={loadData} disabled={loading}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700">
|
||||
<Plus className="h-4 w-4 mr-1" />创建命名空间
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>创建命名空间</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>命名空间名称</Label>
|
||||
<Input
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="例如: my-app"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
|
||||
/>
|
||||
<p className="text-xs text-slate-500">
|
||||
名称需符合 DNS-1123 标准(小写字母/数字/连字符)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>取消</Button>
|
||||
<Button onClick={handleCreate} disabled={creating || !newName.trim()}>
|
||||
{creating ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : null}
|
||||
创建
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-4 flex items-center justify-between">
|
||||
<div><p className="text-xs text-slate-500">命名空间</p><p className="text-xl font-bold text-emerald-400">{namespaces.length}</p></div>
|
||||
<Boxes className="h-6 w-6 opacity-30 text-emerald-400" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-4 flex items-center justify-between">
|
||||
<div><p className="text-xs text-slate-500">Deployments</p><p className="text-xl font-bold text-blue-400">{totalDeploys}</p></div>
|
||||
<Container className="h-6 w-6 opacity-30 text-blue-400" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-4 flex items-center justify-between">
|
||||
<div><p className="text-xs text-slate-500">Pods</p><p className="text-xl font-bold text-purple-400">{totalPods}</p></div>
|
||||
<Activity className="h-6 w-6 opacity-30 text-purple-400" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-4 flex items-center justify-between">
|
||||
<div><p className="text-xs text-slate-500">Services</p><p className="text-xl font-bold text-cyan-400">{totalSvcs}</p></div>
|
||||
<Network className="h-6 w-6 opacity-30 text-cyan-400" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-4 flex items-center justify-between">
|
||||
<div><p className="text-xs text-slate-500">配额限制</p><p className="text-xl font-bold text-amber-400">{quotaCount}</p></div>
|
||||
<Shield className="h-6 w-6 opacity-30 text-amber-400" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="搜索命名空间..."
|
||||
className="max-w-xs bg-slate-900 border-slate-700"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Namespace table */}
|
||||
<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">名称</TableHead>
|
||||
<TableHead className="text-slate-400">状态</TableHead>
|
||||
<TableHead className="text-slate-400">年龄</TableHead>
|
||||
<TableHead className="text-slate-400 text-center">Deploy</TableHead>
|
||||
<TableHead className="text-slate-400 text-center">Pods</TableHead>
|
||||
<TableHead className="text-slate-400 text-center">Svc</TableHead>
|
||||
<TableHead className="text-slate-400 text-center">配额</TableHead>
|
||||
<TableHead className="text-slate-400">标签</TableHead>
|
||||
<TableHead className="text-slate-400 text-center">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow><TableCell colSpan={9} className="text-center text-slate-500 py-12">
|
||||
<Loader2 className="h-5 w-5 animate-spin mx-auto mb-2" />加载中...
|
||||
</TableCell></TableRow>
|
||||
) : filtered.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={9} className="text-center text-slate-500 py-12">
|
||||
<Boxes className="h-8 w-8 mx-auto mb-2 opacity-50" />暂无命名空间
|
||||
</TableCell></TableRow>
|
||||
) : (
|
||||
filtered.map((ns) => {
|
||||
const isSystem = SYSTEM_NAMESPACES.has(ns.name);
|
||||
return (
|
||||
<TableRow key={ns.name} className="border-slate-800 hover:bg-slate-800/30">
|
||||
<TableCell className="font-medium">
|
||||
<button
|
||||
className="text-blue-400 hover:text-blue-300 hover:underline text-left"
|
||||
onClick={() => navigate(`/kubernetes/${clusterCode}/namespace/${ns.name}`)}
|
||||
>
|
||||
{ns.name}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={ns.status === 'Active' ? 'bg-emerald-500/20 text-emerald-400' : 'bg-red-500/20 text-red-400'}>
|
||||
{ns.status === 'Active' ? <CheckCircle2 className="h-3 w-3 mr-1" /> : <AlertTriangle className="h-3 w-3 mr-1" />}
|
||||
{ns.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-slate-400">{ns.age}</TableCell>
|
||||
<TableCell className="text-center text-sm text-slate-300">{ns.resource_counts?.deployments ?? 0}</TableCell>
|
||||
<TableCell className="text-center text-sm text-slate-300">{ns.resource_counts?.pods ?? 0}</TableCell>
|
||||
<TableCell className="text-center text-sm text-slate-300">{ns.resource_counts?.services ?? 0}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{ns.has_resource_quota ? (
|
||||
<Badge className="bg-amber-500/20 text-amber-400" title={JSON.stringify(ns.resource_quotas?.[0]?.status)}>
|
||||
<Shield className="h-3 w-3 mr-1" />有
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-slate-600">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{ns.labels && Object.keys(ns.labels).length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1 max-w-[200px]">
|
||||
{Object.entries(ns.labels).slice(0, 3).map(([k, v]) => (
|
||||
<span key={k} className="px-1.5 py-0.5 bg-slate-800 rounded text-slate-400">{k}={v}</span>
|
||||
))}
|
||||
{Object.keys(ns.labels).length > 3 && (
|
||||
<span className="text-slate-600">+{Object.keys(ns.labels).length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-slate-600">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{isSystem ? (
|
||||
<span className="text-xs text-slate-600">系统</span>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost" size="icon"
|
||||
className="h-7 w-7 text-red-400 hover:text-red-300 hover:bg-red-500/10"
|
||||
onClick={() => setDeleteTarget(ns.name)}
|
||||
title="删除命名空间"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<Dialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-red-400 flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5" />删除命名空间
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 py-4">
|
||||
<div className="bg-red-500/10 border border-red-500/30 rounded-lg p-3">
|
||||
<p className="text-sm text-red-300">
|
||||
⚠️ 即将删除命名空间 <span className="font-bold">{deleteTarget}</span> 及其中所有资源。
|
||||
</p>
|
||||
<p className="text-xs text-red-400/70 mt-1">
|
||||
此操作不可逆,所有 Deployment/Pod/Service/ConfigMap 等资源将被永久删除。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteTarget(null)}>取消</Button>
|
||||
<Button variant="destructive" onClick={handleDelete} disabled={deleting}>
|
||||
{deleting ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : null}
|
||||
确认删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user