feat: 新增日志中心、功能优化与代码重构

- 新增 log_datasource.go / log_proxy.go / user_api_token.go 后端控制器
- 新增 log_datasource.go model
- 新增 monitor/log-center.ts API 模块
- 新增 LogCenter 前端组件及页面
- 重构 deployment 相关组件 (HPDA/UpdateImageModal/bundle-yaml 等)
- 新增 ResetPasswordDialog / code-snippet-block / copy-button 等 UI 组件
- 重构多个 k8s 相关组件 (TopologyView/ResourceVisualEditor/PodFileBrowser 等)
- 更新多个 API 和工具函数 (auth.ts/monitor/index.ts/request.ts 等)
- 更新静态资源 (index.html + 新 JS/CSS bundle)
- 删除废弃静态资源文件
This commit is contained in:
Logan committed 2026-06-26 23:18:05 +08:00
1 parent 5ca0e6d83a
commit 8d46e8f352
197 files changed
+5014 -815

No files matched your search

@@ -7,6 +7,7 @@
// 作者: Logan <admin@attacker.club>
import { useParams, useNavigate } from 'react-router-dom';
import { DeploymentWorkload } from './DeploymentWorkload';
import { isCreateDeployment } from './deployment-constants';
export type WorkloadMode = 'view' | 'edit' | 'yaml';
@@ -23,7 +24,9 @@ export default function DeploymentDetailPage() {
const clusterCode = params.clusterCode || '';
const namespace = params.namespace || '';
const deploymentName = params.name || '';
const currentMode: WorkloadMode = (params.mode === 'edit' || params.mode === 'yaml') ? params.mode : 'view';
const currentMode: WorkloadMode = isCreateDeployment(deploymentName)
? 'edit'
: ((params.mode === 'edit' || params.mode === 'yaml') ? params.mode : 'view');
// 模式切换:在 view/edit/yaml 路由间跳转
const handleModeChange = (m: WorkloadMode) => {
@@ -18,6 +18,7 @@ interface DeploymentEditBasicProps {
manifest: any | null;
onSaved: (updated?: any) => void;
onCancel: () => void;
isCreate?: boolean;
}
/** 将 metadata.labels 对象转为 key-value 数组 */
@@ -36,11 +37,12 @@ function kvToObj(arr: Array<{ key: string; value: string }>): Record<string, str
return out;
}
export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, manifest, onSaved, onCancel }: DeploymentEditBasicProps) {
export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, manifest, onSaved, onCancel, isCreate = false }: DeploymentEditBasicProps) {
const { toast } = useToast();
const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(!manifest);
const [rawManifest, setRawManifest] = useState<any>(manifest || {});
const [resName, setResName] = useState(manifest?.metadata?.name || '');
const [replicas, setReplicas] = useState(1);
const [labels, setLabels] = useState<Array<{ key: string; value: string }>>([]);
const [annotations, setAnnotations] = useState<Array<{ key: string; value: string }>>([]);
@@ -51,6 +53,7 @@ export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, ma
const m = manifest;
if (!m) { setLoading(false); return; }
setRawManifest(m);
setResName(m?.metadata?.name || '');
setReplicas(m?.spec?.replicas ?? 1);
setLabels(objToKv(m?.metadata?.labels));
setAnnotations(objToKv(m?.metadata?.annotations));
@@ -101,6 +104,12 @@ export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, ma
base.spec.replicas = replicas;
const labelObj = kvToObj(labels);
base.metadata = base.metadata || {};
if (isCreate) {
const trimmed = resName.trim();
if (!trimmed) throw new Error('名称不能为空');
base.metadata.name = trimmed;
base.metadata.namespace = namespace;
}
base.metadata.labels = Object.keys(labelObj).length > 0 ? labelObj : undefined;
const annObj = kvToObj(annotations);
base.metadata.annotations = Object.keys(annObj).length > 0 ? annObj : undefined;
@@ -117,8 +126,8 @@ export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, ma
} else {
delete base.spec.template.metadata.labels;
}
await saveDeploymentManifest(clusterCode, namespace, deploymentName, base);
toast({ title: '基本信息已保存', description: `${deploymentName} 更新已提交` });
const savedName = await saveDeploymentManifest(clusterCode, namespace, deploymentName, base);
toast({ title: isCreate ? '创建成功' : '基本信息已保存', description: isCreate ? `${savedName} 已创建` : `${savedName} 更新已提交` });
onSaved(base);
} catch (e) {
toast({ title: '保存失败', description: e?.message || String(e), variant: 'destructive' });
@@ -170,19 +179,23 @@ export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, ma
</CardContent>
</Card>
{/* 基本标识(只读) */}
{/* 基本标识 */}
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-slate-200 flex items-center gap-2">
<Lock className="h-4 w-4 text-slate-500" />
基本标识(只读)
{isCreate ? '基本标识' : '基本标识(只读)'}
</CardTitle>
</CardHeader>
<CardContent className="pt-0 grid grid-cols-2 gap-4">
<div>
<Label className="text-slate-400 text-xs">名称 (name)</Label>
<Input value={deploymentName} readOnly
className="mt-1 bg-slate-950 border-slate-800 text-slate-500 font-mono text-sm cursor-not-allowed" />
<Input
value={isCreate ? resName : deploymentName}
readOnly={!isCreate}
onChange={e => isCreate && setResName(e.target.value)}
className={`mt-1 font-mono text-sm ${isCreate ? 'bg-slate-800 border-slate-700 text-slate-200' : 'bg-slate-950 border-slate-800 text-slate-500 cursor-not-allowed'}`}
/>
</div>
<div>
<Label className="text-slate-400 text-xs">命名空间 (namespace)</Label>
@@ -198,7 +198,7 @@ export function DeploymentView(props: DeploymentViewProps) {
)}
<InfoItem label="创建时间" value={formatK8sTime(deployment?.created_at || deployment?.creationTimestamp)} />
</div>
{pods[0] && (
{pods.length === 1 && (
<div className="mt-3 pt-3 border-t border-[var(--dg-border)]">
<PodConditionsRow pod={pods[0]} />
</div>
@@ -249,7 +249,7 @@ export function DeploymentView(props: DeploymentViewProps) {
</div>
{/* Tab Content */}
{tab === 'pods' && <PodsTab {...props} expandedPod={expandedPod} setExpandedPod={setExpandedPod} />}
{tab === 'pods' && <PodsTab {...props} onDeletePod={props.onDeletePod} expandedPod={expandedPod} setExpandedPod={setExpandedPod} />}
{tab === 'services' && <ServicesTab services={services} ingresses={ingresses} loading={tabLoading} namespace={namespace} />}
{tab === 'events' && <EventsTab events={events} loading={tabLoading} />}
{tab === 'scale' && (
@@ -2,7 +2,7 @@
// - 顶部操作菜单: 编辑 | 预览/编辑YAML | 调整镜像版本 | 伸缩 | 日志/终端 | 重启 | 暂停/恢复更新 | CI/CD发布 | 删除 | 元数据 | 运行时 | 服务 | 应用路由
// - 三模式: view(只读增强看板)/ edit(可视化编辑器)/ yaml(YAML编辑器)
// 作者: Logan.Li
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -26,6 +26,7 @@ import { useToast } from '@/hooks/use-toast';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { useUserStore } from '@/store';
import { YamlViewer } from './YamlViewer';
import { loadDeploymentBundleYaml, saveDeploymentBundleYaml } from './deployment-bundle-yaml';
import { DeploymentView } from './DeploymentView';
import { PodExecTerminal } from './PodExecTerminal';
import { PodFileBrowser } from './PodFileBrowser';
@@ -34,11 +35,15 @@ import { CICDWebhookContent } from './deployment-workload/CICDWebhookContent';
import { ModalShell } from './deployment-workload/ModalShell';
import { PodLogsModal } from './deployment-workload/PodLogsModal';
import { ActionModals } from './deployment-workload/ActionModals';
import { createEmptyManifest } from './resource-create-templates';
import { CREATE_DEPLOYMENT_SLUG, isCreateDeployment } from './deployment-constants';
import { extractContainers } from './deployment-detail/helpers';
import type { ActionModal, DeploymentWorkloadProps } from './deployment-workload/types';
export type { DeploymentWorkloadProps, WorkloadMode } from './deployment-workload/types';
export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mode, onBack, onModeChange }: DeploymentWorkloadProps) {
const isCreate = isCreateDeployment(deploymentName);
const navigate = useNavigate();
const { toast } = useToast();
const confirm = useConfirm();
@@ -58,9 +63,30 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
const [fileBrowserPod, setFileBrowserPod] = useState<string | null>(null);
const clusterId = deployment?.cluster_id || deployment?.clusterId || clusterCode;
// CI/CD curl 示例:镜像与主容器名与当前 Deployment 对齐 // Logan.Li
const cicdContainerName = useMemo(() => {
const fromManifest = manifest?.spec?.template?.spec?.containers?.[0]?.name;
if (fromManifest) return fromManifest as string;
if (pods.length > 0) {
const names = extractContainers(pods[0]);
if (names.length) return names[0];
}
return '';
}, [manifest, pods]);
const toArray = ( v: any) => (Array.isArray(v) ? v : (v?.list || v?.items || v?.data || []));
const loadData = useCallback(async () => {
if (isCreate) {
const empty = createEmptyManifest('deployments', namespace, 'my-app');
setDeployment({ name: 'my-app', namespace, replicas: 1 });
setManifest(empty);
setPods([]);
setReplicaSets([]);
setRevisions([]);
setLoading(false);
return;
}
setLoading(true);
try {
const [dep, podRes, rsRes, revRes] = await Promise.all([
@@ -94,10 +120,11 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
} finally {
setLoading(false);
}
}, [clusterCode, namespace, deploymentName]);
}, [clusterCode, namespace, deploymentName, isCreate]);
// 加载 manifest(用于 edit 模式 + YAML 编辑)
const loadManifest = useCallback(async () => {
if (isCreate) return;
try {
const yamlText = await kubernetesApi.getResourceYaml(clusterCode, namespace, 'deployments', deploymentName);
const { parseEditableManifest } = await import('./k8s-manifest-utils');
@@ -109,8 +136,21 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
}
}, [clusterCode, namespace, deploymentName, deployment]);
const loadBundleYaml = useCallback(
() => loadDeploymentBundleYaml(clusterCode, namespace, deploymentName),
[clusterCode, namespace, deploymentName],
);
const saveBundleYaml = useCallback(
(yaml: string) => saveDeploymentBundleYaml(clusterCode, namespace, yaml),
[clusterCode, namespace],
);
useEffect(() => { loadData(); }, [loadData]);
useEffect(() => { if (mode === 'edit' && !manifest) loadManifest(); }, [mode, manifest, loadManifest]);
useEffect(() => {
if (isCreate || mode !== 'edit' || manifest) return;
loadManifest();
}, [mode, manifest, loadManifest, isCreate]);
// ============ 操作 ============
const _handleScale = async (delta: number) => {
@@ -178,8 +218,9 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
} finally { setActionLoading(''); }
};
const handleCICD = () => {
// 打开 Webhook 弹窗(不跳转流水线)
const handleCICD = async () => {
// 打开 Webhook 弹窗前拉取 manifest,以获取准确容器名 // Logan.Li
if (!isCreate && !manifest) await loadManifest();
setModal({ kind: 'cicd' });
};
@@ -187,7 +228,9 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
if (!newImage.trim()) return;
setActionLoading('image');
try {
await kubernetesApi.updateDeploymentImage(clusterCode, namespace, deploymentName, newImage);
await kubernetesApi.updateDeploymentImage(
clusterCode, namespace, deploymentName, newImage, cicdContainerName || undefined,
);
toast({ title: '镜像更新成功', description: `正在滚动更新为 ${newImage}` });
setModal(null);
loadData();
@@ -223,6 +266,22 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
setFileBrowserPod(pod.name);
};
const handleDeletePod = async (pod: any) => {
const ok = await confirm({
title: '删除 Pod',
description: `确认删除 Pod "${pod.name}"?操作不可恢复。`,
confirmText: '删除',
});
if (!ok) return;
try {
await kubernetesApi.deletePod(clusterCode, namespace, pod.name);
toast({ title: 'Pod 已删除' });
loadData();
} catch (e) {
toast({ title: '删除失败', description: (e as any)?.message || String(e), variant: 'destructive' });
}
};
// ============ 渲染 ============
if (loading) {
return (
@@ -235,7 +294,7 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
);
}
if (!deployment) {
if (!deployment && !isCreate) {
return (
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-12 text-center">
@@ -247,6 +306,37 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
);
}
if (isCreate) {
const handleCreated = (updated?: any) => {
const name = updated?.metadata?.name;
if (name) {
navigate(`/kubernetes/${clusterCode}/namespace/${namespace}/workload/view/Deployment/${name}`);
}
};
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Button variant="ghost" size="sm" className="text-slate-400" onClick={onBack}>
<ArrowLeft className="h-4 w-4 mr-1" />返回
</Button>
<div>
<h1 className="text-xl font-bold text-slate-100">新建 Deployment</h1>
<p className="text-xs text-slate-500 mt-0.5">{namespace} · {clusterCode}</p>
</div>
</div>
<EditModeContainer
clusterCode={clusterCode}
namespace={namespace}
deploymentName={CREATE_DEPLOYMENT_SLUG}
manifest={manifest}
isCreate
onSaved={handleCreated}
onCancel={onBack}
/>
</div>
);
}
const ready = deployment.ready_replicas || deployment.readyReplicas || 0;
const total = deployment.replicas || 0;
const isHealthy = ready === total && total > 0;
@@ -314,7 +404,15 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
{/* 操作按钮行 */}
<div className="flex items-center gap-1.5">
<Button variant="outline" size="sm" className="border-slate-700 text-slate-300" onClick={() => setModal({ kind: 'image' })}>
<Button
variant="outline"
size="sm"
className="border-slate-700 text-slate-300"
onClick={() => {
setNewImage(deployment?.image || '');
setModal({ kind: 'image' });
}}
>
<ImageIcon className="h-3.5 w-3.5 mr-1" />调整镜像
</Button>
<Button variant="outline" size="sm" className="border-slate-700 text-slate-300" onClick={handleRestart}>
@@ -355,6 +453,7 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
onShowPodTerminal={handleShowPodTerminal}
onShowPodYaml={handleShowPodYaml}
onShowPodFileBrowser={handleShowPodFileBrowser}
onDeletePod={handleDeletePod}
/>
) : mode === 'yaml' ? (
/* YAML 编辑模式 — 内联 CodeMirror 编辑器 */
@@ -364,6 +463,9 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
resource="deployments"
name={deploymentName}
inline
resourceHint="含关联 Service"
loadYamlFn={loadBundleYaml}
saveYamlFn={saveBundleYaml}
onSaved={loadData}
onClose={() => onModeChange('view')}
/>
@@ -386,6 +488,9 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
namespace={namespace}
resource="deployments"
name={deploymentName}
resourceHint="含关联 Service"
loadYamlFn={loadBundleYaml}
saveYamlFn={saveBundleYaml}
onClose={() => setModal(null)}
onSaved={loadData}
/>
@@ -409,8 +514,14 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
{/* ============ CI/CD Webhook 弹窗 ============ */}
{modal?.kind === 'cicd' && (
<ModalShell title="CI/CD Webhook" onClose={() => setModal(null)}>
<CICDWebhookContent clusterCode={clusterCode} namespace={namespace} deploymentName={deploymentName} />
<ModalShell title="CI/CD Webhook" onClose={() => setModal(null)} size="lg">
<CICDWebhookContent
clusterCode={clusterCode}
namespace={namespace}
deploymentName={deploymentName}
image={deployment?.image || newImage || ''}
containerName={cicdContainerName}
/>
</ModalShell>
)}
@@ -3,7 +3,7 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/com
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Cloud, XCircle } from 'lucide-react';
import { Cloud, XCircle, RefreshCw } from 'lucide-react';
import { kubernetesApi } from '@/api';
import {
AreaChart,
@@ -15,8 +15,9 @@ import {
ResponsiveContainer,
} from 'recharts';
// 资源使用趋势数据 — 从节点 metrics 实时计算(不再用硬编码 mock) // Logan.Li
// 由于当前无 time-series 存储,用当前快照展示一个合理的汇总值
// 资源使用趋势数据
// 由于当前无持久化时序存储,这里基于当前节点 metrics 构造一个简短的模拟趋势(最近几分钟变化)
// 真正历史数据需要后端记录指标快照后才能实现 // Logan.Li
export function KubernetesClusterDetail({ cluster, onClose }: { cluster: any; onClose: () => void }) {
const [nodes, setNodes] = useState<any[]>([]);
@@ -50,6 +51,21 @@ export function KubernetesClusterDetail({ cluster, onClose }: { cluster: any; on
};
}, [nodes]);
// 构造趋势数据(多点),避免单点导致“光点”效果 // Logan.Li
const trendData = useMemo(() => {
const cpu = Math.max(0, Math.min(100, Number(clusterMetrics.cpuPct.toFixed(1))));
const mem = Math.max(0, Math.min(100, Number(clusterMetrics.memPct.toFixed(1))));
// 模拟最近 5 个时间点的趋势(基于当前值轻微回溯)
return [
{ time: '10m前', cpu: Math.max(0, cpu * 0.72), memory: Math.max(0, mem * 0.78) },
{ time: '7m前', cpu: Math.max(0, cpu * 0.82), memory: Math.max(0, mem * 0.85) },
{ time: '4m前', cpu: Math.max(0, cpu * 0.90), memory: Math.max(0, mem * 0.93) },
{ time: '2m前', cpu: Math.max(0, cpu * 0.96), memory: Math.max(0, mem * 0.97) },
{ time: '现在', cpu, memory: mem },
];
}, [clusterMetrics]);
const loadClusterDetail = async (clusterId: string) => {
try {
// 后端 ListNodes/ListPods/etc 返回 data 直接为数组(非 {list,total})
@@ -132,49 +148,63 @@ export function KubernetesClusterDetail({ cluster, onClose }: { cluster: any; on
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card className="bg-slate-800/50 border-slate-700">
<CardHeader className="pb-2">
<CardTitle className="text-sm text-slate-400">资源使用趋势</CardTitle>
<div className="flex items-center justify-between w-full">
<CardTitle className="text-sm text-slate-400">资源使用趋势(模拟最近变化)</CardTitle>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-slate-400 hover:text-slate-200"
onClick={() => cluster?.id && loadClusterDetail(cluster.id)}
title="刷新节点指标"
>
<RefreshCw className="h-3.5 w-3.5" />
</Button>
</div>
</CardHeader>
<CardContent>
<div className="h-[200px]">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={[
{ time: 'CPU', cpu: Number(clusterMetrics.cpuPct.toFixed(1)), memory: Number(clusterMetrics.memPct.toFixed(1)) },
]}>
<AreaChart data={trendData} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
<defs>
<linearGradient id="colorCpu" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.35} />
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0.05} />
</linearGradient>
<linearGradient id="colorMemory" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10b981" stopOpacity={0.3} />
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
<stop offset="5%" stopColor="#10b981" stopOpacity={0.35} />
<stop offset="95%" stopColor="#10b981" stopOpacity={0.05} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
<XAxis dataKey="time" stroke="#475569" fontSize={12} />
<YAxis stroke="#475569" fontSize={12} domain={[0, 100]} />
<CartesianGrid strokeDasharray="2 2" stroke="#1e293b" />
<XAxis dataKey="time" stroke="#475569" fontSize={11} tickLine={false} />
<YAxis stroke="#475569" fontSize={11} domain={[0, 100]} unit="%" tickLine={false} />
<Tooltip
contentStyle={{
backgroundColor: '#0f172a',
border: '1px solid #1e293b',
borderRadius: '6px',
color: '#f8fafc',
fontSize: '12px',
}}
/>
<Area
type="monotone"
dataKey="cpu"
stroke="#3b82f6"
fillOpacity={1}
strokeWidth={2}
fill="url(#colorCpu)"
dot={false}
activeDot={{ r: 3, fill: '#3b82f6' }}
name="CPU(%)"
/>
<Area
type="monotone"
dataKey="memory"
stroke="#10b981"
fillOpacity={1}
strokeWidth={2}
fill="url(#colorMemory)"
dot={false}
activeDot={{ r: 3, fill: '#10b981' }}
name="内存(%)"
/>
</AreaChart>
@@ -20,6 +20,7 @@ import {
AlertTriangle,
XCircle,
RefreshCw,
Loader2,
Settings,
Eye,
Trash2,
@@ -317,21 +318,21 @@ export function KubernetesClusters() {
<CardContent className="pt-0">
<div className="grid grid-cols-4 gap-2 mb-4">
<div className="text-center p-2 rounded-lg bg-slate-800/50">
<div className="text-lg font-semibold text-slate-200">{cluster.node_count || 0}</div>
<div className="text-xs text-slate-500">节点</div>
</div>
<div className="text-center p-2 rounded-lg bg-slate-800/50">
<div className="text-lg font-semibold text-slate-200">{cluster.pod_count || 0}</div>
<div className="text-xs text-slate-500">Pod</div>
<div className="text-lg font-semibold text-slate-200">{cluster.ingress_count || 0}</div>
<div className="text-xs text-slate-500">Ingress</div>
</div>
<div className="text-center p-2 rounded-lg bg-slate-800/50">
<div className="text-lg font-semibold text-slate-200">{cluster.service_count || 0}</div>
<div className="text-xs text-slate-500">Service</div>
<div className="text-xs text-slate-500">SVC</div>
</div>
<div className="text-center p-2 rounded-lg bg-slate-800/50">
<div className="text-lg font-semibold text-slate-200">{cluster.deployment_count || 0}</div>
<div className="text-xs text-slate-500">Deployment</div>
</div>
<div className="text-center p-2 rounded-lg bg-slate-800/50">
<div className="text-lg font-semibold text-slate-200">{cluster.pod_count || 0}</div>
<div className="text-xs text-slate-500">Pod</div>
</div>
</div>
<div className="space-y-2">
{hasMetrics ? (
@@ -364,7 +365,9 @@ export function KubernetesClusters() {
</div>
</>
) : (
<div className="text-xs text-slate-600 text-center">已接入 {cluster.node_count || 0} 节点 · {cluster.pod_count || 0} Pod · 资源使用率待采集</div>
<div className="text-xs text-slate-600 text-center">
Ingress {cluster.ingress_count || 0} · SVC {cluster.service_count || 0} · Deployment {cluster.deployment_count || 0} · Pod {cluster.pod_count || 0} · 资源使用率待采集
</div>
)}
</div>
</CardContent>
@@ -400,20 +403,39 @@ export function KubernetesClusters() {
/>
)}
{/* 导出资源弹窗 */}
{/* 导出资源弹窗 — 导出中展示 loading,禁止误关 // Logan.Li */}
{exportCluster && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={() => setExportCluster(null)}>
<div className="bg-slate-900 border border-slate-700 rounded-lg p-5 w-96 shadow-xl" onClick={(e) => e.stopPropagation()}>
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
onClick={() => { if (!exporting) setExportCluster(null); }}
>
<div className="bg-slate-900 border border-slate-700 rounded-lg p-5 w-[420px] shadow-xl" onClick={(e) => e.stopPropagation()}>
<h3 className="text-base font-semibold text-slate-100 mb-1">导出集群资源</h3>
<p className="text-xs text-slate-400 mb-4">
导出 {exportCluster.name} 所有命名空间的 Deployment、Service、Ingress、ConfigMap、Secret 为 tar.gz
</p>
<div className="flex justify-end gap-2">
<Button variant="outline" className="border-slate-700 text-slate-300" onClick={() => setExportCluster(null)}>取消</Button>
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={handleExport} disabled={exporting}>
{exporting ? '导出中...' : '确认导出'}
</Button>
</div>
{exporting ? (
<div className="flex flex-col items-center py-6 gap-3">
<Loader2 className="h-9 w-9 animate-spin text-blue-400" />
<p className="text-sm text-slate-200">正在导出 {exportCluster.name}</p>
<p className="text-xs text-slate-500 text-center leading-relaxed px-2">
正在拉取所有命名空间的 Deployment、Service、Ingress、ConfigMap、Secret 并打包为 tar.gz
<br />
资源较多时可能需要 1~3 分钟,请稍候勿关闭页面
</p>
</div>
) : (
<>
<p className="text-xs text-slate-400 mb-4 leading-relaxed">
导出 <span className="text-slate-300">{exportCluster.name}</span> 所有命名空间的
Deployment、Service、Ingress、ConfigMap、Secret 为 tar.gz
</p>
<div className="flex justify-end gap-2">
<Button variant="outline" className="border-slate-700 text-slate-300" onClick={() => setExportCluster(null)}>取消</Button>
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={handleExport}>
<Download className="h-4 w-4 mr-1.5" />
确认导出
</Button>
</div>
</>
)}
</div>
</div>
)}
@@ -31,8 +31,12 @@ type ResourceType =
// 支持可视化编辑/新建的资源类型 // Logan.Li
const VISUAL_RESOURCE_TYPES: ResourceType[] = ['services', 'ingresses', 'configmaps', 'secrets', 'pvcs'];
/** Deployment 列表:新建 + YAML 导入,不做批量导出 // Logan.Li */
const DEPLOYMENT_LIST_TYPES: ResourceType[] = ['deployments'];
/** 支持命名空间 YAML 批量导出(对齐集群导出)// Logan.Li */
const EXPORT_YAML_TYPES: ResourceType[] = ['deployments', 'services', 'ingresses', 'configmaps', 'secrets'];
const EXPORT_YAML_TYPES: ResourceType[] = ['services', 'ingresses', 'configmaps', 'secrets'];
/** 列表页保留 CSV/JSON 导出的配置类资源 // Logan.Li */
const BULK_EXPORT_TYPES: ResourceType[] = ['services', 'ingresses', 'configmaps', 'secrets', 'pvcs'];
const VisualActions = ({ name, opts }: { name: string; opts: { onYaml?: (n: string) => void; onEdit?: (n: string) => void } }) => (
<div className="flex items-center gap-0.5">
@@ -52,7 +56,7 @@ const RESOURCE_CONFIG: Record<ResourceType, {
}> = {
deployments: {
title: 'Deployments', subtitle: 'Deployment 工作负载运行状态', icon: Boxes,
columns: ['名称', '命名空间', '副本', '镜像', '操作'],
columns: ['名称', '命名空间', '副本', '镜像'],
renderRow: (d, opts) => (
<TableRow key={d.name} className="border-slate-800 hover:bg-slate-800/30 cursor-pointer" onClick={() => opts.onDetail?.(d)}>
<TableCell className="font-medium text-slate-200">{d.name}</TableCell>
@@ -72,10 +76,6 @@ const RESOURCE_CONFIG: Record<ResourceType, {
})()}
</TableCell>
<TableCell className="text-xs text-slate-500 max-w-[200px] truncate">{d.image || '-'}</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-blue-400 hover:text-blue-300 hover:bg-blue-500/10" title="查看 YAML"
onClick={() => opts.onYaml?.(d.name)}><FileCode className="h-3.5 w-3.5 mr-1" />YAML</Button>
</TableCell>
</TableRow>
),
},
@@ -95,13 +95,13 @@ const RESOURCE_CONFIG: Record<ResourceType, {
<TableCell className={(p.restart_count > 0 ? 'text-yellow-400' : 'text-slate-500')}>{p.restart_count || 0}</TableCell>
<TableCell className="text-xs text-slate-500 max-w-[200px] truncate">{p.image || '-'}</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-emerald-400 hover:text-emerald-300 hover:bg-emerald-500/10" title="终端"
onClick={() => (opts as Record<string, any>).onTerminal?.(p)}><Terminal className="h-3.5 w-3.5 mr-1" />终端</Button>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-amber-400 hover:text-amber-300 hover:bg-amber-500/10" title="文件浏览器"
onClick={() => (opts as Record<string, any>).onFileBrowser?.(p)}><HardDrive className="h-3.5 w-3.5 mr-1" />文件浏览器</Button>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-blue-400 hover:text-blue-300 hover:bg-blue-500/10" title="查看YAML"
onClick={() => opts.onYaml?.(p.name)}><FileCode className="h-3.5 w-3.5 mr-1" />YAML</Button>
<div className="flex items-center gap-0.5">
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0 text-emerald-400" title="终端"
onClick={() => (opts as Record<string, any>).onTerminal?.(p)}><Terminal className="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0 text-amber-400" title="文件"
onClick={() => (opts as Record<string, any>).onFileBrowser?.(p)}><HardDrive className="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0 text-blue-400" title="YAML"
onClick={() => opts.onYaml?.(p.name)}><FileCode className="h-3.5 w-3.5" /></Button>
</div>
</TableCell>
</TableRow>
@@ -414,7 +414,13 @@ export default function NamespaceResourcePage() {
setYamlItem({ resource: type, name, editMode: true });
}
};
const handleCreate = () => setVisualItem({ resource: type, name: '', isCreate: true });
const handleCreate = () => {
if (type === 'deployments') {
navigate(`/kubernetes/${clusterCode}/namespace/${namespace}/workload/edit/Deployment/_create`);
return;
}
setVisualItem({ resource: type, name: '', isCreate: true });
};
const handleExportCSV = () => kubernetesApi.exportAsCSV(`${type}-${clusterCode}-${namespace}.csv`, filtered);
const handleExportJSON = () => kubernetesApi.exportAsJSON(`${type}-${clusterCode}-${namespace}.json`, filtered);
const handleExportYaml = async () => {
@@ -484,18 +490,22 @@ export default function NamespaceResourcePage() {
<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>
<Button variant="outline" size="sm" className="h-9 border-slate-700 text-slate-400 text-xs" onClick={handleExportCSV} title="导出CSV">
<Download className="h-3 w-3 mr-1" />CSV
</Button>
<Button variant="outline" size="sm" className="h-9 border-slate-700 text-slate-400 text-xs" onClick={handleExportJSON} title="导出JSON">
<Download className="h-3 w-3 mr-1" />JSON
</Button>
{BULK_EXPORT_TYPES.includes(type) && (
<>
<Button variant="outline" size="sm" className="h-9 border-slate-700 text-slate-400 text-xs" onClick={handleExportCSV} title="导出CSV">
<Download className="h-3 w-3 mr-1" />CSV
</Button>
<Button variant="outline" size="sm" className="h-9 border-slate-700 text-slate-400 text-xs" onClick={handleExportJSON} title="导出JSON">
<Download className="h-3 w-3 mr-1" />JSON
</Button>
</>
)}
{EXPORT_YAML_TYPES.includes(type) && (
<Button variant="outline" size="sm" className="h-9 border-slate-700 text-slate-400 text-xs" onClick={handleExportYaml} title="导出命名空间 YAML(tar.gz)">
<Download className="h-3 w-3 mr-1" />导出YAML
</Button>
)}
{VISUAL_RESOURCE_TYPES.includes(type) && (
{(VISUAL_RESOURCE_TYPES.includes(type) || DEPLOYMENT_LIST_TYPES.includes(type)) && (
<>
<Button variant="outline" size="sm" className="h-9 border-slate-700 text-slate-400 text-xs" onClick={() => setShowYamlImport(true)}>
<Upload className="h-3 w-3 mr-1" />导入
@@ -166,11 +166,11 @@ export function PodFileBrowser({
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-3 border-b border-slate-700 shrink-0">
<div className="flex items-center gap-2">
<HardDrive className="h-4 w-4 text-amber-400" />
<span className="text-sm font-semibold text-slate-200">文件浏览器: {podName}</span>
<Badge variant="outline" className="border-slate-700 text-slate-400 text-[10px]">{namespace}</Badge>
<div className="flex items-center justify-between p-3 border-b border-slate-700 shrink-0 gap-3">
<div className="flex items-center gap-2 min-w-0 flex-1">
<HardDrive className="h-4 w-4 shrink-0 text-amber-400" />
<span className="text-sm font-semibold text-slate-200 truncate" title={podName}>文件: {podName}</span>
<Badge variant="outline" className="border-slate-700 text-slate-400 text-[10px] shrink-0">{namespace}</Badge>
</div>
<div className="flex items-center gap-2">
{containers.length > 0 && (
@@ -171,16 +171,23 @@ export function ResourceVisualEditor({ clusterCode, namespace, resource, name, o
className="bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-xl w-[90vw] max-w-3xl h-[85vh] flex flex-col shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-[var(--dg-border)]">
<div className="flex items-center gap-2">
<MetaIcon className={`h-5 w-5 ${meta.color}`} />
<div>
<h2 className="text-lg font-bold text-[var(--dg-text-primary)]">{isCreate ? `新建 ${meta.title}` : `${meta.title} 编辑`}</h2>
<p className="text-xs text-[var(--dg-text-muted)]">{isCreate ? namespace : name} · {namespace} · {clusterCode}</p>
{/* Header:长名称/域名截断,避免挤压模式切换按钮 // Logan.Li */}
<div className="flex items-center justify-between p-4 border-b border-[var(--dg-border)] gap-3 shrink-0">
<div className="flex items-center gap-2 min-w-0 flex-1">
<MetaIcon className={`h-5 w-5 shrink-0 ${meta.color}`} />
<div className="min-w-0">
<h2 className="text-lg font-bold text-[var(--dg-text-primary)] truncate">
{isCreate ? `新建 ${meta.title}` : `${meta.title} 编辑`}
</h2>
<p
className="text-xs text-[var(--dg-text-muted)] truncate"
title={isCreate ? `${namespace} · ${clusterCode}` : `${name} · ${namespace} · ${clusterCode}`}
>
{isCreate ? `${namespace} · ${clusterCode}` : `${name} · ${namespace} · ${clusterCode}`}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 shrink-0">
{/* Mode switcher */}
<div className="flex rounded-lg border border-[var(--dg-border)] overflow-hidden">
<button
@@ -198,7 +205,7 @@ export function ResourceVisualEditor({ clusterCode, namespace, resource, name, o
</div>
{/* Body */}
<div className={`flex-1 min-h-0 p-4 ${mode === 'yaml' ? 'flex flex-col overflow-hidden' : 'overflow-auto'}`}>
<div className={`flex-1 min-h-0 p-4 ${mode === 'yaml' ? 'flex flex-col overflow-hidden' : 'flex flex-col overflow-hidden'}`}>
{loading ? (
<div className="flex items-center justify-center h-full py-20"><Loader2 className="h-6 w-6 animate-spin text-[var(--dg-text-muted)]" /></div>
) : mode === 'yaml' ? (
@@ -241,19 +248,21 @@ export function ResourceVisualEditor({ clusterCode, namespace, resource, name, o
/>
</div>
) : manifest ? (
<>
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
{resource === 'configmaps' && <ConfigMapEditor manifest={manifest} onSave={handleSave} saving={saving} isCreate={isCreate} />}
{resource === 'secrets' && <SecretEditor manifest={manifest} onSave={handleSave} saving={saving} isCreate={isCreate} />}
{resource === 'services' && <ServiceEditor manifest={manifest} onSave={handleSave} saving={saving} isCreate={isCreate} />}
{resource === 'ingresses' && <IngressEditor manifest={manifest} onSave={handleSave} saving={saving} isCreate={isCreate} />}
{resource === 'ingresses' && <IngressEditor manifest={manifest} clusterCode={clusterCode} onSave={handleSave} saving={saving} isCreate={isCreate} />}
{resource === 'pvcs' && <PvcEditor manifest={manifest} onSave={handleSave} saving={saving} isCreate={isCreate} />}
{!['configmaps', 'secrets', 'services', 'ingresses', 'pvcs'].includes(resource) && (
<div className="text-center py-12 text-[var(--dg-text-muted)]">
<p>该资源类型暂不支持可视化编辑</p>
<Button variant="outline" size="sm" className="mt-4" onClick={() => setMode('yaml')}>切换到 YAML 编辑</Button>
<div className="flex-1 flex items-center justify-center text-center text-[var(--dg-text-muted)]">
<div>
<p>该资源类型暂不支持可视化编辑</p>
<Button variant="outline" size="sm" className="mt-4" onClick={() => setMode('yaml')}>切换到 YAML 编辑</Button>
</div>
</div>
)}
</>
</div>
) : (
<div className="text-center py-12 text-[var(--dg-text-muted)]">
<p>无法解析资源结构</p>
@@ -275,6 +284,7 @@ function ConfigMapEditor({ manifest, onSave, saving, isCreate }: { manifest: any
const [entries, setEntries] = useState<{ key: string; value: string }[]>(
Object.entries(data).map(([key, value]) => ({ key, value: String(value) }))
);
const [expandAll, setExpandAll] = useState(false);
const update = (i: number, field: 'key' | 'value', val: string) => {
const next = [...entries];
@@ -294,19 +304,25 @@ function ConfigMapEditor({ manifest, onSave, saving, isCreate }: { manifest: any
};
return (
<div className="space-y-4">
<div>
<div className="flex flex-col h-full">
<div className="shrink-0">
<Label className="text-[var(--dg-text-secondary)] text-xs">名称</Label>
<Input value={resName} onChange={(e) => setResName(e.target.value)} readOnly={!isCreate} className="mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)]" />
</div>
<div>
<div className="flex items-center justify-between mb-2">
<div className="flex-1 min-h-0 flex flex-col mt-3">
<div className="flex items-center justify-between mb-2 shrink-0">
<Label className="text-[var(--dg-text-secondary)] text-sm">配置数据 (键值对)</Label>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={addRow}><Plus className="h-3 w-3 mr-1" />添加</Button>
<div className="flex gap-1">
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => setExpandAll(!expandAll)}>
{expandAll ? '折叠全部' : '展开全部'}
</Button>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={addRow}><Plus className="h-3 w-3 mr-1" />添加</Button>
</div>
</div>
<div className="space-y-2 max-h-[45vh] overflow-y-auto pr-1">
<div className="flex-1 min-h-0 overflow-y-auto pr-1 space-y-2 border border-[var(--dg-border)] rounded-lg bg-[var(--dg-bg-secondary)] p-2">
{entries.map((entry, i) => (
<div key={i} className="flex items-start gap-2 p-2 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)]">
<div key={i} className="flex items-start gap-2 p-2 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
<Input
value={entry.key}
onChange={e => update(i, 'key', e.target.value)}
@@ -317,7 +333,8 @@ function ConfigMapEditor({ manifest, onSave, saving, isCreate }: { manifest: any
value={entry.value}
onChange={e => update(i, 'value', e.target.value)}
placeholder="值"
className="flex-[2] font-mono text-xs min-h-[36px] max-h-[120px] bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
rows={expandAll || entry.value.length > 300 ? 10 : (entry.value.length > 100 ? 6 : 3)}
className="flex-[2] font-mono text-xs min-h-[70px] max-h-[420px] resize-y bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] leading-relaxed"
/>
<Button variant="ghost" size="icon" className="h-8 w-8 text-red-400 hover:text-red-300 shrink-0" onClick={() => removeRow(i)}>
<Trash2 className="h-3.5 w-3.5" />
@@ -329,7 +346,8 @@ function ConfigMapEditor({ manifest, onSave, saving, isCreate }: { manifest: any
)}
</div>
</div>
<div className="flex justify-end gap-2 pt-2 border-t border-[var(--dg-border)]">
<div className="shrink-0 flex justify-end gap-2 pt-3 mt-2 border-t border-[var(--dg-border)]">
<Button size="sm" className="bg-blue-600 hover:bg-blue-700" onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin mr-1" /> : <Save className="h-3.5 w-3.5 mr-1" />}
保存配置
@@ -345,6 +363,7 @@ function ConfigMapEditor({ manifest, onSave, saving, isCreate }: { manifest: any
function SecretEditor({ manifest, onSave, saving, isCreate }: { manifest: any; onSave: (m: any) => void; saving: boolean; isCreate?: boolean }) {
const rawData = manifest?.data || {};
const [resName, setResName] = useState(manifest?.metadata?.name || '');
const [expandAll, setExpandAll] = useState(false);
// Decode base64 values for editing
const [entries, setEntries] = useState<{ key: string; value: string; encoded: boolean }[]>(
Object.entries(rawData).map(([key, value]) => {
@@ -380,34 +399,43 @@ function SecretEditor({ manifest, onSave, saving, isCreate }: { manifest: any; o
};
return (
<div className="space-y-4">
<div>
<Label className="text-[var(--dg-text-secondary)] text-xs">名称</Label>
<Input value={resName} onChange={(e) => setResName(e.target.value)} readOnly={!isCreate} className="mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)]" />
</div>
<div>
<Label className="text-[var(--dg-text-secondary)] text-xs">类型</Label>
<Input value={manifest?.type || 'Opaque'} readOnly className="mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)]" />
</div>
<div>
<div className="flex items-center justify-between mb-2">
<Label className="text-[var(--dg-text-secondary)] text-sm">密钥数据 (已自动解码 Base64)</Label>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={addRow}><Plus className="h-3 w-3 mr-1" />添加</Button>
<div className="flex flex-col h-full">
<div className="shrink-0 grid grid-cols-2 gap-4">
<div>
<Label className="text-[var(--dg-text-secondary)] text-xs">名称</Label>
<Input value={resName} onChange={(e) => setResName(e.target.value)} readOnly={!isCreate} className="mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)]" />
</div>
<div className="space-y-2 max-h-[45vh] overflow-y-auto pr-1">
<div>
<Label className="text-[var(--dg-text-secondary)] text-xs">类型</Label>
<Input value={manifest?.type || 'Opaque'} readOnly className="mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)]" />
</div>
</div>
<div className="flex-1 min-h-0 flex flex-col mt-3">
<div className="flex items-center justify-between mb-2 shrink-0">
<Label className="text-[var(--dg-text-secondary)] text-sm">密钥数据 (已自动解码 Base64)</Label>
<div className="flex gap-1">
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => setExpandAll(!expandAll)}>
{expandAll ? '折叠全部' : '展开全部'}
</Button>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={addRow}><Plus className="h-3 w-3 mr-1" />添加</Button>
</div>
</div>
<div className="flex-1 min-h-0 overflow-y-auto pr-1 space-y-2 border border-[var(--dg-border)] rounded-lg bg-[var(--dg-bg-secondary)] p-2">
{entries.map((entry, i) => (
<div key={i} className="flex items-start gap-2 p-2 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)]">
<div key={i} className="flex items-start gap-2 p-2 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
<Input
value={entry.key}
onChange={e => update(i, 'key', e.target.value)}
placeholder="键名 (如 password)"
className="flex-1 font-mono text-xs h-8 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
/>
<Input
<Textarea
value={entry.value}
onChange={e => update(i, 'value', e.target.value)}
placeholder="明文值"
className="flex-[2] font-mono text-xs h-8 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
rows={expandAll || entry.value.length > 300 ? 10 : (entry.value.length > 100 ? 6 : 3)}
className="flex-[2] font-mono text-xs min-h-[70px] max-h-[420px] resize-y bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] leading-relaxed"
/>
<Button variant="ghost" size="icon" className="h-8 w-8 text-red-400 hover:text-red-300 shrink-0" onClick={() => removeRow(i)}>
<Trash2 className="h-3.5 w-3.5" />
@@ -419,7 +447,8 @@ function SecretEditor({ manifest, onSave, saving, isCreate }: { manifest: any; o
)}
</div>
</div>
<div className="flex justify-end gap-2 pt-2 border-t border-[var(--dg-border)]">
<div className="shrink-0 flex justify-end gap-2 pt-3 mt-2 border-t border-[var(--dg-border)]">
<Button size="sm" className="bg-blue-600 hover:bg-blue-700" onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin mr-1" /> : <Save className="h-3.5 w-3.5 mr-1" />}
保存密钥
@@ -491,12 +520,12 @@ function ServiceEditor({ manifest, onSave, saving, isCreate }: { manifest: any;
];
return (
<div className="space-y-5">
<div>
<div className="flex flex-col h-full">
<div className="shrink-0">
<Label className="text-[var(--dg-text-secondary)] text-xs">服务名称</Label>
<Input value={resName} onChange={(e) => setResName(e.target.value)} readOnly={!isCreate} className="mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)]" />
</div>
<div>
<div className="shrink-0 mt-3">
<Label className="text-[var(--dg-text-secondary)] text-xs mb-2 block">服务类型</Label>
<div className="flex rounded-lg border border-[var(--dg-border)] overflow-hidden w-fit">
{svcTypes.map((t) => (
@@ -512,15 +541,17 @@ function ServiceEditor({ manifest, onSave, saving, isCreate }: { manifest: any;
</div>
</div>
{/* Ports */}
<div>
<div className="flex items-center justify-between mb-2">
<Label className="text-[var(--dg-text-secondary)] text-sm">端口映射</Label>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={addPort}><Plus className="h-3 w-3 mr-1" />添加端口</Button>
</div>
<div className="space-y-2">
{/* 端口 + 选择器:统一滚动,避免条目过多时底部被裁切 // Logan.Li */}
<div className="flex-1 min-h-0 overflow-y-auto mt-3 pr-1 space-y-3">
<div>
<div className="flex items-center justify-between mb-2">
<Label className="text-[var(--dg-text-secondary)] text-sm">端口映射</Label>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={addPort}><Plus className="h-3 w-3 mr-1" />添加端口</Button>
</div>
<div className="space-y-2 border border-[var(--dg-border)] rounded-lg p-2 bg-[var(--dg-bg-secondary)]">
{ports.map((p, i) => (
<div key={i} className="grid grid-cols-[1fr_80px_80px_80px_80px_32px] gap-2 items-center">
<div key={i} className="overflow-x-auto">
<div className="grid grid-cols-[minmax(80px,1fr)_80px_80px_80px_80px_32px] gap-2 items-center min-w-[480px]">
<Input value={p.name} onChange={e => updatePort(i, 'name', e.target.value)} placeholder="名称" className="font-mono text-xs h-7 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
<Input value={p.port} onChange={e => updatePort(i, 'port', e.target.value)} placeholder="端口" type="number" className="font-mono text-xs h-7 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
<Input value={p.targetPort} onChange={e => updatePort(i, 'targetPort', e.target.value)} placeholder="目标" type="number" className="font-mono text-xs h-7 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
@@ -533,31 +564,33 @@ function ServiceEditor({ manifest, onSave, saving, isCreate }: { manifest: any;
) : <div />}
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-400 hover:text-red-300" onClick={() => removePort(i)}><Trash2 className="h-3 w-3" /></Button>
</div>
))}
{ports.length === 0 && <div className="text-center py-4 text-[var(--dg-text-muted)] text-xs">暂无端口,点击「添加端口」</div>}
</div>
</div>
{/* Selector */}
<div>
<div className="flex items-center justify-between mb-2">
<Label className="text-[var(--dg-text-secondary)] text-sm">选择器 (Selector)</Label>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={addSelector}><Plus className="h-3 w-3 mr-1" />添加标签</Button>
</div>
<div className="space-y-2">
{selector.map((s, i) => (
<div key={i} className="flex items-center gap-2">
<Input value={s.key} onChange={e => updateSelector(i, 'key', e.target.value)} placeholder="键 (如 app)" className="flex-1 font-mono text-xs h-7 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
<span className="text-[var(--dg-text-muted)] text-xs">=</span>
<Input value={s.value} onChange={e => updateSelector(i, 'value', e.target.value)} placeholder="值 (如 nginx)" className="flex-1 font-mono text-xs h-7 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-400 hover:text-red-300" onClick={() => removeSelector(i)}><Trash2 className="h-3 w-3" /></Button>
</div>
))}
{selector.length === 0 && <div className="text-center py-4 text-[var(--dg-text-muted)] text-xs">暂无选择器</div>}
{ports.length === 0 && <div className="text-center py-4 text-[var(--dg-text-muted)] text-xs">暂无端口,点击「添加端口」</div>}
</div>
</div>
{/* Selector */}
<div>
<div className="flex items-center justify-between mb-2">
<Label className="text-[var(--dg-text-secondary)] text-sm">选择器 (Selector)</Label>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={addSelector}><Plus className="h-3 w-3 mr-1" />添加标签</Button>
</div>
<div className="space-y-2">
{selector.map((s, i) => (
<div key={i} className="flex items-center gap-2 min-w-0">
<Input value={s.key} onChange={e => updateSelector(i, 'key', e.target.value)} placeholder="键 (如 app)" className="flex-1 min-w-0 font-mono text-xs h-7 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
<span className="text-[var(--dg-text-muted)] text-xs shrink-0">=</span>
<Input value={s.value} onChange={e => updateSelector(i, 'value', e.target.value)} placeholder="值 (如 nginx)" className="flex-1 min-w-0 font-mono text-xs h-7 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0 text-red-400 hover:text-red-300" onClick={() => removeSelector(i)}><Trash2 className="h-3 w-3" /></Button>
</div>
))}
{selector.length === 0 && <div className="text-center py-4 text-[var(--dg-text-muted)] text-xs">暂无选择器</div>}
</div>
</div>
</div>
<div className="flex justify-end gap-2 pt-2 border-t border-[var(--dg-border)]">
<div className="shrink-0 flex justify-end gap-2 pt-3 mt-2 border-t border-[var(--dg-border)]">
<Button size="sm" className="bg-blue-600 hover:bg-blue-700" onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin mr-1" /> : <Save className="h-3.5 w-3.5 mr-1" />}
保存 Service
@@ -570,10 +603,29 @@ function ServiceEditor({ manifest, onSave, saving, isCreate }: { manifest: any;
// ══════════════════════════════════════════════
// Ingress 编辑器 — Host/Path 路由规则
// ══════════════════════════════════════════════
function IngressEditor({ manifest, onSave, saving, isCreate }: { manifest: any; onSave: (m: any) => void; saving: boolean; isCreate?: boolean }) {
function IngressEditor({ manifest, clusterCode, onSave, saving, isCreate }: { manifest: any; clusterCode: string; onSave: (m: any) => void; saving: boolean; isCreate?: boolean }) {
const spec = manifest?.spec || {};
const [resName, setResName] = useState(manifest?.metadata?.name || '');
const [ingressClass, setIngressClass] = useState(spec.ingressClassName || 'nginx');
// IngressClass 列表(关联集群中的 IngressController)
const [ingressClasses, setIngressClasses] = useState<Array<{ name: string; controller: string }>>([]);
const [loadingClasses, setLoadingClasses] = useState(false);
// 加载集群 IngressClass 列表
useEffect(() => {
if (!clusterCode) return;
setLoadingClasses(true);
kubernetesApi.getIngressClasses(clusterCode).then((res: any) => {
const list = Array.isArray(res) ? res : (res?.list || res || []);
setIngressClasses(list);
// 新建时如果还没指定且有列表,尝试默认选第一个
if (isCreate && (!spec.ingressClassName || spec.ingressClassName === 'nginx') && list.length > 0) {
// 优先选 nginx 或第一个
const prefer = list.find((c: any) => c.name?.toLowerCase().includes('nginx')) || list[0];
if (prefer?.name) setIngressClass(prefer.name);
}
}).catch(() => {}).finally(() => setLoadingClasses(false));
}, [clusterCode]);
const [rules, setRules] = useState<{ host: string; paths: { path: string; pathType: string; serviceName: string; servicePort: string }[] }[]>(
(spec.rules || []).map(( r: any) => ({
host: r.host || '',
@@ -632,44 +684,103 @@ function IngressEditor({ manifest, onSave, saving, isCreate }: { manifest: any;
};
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<div className="flex flex-col h-full min-h-0">
<div className="shrink-0 grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="min-w-0">
<Label className="text-[var(--dg-text-secondary)] text-xs">名称</Label>
<Input value={resName} onChange={(e) => setResName(e.target.value)} readOnly={!isCreate} className="mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)]" />
</div>
<div>
<Label className="text-[var(--dg-text-secondary)] text-xs">Ingress Class</Label>
<Input value={ingressClass} onChange={(e) => setIngressClass(e.target.value)} placeholder="nginx / alb" className="mt-1 font-mono text-xs h-8 bg-[var(--dg-bg-tertiary)]" />
<div className="min-w-0">
<Label className="text-[var(--dg-text-secondary)] text-xs flex items-center gap-1">
Ingress Class
<span className="text-[10px] text-[var(--dg-text-muted)]">(可选)</span>
</Label>
{/* IngressClass 下拉 + 手工输入支持 // Logan.Li */}
<div className="space-y-1 mt-1">
<Select
value={ingressClass || ''}
onValueChange={(val) => {
if (val && val !== '__manual__') {
setIngressClass(val);
}
}}
>
<SelectTrigger className="h-8 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
<SelectValue placeholder={loadingClasses ? '加载中...' : '选择或输入 IngressClass'} />
</SelectTrigger>
<SelectContent>
{ingressClasses.length > 0 ? (
ingressClasses.map((ic) => (
<SelectItem key={ic.name} value={ic.name}>
{ic.name} {ic.controller ? <span className="text-[var(--dg-text-muted)] text-[10px]">({ic.controller})</span> : ''}
</SelectItem>
))
) : (
!loadingClasses && <div className="px-2 py-1.5 text-xs text-[var(--dg-text-muted)]">未发现 IngressClass,请手动输入</div>
)}
{/* 当前自定义值作为选项,确保选中态 */}
{ingressClass && !ingressClasses.some((ic) => ic.name === ingressClass) && (
<SelectItem value={ingressClass}>{ingressClass} (手动输入)</SelectItem>
)}
<SelectItem value="__manual__">—— 继续手动输入 ——</SelectItem>
</SelectContent>
</Select>
{/* 总是显示输入框支持手工输入或修改 */}
<div className="text-[10px] text-[var(--dg-text-muted)] flex items-center gap-1">
手工输入:
<Input
value={ingressClass}
onChange={(e) => setIngressClass(e.target.value)}
placeholder="alb / nginx / my-ingress-class"
className="flex-1 font-mono text-xs h-7 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]"
/>
</div>
{/* 帮助提示 */}
<div className="text-[10px] text-[var(--dg-text-muted)] leading-tight">
您的集群中可能有多个 IngressController,您可在此指定当前 Ingress 对应哪个 IngressController。<br />
从 IngressClass 列表选择,或手工输入名称。
</div>
{/* 常见示例 */}
{(ingressClasses.length === 0 || ingressClass) && (
<div className="text-[10px] text-[var(--dg-text-muted)]">
常见:<span className="font-mono">alb</span> → ingress.k8s.alibabacloud/alb &nbsp;&nbsp; <span className="font-mono">nginx</span> → k8s.io/ingress-nginx
</div>
)}
</div>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<div className="flex-1 min-h-0 flex flex-col mt-3">
<div className="flex items-center justify-between mb-2 shrink-0">
<Label className="text-[var(--dg-text-secondary)] text-sm">路由规则</Label>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={addRule}><Plus className="h-3 w-3 mr-1" />添加 Host</Button>
</div>
<div className="space-y-3 max-h-[50vh] overflow-y-auto pr-1">
<div className="flex-1 min-h-0 overflow-y-auto pr-1 space-y-3 border border-[var(--dg-border)] rounded-lg p-2 bg-[var(--dg-bg-secondary)]">
{rules.map((rule, ri) => (
<div key={ri} className="p-3 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] space-y-2">
<div className="flex items-center gap-2">
<div key={ri} className="p-3 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] space-y-2 min-w-0">
<div className="flex items-center gap-2 min-w-0">
<Input
value={rule.host}
onChange={e => updateRule(ri, e.target.value)}
placeholder="域名 (如 api.example.com)"
className="flex-1 font-mono text-xs h-7 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
className="flex-1 min-w-0 font-mono text-xs h-7 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
title={rule.host}
/>
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-400 hover:text-red-300" onClick={() => removeRule(ri)}><Trash2 className="h-3 w-3" /></Button>
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0 text-red-400 hover:text-red-300" onClick={() => removeRule(ri)}><Trash2 className="h-3 w-3" /></Button>
</div>
{rule.paths.map((p, pi) => (
<div key={pi} className="grid grid-cols-[1fr_100px_1fr_60px_28px] gap-1.5 items-center pl-4">
<Input value={p.path} onChange={e => updatePath(ri, pi, 'path', e.target.value)} placeholder="路径 /" className="font-mono text-xs h-7 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
<Select value={p.pathType} onValueChange={v => updatePath(ri, pi, 'pathType', v)}>
<SelectTrigger className="h-7 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="Prefix">Prefix</SelectItem><SelectItem value="Exact">Exact</SelectItem></SelectContent>
</Select>
<Input value={p.serviceName} onChange={e => updatePath(ri, pi, 'serviceName', e.target.value)} placeholder="Service" className="font-mono text-xs h-7 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
<Input value={p.servicePort} onChange={e => updatePath(ri, pi, 'servicePort', e.target.value)} placeholder="端口" type="number" className="font-mono text-xs h-7 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-400 hover:text-red-300" onClick={() => removePath(ri, pi)}><Trash2 className="h-3 w-3" /></Button>
<div key={pi} className="overflow-x-auto pl-4">
<div className="grid grid-cols-[minmax(80px,1fr)_100px_minmax(80px,1fr)_60px_28px] gap-1.5 items-center min-w-[420px]">
<Input value={p.path} onChange={e => updatePath(ri, pi, 'path', e.target.value)} placeholder="路径 /" className="font-mono text-xs h-7 min-w-0 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
<Select value={p.pathType} onValueChange={v => updatePath(ri, pi, 'pathType', v)}>
<SelectTrigger className="h-7 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="Prefix">Prefix</SelectItem><SelectItem value="Exact">Exact</SelectItem></SelectContent>
</Select>
<Input value={p.serviceName} onChange={e => updatePath(ri, pi, 'serviceName', e.target.value)} placeholder="Service" className="font-mono text-xs h-7 min-w-0 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
<Input value={p.servicePort} onChange={e => updatePath(ri, pi, 'servicePort', e.target.value)} placeholder="端口" type="number" className="font-mono text-xs h-7 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-400 hover:text-red-300" onClick={() => removePath(ri, pi)}><Trash2 className="h-3 w-3" /></Button>
</div>
</div>
))}
<Button variant="ghost" size="sm" className="h-6 text-xs text-[var(--dg-text-muted)]" onClick={() => addPath(ri)}><Plus className="h-3 w-3 mr-1" />添加路径</Button>
@@ -678,7 +789,7 @@ function IngressEditor({ manifest, onSave, saving, isCreate }: { manifest: any;
{rules.length === 0 && <div className="text-center py-4 text-[var(--dg-text-muted)] text-xs">暂无路由规则,点击「添加 Host」</div>}
</div>
</div>
<div className="flex justify-end gap-2 pt-2 border-t border-[var(--dg-border)]">
<div className="shrink-0 flex justify-end gap-2 pt-3 mt-2 border-t border-[var(--dg-border)]">
<Button size="sm" className="bg-blue-600 hover:bg-blue-700" onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin mr-1" /> : <Save className="h-3.5 w-3.5 mr-1" />}
保存 Ingress
@@ -713,12 +824,12 @@ function PvcEditor({ manifest, onSave, saving, isCreate }: { manifest: any; onSa
};
return (
<div className="space-y-4">
<div>
<div className="flex flex-col h-full">
<div className="shrink-0">
<Label className="text-[var(--dg-text-secondary)] text-xs">名称</Label>
<Input value={resName} onChange={(e) => setResName(e.target.value)} readOnly={!isCreate} className="mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)]" />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-2 gap-4 mt-3 shrink-0">
<div>
<Label className="text-[var(--dg-text-secondary)] text-xs">存储大小</Label>
<Input
@@ -743,7 +854,7 @@ function PvcEditor({ manifest, onSave, saving, isCreate }: { manifest: any; onSa
</Select>
</div>
</div>
<div>
<div className="shrink-0 mt-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">StorageClass</Label>
<Input
value={storageClass}
@@ -752,7 +863,10 @@ function PvcEditor({ manifest, onSave, saving, isCreate }: { manifest: any; onSa
className="mt-1 font-mono text-xs h-8 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
/>
</div>
<div className="flex justify-end gap-2 pt-2 border-t border-[var(--dg-border)]">
<div className="flex-1" />
<div className="shrink-0 flex justify-end gap-2 pt-3 mt-2 border-t border-[var(--dg-border)]">
<Button size="sm" className="bg-blue-600 hover:bg-blue-700" onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin mr-1" /> : <Save className="h-3.5 w-3.5 mr-1" />}
保存 PVC
@@ -95,14 +95,15 @@ export function TopologyView() {
setLoading(true);
try {
const toArray = ( v: any) => (Array.isArray(v) ? v : (v?.list || []));
const [nodesRes, svcRes, depRes, ingRes] = await Promise.all([
const [nodesRes, svcRes, depRes, ingRes, podRes] = await Promise.all([
kubernetesApi.getNodes(selectedClusterId).catch(() => []),
kubernetesApi.getServices(selectedClusterId).catch(() => []),
kubernetesApi.getDeployments(selectedClusterId).catch(() => []),
kubernetesApi.getIngresses(selectedClusterId).catch(() => []),
kubernetesApi.getPods(selectedClusterId).catch(() => []),
]);
const cluster = clusters.find((c) => String(c.id) === selectedClusterId) || {};
const built = buildTopology(cluster, toArray(ingRes), toArray(svcRes), toArray(depRes), toArray(nodesRes));
const built = buildTopology(cluster, toArray(ingRes), toArray(svcRes), toArray(depRes), toArray(nodesRes), toArray(podRes));
// 合并已保存布局
const layout = await loadSavedLayout(selectedClusterId);
@@ -389,6 +390,19 @@ export function TopologyView() {
<Button variant="outline" size="icon" className="h-9 w-9 border-slate-700 text-slate-400" onClick={loadTopology} disabled={loading}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
</Button>
<Button
variant="outline"
size="sm"
className="h-9 text-xs border-blue-600/40 text-blue-400"
onClick={async () => {
setSavedLayout(null);
await loadTopology();
}}
disabled={loading}
title="忽略保存的布局,重新根据 K8s 资源自动生成关联 (Ingress路径→Svc→Deploy→Pod)"
>
<RotateCcw className="h-3.5 w-3.5 mr-1" /> 重新自动生成
</Button>
</div>
</div>
@@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button';
import { ModalOverlay } from '@/components/ui/modal-overlay';
import { useToast } from '@/hooks/use-toast';
import { kubernetesApi } from '@/api';
import { parseEditableManifest, prepareYamlForSave } from './k8s-manifest-utils';
import { parseEditableManifest, parseEditableManifests, prepareYamlForSave } from './k8s-manifest-utils';
import { getYamlResourceConfig, yamlDownloadFilename } from './k8s-yaml-resource-config';
import { YamlEditorToolbar } from './YamlEditorToolbar';
import { YamlCodeMirror } from './YamlCodeMirror';
@@ -23,11 +23,18 @@ interface YamlViewerProps {
initialYaml?: string;
/** 覆盖资源配置的只读行为(Pod 默认只读)// Logan.Li */
readOnly?: boolean;
/** 自定义加载(如 Deployment + Service 多文档)// Logan.Li */
loadYamlFn?: () => Promise<string>;
/** 自定义保存(如 Deployment + Service 多文档)// Logan.Li */
saveYamlFn?: (yaml: string) => Promise<void>;
/** 资源描述补充(如 "含关联 Service")// Logan.Li */
resourceHint?: string;
}
export function YamlViewer({
clusterCode, namespace, resource, name, onClose, onSaved,
autoEdit = false, inline = false, initialYaml, readOnly: readOnlyProp,
loadYamlFn, saveYamlFn, resourceHint,
}: YamlViewerProps) {
const { toast } = useToast();
const resCfg = getYamlResourceConfig(resource);
@@ -42,7 +49,9 @@ export function YamlViewer({
const [showRaw, setShowRaw] = useState(false);
const applyStripped = useCallback(async (text: string) => {
const { yaml: stripped } = await parseEditableManifest(text);
const { yaml: stripped } = text.includes('\n---')
? await parseEditableManifests(text)
: await parseEditableManifest(text);
setDisplayYaml(stripped);
setEditYaml(stripped);
return stripped;
@@ -57,7 +66,9 @@ export function YamlViewer({
}
setLoading(true);
try {
const text = await kubernetesApi.getResourceYaml(clusterCode, namespace, resource, name);
const text = loadYamlFn
? await loadYamlFn()
: await kubernetesApi.getResourceYaml(clusterCode, namespace, resource, name);
setRawYaml(text);
await applyStripped(text);
} catch (e) {
@@ -67,7 +78,7 @@ export function YamlViewer({
setDisplayYaml(err);
setEditYaml(err);
} finally { setLoading(false); }
}, [clusterCode, namespace, resource, name, initialYaml, applyStripped, toast]);
}, [clusterCode, namespace, resource, name, initialYaml, loadYamlFn, applyStripped, toast]);
useEffect(() => { loadYaml(); }, [loadYaml]);
@@ -86,8 +97,12 @@ export function YamlViewer({
setSaving(true);
try {
const yamlStr = await prepareYamlForSave(editYaml);
await kubernetesApi.updateResourceYaml(clusterCode, namespace, resource, name, yamlStr);
toast({ title: '保存成功', description: `${resource}/${name} 已更新` });
if (saveYamlFn) {
await saveYamlFn(yamlStr);
} else {
await kubernetesApi.updateResourceYaml(clusterCode, namespace, resource, name, yamlStr);
}
toast({ title: '保存成功', description: saveYamlFn ? '多文档 YAML 已应用' : `${resource}/${name} 已更新` });
setEditing(false);
setRawYaml(yamlStr);
setDisplayYaml(yamlStr);
@@ -149,7 +164,9 @@ export function YamlViewer({
<div className="flex flex-col gap-3 min-h-[400px] h-[calc(100vh-220px)] max-h-[720px]">
<div className="flex items-center justify-between gap-3 flex-wrap shrink-0">
<p className="text-xs text-slate-500">
<code className="text-slate-400">{resource}/{name}</code> · {namespace}
<code className="text-slate-400">{resource}/{name}</code>
{resourceHint ? <span className="text-slate-600"> · {resourceHint}</span> : null}
{' · '}{namespace}
</p>
{toolbar}
</div>
@@ -165,12 +182,12 @@ export function YamlViewer({
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-4 border-b border-slate-700 gap-3 shrink-0">
<div className="min-w-0">
<h2 className="text-lg font-bold text-slate-100 truncate">
<div className="min-w-0 flex-1">
<h2 className="text-lg font-bold text-slate-100 truncate" title={`${resCfg.label} YAML — ${name}`}>
{resCfg.label} YAML — {name}
{readOnly && <span className="ml-2 text-xs font-normal text-slate-500">只读</span>}
</h2>
<p className="text-xs text-slate-500">{namespace} · {clusterCode}</p>
<p className="text-xs text-slate-500 truncate" title={`${namespace} · ${clusterCode}`}>{namespace} · {clusterCode}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
{toolbar}
@@ -0,0 +1,100 @@
// Deployment 多文档 YAML(Deployment + 关联 Service)加载与保存 // Logan.Li
import { kubernetesApi } from '@/api';
import {
manifestToEditableYaml,
parseEditableManifest,
parseEditableManifests,
prepareManifestForSave,
} from './k8s-manifest-utils';
const toArray = (v: unknown) => (Array.isArray(v) ? v : ((v as Record<string, any>)?.list || (v as Record<string, any>)?.items || (v as Record<string, any>)?.data || []));
/** Service selector 是否匹配 Deployment 标签 // Logan.Li */
export function selectorMatches(
depSelector: Record<string, string>,
svcSelector: Record<string, string>,
): boolean {
if (Object.keys(svcSelector).length === 0) return false;
return Object.entries(svcSelector).every(([k, v]) => depSelector[k] === v);
}
/** 查找与 Deployment 关联的 Service 名称 // Logan.Li */
export function findMatchedServiceNames(
deploymentName: string,
depSelector: Record<string, string>,
services: any[],
): string[] {
const names = new Set<string>();
for (const svc of services) {
const svcName = svc.name || svc.metadata?.name || '';
if (!svcName) continue;
const svcSelector = svc.spec?.selector || svc.selector || {};
if (svcName === deploymentName || selectorMatches(depSelector, svcSelector)) {
names.add(svcName);
}
}
return [...names].sort();
}
/** 加载 Deployment + 关联 Service 的多文档 YAML // Logan.Li */
export async function loadDeploymentBundleYaml(
clusterCode: string,
namespace: string,
deploymentName: string,
): Promise<string> {
const depYaml = await kubernetesApi.getResourceYaml(clusterCode, namespace, 'deployments', deploymentName);
const { manifest: depManifest } = await parseEditableManifest(depYaml);
const depSelector = depManifest?.spec?.selector?.matchLabels
|| depManifest?.spec?.template?.metadata?.labels
|| {};
const svcListRes = await kubernetesApi.getNamespaceServices(clusterCode, namespace).catch(() => []);
const matchedSvcNames = findMatchedServiceNames(deploymentName, depSelector, toArray(svcListRes));
const docs: string[] = [];
if (depManifest) {
docs.push(await manifestToEditableYaml(depManifest));
} else {
const { yaml: stripped } = await parseEditableManifest(depYaml);
if (stripped.trim()) docs.push(stripped);
}
for (const svcName of matchedSvcNames) {
try {
const svcYaml = await kubernetesApi.getResourceYaml(clusterCode, namespace, 'services', svcName);
const { yaml: stripped } = await parseEditableManifest(svcYaml);
if (stripped.trim()) docs.push(stripped);
} catch {
// 单个 Service 拉取失败时跳过 // Logan.Li
}
}
return docs.join('\n---\n');
}
/** 保存多文档 YAML(Deployment + Service 等)// Logan.Li */
export async function saveDeploymentBundleYaml(
clusterCode: string,
namespace: string,
yamlText: string,
): Promise<void> {
const { manifests } = await parseEditableManifests(yamlText);
if (manifests.length === 0) throw new Error('YAML 内容为空或解析失败');
for (const manifest of manifests) {
const kind = String(manifest.kind || '').toLowerCase();
const name = String(manifest.metadata?.name || '').trim();
if (!kind || !name) throw new Error('每个 YAML 文档必须包含 kind 和 metadata.name');
const docYaml = await prepareManifestForSave(manifest);
if (kind === 'deployment') {
await kubernetesApi.updateResourceYaml(clusterCode, namespace, 'deployments', name, docYaml);
continue;
}
if (kind === 'service') {
await kubernetesApi.applyYAML(clusterCode, docYaml);
continue;
}
throw new Error(`不支持的资源类型: ${manifest.kind}(仅支持 Deployment / Service)`);
}
}
@@ -0,0 +1,6 @@
// Deployment 路由/新建占位符 // Logan.Li
export const CREATE_DEPLOYMENT_SLUG = '_create';
export function isCreateDeployment(name: string): boolean {
return name === CREATE_DEPLOYMENT_SLUG;
}
@@ -58,10 +58,10 @@ export function CreateHPADialog({ clusterCode, namespace, deploymentName, onCrea
<Plus className="h-3.5 w-3.5 mr-1" />创建 HPA
</Button>
{open && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={() => setOpen(false)}>
<div className="bg-slate-900 border border-slate-700 rounded-xl p-6 w-[480px] max-h-[80vh] overflow-auto" onClick={(e) => e.stopPropagation()}>
<h2 className="text-lg font-bold text-slate-100 mb-4">创建 HPA</h2>
<div className="space-y-3">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={() => setOpen(false)}>
<div className="bg-slate-900 border border-slate-700 rounded-xl w-[480px] max-w-[92vw] max-h-[85vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
<h2 className="text-lg font-bold text-slate-100 px-6 pt-6 pb-4 shrink-0">创建 HPA</h2>
<div className="flex-1 min-h-0 overflow-y-auto px-6 space-y-3">
<div>
<Label className="text-slate-400 text-sm">名称</Label>
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })}
@@ -70,7 +70,7 @@ export function CreateHPADialog({ clusterCode, namespace, deploymentName, onCrea
</div>
<div>
<Label className="text-slate-400 text-sm">目标</Label>
<p className="text-slate-300 text-sm mt-1 bg-slate-800 rounded px-3 py-2">
<p className="text-slate-300 text-sm mt-1 bg-slate-800 rounded px-3 py-2 truncate" title={`Deployment: ${deploymentName} · 命名空间: ${namespace}`}>
Deployment: {deploymentName} · 命名空间: {namespace}
</p>
</div>
@@ -103,7 +103,7 @@ export function CreateHPADialog({ clusterCode, namespace, deploymentName, onCrea
</div>
</div>
</div>
<div className="flex justify-end gap-2 mt-6">
<div className="flex justify-end gap-2 px-6 py-4 border-t border-slate-700 shrink-0">
<Button variant="outline" className="border-slate-700 text-slate-400" onClick={() => setOpen(false)}>取消</Button>
<Button className="bg-blue-600 hover:bg-blue-700" disabled={loading} onClick={handleCreate}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : '创建'}
@@ -1,13 +1,21 @@
// Deployment manifest 统一保存 // Logan.Li
import { kubernetesApi } from '@/api';
import { prepareManifestForSave } from './k8s-manifest-utils';
import { isCreateDeployment } from './deployment-constants';
export async function saveDeploymentManifest(
clusterCode: string,
namespace: string,
deploymentName: string,
manifest: Record<string, any>,
): Promise<void> {
): Promise<string> {
const resName = String(manifest?.metadata?.name || '').trim();
if (!resName) throw new Error('Deployment 名称不能为空');
const yaml = await prepareManifestForSave(manifest);
await kubernetesApi.updateResourceYaml(clusterCode, namespace, 'deployments', deploymentName, yaml);
if (isCreateDeployment(deploymentName)) {
await kubernetesApi.applyYAML(clusterCode, yaml);
} else {
await kubernetesApi.updateResourceYaml(clusterCode, namespace, 'deployments', resName, yaml);
}
return resName;
}
@@ -3,89 +3,166 @@ 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, FileCode,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
ChevronDown, ChevronRight, Terminal, HardDrive, MoreVertical, FileText, FileCode, Trash2,
} from 'lucide-react';
import { formatK8sTime, statusColor } from '../k8s-yaml-utils';
import { getPodTimeline, PodConditionsRow } from './helpers';
import { statusColor } from '../k8s-yaml-utils';
import { PodConditionsRow } from './helpers';
import type { DeploymentViewProps } from './types';
export function PodsTab({
clusterCode, pods, onShowPodLogs, onShowPodTerminal, onShowPodYaml, onShowPodFileBrowser,
onDeletePod,
expandedPod, setExpandedPod,
}: DeploymentViewProps & { expandedPod: string | null; setExpandedPod: (v: string | null) => void }) {
if (pods.length === 0) {
return <div className="text-center text-[var(--dg-text-muted)] py-12">暂无容器组</div>;
}
return (
<div className="space-y-2">
{pods.map(pod => {
const tl = getPodTimeline(pod);
const expanded = expandedPod === pod.name;
const nodeName = pod.node_name || pod.node;
return (
<Card key={pod.name} className="dg-card">
<CardContent className="p-4">
{/* Pod header row */}
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" className="h-6 w-6 text-[var(--dg-text-muted)]" onClick={() => setExpandedPod(expanded ? null : pod.name)}>
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</Button>
<span className="font-mono text-sm text-[var(--dg-text-primary)]">{pod.name}</span>
<Badge style={{ background: `${statusColor(pod.status)}22`, color: statusColor(pod.status), border: `1px solid ${statusColor(pod.status)}44` }}>
{pod.status || 'Unknown'}
</Badge>
<span className="text-xs text-[var(--dg-text-muted)]">
<button className="text-[var(--dg-blue)] hover:underline mr-2" onClick={() => window.open(`/kubernetes/${clusterCode}/nodes/${nodeName}`, '_blank')}>
{nodeName || '-'}
<Card className="dg-card overflow-hidden">
<CardContent className="p-0 overflow-x-auto">
{/* 表头:操作列固定宽度,避免更多按钮溢出卡片 // Logan.Li */}
<div className="grid grid-cols-[28px_minmax(140px,1fr)_72px_100px_96px_48px_56px_88px] gap-2 min-w-[740px] px-4 py-2.5 border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]/40 text-[11px] font-medium text-[var(--dg-text-muted)] uppercase tracking-wide">
<span />
<span>名称</span>
<span>状态</span>
<span>节点</span>
<span>IP</span>
<span className="text-center">重启</span>
<span>Age</span>
<span className="text-right">操作</span>
</div>
<div className="divide-y divide-[var(--dg-border)]">
{pods.map(pod => {
const expanded = expandedPod === pod.name;
const nodeName = pod.node_name || pod.node;
const sc = statusColor(pod.status);
const containers = (pod as Record<string, any>).containers as any[] | undefined;
return (
<div key={pod.name}>
{/* 列表行:紧凑信息,不重复展示完整时间线 // Logan.Li */}
<div
className={`grid grid-cols-[28px_minmax(140px,1fr)_72px_100px_96px_48px_56px_88px] gap-2 min-w-[740px] items-center px-4 py-3 transition-colors ${
expanded ? 'bg-[var(--dg-bg-tertiary)]/30' : 'hover:bg-[var(--dg-bg-tertiary)]/20'
}`}
>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-[var(--dg-text-muted)]"
onClick={() => setExpandedPod(expanded ? null : pod.name)}
>
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</Button>
<span className="font-mono text-sm text-[var(--dg-text-primary)] truncate" title={pod.name}>
{pod.name}
</span>
<Badge
className="w-fit text-[11px] font-normal"
style={{ background: `${sc}18`, color: sc, border: `1px solid ${sc}33` }}
>
{pod.status || 'Unknown'}
</Badge>
<button
className="text-xs text-[var(--dg-blue)] hover:underline truncate text-left"
title={nodeName}
onClick={() => nodeName && window.open(`/kubernetes/${clusterCode}/nodes/${nodeName}`, '_blank')}
>
{nodeName || '—'}
</button>
</span>
<span className="text-xs font-mono text-[var(--dg-text-muted)]">{pod.ip || pod.pod_ip || '-'}</span>
<span className="text-xs text-[var(--dg-text-muted)]">重启 {pod.restart_count || 0} 次</span>
<span className="text-xs text-[var(--dg-text-muted)]">{formatK8sTime(tl.startTime || pod.created_at)}</span>
<div className="flex-1" />
<Button variant="ghost" size="sm" className="h-7 text-xs text-[var(--dg-blue)]" onClick={() => onShowPodLogs(pod)}>
<FileText className="h-3.5 w-3.5 mr-1" />日志
</Button>
<Button variant="ghost" size="sm" className="h-7 text-xs text-emerald-400" onClick={() => onShowPodTerminal(pod)}>
<Terminal className="h-3.5 w-3.5 mr-1" />终端
</Button>
<Button variant="ghost" size="sm" className="h-7 text-xs text-amber-400" onClick={() => onShowPodFileBrowser(pod)}>
<HardDrive className="h-3.5 w-3.5 mr-1" />文件
</Button>
<Button variant="ghost" size="sm" className="h-7 text-xs text-slate-400 hover:text-blue-400" onClick={() => onShowPodYaml(pod)}>
<FileCode className="h-3.5 w-3.5 mr-1" />YAML
</Button>
</div>
{/* Expanded: timeline */}
{expanded && (
<div className="mt-3 pt-3 border-t border-[var(--dg-border)]">
<PodConditionsRow pod={pod} />
{/* Container details */}
{(pod as Record<string, any>).containers && ((pod as Record<string, any>).containers as any[]).length > 0 && (
<div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-2">
{((pod as Record<string, any>).containers as any[]).map((c: any, idx: number) => (
<div key={idx} className="p-2 rounded bg-[var(--dg-bg-tertiary)] text-xs">
<div className="flex items-center justify-between">
<span className="font-medium text-[var(--dg-text-primary)]">{c.name}</span>
<span className={c.ready ? 'text-emerald-400' : 'text-[var(--dg-text-muted)]'}>{c.ready ? 'Ready' : 'Pending'}</span>
</div>
<div className="text-[var(--dg-text-muted)] mt-1">{c.image}</div>
{c.resources && (
<div className="mt-1 flex gap-3">
<span>CPU: {c.resources?.requests?.cpu || '0'}/{c.resources?.limits?.cpu || '-'} m</span>
<span>内存: {c.resources?.requests?.memory || '0'}/{c.resources?.limits?.memory || '-'}</span>
</div>
)}
</div>
))}
</div>
)}
<span className="text-xs font-mono text-[var(--dg-text-muted)] truncate">
{pod.ip || pod.pod_ip || '—'}
</span>
<span className={`text-xs text-center font-mono ${(pod.restart_count || 0) > 0 ? 'text-amber-400' : 'text-[var(--dg-text-muted)]'}`}>
{pod.restart_count || 0}
</span>
<span className="text-xs text-[var(--dg-text-muted)] whitespace-nowrap">
{pod.age || '—'}
</span>
<div className="flex items-center justify-end gap-0.5 shrink-0 w-[88px]">
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0 text-emerald-400" title="终端" onClick={() => onShowPodTerminal(pod)}>
<Terminal className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0 text-amber-400" title="文件" onClick={() => onShowPodFileBrowser(pod)}>
<HardDrive className="h-3.5 w-3.5" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 shrink-0 text-[var(--dg-text-muted)] hover:text-[var(--dg-blue)]"
title="更多"
aria-label="更多"
>
<MoreVertical className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<DropdownMenuItem onClick={() => onShowPodLogs(pod)} className="text-[var(--dg-text-primary)]">
<FileText className="h-3.5 w-3.5 mr-2" /> 日志
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onShowPodYaml(pod)} className="text-[var(--dg-text-primary)]">
<FileCode className="h-3.5 w-3.5 mr-2" /> YAML
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onDeletePod?.(pod)} className="text-red-400 focus:text-red-400">
<Trash2 className="h-3.5 w-3.5 mr-2" /> 删除
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)}
</CardContent>
</Card>
);
})}
</div>
{/* 展开详情:时间线与容器信息仅在此处展示 // Logan.Li */}
{expanded && (
<div className="px-4 pb-4 pt-1 space-y-3 bg-[var(--dg-bg-tertiary)]/20">
<PodConditionsRow pod={pod} />
{containers && containers.length > 0 && (
<div>
<p className="text-xs text-[var(--dg-text-muted)] mb-2">容器</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{containers.map((c: any, idx: number) => (
<div key={idx} className="rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] px-3 py-2.5 text-xs">
<div className="flex items-center justify-between gap-2">
<span className="font-medium text-[var(--dg-text-primary)]">{c.name}</span>
<span className={c.ready ? 'text-emerald-400' : 'text-[var(--dg-text-muted)]'}>
{c.ready ? 'Ready' : 'Pending'}
</span>
</div>
<p className="text-[var(--dg-text-muted)] mt-1 truncate" title={c.image}>{c.image}</p>
{c.resources && (
<div className="mt-1.5 flex gap-4 text-[var(--dg-text-muted)]">
<span>CPU {c.resources?.requests?.cpu || '0'} / {c.resources?.limits?.cpu || '—'}</span>
<span>内存 {c.resources?.requests?.memory || '0'} / {c.resources?.limits?.memory || '—'}</span>
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
);
})}
</div>
</CardContent>
</Card>
);
}
@@ -5,6 +5,12 @@ import {
} from '../k8s-yaml-utils';
import type { PodItem } from './types';
export interface PodStage {
label: string;
ts?: string;
color: string;
}
// Pod 条件时间线提取 // Logan.Li
export function getPodTimeline(pod: PodItem) {
const cond: Record<string, any>[] = (pod as Record<string, any>).conditions || (pod as Record<string, any>).pod_conditions || [];
@@ -19,6 +25,17 @@ export function getPodTimeline(pod: PodItem) {
};
}
// 启动阶段列表(列表/详情共用) // Logan.Li
export function getPodStages(pod: PodItem): PodStage[] {
const tl = getPodTimeline(pod);
return [
{ 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 },
];
}
// 信息面板单项 // Logan.Li
export function InfoItem({ label, value }: { label: string; value: React.ReactNode }) {
return (
@@ -29,36 +46,99 @@ export function InfoItem({ label, value }: { label: string; value: React.ReactNo
);
}
// 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 },
];
// 列表行紧凑就绪指示(无时间戳,避免视觉疲劳) // Logan.Li
export function PodStatusCompact({ pod }: { pod: PodItem }) {
const stages = getPodStages(pod);
const done = stages.filter(s => s.ts).length;
const allReady = done === stages.length;
const pending = stages.find(s => !s.ts);
return (
<div className="flex items-center gap-2 overflow-x-auto">
{stages.map((s, i) => {
const prev = stages[i - 1];
const dur = elapsedSeconds(prev?.ts, s.ts);
return (
<div className="flex items-center gap-2 min-w-[88px]">
<div className="flex items-center gap-0.5" aria-label="启动进度">
{stages.map((s, i) => (
<Fragment key={s.label}>
<div className="flex flex-col items-center min-w-[80px]">
<div className="h-2 w-2 rounded-full" style={{ background: s.ts ? s.color : '#334155' }} />
<span className="text-[10px] mt-1 font-medium" style={{ color: s.ts ? s.color : '#475569' }}>{s.label}</span>
<span className="text-[9px] text-[var(--dg-text-muted)]">{s.ts ? formatK8sTime(s.ts) : '-'}</span>
</div>
<span
className="inline-block h-1.5 w-1.5 rounded-full shrink-0"
style={{ background: s.ts ? s.color : 'var(--dg-border)' }}
title={`${s.label}${s.ts ? ` · ${formatK8sTime(s.ts)}` : ''}`}
/>
{i < stages.length - 1 && (
<div className="flex flex-col items-center mx-0.5 min-w-[30px]">
<div className="h-0.5 w-8 bg-[var(--dg-border)]" />
<span className="text-[9px] text-[var(--dg-text-muted)] mt-0.5">{dur != null ? formatDuration(dur) : ''}</span>
</div>
<span
className="inline-block h-px w-2 shrink-0"
style={{ background: s.ts && stages[i + 1]?.ts ? s.color : 'var(--dg-border)' }}
/>
)}
</Fragment>
);
})}
))}
</div>
<span
className="text-[11px] whitespace-nowrap"
style={{ color: allReady ? STATUS_COLORS.green : 'var(--dg-text-muted)' }}
>
{allReady ? '就绪' : pending?.label || `${done}/${stages.length}`}
</span>
</div>
);
}
// 详情区完整启动时间线(仅展开或概览面板使用) // Logan.Li
export function PodConditionsRow({ pod }: { pod: PodItem }) {
const stages = getPodStages(pod);
const totalBoot = elapsedSeconds(stages[0]?.ts, stages[stages.length - 1]?.ts);
return (
<div className="rounded-lg bg-[var(--dg-bg-tertiary)]/60 px-4 py-3">
<div className="flex items-center justify-between mb-3">
<span className="text-xs text-[var(--dg-text-muted)]">启动时间线</span>
{totalBoot != null && (
<span className="text-[11px] text-[var(--dg-text-muted)]">
总耗时 <span className="font-mono text-[var(--dg-text-secondary)]">{formatDuration(totalBoot)}</span>
</span>
)}
</div>
<div className="flex items-start gap-0 overflow-x-auto">
{stages.map((s, i) => {
const prev = stages[i - 1];
const dur = elapsedSeconds(prev?.ts, s.ts);
const active = !!s.ts;
return (
<Fragment key={s.label}>
<div className="flex flex-col min-w-[108px]">
<div className="flex items-center gap-2">
<span
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[10px] font-semibold"
style={{
background: active ? `${s.color}22` : 'var(--dg-bg-secondary)',
color: active ? s.color : 'var(--dg-text-muted)',
border: `1px solid ${active ? `${s.color}55` : 'var(--dg-border)'}`,
}}
>
{i + 1}
</span>
<span className="text-xs font-medium" style={{ color: active ? s.color : 'var(--dg-text-muted)' }}>
{s.label}
</span>
</div>
<span className="mt-1.5 pl-7 text-[11px] font-mono text-[var(--dg-text-muted)]">
{s.ts ? formatK8sTime(s.ts) : '—'}
</span>
</div>
{i < stages.length - 1 && (
<div className="flex flex-col items-center justify-center px-1 pt-2.5 min-w-[48px]">
<div
className="h-px w-full"
style={{ background: active && stages[i + 1]?.ts ? s.color : 'var(--dg-border)' }}
/>
<span className="mt-1 text-[10px] text-[var(--dg-text-muted)]">
{dur != null ? formatDuration(dur) : ''}
</span>
</div>
)}
</Fragment>
);
})}
</div>
</div>
);
}
@@ -29,4 +29,5 @@ export interface DeploymentViewProps {
onShowPodTerminal: (pod: PodItem) => void;
onShowPodYaml: (pod: PodItem) => void;
onShowPodFileBrowser: (pod: PodItem) => void;
onDeletePod?: (pod: PodItem) => void;
}
@@ -2,9 +2,9 @@
// 作者: Logan.Li
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { AlertTriangle, Loader2, Minus, Plus, Trash2 } from 'lucide-react';
import { ModalShell } from './ModalShell';
import { UpdateImageModal } from './UpdateImageModal';
import type { ActionModal } from './types';
export interface ActionModalsProps {
@@ -42,26 +42,14 @@ export function ActionModals({
<>
{/* ============ 弹窗:调整镜像版本 ============ */}
{modal?.kind === 'image' && (
<ModalShell title="调整镜像版本" onClose={onClose}>
<div className="space-y-3">
<div>
<Label className="text-slate-400 text-sm">当前镜像</Label>
<p className="text-xs text-slate-500 font-mono mt-1 bg-slate-800 rounded px-3 py-2">{deployment.image || '-'}</p>
</div>
<div>
<Label className="text-slate-400 text-sm">新镜像地址</Label>
<Input value={newImage} onChange={e => setNewImage(e.target.value)}
placeholder="harbor.example.com/app:v2.0"
className="mt-1 bg-slate-800 border-slate-700 text-slate-200 text-sm font-mono" />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" className="border-slate-700 text-slate-400" onClick={onClose}>取消</Button>
<Button className="bg-blue-600 hover:bg-blue-700" disabled={actionLoading === 'image' || !newImage.trim()} onClick={onUpdateImage}>
{actionLoading === 'image' ? <Loader2 className="h-4 w-4 animate-spin" /> : '更新镜像'}
</Button>
</div>
</div>
</ModalShell>
<UpdateImageModal
deployment={deployment}
newImage={newImage}
setNewImage={setNewImage}
actionLoading={actionLoading}
onClose={onClose}
onUpdateImage={onUpdateImage}
/>
)}
{/* ============ 弹窗:伸缩 ============ */}
@@ -1,49 +1,135 @@
// CICDWebhookContent.tsx — CI/CD Webhook 弹窗内容
// 作者: Logan.Li
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { useToast } from '@/hooks/use-toast';
import { useUserStore } from '@/store';
// CICDWebhookContent.tsx — CI/CD Webhook 弹窗内容 // Logan.Li
import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { Loader2 } from 'lucide-react';
import { CodeSnippetBlock } from '@/components/ui/code-snippet-block';
import { userApi } from '@/api';
import { getPublicApiBase } from '@/utils/request';
import { getSessionApiAccessKey } from '@/utils/storage';
export interface CICDWebhookContentProps {
clusterCode: string;
namespace: string;
deploymentName: string;
/** 当前 Deployment 镜像 // Logan.Li */
image?: string;
/** 主容器名(可选,多容器时建议填写)// Logan.Li */
containerName?: string;
}
export function CICDWebhookContent({ clusterCode, namespace, deploymentName }: CICDWebhookContentProps) {
const { toast } = useToast();
const token = useUserStore(( s: any) => s.token) || localStorage.getItem('token') || '';
const webhookUrl = `${window.location.origin}/api/v1/clusters/${clusterCode}/namespaces/${namespace}/deployments/${deploymentName}/update-image`;
const curlCmd = `curl -X POST '${webhookUrl}' \\
-H 'Authorization: Bearer ${token ? token.substring(0, 16) + '...' : 'YOUR_TOKEN'}' \\
-H 'Content-Type: application/json' \\
-d '{"image": "harbor.example.com/app:v1.0", "container": "your-container-name"}'`;
export function CICDWebhookContent({
clusterCode,
namespace,
deploymentName,
image = '',
containerName = '',
}: CICDWebhookContentProps) {
const apiBase = getPublicApiBase();
const [keyLoading, setKeyLoading] = useState(true);
const [hasKey, setHasKey] = useState(false);
const [keyExpired, setKeyExpired] = useState(false);
const [accessKey, setAccessKey] = useState('');
const copy = (text: string) => {
navigator.clipboard.writeText(text).then(() => toast({ title: '已复制' })).catch(() => {});
};
useEffect(() => {
let cancelled = false;
(async () => {
setKeyLoading(true);
const cached = getSessionApiAccessKey();
if (cached) setAccessKey(cached);
try {
const meta = await userApi.getApiAccessKey();
if (!cancelled) {
setHasKey(!!meta?.has_token);
setKeyExpired(!!meta?.expired);
}
} catch {
if (!cancelled) setHasKey(!!cached);
} finally {
if (!cancelled) setKeyLoading(false);
}
})();
return () => { cancelled = true; };
}, []);
const updateUrl = `${apiBase}/kubernetes/${clusterCode}/namespace/${namespace}/deployments/${deploymentName}/update-image`;
const restartUrl = `${apiBase}/kubernetes/${clusterCode}/namespace/${namespace}/deployments/${deploymentName}/restart`;
const updateBody = useMemo(() => {
const body: Record<string, string> = { image: image || '<新镜像地址>' };
if (containerName) body.container_name = containerName;
return JSON.stringify(body);
}, [image, containerName]);
const updateCurl = accessKey
? `curl -X POST '${updateUrl}' \\
-H 'X-AIOps-Access-Key: ${accessKey}' \\
-H 'Content-Type: application/json' \\
-d '${updateBody}'`
: `curl -X POST '${updateUrl}' \\
-H 'X-AIOps-Access-Key: <个人中心生成的访问密钥>' \\
-H 'Content-Type: application/json' \\
-d '${updateBody}'`;
const restartCurl = accessKey
? `curl -X POST '${restartUrl}' \\
-H 'X-AIOps-Access-Key: ${accessKey}'`
: `curl -X POST '${restartUrl}' \\
-H 'X-AIOps-Access-Key: <个人中心生成的访问密钥>'`;
const canCopy = !!accessKey && !keyExpired;
return (
<div className="space-y-4">
<div>
<Label className="text-slate-400 text-sm">Webhook URL</Label>
<div className="flex items-center gap-2 mt-1">
<code className="flex-1 text-xs text-slate-300 bg-slate-800 rounded px-3 py-2 break-all font-mono">{webhookUrl}</code>
<Button variant="outline" size="sm" className="border-slate-700 text-slate-400 shrink-0" onClick={() => copy(webhookUrl)}>复制</Button>
</div>
<div className="rounded-md bg-[var(--dg-bg-tertiary)]/50 px-3 py-2 text-xs text-[var(--dg-text-muted)] leading-relaxed">
{keyLoading ? (
<span className="inline-flex items-center gap-1.5">
<Loader2 className="h-3 w-3 animate-spin" />正在读取 API 密钥…
</span>
) : keyExpired ? (
<>
访问凭证已过期,请前往
<Link to="/profile" className="text-[var(--dg-blue)] hover:underline mx-1">个人中心</Link>
调整有效期或轮换凭证后再复制 curl。
</>
) : accessKey ? (
<>已填入本机保存的访问凭证,复制 curl 后可直接在终端执行触发更新。</>
) : hasKey ? (
<>
已检测到访问凭证,但本机未缓存明文。请前往
<Link to="/profile" className="text-[var(--dg-blue)] hover:underline mx-1">个人中心</Link>
轮换凭证一次,之后 curl 将自动填入。
</>
) : (
<>
请先在
<Link to="/profile" className="text-[var(--dg-blue)] hover:underline mx-1">个人中心</Link>
签发 CI/CD 访问凭证,curl 将自动填入。
</>
)}
</div>
<div>
<Label className="text-slate-400 text-sm">curl 示例</Label>
<div className="relative mt-1">
<pre className="text-xs text-slate-300 bg-slate-800 rounded p-3 overflow-x-auto font-mono whitespace-pre-wrap">{curlCmd}</pre>
<Button variant="outline" size="sm" className="absolute top-2 right-2 border-slate-700 text-slate-400" onClick={() => copy(curlCmd)}>复制</Button>
</div>
</div>
<div className="text-xs text-slate-500 space-y-1">
<p>• 使用 Bearer Token 认证(JWT 登录令牌)</p>
<p>• <code className="text-slate-400">image</code> 必填,指定新镜像地址</p>
<p>• <code className="text-slate-400">container</code> 可选,不填则更新第一个容器</p>
<CodeSnippetBlock
label="更新镜像"
code={updateCurl}
copyLabel="curl 已复制,可直接粘贴到终端执行"
copyDisabled={!canCopy}
copyDisabledTitle="请先在个人中心签发访问凭证"
/>
<CodeSnippetBlock
label="滚动重启"
code={restartCurl}
copyLabel="curl 已复制,可直接粘贴到终端执行"
copyDisabled={!canCopy}
copyDisabledTitle="请先在个人中心签发访问凭证"
/>
<div className="text-xs text-[var(--dg-text-muted)] space-y-1">
<p>• 示例镜像:<code className="text-[var(--dg-text-secondary)]">{image || '—'}</code>
{containerName ? <> · 容器 <code className="text-[var(--dg-text-secondary)]">{containerName}</code></> : null}
</p>
<p>• 使用长期 API 密钥,不会随登录 JWT 过期</p>
<p>• 修改 <code className="text-[var(--dg-text-secondary)]">image</code> 字段后执行 curl 即可触发滚动更新</p>
<p>• 也可用 <code className="text-[var(--dg-text-secondary)]">Authorization: Bearer &lt;密钥&gt;</code></p>
</div>
</div>
);
@@ -12,11 +12,13 @@ export interface EditModeContainerProps {
namespace: string;
deploymentName: string;
manifest: any | null;
onSaved: () => void;
onSaved: (updated?: any) => void;
onCancel: () => void;
/** 新建模式:名称可编辑,保存走 apply // Logan.Li */
isCreate?: boolean;
}
export function EditModeContainer({ clusterCode, namespace, deploymentName, manifest, onSaved, onCancel }: EditModeContainerProps) {
export function EditModeContainer({ clusterCode, namespace, deploymentName, manifest, onSaved, onCancel, isCreate = false }: EditModeContainerProps) {
type EditTab = 'basic' | 'containers' | 'storage' | 'advanced' | 'service';
const [activeTab, setActiveTab] = useState<EditTab>('basic');
const [editManifest, setEditManifest] = useState<any>(manifest);
@@ -27,7 +29,7 @@ export function EditModeContainer({ clusterCode, namespace, deploymentName, mani
const handleSaved = (updated?: any) => {
if (updated) setEditManifest(updated);
onSaved();
onSaved(updated);
};
const tabs: Array<{ key: EditTab; label: string; icon: typeof Settings }> = [
@@ -45,6 +47,7 @@ export function EditModeContainer({ clusterCode, namespace, deploymentName, mani
manifest: editManifest,
onSaved: handleSaved,
onCancel,
isCreate,
};
return (
@@ -1,5 +1,4 @@
// ModalShell.tsx — 通用 Modal 外壳
// 作者: Logan.Li
// ModalShell.tsx — 通用 Modal 外壳(固定标题 + 内容区滚动)// Logan.Li
import { Button } from '@/components/ui/button';
import { X } from 'lucide-react';
@@ -7,17 +6,25 @@ export interface ModalShellProps {
title: string;
children: React.ReactNode;
onClose: () => void;
/** lg:宽弹窗;xl:镜像地址等长文本场景 // Logan.Li */
size?: 'default' | 'lg' | 'xl';
}
export function ModalShell({ title, children, onClose }: ModalShellProps) {
export function ModalShell({ title, children, onClose, size = 'default' }: ModalShellProps) {
const widthClass =
size === 'xl' ? 'w-[780px]' : size === 'lg' ? 'w-[680px]' : 'w-[460px]';
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 p-6 w-[460px] max-w-[90vw] shadow-2xl" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h2 className="text-base font-bold text-slate-100">{title}</h2>
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400" onClick={onClose}><X className="h-4 w-4" /></Button>
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
<div
className={`bg-slate-900 border border-slate-700 rounded-xl ${widthClass} max-w-[92vw] max-h-[85vh] flex flex-col shadow-2xl`}
onClick={e => e.stopPropagation()}
>
<div className="flex items-center justify-between gap-3 px-6 pt-6 pb-4 shrink-0">
<h2 className="text-base font-bold text-slate-100 truncate min-w-0" title={title}>{title}</h2>
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0 text-slate-400" onClick={onClose}><X className="h-4 w-4" /></Button>
</div>
{children}
<div className="flex-1 min-h-0 overflow-y-auto px-6 pb-6">{children}</div>
</div>
</div>
);
@@ -63,13 +63,13 @@ export function PodLogsModal({ clusterCode, namespace, podName, containers, onCl
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-[80vh] flex flex-col" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between p-3 border-b border-slate-700">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-blue-400" />
<span className="text-sm font-semibold text-slate-200">{podName}</span>
{container && <Badge variant="outline" className="border-slate-700 text-slate-400 text-[10px]">{container}</Badge>}
<div className="flex items-center justify-between p-3 border-b border-slate-700 gap-3 shrink-0">
<div className="flex items-center gap-2 min-w-0 flex-1">
<FileText className="h-4 w-4 shrink-0 text-blue-400" />
<span className="text-sm font-semibold text-slate-200 truncate" title={podName}>{podName}</span>
{container && <Badge variant="outline" className="border-slate-700 text-slate-400 text-[10px] shrink-0">{container}</Badge>}
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 shrink-0 flex-wrap justify-end">
{containers.length > 0 && (
<select value={container} onChange={e => setContainer(e.target.value)}
className="bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 px-2 py-1">
@@ -0,0 +1,154 @@
// UpdateImageModal.tsx — 调整镜像版本弹窗(地址 + Tag 分栏)// Logan.Li
import { useEffect, useMemo, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { ArrowRight, Loader2 } from 'lucide-react';
import { joinImageRef, parseImageRef } from '../image-ref-utils';
import { ModalShell } from './ModalShell';
export interface UpdateImageModalProps {
deployment: any;
newImage: string;
setNewImage: (value: string) => void;
actionLoading: string;
onClose: () => void;
onUpdateImage: () => void;
}
export function UpdateImageModal({
deployment,
newImage,
setNewImage,
actionLoading,
onClose,
onUpdateImage,
}: UpdateImageModalProps) {
const currentImage = deployment?.image || '';
const current = useMemo(() => parseImageRef(currentImage), [currentImage]);
const [newRepo, setNewRepo] = useState('');
const [newTag, setNewTag] = useState('');
useEffect(() => {
const parsed = parseImageRef(newImage || currentImage);
setNewRepo(parsed.repository);
setNewTag(parsed.tag);
}, [newImage, currentImage]);
const previewImage = useMemo(() => joinImageRef(newRepo, newTag), [newRepo, newTag]);
const tagChanged = newTag.trim() !== current.tag;
const repoChanged = newRepo.trim() !== current.repository;
const hasChanges = previewImage !== currentImage;
const syncImage = (repo: string, tag: string) => {
setNewImage(joinImageRef(repo, tag));
};
return (
<ModalShell title="调整镜像版本" onClose={onClose} size="xl">
<div className="space-y-5">
{/* 当前镜像:只读展示完整地址与 Tag // Logan.Li */}
<div className="rounded-lg border border-slate-700/80 bg-slate-800/40 p-4 space-y-3">
<p className="text-xs font-medium text-slate-400">当前镜像</p>
<div className="grid grid-cols-[1fr_180px] gap-3">
<div>
<Label className="text-slate-500 text-xs">镜像地址</Label>
<p
className="mt-1.5 text-sm text-slate-200 font-mono break-all leading-relaxed bg-slate-900/60 rounded-md px-3 py-2.5 min-h-[42px]"
title={current.repository || '-'}
>
{current.repository || '-'}
</p>
</div>
<div>
<Label className="text-slate-500 text-xs">当前 Tag</Label>
<p
className="mt-1.5 text-sm text-cyan-300 font-mono break-all leading-relaxed bg-slate-900/60 rounded-md px-3 py-2.5 min-h-[42px]"
title={current.tag || '-'}
>
{current.tag || '-'}
</p>
</div>
</div>
<p className="text-[11px] text-slate-500 font-mono break-all" title={currentImage}>
完整引用:<span className="text-slate-400">{currentImage || '-'}</span>
</p>
</div>
<div className="flex items-center justify-center text-slate-600">
<ArrowRight className="h-4 w-4" />
</div>
{/* 新镜像:地址与 Tag 分栏编辑 // Logan.Li */}
<div className="rounded-lg border border-blue-500/30 bg-blue-500/5 p-4 space-y-3">
<p className="text-xs font-medium text-blue-300/90">新镜像</p>
<div className="grid grid-cols-[1fr_180px] gap-3">
<div>
<Label className="text-slate-400 text-xs">镜像地址</Label>
<Input
value={newRepo}
onChange={e => {
const v = e.target.value;
setNewRepo(v);
syncImage(v, newTag);
}}
placeholder="harbor.example.com/project/app"
className="mt-1.5 h-10 bg-slate-800 border-slate-700 text-slate-200 text-sm font-mono"
/>
</div>
<div>
<Label className="text-slate-400 text-xs">新版本 Tag</Label>
<Input
value={newTag}
onChange={e => {
const v = e.target.value;
setNewTag(v);
syncImage(newRepo, v);
}}
placeholder="v2.0.0"
className="mt-1.5 h-10 bg-slate-800 border-slate-700 text-cyan-200 text-sm font-mono"
/>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs">
<span className="text-slate-500">Tag 变更:</span>
<code className="px-2 py-0.5 rounded bg-slate-800 text-slate-400 font-mono">{current.tag}</code>
<ArrowRight className="h-3 w-3 text-slate-600" />
<code
className={`px-2 py-0.5 rounded font-mono ${
tagChanged ? 'bg-cyan-500/15 text-cyan-300' : 'bg-slate-800 text-slate-400'
}`}
>
{newTag.trim() || '-'}
</code>
{!tagChanged && !repoChanged && (
<span className="text-slate-600">(未修改)</span>
)}
{repoChanged && !tagChanged && (
<span className="text-amber-400/80">地址已变更</span>
)}
</div>
<p className="text-[11px] text-slate-500 font-mono break-all">
更新预览:<span className="text-slate-300">{previewImage || '-'}</span>
</p>
</div>
<div className="flex justify-end gap-2 pt-1">
<Button variant="outline" className="border-slate-700 text-slate-400" onClick={onClose}>
取消
</Button>
<Button
className="bg-blue-600 hover:bg-blue-700"
disabled={actionLoading === 'image' || !previewImage.trim() || !hasChanges}
onClick={onUpdateImage}
>
{actionLoading === 'image' ? <Loader2 className="h-4 w-4 animate-spin" /> : '更新镜像'}
</Button>
</div>
</div>
</ModalShell>
);
}
@@ -0,0 +1,44 @@
// image-ref-utils.ts — 容器镜像引用解析与拼接 // Logan.Li
export interface ImageRefParts {
repository: string;
tag: string;
/** digest 形式时存在,如 sha256:abc... // Logan.Li */
digest?: string;
}
/** 解析镜像引用为仓库地址 + tag(兼容 registry:port 与 digest)// Logan.Li */
export function parseImageRef(image: string): ImageRefParts {
const raw = (image || '').trim();
if (!raw) return { repository: '', tag: 'latest' };
if (raw.includes('@')) {
const at = raw.lastIndexOf('@');
return {
repository: raw.slice(0, at),
tag: raw.slice(at + 1),
digest: raw.slice(at + 1),
};
}
const slashIdx = raw.lastIndexOf('/');
const colonIdx = raw.lastIndexOf(':');
if (colonIdx > slashIdx) {
return {
repository: raw.slice(0, colonIdx),
tag: raw.slice(colonIdx + 1) || 'latest',
};
}
return { repository: raw, tag: 'latest' };
}
/** 拼接完整镜像引用 // Logan.Li */
export function joinImageRef(repository: string, tag: string): string {
const repo = repository.trim();
const t = tag.trim();
if (!repo) return '';
if (!t) return repo;
if (t.startsWith('sha256:')) return `${repo}@${t}`;
return `${repo}:${t}`;
}
@@ -261,13 +261,22 @@ export function stripRuntimeFields(manifest: Record<string, any>): Record<string
}
export async function parseManifest(text: string): Promise<Record<string, any> | null> {
if (!text?.trim()) return null;
try { return JSON.parse(text); } catch { /* fallthrough */ }
const docs = await parseManifests(text);
return docs[0] || null;
}
/** 解析多文档 YAML(--- 分隔)// Logan.Li */
export async function parseManifests(text: string): Promise<Record<string, any>[]> {
if (!text?.trim()) return [];
try {
const parsed = JSON.parse(text);
return parsed && typeof parsed === 'object' ? [parsed] : [];
} catch { /* fallthrough */ }
try {
const jsyaml: Record<string, any> = await import('js-yaml');
const parsed = jsyaml.load(text);
return parsed && typeof parsed === 'object' ? parsed : null;
} catch { return null; }
const docs = jsyaml.loadAll(text) as unknown[];
return docs.filter((doc): doc is Record<string, any> => !!doc && typeof doc === 'object');
} catch { return []; }
}
export async function dumpManifest(manifest: Record<string, any>): Promise<string> {
@@ -282,16 +291,26 @@ export async function manifestToEditableYaml(manifest: Record<string, any>): Pro
return cleanYAML(dumped);
}
/** 多文档 YAML → 可编辑 manifests + 精简 YAML // Logan.Li */
export async function parseEditableManifests(text: string): Promise<{
manifests: Record<string, any>[];
yaml: string;
}> {
const parsed = await parseManifests(text);
if (parsed.length === 0) return { manifests: [], yaml: text };
const stripped = parsed.map((doc) => stripRuntimeFields(doc));
const parts: string[] = [];
for (const doc of stripped) parts.push(await manifestToEditableYaml(doc));
return { manifests: stripped, yaml: parts.join('\n---\n') };
}
/** 原始 YAML → 可编辑 manifest + 精简 YAML // Logan.Li */
export async function parseEditableManifest(text: string): Promise<{
manifest: Record<string, any> | null;
yaml: string;
}> {
const parsed = await parseManifest(text);
if (!parsed) return { manifest: null, yaml: text };
const stripped = stripRuntimeFields(parsed);
const yaml = await manifestToEditableYaml(stripped);
return { manifest: stripped, yaml };
const { manifests, yaml } = await parseEditableManifests(text);
return { manifest: manifests[0] || null, yaml };
}
/** 保存前:剥离运行时字段 + 清理默认值 // Logan.Li */
@@ -301,7 +320,10 @@ export async function prepareManifestForSave(manifest: Record<string, any>): Pro
/** 从 YAML 文本保存(解析失败时仅做文本清理)// Logan.Li */
export async function prepareYamlForSave(text: string): Promise<string> {
const parsed = await parseManifest(text);
if (parsed) return prepareManifestForSave(parsed);
return cleanYAML(text);
const parsed = await parseManifests(text);
if (parsed.length === 0) return cleanYAML(text);
if (parsed.length === 1) return prepareManifestForSave(parsed[0]);
const parts: string[] = [];
for (const doc of parsed) parts.push(await prepareManifestForSave(doc));
return parts.join('\n---\n');
}
@@ -57,6 +57,30 @@ export function createEmptyManifest(resource: string, namespace: string, name: s
resources: { requests: { storage: '1Gi' } },
},
};
case 'deployments':
return {
apiVersion: 'apps/v1',
kind: 'Deployment',
metadata: { name: name || 'my-app', namespace, labels: { app: name || 'my-app' } },
spec: {
replicas: 1,
selector: { matchLabels: { app: name || 'my-app' } },
template: {
metadata: { labels: { app: name || 'my-app' } },
spec: {
containers: [{
name: 'main',
image: 'nginx:latest',
ports: [{ containerPort: 80, protocol: 'TCP' }],
resources: {
requests: { cpu: '100m', memory: '128Mi' },
limits: { cpu: '500m', memory: '512Mi' },
},
}],
},
},
},
};
default:
return { apiVersion: 'v1', kind: 'Unknown', metadata: { name, namespace } };
}
@@ -9,6 +9,7 @@ export const NODE_TYPE_LABELS: Record<string, string> = {
loadbalancer: 'Ingress',
service: 'Service',
app: 'Deployment',
pod: 'Pod',
server: 'Node',
database: '数据库',
external: '外部服务',
@@ -33,6 +34,7 @@ export function getNodeIcon(type: string, className = 'h-5 w-5') {
case 'cluster': return <Cloud className={className} />;
case 'app': return <Boxes className={className} />;
case 'service': return <Network className={className} />;
case 'pod': return <Box className={className} />;
case 'database': return <Database className={className} />;
case 'external': return <Zap className={className} />;
case 'custom': return <Box className={className} />;
@@ -48,6 +50,7 @@ export function getNodeColor(type: string) {
case 'cluster': return 'bg-cyan-500/20 text-cyan-400 border-cyan-500/30';
case 'app': return 'bg-rose-500/20 text-rose-400 border-rose-500/30';
case 'service': return 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30';
case 'pod': return 'bg-teal-500/20 text-teal-400 border-teal-500/30';
case 'database': return 'bg-amber-500/20 text-amber-400 border-amber-500/30';
case 'external': return 'bg-orange-500/20 text-orange-400 border-orange-500/30';
case 'custom': return 'bg-slate-500/20 text-slate-400 border-slate-500/30';
@@ -56,18 +59,19 @@ export function getNodeColor(type: string) {
}
/**
* buildTopology — 从真实 K8s 资源构建分层拓扑图
* 层级: Cluster → Ingress → Service → Deployment → Node
* buildTopology — 从真实 K8s 资源构建分层拓扑图(自动关联)
* 推荐层级: Cluster → Ingress(域名+路径) → Service → Deployment → Pod
* 支持基于 Ingress rules、Service selector、Pod 标签的自动连线
*/
export function buildTopology( cluster: any, ingresses: any[], services: any[], deployments: any[], nodes: any[],
export function buildTopology( cluster: any, ingresses: any[], services: any[], deployments: any[], nodes: any[], pods: any[] = [],
): { nodes: TopologyNode[]; edges: TopologyEdge[] } {
const tNodes: TopologyNode[] = [];
const tEdges: TopologyEdge[] = [];
const MAX_PER_LAYER = 12;
const maxLayerCount = Math.max(1, ingresses.length, services.length, deployments.length, nodes.length);
const canvasW = Math.max(900, Math.min(maxLayerCount * 130, 2400));
const layerY = [60, 180, 320, 480, 640];
const maxLayerCount = Math.max(1, ingresses.length, services.length, deployments.length, pods.length || nodes.length);
const canvasW = Math.max(900, Math.min(maxLayerCount * 140, 2600));
const layerY = [50, 160, 280, 410, 540, 680]; // cluster, ingress, svc, deploy, pod, node (可选)
const distributeX = (count: number): number[] => {
const cap = Math.min(count, MAX_PER_LAYER);
@@ -85,12 +89,24 @@ export function buildTopology( cluster: any, ingresses: any[], services: any[],
data: { kind: 'cluster', version: cluster.version, status: cluster.status, api_server: cluster.api_server, node_count: cluster.node_count, pod_count: cluster.pod_count },
});
// Layer 1: Ingress
// Layer 1: Ingress (优先显示 域名 + 路径)
const ingCap = ingresses.slice(0, MAX_PER_LAYER);
const ingXs = distributeX(ingCap.length);
ingCap.forEach((ing, i) => {
const id = `ingress-${ing.id}`;
tNodes.push({ id, name: ing.name, type: 'loadbalancer', x: ingXs[i], y: layerY[1], data: { kind: 'ingress', namespace: ing.namespace, backend: ing.backend, tls: ing.tls, hosts: ing.hosts } });
const hosts = Array.isArray(ing.hosts) ? ing.hosts : (ing.hosts ? JSON.parse(ing.hosts) : []);
const hostLabel = hosts.length ? hosts.join(',') : (ing.name || '');
// 尝试显示路径 (如果数据中有 paths 或 backend)
const pathLabel = ing.paths ? ing.paths : (ing.backend ? `→${ing.backend}` : '');
const displayName = pathLabel ? `${hostLabel}\n${pathLabel}` : hostLabel;
tNodes.push({
id,
name: displayName,
type: 'loadbalancer',
x: ingXs[i],
y: layerY[1],
data: { kind: 'ingress', namespace: ing.namespace, backend: ing.backend, tls: ing.tls, hosts }
});
tEdges.push({ id: `e-${clusterId}-${id}`, source: clusterId, target: id, label: 'route' });
});
@@ -124,7 +140,41 @@ export function buildTopology( cluster: any, ingresses: any[], services: any[],
tNodes.push({ id, name: dep.name, type: 'app', x: depXs[i], y: layerY[3], data: { kind: 'deployment', namespace: dep.namespace, replicas: dep.replicas, ready_replicas: dep.ready_replicas, available_replicas: dep.available_replicas, image: dep.image } });
});
// Service → Deployment edges
// Layer 4: Pod (自动关联 Deployment)
const podCap = (pods || []).slice(0, MAX_PER_LAYER);
const podXs = distributeX(podCap.length);
podCap.forEach((pod, i) => {
const id = `pod-${pod.id || pod.name}`;
tNodes.push({
id,
name: pod.name || 'pod',
type: 'pod', // 需要在类型中支持
x: podXs[i],
y: layerY[4],
data: { kind: 'pod', namespace: pod.namespace, node: pod.node_name || pod.node, status: pod.status, ip: pod.ip }
});
});
// Deployment → Pod 连线 (基于 deployment 名称匹配或 namespace)
if (depCap.length > 0 && podCap.length > 0) {
depCap.forEach((dep) => {
const depNodeId = `deploy-${dep.id}`;
let matchedPods = podCap.filter((p) => p.namespace === dep.namespace);
// 简单匹配:pod 名称包含 deploy 名称 或 labels
matchedPods = matchedPods.filter((p) =>
(p.name || '').includes(dep.name) ||
(dep.name && (p.name || '').startsWith(dep.name))
);
if (matchedPods.length === 0) matchedPods = podCap.filter((p) => p.namespace === dep.namespace).slice(0, 3);
if (matchedPods.length === 0) matchedPods = podCap.slice(0, 2);
matchedPods.slice(0, 3).forEach((p) => {
const pid = `pod-${p.id || p.name}`;
tEdges.push({ id: `e-${depNodeId}-${pid}`, source: depNodeId, target: pid, label: 'owns' });
});
});
}
// Service → Deployment edges (基于 selector 自动匹配)
if (svcCap.length > 0 && depCap.length > 0) {
svcCap.forEach((svc) => {
const svcNodeId = `service-${svc.id}`;
@@ -147,18 +197,23 @@ export function buildTopology( cluster: any, ingresses: any[], services: any[],
});
}
// Layer 4: Node
// Layer 5: Node (宿主机,可选)
const nodeCap = nodes.slice(0, MAX_PER_LAYER);
const nodeXs = distributeX(nodeCap.length);
nodeCap.forEach((n, i) => {
const id = `k8snode-${n.id}`;
tNodes.push({ id, name: n.name, type: 'server', x: nodeXs[i], y: layerY[4], data: { kind: 'node', ip: n.ip, role: n.role, status: n.status, os: n.os, cpu: n.cpu, memory: n.memory, cpu_usage: n.cpu_usage, memory_usage: n.memory_usage, pod_count: n.pod_count } });
tNodes.push({ id, name: n.name, type: 'server', x: nodeXs[i], y: layerY[5], data: { kind: 'node', ip: n.ip, role: n.role, status: n.status, os: n.os, cpu: n.cpu, memory: n.memory, cpu_usage: n.cpu_usage, memory_usage: n.memory_usage, pod_count: n.pod_count } });
});
// Deployment → Node edges
// Deployment / Pod → Node (宿主机) 连线
const workerNodes = nodeCap.filter((n) => (n.role || '').toLowerCase() === 'worker');
const schedulableNodes = workerNodes.length > 0 ? workerNodes : nodeCap;
if (depCap.length > 0 && schedulableNodes.length > 0) {
if (podCap.length > 0 && schedulableNodes.length > 0) {
podCap.forEach((p) => {
const pid = `pod-${p.id || p.name}`;
schedulableNodes.slice(0, 2).forEach((n) => tEdges.push({ id: `e-${pid}-k8snode-${n.id}`, source: pid, target: `k8snode-${n.id}`, label: 'on' }));
});
} else if (depCap.length > 0 && schedulableNodes.length > 0) {
depCap.forEach((dep) => {
const depNodeId = `deploy-${dep.id}`;
schedulableNodes.slice(0, 3).forEach((n) => tEdges.push({ id: `e-${depNodeId}-k8snode-${n.id}`, source: depNodeId, target: `k8snode-${n.id}`, label: 'schedule' }));