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:
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 <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 <密钥></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' }));
|
||||
|
||||
Reference in new issue
Block a user