优化 K8s 页面布局与 local 模式部署收尾
P0-P1: - Dockerfile.embed 启用 CGO 支持 SQLite+redka - K8s scale>1 时警告 local 模式数据不共享 K8s UI: - 移除侧边栏资源用量/容量分析及资源页横向 Tab 栏 - 修复事件路由(/workloads/events)与 Deployment 事件过滤 - 网络/配置/存储统一 YamlViewer 编辑,加大编辑弹窗
This commit is contained in:
1 parent
859db427a4
commit
339b24a579
12 files changed
+87
-90
No files matched your search
@@ -30,6 +30,7 @@ const KubernetesNodes = lazy(() => import('@/components/kubernetes/KubernetesNod
|
||||
const PodMetricsPage = lazy(() => import('@/components/kubernetes/PodMetricsPage').then(m => ({ default: m.default })));
|
||||
const EventsPage = lazy(() => import('@/components/kubernetes/EventsPage').then(m => ({ default: m.default })));
|
||||
const CapacityPage = lazy(() => import('@/components/kubernetes/CapacityPage').then(m => ({ default: m.default })));
|
||||
const K8sShortcutRedirect = lazy(() => import('@/components/kubernetes/K8sShortcutRedirect').then(m => ({ default: m.default })));
|
||||
const AlertManager = lazy(() => import('@/components/monitor/AlertManager').then(m => ({ default: m.AlertManager })));
|
||||
const ApprovalFlowManager = lazy(() => import('@/components/approval/ApprovalFlow').then(m => ({ default: m.ApprovalFlowManager })));
|
||||
const ApprovalResourcePage = lazy(() => import('@/pages/ApprovalResourcePage').then(m => ({ default: m.ApprovalResourcePage })));
|
||||
@@ -154,6 +155,7 @@ function App() {
|
||||
|
||||
{/* Quick Access Routes (redirect to first cluster) */}
|
||||
<Route path="workloads" element={<LazyPage><NamespaceResourcePage /></LazyPage>} />
|
||||
<Route path="workloads/events" element={<LazyPage><K8sShortcutRedirect target="events" /></LazyPage>} />
|
||||
<Route path="workloads/:type" element={<LazyPage><NamespaceResourcePage /></LazyPage>} />
|
||||
<Route path="nodes" element={<LazyPage><KubernetesNodes /></LazyPage>} />
|
||||
<Route path="topology" element={<LazyPage><TopologyView /></LazyPage>} />
|
||||
|
||||
@@ -104,17 +104,20 @@ export function DeploymentView(props: DeploymentViewProps) {
|
||||
setTabLoading('events');
|
||||
kubernetesApi.getEvents(clusterCode, namespace, undefined).then((res: any) => {
|
||||
const allEvents = Array.isArray(res) ? res : (res?.list || res?.items || res?.data || []);
|
||||
const depName = deployment?.name || '';
|
||||
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 + '-');
|
||||
const obj = String(e.object || '');
|
||||
if (!depName || !obj) return false;
|
||||
if (obj.endsWith(`/${depName}`)) return true;
|
||||
const name = obj.split('/').pop() || '';
|
||||
return name === depName || name.startsWith(`${depName}-`);
|
||||
}).map((e: any) => ({
|
||||
type: e.type || 'Normal',
|
||||
reason: e.reason || '',
|
||||
message: e.message || '',
|
||||
source: (e.source?.component || e.sourceComponent || '-'),
|
||||
firstTimestamp: e.firstTimestamp || e.first_timestamp || '',
|
||||
lastTimestamp: e.lastTimestamp || e.last_timestamp || '',
|
||||
source: e.object || e.source?.component || e.sourceComponent || '-',
|
||||
firstTimestamp: e.first_time || e.firstTimestamp || e.first_timestamp || '',
|
||||
lastTimestamp: e.last_time || e.lastTimestamp || e.last_timestamp || '',
|
||||
count: e.count || 1,
|
||||
}));
|
||||
setEvents(depEvents);
|
||||
|
||||
@@ -85,6 +85,14 @@ export default function EventsPage() {
|
||||
// Filter events by selected reason/kind from aggregation click
|
||||
const displayEvents = useMemo(() => {
|
||||
let filtered = events;
|
||||
const hours = parseInt(timeWindow, 10);
|
||||
if (hours > 0) {
|
||||
const since = Date.now() - hours * 3600 * 1000;
|
||||
filtered = filtered.filter((e) => {
|
||||
const ts = e.last_time || e.lastTime || e.last_timestamp;
|
||||
return !ts || new Date(ts).getTime() >= since;
|
||||
});
|
||||
}
|
||||
if (selectedReason) {
|
||||
filtered = filtered.filter(e => e.reason === selectedReason);
|
||||
}
|
||||
@@ -92,7 +100,7 @@ export default function EventsPage() {
|
||||
filtered = filtered.filter(e => (e.object || '').startsWith(selectedKind + '/'));
|
||||
}
|
||||
return filtered;
|
||||
}, [events, selectedReason, selectedKind]);
|
||||
}, [events, selectedReason, selectedKind, timeWindow]);
|
||||
|
||||
const warningCount = useMemo(() => events.filter(e => e.type === 'Warning').length, [events]);
|
||||
const normalCount = useMemo(() => events.filter(e => e.type === 'Normal').length, [events]);
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// K8sShortcutRedirect — 侧边栏快捷入口跳转到首个集群 // Logan.Li
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { kubernetesApi } from '@/api';
|
||||
|
||||
interface Props {
|
||||
/** 集群下路径,如 events、namespace/default/deployments */
|
||||
target: string;
|
||||
}
|
||||
|
||||
export default function K8sShortcutRedirect({ target }: Props) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
kubernetesApi.getClusters()
|
||||
.then((res: any) => {
|
||||
const list = res?.list || [];
|
||||
if (list.length > 0) {
|
||||
const code = list[0].code || String(list[0].id);
|
||||
navigate(`/kubernetes/${code}/${target}`, { replace: true });
|
||||
} else {
|
||||
navigate('/kubernetes', { replace: true });
|
||||
}
|
||||
})
|
||||
.catch(() => navigate('/kubernetes', { replace: true }));
|
||||
}, [navigate, target]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-slate-500 gap-2">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
<span className="text-sm">正在跳转...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
Download, FileCode, Pencil, Terminal, HardDrive,
|
||||
} from 'lucide-react';
|
||||
import { YamlViewer } from './YamlViewer';
|
||||
import { ResourceVisualEditor } from './ResourceVisualEditor';
|
||||
import { PodExecTerminal } from './PodExecTerminal';
|
||||
import { PodFileBrowser } from './PodFileBrowser';
|
||||
import { kubernetesApi } from '@/api';
|
||||
@@ -106,10 +105,8 @@ const RESOURCE_CONFIG: Record<ResourceType, {
|
||||
<TableCell className="text-sm text-slate-400">{s.cluster_ip || '-'}</TableCell>
|
||||
<TableCell className="text-sm text-slate-400">{s.ports || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400" title="查看YAML" onClick={() => opts.onYaml?.(s.name)}><FileCode className="h-3.5 w-3.5" /></Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-emerald-400 hover:text-emerald-300" title="编辑" onClick={() => opts.onEdit?.(s.name)}><Pencil className="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-blue-400 hover:text-blue-300" title="YAML 编辑"
|
||||
onClick={() => opts.onEdit?.(s.name)}><FileCode className="h-3.5 w-3.5 mr-1" />YAML</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
),
|
||||
@@ -126,10 +123,8 @@ const RESOURCE_CONFIG: Record<ResourceType, {
|
||||
<TableCell className="text-sm text-slate-400 max-w-[250px] truncate">{hosts.length > 0 ? hosts.join(', ') : '-'}</TableCell>
|
||||
<TableCell><Badge className={i.tls ? 'bg-emerald-500/20 text-emerald-400' : 'bg-slate-500/20 text-slate-400'}>{i.tls ? 'HTTPS' : 'HTTP'}</Badge></TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400" title="查看YAML" onClick={() => opts.onYaml?.(i.name)}><FileCode className="h-3.5 w-3.5" /></Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-emerald-400 hover:text-emerald-300" title="编辑" onClick={() => opts.onEdit?.(i.name)}><Pencil className="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-blue-400 hover:text-blue-300" title="YAML 编辑"
|
||||
onClick={() => opts.onEdit?.(i.name)}><FileCode className="h-3.5 w-3.5 mr-1" />YAML</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
@@ -144,12 +139,8 @@ const RESOURCE_CONFIG: Record<ResourceType, {
|
||||
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{c.namespace}</Badge></TableCell>
|
||||
<TableCell className="text-sm text-slate-400">{c.key_count || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400" title="查看YAML"
|
||||
onClick={() => opts.onYaml?.(c.name)}><FileCode className="h-3.5 w-3.5" /></Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-emerald-400 hover:text-emerald-300" title="编辑"
|
||||
onClick={() => opts.onEdit?.(c.name)}><Pencil className="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-blue-400 hover:text-blue-300" title="YAML 编辑"
|
||||
onClick={() => opts.onEdit?.(c.name)}><FileCode className="h-3.5 w-3.5 mr-1" />YAML</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
),
|
||||
@@ -163,12 +154,8 @@ const RESOURCE_CONFIG: Record<ResourceType, {
|
||||
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{s.namespace}</Badge></TableCell>
|
||||
<TableCell className="text-sm text-slate-400">{s.type || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400" title="查看YAML"
|
||||
onClick={() => opts.onYaml?.(s.name)}><FileCode className="h-3.5 w-3.5" /></Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-emerald-400 hover:text-emerald-300" title="编辑"
|
||||
onClick={() => opts.onEdit?.(s.name)}><Pencil className="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-blue-400 hover:text-blue-300" title="YAML 编辑"
|
||||
onClick={() => opts.onEdit?.(s.name)}><FileCode className="h-3.5 w-3.5 mr-1" />YAML</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
),
|
||||
@@ -211,10 +198,8 @@ const RESOURCE_CONFIG: Record<ResourceType, {
|
||||
<TableCell className="text-sm text-slate-400">{p.size || '-'}</TableCell>
|
||||
<TableCell className="text-sm text-slate-400">{p.storage_class || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400" title="查看YAML" onClick={() => opts.onYaml?.(p.name)}><FileCode className="h-3.5 w-3.5" /></Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-emerald-400 hover:text-emerald-300" title="编辑" onClick={() => opts.onEdit?.(p.name)}><Pencil className="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-blue-400 hover:text-blue-300" title="YAML 编辑"
|
||||
onClick={() => opts.onEdit?.(p.name)}><FileCode className="h-3.5 w-3.5 mr-1" />YAML</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
),
|
||||
@@ -353,7 +338,7 @@ export default function NamespaceResourcePage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [yamlItem, setYamlItem] = useState<{resource: string; name: string; editMode?: boolean} | null>(null);
|
||||
const [visualEditItem, setVisualEditItem] = useState<{resource: string; name: string} | null>(null);
|
||||
|
||||
const [terminalPod, setTerminalPod] = useState<any>(null);
|
||||
const [fileBrowserPod, setFileBrowserPod] = useState<any>(null);
|
||||
|
||||
@@ -420,34 +405,11 @@ export default function NamespaceResourcePage() {
|
||||
};
|
||||
|
||||
const handleYaml = (name: string) => setYamlItem({ resource: type, name });
|
||||
const handleEdit = (name: string) => {
|
||||
// 使用可视化编辑器的资源类型
|
||||
if (['configmaps', 'secrets', 'services', 'ingresses', 'pvcs'].includes(type)) {
|
||||
setVisualEditItem({ resource: type, name });
|
||||
} else {
|
||||
setYamlItem({ resource: type, name, editMode: true });
|
||||
}
|
||||
};
|
||||
// 网络/配置/存储统一走 YamlViewer 编辑(清理只读字段 + CodeMirror)// Logan.Li
|
||||
const handleEdit = (name: string) => setYamlItem({ resource: type, name, editMode: true });
|
||||
const handleExportCSV = () => kubernetesApi.exportAsCSV(`${type}-${clusterCode}-${namespace}.csv`, filtered);
|
||||
const handleExportJSON = () => kubernetesApi.exportAsJSON(`${type}-${clusterCode}-${namespace}.json`, filtered);
|
||||
|
||||
const sidebarResources = useMemo(() => [
|
||||
{ type: 'deployments' as ResourceType, label: 'Deployments', icon: Boxes },
|
||||
{ type: 'statefulsets' as ResourceType, label: 'StatefulSets', icon: Boxes },
|
||||
{ type: 'daemonsets' as ResourceType, label: 'DaemonSets', icon: Boxes },
|
||||
{ type: 'pods' as ResourceType, label: 'Pods', icon: Container },
|
||||
{ type: 'cronjobs' as ResourceType, label: 'CronJobs', icon: Clock },
|
||||
{ type: 'services' as ResourceType, label: 'Services', icon: Network },
|
||||
{ type: 'ingresses' as ResourceType, label: 'Ingresses', icon: Network },
|
||||
{ type: 'configmaps' as ResourceType, label: 'ConfigMaps', icon: FileText },
|
||||
{ type: 'secrets' as ResourceType, label: 'Secrets', icon: Shield },
|
||||
{ type: 'hpas' as ResourceType, label: 'HPAs', icon: Activity },
|
||||
{ type: 'pvcs' as ResourceType, label: '存储卷', icon: Boxes },
|
||||
{ type: 'serviceaccounts' as ResourceType, label: 'ServiceAccounts', icon: Shield },
|
||||
{ type: 'roles' as ResourceType, label: 'Roles', icon: Shield },
|
||||
{ type: 'rolebindings' as ResourceType, label: 'RoleBindings', icon: Shield },
|
||||
], []);
|
||||
|
||||
if (!config) return <div className="text-slate-400 p-8 text-center">未知资源类型: {type}</div>;
|
||||
|
||||
return (
|
||||
@@ -464,16 +426,7 @@ export default function NamespaceResourcePage() {
|
||||
autoEdit={yamlItem.editMode === true}
|
||||
/>
|
||||
)}
|
||||
{visualEditItem && (
|
||||
<ResourceVisualEditor
|
||||
clusterCode={clusterCode || ''}
|
||||
namespace={namespace || ''}
|
||||
resource={visualEditItem.resource}
|
||||
name={visualEditItem.name}
|
||||
onClose={() => setVisualEditItem(null)}
|
||||
onSaved={loadData}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-slate-500">
|
||||
<Link to="/kubernetes" className="hover:text-slate-300 transition-colors">集群</Link>
|
||||
{clusterCode && <><ChevronRight className="h-3 w-3" /><Link to={`/kubernetes/${clusterCode}`} className="hover:text-slate-300 transition-colors">{clusterCode}</Link></>}
|
||||
@@ -505,18 +458,12 @@ export default function NamespaceResourcePage() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 bg-slate-900/50 border border-slate-800 rounded-lg p-1 overflow-x-auto">
|
||||
{sidebarResources.map((r) => (
|
||||
<Link key={r.type} to={`/kubernetes/${clusterCode}/namespace/${namespace}/${r.type}`}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md transition-colors whitespace-nowrap ${r.type === type ? 'bg-blue-600/20 text-blue-400' : 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'}`}>
|
||||
<r.icon className="h-3 w-3" />{r.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<div className="relative w-56">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="relative w-56 flex-1 min-w-[200px] max-w-xs">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
||||
<Input placeholder="搜索..." value={search} onChange={(e) => setSearch(e.target.value)} className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200" />
|
||||
</div>
|
||||
</div>
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
|
||||
@@ -183,12 +183,12 @@ export function YamlViewer({ clusterCode, namespace, resource, name, onClose, on
|
||||
// 弹窗模式(原有行为)
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
|
||||
<div className="bg-slate-900 border border-slate-700 rounded-xl w-[90vw] max-w-4xl max-h-[85vh] flex flex-col shadow-2xl" onClick={e => e.stopPropagation()}>
|
||||
<div className="bg-slate-900 border border-slate-700 rounded-xl w-[92vw] max-w-5xl h-[88vh] flex flex-col shadow-2xl" onClick={e => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-slate-700">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-slate-100">{resource}/{name}</h2>
|
||||
<p className="text-xs text-slate-500">{namespace} · {clusterCode}</p>
|
||||
<h2 className="text-lg font-bold text-slate-100">{editing ? 'YAML 编辑' : 'YAML 查看'} — {resource}/{name}</h2>
|
||||
<p className="text-xs text-slate-500">{namespace} · {clusterCode}{editing ? ' · 已清理只读字段' : ''}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" className="h-8 border-slate-700 text-slate-300" onClick={copyYaml}>
|
||||
@@ -229,13 +229,13 @@ export function YamlViewer({ clusterCode, namespace, resource, name, onClose, on
|
||||
</div>
|
||||
</div>
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
<div className="flex-1 min-h-0 overflow-hidden p-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full"><Loader2 className="h-6 w-6 animate-spin text-slate-500" /></div>
|
||||
) : (
|
||||
<CodeMirror
|
||||
value={display}
|
||||
height="600px"
|
||||
height="100%"
|
||||
theme={oneDark}
|
||||
extensions={extensions}
|
||||
onChange={(val) => editing && setEditYaml(val)}
|
||||
@@ -249,7 +249,7 @@ export function YamlViewer({ clusterCode, namespace, resource, name, onClose, on
|
||||
closeBrackets: true,
|
||||
indentOnInput: true,
|
||||
}}
|
||||
style={{ fontSize: '12px' }}
|
||||
style={{ fontSize: '13px', height: '100%' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -36,7 +36,7 @@ export function EventsTab({ events, loading }: { events: EventItem[]; loading: s
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{e.reason}</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-primary)] max-w-md truncate" title={e.message}>{e.message}</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-primary)] max-w-md whitespace-normal break-words">{e.message}</TableCell>
|
||||
<TableCell className="text-xs text-[var(--dg-text-muted)]">{e.source}</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-muted)]">{e.count}</TableCell>
|
||||
<TableCell className="text-xs text-[var(--dg-text-muted)]">{formatK8sTime(e.lastTimestamp)}</TableCell>
|
||||
|
||||
@@ -27,9 +27,8 @@ const ROUTE_NAME_MAP: Record<string, string> = {
|
||||
'secrets': 'Secrets',
|
||||
'hpas': 'HPA',
|
||||
'pvcs': '存储',
|
||||
'metrics': '资源用量',
|
||||
|
||||
'events': '事件',
|
||||
'capacity': '容量分析',
|
||||
'topology': '架构拓扑',
|
||||
'namespace': '命名空间',
|
||||
'workload': '工作负载',
|
||||
|
||||
@@ -98,9 +98,7 @@ const menuItems: MenuItem[] = [
|
||||
],
|
||||
},
|
||||
{ id: 'k8s-storage', name: '存储', icon: Database, path: '/workloads/pvcs', permission: 'k8s:workloads' },
|
||||
{ id: 'k8s-metrics', name: '资源用量', icon: Activity, path: '/workloads/metrics', permission: 'k8s:workloads' },
|
||||
{ id: 'k8s-events', name: '事件', icon: Bell, path: '/workloads/events', permission: 'k8s:workloads' },
|
||||
{ id: 'k8s-capacity', name: '容量分析', icon: BarChart3, path: '/workloads/capacity', permission: 'k8s:workloads' },
|
||||
{ id: 'k8s-topology', name: '架构拓扑', icon: Network, path: '/topology', permission: 'k8s:topology' },
|
||||
],
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user