feat: K8s→Kubernetes命名统一 + 工具安装bug修复 + 资源可视化编辑器
1. 工具安装bug修复 (P0 critical):
- 根因: ToolConfigHandler.InstallTool的goroutine只打了日志,
从未调用实际安装逻辑
- 修复: 注入ToolInstaller到handler, 新增ForceInstall方法绕过sync.Once
- 影响: 流水线工具配置页「安装」按钮现在能真正触发下载安装
2. K8s→Kubernetes命名统一:
- Dashboard/Prometheus/AIOpsAssistant等8个文件12处用户可见文案
- 内部变量名/文件名/API group保持不变
3. 资源可视化编辑器 (新增ResourceVisualEditor.tsx):
- ConfigMap: 键值对表格编辑器(增删改)
- Secret: 键值对编辑器(自动Base64编解码)
- Service: 端口/类型/选择器可视化配置
- Ingress: Host/Path路由规则可视化编辑
- PVC: 存储大小/访问模式/StorageClass配置
- 双模式: 可视化编辑 ↔ YAML编辑一键切换
- NamespaceResourcePage: 所有资源类型添加编辑按钮入口
This commit is contained in:
1 parent
e29dfb5868
commit
aaab923f4e
14 files changed
+747
-19
No files matched your search
@@ -6,6 +6,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
|
|
||||||
"ezops/internal/models"
|
"ezops/internal/models"
|
||||||
|
"ezops/pkg/toolinstaller"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -16,11 +17,12 @@ import (
|
|||||||
// ToolConfigHandler handles deploy tool configuration
|
// ToolConfigHandler handles deploy tool configuration
|
||||||
type ToolConfigHandler struct {
|
type ToolConfigHandler struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
|
installer *toolinstaller.ToolInstaller
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewToolConfigHandler creates handler
|
// NewToolConfigHandler creates handler
|
||||||
func NewToolConfigHandler(db *gorm.DB) *ToolConfigHandler {
|
func NewToolConfigHandler(db *gorm.DB, ti *toolinstaller.ToolInstaller) *ToolConfigHandler {
|
||||||
return &ToolConfigHandler{db: db}
|
return &ToolConfigHandler{db: db, installer: ti}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListTools returns all configured tools
|
// ListTools returns all configured tools
|
||||||
@@ -127,8 +129,17 @@ func (h *ToolConfigHandler) InstallTool(c *gin.Context) {
|
|||||||
tool.DefaultVersion = version
|
tool.DefaultVersion = version
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mark as installing immediately
|
||||||
|
h.db.Model(&tool).Update("status", models.ToolInstalling)
|
||||||
|
|
||||||
|
// Background installation — actually call the installer
|
||||||
go func() {
|
go func() {
|
||||||
log.Printf("[ToolConfig] Background install triggered for %s (version: %s)", tool.Name, version)
|
log.Printf("[ToolConfig] Background install triggered for %s (version: %s)", tool.Name, version)
|
||||||
|
if err := h.installer.ForceInstall(tool.Name, version); err != nil {
|
||||||
|
log.Printf("[ToolConfig] ERROR installing %s: %v", tool.Name, err)
|
||||||
|
} else {
|
||||||
|
log.Printf("[ToolConfig] %s installed successfully", tool.Name)
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "安装已触发", Data: gin.H{"version": version}})
|
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "安装已触发", Data: gin.H{"version": version}})
|
||||||
|
|||||||
@@ -558,7 +558,7 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 工具配置路由(发布配置 Tab ④)// Logan.Li
|
// 工具配置路由(发布配置 Tab ④)// Logan.Li
|
||||||
toolConfigHandler := handlers.NewToolConfigHandler(db)
|
toolConfigHandler := handlers.NewToolConfigHandler(db, ti)
|
||||||
tools := authorized.Group("/deploy/tools")
|
tools := authorized.Group("/deploy/tools")
|
||||||
{
|
{
|
||||||
tools.GET("", toolConfigHandler.ListTools)
|
tools.GET("", toolConfigHandler.ListTools)
|
||||||
|
|||||||
@@ -45,6 +45,24 @@ func (ti *ToolInstaller) ensureOnce(name string) *sync.Once {
|
|||||||
return o
|
return o
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ForceInstall unconditionally installs a tool (bypasses sync.Once).
|
||||||
|
// Used by the manual "安装" button in the tool config UI.
|
||||||
|
// The version parameter overrides DefaultVersion if non-empty.
|
||||||
|
func (ti *ToolInstaller) ForceInstall(toolName string, version string) error {
|
||||||
|
var tool models.DeployTool
|
||||||
|
if err := ti.db.Where("name = ?", toolName).First(&tool).Error; err != nil {
|
||||||
|
return fmt.Errorf("tool %s not found in DB: %w", toolName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override version if specified
|
||||||
|
if version != "" && version != tool.DefaultVersion {
|
||||||
|
ti.db.Model(&tool).Update("default_version", version)
|
||||||
|
tool.DefaultVersion = version
|
||||||
|
}
|
||||||
|
|
||||||
|
return ti.installTool(&tool)
|
||||||
|
}
|
||||||
|
|
||||||
// EnsureAvailable checks if a tool is available and installs it if configured for lazy loading.
|
// EnsureAvailable checks if a tool is available and installs it if configured for lazy loading.
|
||||||
// Returns nil if the tool is ready, or an error if installation failed.
|
// Returns nil if the tool is ready, or an error if installation failed.
|
||||||
func (ti *ToolInstaller) EnsureAvailable(toolName string) error {
|
func (ti *ToolInstaller) EnsureAvailable(toolName string) error {
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ async function loadReportContext(): Promise<string> {
|
|||||||
]);
|
]);
|
||||||
const s: any = stats || {};
|
const s: any = stats || {};
|
||||||
const ss = s.server_stats || {}, ks = s.kubernetes_stats || {}, as = s.alert_stats || {}, ps = s.pipeline_stats || {}, cs = s.cost_stats || {};
|
const ss = s.server_stats || {}, ks = s.kubernetes_stats || {}, as = s.alert_stats || {}, ps = s.pipeline_stats || {}, cs = s.cost_stats || {};
|
||||||
const overview = `服务器:${ss.total ?? 0}(运行${ss.running ?? 0}) | K8s集群:${ks.total ?? 0} 节点:${ks.total_nodes ?? 0} Pod:${ks.total_pods ?? 0} | 告警:${as.total ?? 0}(firing ${as.firing ?? 0}) | 流水线:${ps.total ?? 0}(成功率${ps.success_rate ?? 0}%) | 本月成本:¥${cs.month_cost ?? 0}`;
|
const overview = `服务器:${ss.total ?? 0}(运行${ss.running ?? 0}) | Kubernetes集群:${ks.total ?? 0} 节点:${ks.total_nodes ?? 0} Pod:${ks.total_pods ?? 0} | 告警:${as.total ?? 0}(firing ${as.firing ?? 0}) | 流水线:${ps.total ?? 0}(成功率${ps.success_rate ?? 0}%) | 本月成本:¥${cs.month_cost ?? 0}`;
|
||||||
const alertList: any[] = Array.isArray(alerts) ? alerts : (alerts as any)?.list || [];
|
const alertList: any[] = Array.isArray(alerts) ? alerts : (alerts as any)?.list || [];
|
||||||
const releaseList: any[] = Array.isArray(releases) ? releases : (releases as any)?.list || [];
|
const releaseList: any[] = Array.isArray(releases) ? releases : (releases as any)?.list || [];
|
||||||
const alertLines = alertList.slice(0, 10).map((a, i) => `${i + 1}.[${a.severity || '?'}|${a.status || '?'}] ${a.rule_name || '-'} → ${a.target || '-'}`);
|
const alertLines = alertList.slice(0, 10).map((a, i) => `${i + 1}.[${a.severity || '?'}|${a.status || '?'}] ${a.rule_name || '-'} → ${a.target || '-'}`);
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ export function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="animate-fade-in-delay-2">
|
<div className="animate-fade-in-delay-2">
|
||||||
<StatCard
|
<StatCard
|
||||||
title="K8s集群"
|
title="Kubernetes 集群"
|
||||||
value={k8sStats?.total ?? 0}
|
value={k8sStats?.total ?? 0}
|
||||||
description={`${k8sStats?.healthy ?? 0} 个健康`}
|
description={`${k8sStats?.healthy ?? 0} 个健康`}
|
||||||
icon={Cloud}
|
icon={Cloud}
|
||||||
|
|||||||
@@ -391,7 +391,7 @@ export function KubernetesClusterCard({ clusters }: { clusters: any[] }) {
|
|||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-[var(--dg-text-primary)]">K8s集群</CardTitle>
|
<CardTitle className="text-[var(--dg-text-primary)]">Kubernetes 集群</CardTitle>
|
||||||
<CardDescription className="text-[var(--dg-text-muted)]">Kubernetes集群健康状态</CardDescription>
|
<CardDescription className="text-[var(--dg-text-muted)]">Kubernetes集群健康状态</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ export default function ClusterComparePage() {
|
|||||||
<Card className="bg-slate-900/60 border-slate-800">
|
<Card className="bg-slate-900/60 border-slate-800">
|
||||||
<CardContent className="py-12 text-center">
|
<CardContent className="py-12 text-center">
|
||||||
<Cloud className="h-12 w-12 text-slate-700 mx-auto mb-3" />
|
<Cloud className="h-12 w-12 text-slate-700 mx-auto mb-3" />
|
||||||
<p className="text-slate-500">暂无集群数据,请先添加 K8s 集群并同步</p>
|
<p className="text-slate-500">暂无集群数据,请先添加 Kubernetes 集群并同步</p>
|
||||||
<Link to="/kubernetes" className="inline-block mt-3">
|
<Link to="/kubernetes" className="inline-block mt-3">
|
||||||
<Button variant="outline" size="sm" className="border-slate-700">
|
<Button variant="outline" size="sm" className="border-slate-700">
|
||||||
去添加集群 <ChevronRight className="h-3.5 w-3.5 ml-1" />
|
去添加集群 <ChevronRight className="h-3.5 w-3.5 ml-1" />
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export function KubernetesNodes() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-slate-100">节点管理</h1>
|
<h1 className="text-2xl font-bold text-slate-100">节点管理</h1>
|
||||||
<p className="text-sm text-slate-500 mt-1">K8s 集群工作节点状态与资源</p>
|
<p className="text-sm text-slate-500 mt-1">Kubernetes 集群工作节点状态与资源</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Select value={selectedCluster} onValueChange={setSelectedCluster}>
|
<Select value={selectedCluster} onValueChange={setSelectedCluster}>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
Download, FileCode, Pencil, Terminal, HardDrive,
|
Download, FileCode, Pencil, Terminal, HardDrive,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { YamlViewer } from './YamlViewer';
|
import { YamlViewer } from './YamlViewer';
|
||||||
|
import { ResourceVisualEditor } from './ResourceVisualEditor';
|
||||||
import { PodExecTerminal } from './PodExecTerminal';
|
import { PodExecTerminal } from './PodExecTerminal';
|
||||||
import { PodFileBrowser } from './PodFileBrowser';
|
import { PodFileBrowser } from './PodFileBrowser';
|
||||||
import { kubernetesApi } from '@/services/api';
|
import { kubernetesApi } from '@/services/api';
|
||||||
@@ -104,7 +105,12 @@ const RESOURCE_CONFIG: Record<ResourceType, {
|
|||||||
<TableCell><Badge className="bg-blue-500/20 text-blue-400 border-blue-500/30">{s.type || '-'}</Badge></TableCell>
|
<TableCell><Badge className="bg-blue-500/20 text-blue-400 border-blue-500/30">{s.type || '-'}</Badge></TableCell>
|
||||||
<TableCell className="text-sm text-slate-400">{s.cluster_ip || '-'}</TableCell>
|
<TableCell className="text-sm text-slate-400">{s.cluster_ip || '-'}</TableCell>
|
||||||
<TableCell className="text-sm text-slate-400">{s.ports || '-'}</TableCell>
|
<TableCell className="text-sm text-slate-400">{s.ports || '-'}</TableCell>
|
||||||
<TableCell><Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400" title="查看YAML" onClick={() => opts.onYaml?.(s.name)}><FileCode className="h-3.5 w-3.5" /></Button></TableCell>
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400" title="查看YAML" onClick={() => opts.onYaml?.(s.name)}><FileCode className="h-3.5 w-3.5" /></Button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 text-emerald-400 hover:text-emerald-300" title="编辑" onClick={() => opts.onEdit?.(s.name)}><Pencil className="h-3.5 w-3.5" /></Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -119,7 +125,12 @@ const RESOURCE_CONFIG: Record<ResourceType, {
|
|||||||
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{i.namespace}</Badge></TableCell>
|
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{i.namespace}</Badge></TableCell>
|
||||||
<TableCell className="text-sm text-slate-400 max-w-[250px] truncate">{hosts.length > 0 ? hosts.join(', ') : '-'}</TableCell>
|
<TableCell className="text-sm text-slate-400 max-w-[250px] truncate">{hosts.length > 0 ? hosts.join(', ') : '-'}</TableCell>
|
||||||
<TableCell><Badge className={i.tls ? 'bg-emerald-500/20 text-emerald-400' : 'bg-slate-500/20 text-slate-400'}>{i.tls ? 'HTTPS' : 'HTTP'}</Badge></TableCell>
|
<TableCell><Badge className={i.tls ? 'bg-emerald-500/20 text-emerald-400' : 'bg-slate-500/20 text-slate-400'}>{i.tls ? 'HTTPS' : 'HTTP'}</Badge></TableCell>
|
||||||
<TableCell><Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400" title="查看YAML" onClick={() => opts.onYaml?.(i.name)}><FileCode className="h-3.5 w-3.5" /></Button></TableCell>
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400" title="查看YAML" onClick={() => opts.onYaml?.(i.name)}><FileCode className="h-3.5 w-3.5" /></Button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 text-emerald-400 hover:text-emerald-300" title="编辑" onClick={() => opts.onEdit?.(i.name)}><Pencil className="h-3.5 w-3.5" /></Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -191,7 +202,7 @@ const RESOURCE_CONFIG: Record<ResourceType, {
|
|||||||
},
|
},
|
||||||
pvcs: {
|
pvcs: {
|
||||||
title: '存储卷声明', subtitle: 'PersistentVolumeClaim', icon: Boxes,
|
title: '存储卷声明', subtitle: 'PersistentVolumeClaim', icon: Boxes,
|
||||||
columns: ['名称', '命名空间', '状态', '大小', 'StorageClass'],
|
columns: ['名称', '命名空间', '状态', '大小', 'StorageClass', '操作'],
|
||||||
renderRow: (p, opts) => (
|
renderRow: (p, opts) => (
|
||||||
<TableRow key={p.name} className="border-slate-800 hover:bg-slate-800/30">
|
<TableRow key={p.name} className="border-slate-800 hover:bg-slate-800/30">
|
||||||
<TableCell className="font-medium text-slate-200">{p.name}</TableCell>
|
<TableCell className="font-medium text-slate-200">{p.name}</TableCell>
|
||||||
@@ -199,6 +210,12 @@ const RESOURCE_CONFIG: Record<ResourceType, {
|
|||||||
<TableCell><Badge className={p.phase === 'Bound' ? 'bg-emerald-500/20 text-emerald-400' : 'bg-yellow-500/20 text-yellow-400'}>{p.phase || '-'}</Badge></TableCell>
|
<TableCell><Badge className={p.phase === 'Bound' ? 'bg-emerald-500/20 text-emerald-400' : 'bg-yellow-500/20 text-yellow-400'}>{p.phase || '-'}</Badge></TableCell>
|
||||||
<TableCell className="text-sm text-slate-400">{p.size || '-'}</TableCell>
|
<TableCell className="text-sm text-slate-400">{p.size || '-'}</TableCell>
|
||||||
<TableCell className="text-sm text-slate-400">{p.storage_class || '-'}</TableCell>
|
<TableCell className="text-sm text-slate-400">{p.storage_class || '-'}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400" title="查看YAML" onClick={() => opts.onYaml?.(p.name)}><FileCode className="h-3.5 w-3.5" /></Button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 text-emerald-400 hover:text-emerald-300" title="编辑" onClick={() => opts.onEdit?.(p.name)}><Pencil className="h-3.5 w-3.5" /></Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -336,6 +353,7 @@ export default function NamespaceResourcePage() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [yamlItem, setYamlItem] = useState<{resource: string; name: string; editMode?: boolean} | null>(null);
|
const [yamlItem, setYamlItem] = useState<{resource: string; name: string; editMode?: boolean} | null>(null);
|
||||||
|
const [visualEditItem, setVisualEditItem] = useState<{resource: string; name: string} | null>(null);
|
||||||
const [terminalPod, setTerminalPod] = useState<any>(null);
|
const [terminalPod, setTerminalPod] = useState<any>(null);
|
||||||
const [fileBrowserPod, setFileBrowserPod] = useState<any>(null);
|
const [fileBrowserPod, setFileBrowserPod] = useState<any>(null);
|
||||||
|
|
||||||
@@ -402,7 +420,14 @@ export default function NamespaceResourcePage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleYaml = (name: string) => setYamlItem({ resource: type, name });
|
const handleYaml = (name: string) => setYamlItem({ resource: type, name });
|
||||||
const handleEdit = (name: string) => setYamlItem({ resource: type, name, editMode: true });
|
const handleEdit = (name: string) => {
|
||||||
|
// 使用可视化编辑器的资源类型
|
||||||
|
if (['configmaps', 'secrets', 'services', 'ingresses', 'pvcs'].includes(type)) {
|
||||||
|
setVisualEditItem({ resource: type, name });
|
||||||
|
} else {
|
||||||
|
setYamlItem({ resource: type, name, editMode: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
const handleExportCSV = () => kubernetesApi.exportAsCSV(`${type}-${clusterCode}-${namespace}.csv`, filtered);
|
const handleExportCSV = () => kubernetesApi.exportAsCSV(`${type}-${clusterCode}-${namespace}.csv`, filtered);
|
||||||
const handleExportJSON = () => kubernetesApi.exportAsJSON(`${type}-${clusterCode}-${namespace}.json`, filtered);
|
const handleExportJSON = () => kubernetesApi.exportAsJSON(`${type}-${clusterCode}-${namespace}.json`, filtered);
|
||||||
|
|
||||||
@@ -439,6 +464,16 @@ export default function NamespaceResourcePage() {
|
|||||||
autoEdit={yamlItem.editMode === true}
|
autoEdit={yamlItem.editMode === true}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{visualEditItem && (
|
||||||
|
<ResourceVisualEditor
|
||||||
|
clusterCode={clusterCode || ''}
|
||||||
|
namespace={namespace || ''}
|
||||||
|
resource={visualEditItem.resource}
|
||||||
|
name={visualEditItem.name}
|
||||||
|
onClose={() => setVisualEditItem(null)}
|
||||||
|
onSaved={loadData}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="flex items-center gap-2 text-sm text-slate-500">
|
<div className="flex items-center gap-2 text-sm text-slate-500">
|
||||||
<Link to="/kubernetes" className="hover:text-slate-300 transition-colors">集群</Link>
|
<Link to="/kubernetes" className="hover:text-slate-300 transition-colors">集群</Link>
|
||||||
{clusterCode && <><ChevronRight className="h-3 w-3" /><Link to={`/kubernetes/${clusterCode}`} className="hover:text-slate-300 transition-colors">{clusterCode}</Link></>}
|
{clusterCode && <><ChevronRight className="h-3 w-3" /><Link to={`/kubernetes/${clusterCode}`} className="hover:text-slate-300 transition-colors">{clusterCode}</Link></>}
|
||||||
|
|||||||
@@ -0,0 +1,664 @@
|
|||||||
|
// ResourceVisualEditor.tsx — Kubernetes 资源可视化编辑器
|
||||||
|
// 支持:ConfigMap / Secret / Service / Ingress / PVC
|
||||||
|
// 替代纯 YAML 编辑,提供表单式交互体验
|
||||||
|
// 作者: Logan <admin@attacker.club>
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { X, Save, RefreshCw, Loader2, Plus, Trash2, FileText, Network, Shield, Database, ChevronRight } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { kubernetesApi } from '@/services/api';
|
||||||
|
import CodeMirror from '@uiw/react-codemirror';
|
||||||
|
import { yaml } from '@codemirror/lang-yaml';
|
||||||
|
import { oneDark } from '@codemirror/theme-one-dark';
|
||||||
|
import { cleanYAML } from './k8s-yaml-utils';
|
||||||
|
|
||||||
|
interface ResourceVisualEditorProps {
|
||||||
|
clusterCode: string;
|
||||||
|
namespace: string;
|
||||||
|
resource: string; // 'configmaps' | 'secrets' | 'services' | 'ingresses' | 'pvcs'
|
||||||
|
name: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple YAML→JS parser (avoids js-yaml dependency — K8s API returns JSON-compatible YAML)
|
||||||
|
function tryParseYAML(text: string): any {
|
||||||
|
try { return JSON.parse(text); } catch {}
|
||||||
|
// Fallback: try trimming and re-parsing as JSON (K8s API mostly returns JSON-serialized YAML)
|
||||||
|
try {
|
||||||
|
const lines = text.split('\n').filter(l => !l.trim().startsWith('#'));
|
||||||
|
return JSON.parse(lines.join('\n'));
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResourceVisualEditor({ clusterCode, namespace, resource, name, onClose, onSaved }: ResourceVisualEditorProps) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [rawYaml, setRawYaml] = useState('');
|
||||||
|
const [manifest, setManifest] = useState<any>(null);
|
||||||
|
// Track which tab: 'visual' | 'yaml'
|
||||||
|
const [mode, setMode] = useState<'visual' | 'yaml'>('visual');
|
||||||
|
// YAML editor state (for fallback yaml mode)
|
||||||
|
const [editYaml, setEditYaml] = useState('');
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const text = await kubernetesApi.getResourceYaml(clusterCode, namespace, resource, name);
|
||||||
|
const cleaned = cleanYAML(text);
|
||||||
|
setRawYaml(text);
|
||||||
|
setEditYaml(cleaned);
|
||||||
|
const parsed = tryParseYAML(cleaned);
|
||||||
|
setManifest(parsed);
|
||||||
|
if (!parsed) {
|
||||||
|
// Can't parse — fall back to YAML mode
|
||||||
|
setMode('yaml');
|
||||||
|
toast({ title: '可视化编辑不可用', description: '无法解析资源结构,已切换到 YAML 编辑模式' });
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
toast({ title: '加载失败', description: e.message, variant: 'destructive' });
|
||||||
|
} finally { setLoading(false); }
|
||||||
|
}, [clusterCode, namespace, resource, name]);
|
||||||
|
|
||||||
|
useEffect(() => { loadData(); }, [loadData]);
|
||||||
|
|
||||||
|
const handleSave = async (updatedManifest: any) => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
// Serialize manifest back to YAML
|
||||||
|
const yamlStr = manifestToYaml(updatedManifest);
|
||||||
|
await kubernetesApi.updateResourceYaml(clusterCode, namespace, resource, name, yamlStr);
|
||||||
|
toast({ title: '保存成功', description: `${resource}/${name} 已更新` });
|
||||||
|
onSaved?.();
|
||||||
|
onClose();
|
||||||
|
} catch (e: any) {
|
||||||
|
toast({ title: '保存失败', description: e.message, variant: 'destructive' });
|
||||||
|
} finally { setSaving(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveYaml = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await kubernetesApi.updateResourceYaml(clusterCode, namespace, resource, name, editYaml);
|
||||||
|
toast({ title: 'YAML 已应用', description: `${resource}/${name} 更新成功` });
|
||||||
|
onSaved?.();
|
||||||
|
onClose();
|
||||||
|
} catch (e: any) {
|
||||||
|
toast({ title: '保存失败', description: e.message, variant: 'destructive' });
|
||||||
|
} finally { setSaving(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Manifest → YAML string (JSON serialization works as YAML superset for K8s API)
|
||||||
|
function manifestToYaml(m: any): string {
|
||||||
|
// Use 2-space indented JSON — K8s API accepts JSON as YAML
|
||||||
|
return JSON.stringify(m, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Resource type config ───
|
||||||
|
const resourceMeta: Record<string, { title: string; icon: any; color: string }> = {
|
||||||
|
configmaps: { title: 'ConfigMap', icon: FileText, color: 'text-blue-400' },
|
||||||
|
secrets: { title: 'Secret', icon: Shield, color: 'text-amber-400' },
|
||||||
|
services: { title: 'Service', icon: Network, color: 'text-cyan-400' },
|
||||||
|
ingresses: { title: 'Ingress', icon: ChevronRight, color: 'text-purple-400' },
|
||||||
|
pvcs: { title: 'PersistentVolumeClaim', icon: Database, color: 'text-emerald-400' },
|
||||||
|
};
|
||||||
|
const meta = resourceMeta[resource] || resourceMeta.configmaps;
|
||||||
|
const MetaIcon = meta.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
|
||||||
|
<div className="bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-xl w-[90vw] max-w-3xl max-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)]">{meta.title} 编辑器</h2>
|
||||||
|
<p className="text-xs text-[var(--dg-text-muted)]">{name} · {namespace} · {clusterCode}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Mode switcher */}
|
||||||
|
<div className="flex rounded-lg border border-[var(--dg-border)] overflow-hidden">
|
||||||
|
<button
|
||||||
|
className={`px-3 py-1.5 text-xs font-medium transition-colors ${mode === 'visual' ? 'bg-blue-600 text-white' : 'bg-transparent text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-tertiary)]'}`}
|
||||||
|
onClick={() => manifest && setMode('visual')}
|
||||||
|
disabled={!manifest}
|
||||||
|
>可视化编辑</button>
|
||||||
|
<button
|
||||||
|
className={`px-3 py-1.5 text-xs font-medium transition-colors ${mode === 'yaml' ? 'bg-blue-600 text-white' : 'bg-transparent text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-tertiary)]'}`}
|
||||||
|
onClick={() => setMode('yaml')}
|
||||||
|
>YAML 编辑</button>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-[var(--dg-text-secondary)]" onClick={onClose}><X className="h-4 w-4" /></Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="flex-1 overflow-auto p-4">
|
||||||
|
{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' ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-xs text-amber-400/80">⚠️ YAML 编辑模式 — 修改后点击保存。已自动清理只读字段(status/managedFields/uid)</p>
|
||||||
|
<CodeMirror
|
||||||
|
value={editYaml}
|
||||||
|
height="550px"
|
||||||
|
theme={oneDark}
|
||||||
|
extensions={[yaml()]}
|
||||||
|
onChange={(val) => setEditYaml(val)}
|
||||||
|
basicSetup={{ lineNumbers: true, foldGutter: true, highlightActiveLine: true, autocompletion: true }}
|
||||||
|
style={{ fontSize: '12px' }}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => { setEditYaml(cleanYAML(rawYaml)); }}>重置</Button>
|
||||||
|
<Button size="sm" className="bg-blue-600 hover:bg-blue-700" onClick={handleSaveYaml} 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" />}
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : manifest ? (
|
||||||
|
<>
|
||||||
|
{resource === 'configmaps' && <ConfigMapEditor manifest={manifest} onSave={handleSave} saving={saving} />}
|
||||||
|
{resource === 'secrets' && <SecretEditor manifest={manifest} onSave={handleSave} saving={saving} />}
|
||||||
|
{resource === 'services' && <ServiceEditor manifest={manifest} onSave={handleSave} saving={saving} />}
|
||||||
|
{resource === 'ingresses' && <IngressEditor manifest={manifest} onSave={handleSave} saving={saving} />}
|
||||||
|
{resource === 'pvcs' && <PvcEditor manifest={manifest} onSave={handleSave} saving={saving} />}
|
||||||
|
{!['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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════
|
||||||
|
// ConfigMap 编辑器 — 键值对表格
|
||||||
|
// ══════════════════════════════════════════════
|
||||||
|
function ConfigMapEditor({ manifest, onSave, saving }: { manifest: any; onSave: (m: any) => void; saving: boolean }) {
|
||||||
|
const data = manifest?.data || {};
|
||||||
|
const [entries, setEntries] = useState<{ key: string; value: string }[]>(
|
||||||
|
Object.entries(data).map(([key, value]) => ({ key, value: String(value) }))
|
||||||
|
);
|
||||||
|
|
||||||
|
const update = (i: number, field: 'key' | 'value', val: string) => {
|
||||||
|
const next = [...entries];
|
||||||
|
next[i] = { ...next[i], [field]: val };
|
||||||
|
setEntries(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addRow = () => setEntries([...entries, { key: '', value: '' }]);
|
||||||
|
const removeRow = (i: number) => setEntries(entries.filter((_, idx) => idx !== i));
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
const newData: Record<string, string> = {};
|
||||||
|
entries.forEach(e => {
|
||||||
|
if (e.key.trim()) newData[e.key.trim()] = e.value;
|
||||||
|
});
|
||||||
|
onSave({ ...manifest, data: newData });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label className="text-[var(--dg-text-secondary)] text-xs">名称</Label>
|
||||||
|
<Input value={manifest?.metadata?.name || ''} 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">配置数据 (键值对)</Label>
|
||||||
|
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={addRow}><Plus className="h-3 w-3 mr-1" />添加</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 max-h-[45vh] overflow-y-auto pr-1">
|
||||||
|
{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)]">
|
||||||
|
<Input
|
||||||
|
value={entry.key}
|
||||||
|
onChange={e => update(i, 'key', e.target.value)}
|
||||||
|
placeholder="键名 (如 LOG_LEVEL)"
|
||||||
|
className="flex-1 font-mono text-xs h-8 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
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)]"
|
||||||
|
/>
|
||||||
|
<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" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{entries.length === 0 && (
|
||||||
|
<div className="text-center py-8 text-[var(--dg-text-muted)] text-xs">暂无配置数据,点击「添加」创建</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 pt-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" />}
|
||||||
|
保存配置
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════
|
||||||
|
// Secret 编辑器 — 键值对表格 (Base64 编码/解码)
|
||||||
|
// ══════════════════════════════════════════════
|
||||||
|
function SecretEditor({ manifest, onSave, saving }: { manifest: any; onSave: (m: any) => void; saving: boolean }) {
|
||||||
|
const rawData = manifest?.data || {};
|
||||||
|
// Decode base64 values for editing
|
||||||
|
const [entries, setEntries] = useState<{ key: string; value: string; encoded: boolean }[]>(
|
||||||
|
Object.entries(rawData).map(([key, value]) => {
|
||||||
|
const v = String(value);
|
||||||
|
try {
|
||||||
|
// Try to decode — if it's valid base64
|
||||||
|
const decoded = atob(v);
|
||||||
|
return { key, value: decoded, encoded: true };
|
||||||
|
} catch {
|
||||||
|
return { key, value: v, encoded: false };
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const update = (i: number, field: 'key' | 'value', val: string) => {
|
||||||
|
const next = [...entries];
|
||||||
|
next[i] = { ...next[i], [field]: val };
|
||||||
|
setEntries(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addRow = () => setEntries([...entries, { key: '', value: '', encoded: true }]);
|
||||||
|
const removeRow = (i: number) => setEntries(entries.filter((_, idx) => idx !== i));
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
const newData: Record<string, string> = {};
|
||||||
|
entries.forEach(e => {
|
||||||
|
if (e.key.trim()) {
|
||||||
|
// Re-encode if originally base64
|
||||||
|
newData[e.key.trim()] = e.encoded ? btoa(e.value) : e.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
onSave({ ...manifest, data: newData });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label className="text-[var(--dg-text-secondary)] text-xs">名称</Label>
|
||||||
|
<Input value={manifest?.metadata?.name || ''} readOnly 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>
|
||||||
|
<div className="space-y-2 max-h-[45vh] overflow-y-auto pr-1">
|
||||||
|
{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)]">
|
||||||
|
<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
|
||||||
|
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)]"
|
||||||
|
/>
|
||||||
|
<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" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{entries.length === 0 && (
|
||||||
|
<div className="text-center py-8 text-[var(--dg-text-muted)] text-xs">暂无密钥数据,点击「添加」创建</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 pt-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" />}
|
||||||
|
保存密钥
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════
|
||||||
|
// Service 编辑器 — 端口配置 + 类型 + 选择器
|
||||||
|
// ══════════════════════════════════════════════
|
||||||
|
function ServiceEditor({ manifest, onSave, saving }: { manifest: any; onSave: (m: any) => void; saving: boolean }) {
|
||||||
|
const spec = manifest?.spec || {};
|
||||||
|
const [svcType, setSvcType] = useState(spec.type || 'ClusterIP');
|
||||||
|
const [ports, setPorts] = useState<any[]>(
|
||||||
|
(spec.ports || []).map((p: any) => ({
|
||||||
|
name: p.name || '',
|
||||||
|
port: String(p.port || ''),
|
||||||
|
targetPort: String(p.targetPort || ''),
|
||||||
|
protocol: p.protocol || 'TCP',
|
||||||
|
nodePort: p.nodePort ? String(p.nodePort) : '',
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const [selector, setSelector] = useState<{ key: string; value: string }[]>(
|
||||||
|
Object.entries(spec.selector || {}).map(([key, value]) => ({ key, value: String(value) }))
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatePort = (i: number, field: string, val: string) => {
|
||||||
|
const next = [...ports];
|
||||||
|
next[i] = { ...next[i], [field]: val };
|
||||||
|
setPorts(next);
|
||||||
|
};
|
||||||
|
const addPort = () => setPorts([...ports, { name: '', port: '', targetPort: '', protocol: 'TCP', nodePort: '' }]);
|
||||||
|
const removePort = (i: number) => setPorts(ports.filter((_, idx) => idx !== i));
|
||||||
|
|
||||||
|
const updateSelector = (i: number, field: 'key' | 'value', val: string) => {
|
||||||
|
const next = [...selector];
|
||||||
|
next[i] = { ...next[i], [field]: val };
|
||||||
|
setSelector(next);
|
||||||
|
};
|
||||||
|
const addSelector = () => setSelector([...selector, { key: '', value: '' }]);
|
||||||
|
const removeSelector = (i: number) => setSelector(selector.filter((_, idx) => idx !== i));
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
const newSpec: any = { ...spec };
|
||||||
|
newSpec.type = svcType;
|
||||||
|
newSpec.ports = ports.map(p => {
|
||||||
|
const port: any = {
|
||||||
|
port: parseInt(p.port) || 80,
|
||||||
|
targetPort: parseInt(p.targetPort) || parseInt(p.port) || 80,
|
||||||
|
protocol: p.protocol,
|
||||||
|
};
|
||||||
|
if (p.name) port.name = p.name;
|
||||||
|
if (p.nodePort && svcType === 'NodePort') port.nodePort = parseInt(p.nodePort);
|
||||||
|
return port;
|
||||||
|
});
|
||||||
|
const selObj: Record<string, string> = {};
|
||||||
|
selector.forEach(s => { if (s.key.trim()) selObj[s.key.trim()] = s.value; });
|
||||||
|
newSpec.selector = selObj;
|
||||||
|
onSave({ ...manifest, spec: newSpec });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label className="text-[var(--dg-text-secondary)] text-xs">名称</Label>
|
||||||
|
<Input value={manifest?.metadata?.name || ''} readOnly className="mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[var(--dg-text-secondary)] text-xs">Service 类型</Label>
|
||||||
|
<Select value={svcType} onValueChange={setSvcType}>
|
||||||
|
<SelectTrigger className="mt-1 h-8 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="ClusterIP">ClusterIP (集群内部)</SelectItem>
|
||||||
|
<SelectItem value="NodePort">NodePort (节点端口)</SelectItem>
|
||||||
|
<SelectItem value="LoadBalancer">LoadBalancer (负载均衡)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</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">
|
||||||
|
{ports.map((p, i) => (
|
||||||
|
<div key={i} className="grid grid-cols-[1fr_80px_80px_80px_80px_32px] gap-2 items-center">
|
||||||
|
<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)]" />
|
||||||
|
<Select value={p.protocol} onValueChange={v => updatePort(i, 'protocol', 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="TCP">TCP</SelectItem><SelectItem value="UDP">UDP</SelectItem></SelectContent>
|
||||||
|
</Select>
|
||||||
|
{svcType === 'NodePort' ? (
|
||||||
|
<Input value={p.nodePort} onChange={e => updatePort(i, 'nodePort', e.target.value)} placeholder="NodePort" type="number" className="font-mono text-xs h-7 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
|
||||||
|
) : <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>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-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
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════
|
||||||
|
// Ingress 编辑器 — Host/Path 路由规则
|
||||||
|
// ══════════════════════════════════════════════
|
||||||
|
function IngressEditor({ manifest, onSave, saving }: { manifest: any; onSave: (m: any) => void; saving: boolean }) {
|
||||||
|
const spec = manifest?.spec || {};
|
||||||
|
const [rules, setRules] = useState<{ host: string; paths: { path: string; pathType: string; serviceName: string; servicePort: string }[] }[]>(
|
||||||
|
(spec.rules || []).map((r: any) => ({
|
||||||
|
host: r.host || '',
|
||||||
|
paths: (r.http?.backend?.servicePort != null
|
||||||
|
? // Old-style single backend
|
||||||
|
[{ path: '/', pathType: 'Prefix', serviceName: r.http?.backend?.serviceName || '', servicePort: String(r.http?.backend?.servicePort || '80') }]
|
||||||
|
: (r.http?.paths || []).map((p: any) => ({
|
||||||
|
path: p.path || '/',
|
||||||
|
pathType: p.pathType || 'Prefix',
|
||||||
|
serviceName: p.backend?.service?.name || p.backend?.serviceName || '',
|
||||||
|
servicePort: String(p.backend?.service?.port?.number ?? p.backend?.servicePort ?? '80'),
|
||||||
|
}))
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateRule = (ri: number, host: string) => {
|
||||||
|
const next = [...rules];
|
||||||
|
next[ri] = { ...next[ri], host };
|
||||||
|
setRules(next);
|
||||||
|
};
|
||||||
|
const addRule = () => setRules([...rules, { host: '', paths: [{ path: '/', pathType: 'Prefix', serviceName: '', servicePort: '80' }] }]);
|
||||||
|
const removeRule = (ri: number) => setRules(rules.filter((_, idx) => idx !== ri));
|
||||||
|
|
||||||
|
const updatePath = (ri: number, pi: number, field: string, val: string) => {
|
||||||
|
const next = [...rules];
|
||||||
|
next[ri].paths[pi] = { ...next[ri].paths[pi], [field]: val };
|
||||||
|
setRules(next);
|
||||||
|
};
|
||||||
|
const addPath = (ri: number) => {
|
||||||
|
const next = [...rules];
|
||||||
|
next[ri].paths.push({ path: '/', pathType: 'Prefix', serviceName: '', servicePort: '80' });
|
||||||
|
setRules(next);
|
||||||
|
};
|
||||||
|
const removePath = (ri: number, pi: number) => {
|
||||||
|
const next = [...rules];
|
||||||
|
next[ri].paths = next[ri].paths.filter((_, idx) => idx !== pi);
|
||||||
|
setRules(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
const newRules = rules.map(r => ({
|
||||||
|
host: r.host,
|
||||||
|
http: {
|
||||||
|
paths: r.paths.map(p => ({
|
||||||
|
path: p.path,
|
||||||
|
pathType: p.pathType,
|
||||||
|
backend: { service: { name: p.serviceName, port: { number: parseInt(p.servicePort) || 80 } } },
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
onSave({ ...manifest, spec: { ...spec, rules: newRules } });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label className="text-[var(--dg-text-secondary)] text-xs">名称</Label>
|
||||||
|
<Input value={manifest?.metadata?.name || ''} 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">路由规则</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">
|
||||||
|
{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">
|
||||||
|
<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)]"
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
))}
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{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)]">
|
||||||
|
<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
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════
|
||||||
|
// PVC 编辑器 — 存储大小/访问模式/StorageClass
|
||||||
|
// ══════════════════════════════════════════════
|
||||||
|
function PvcEditor({ manifest, onSave, saving }: { manifest: any; onSave: (m: any) => void; saving: boolean }) {
|
||||||
|
const spec = manifest?.spec || {};
|
||||||
|
const resources = spec.resources?.requests || {};
|
||||||
|
const [size, setSize] = useState(resources.storage || '1Gi');
|
||||||
|
const [accessMode, setAccessMode] = useState(spec.accessModes?.[0] || 'ReadWriteOnce');
|
||||||
|
const [storageClass, setStorageClass] = useState(spec.storageClassName || '');
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
onSave({
|
||||||
|
...manifest,
|
||||||
|
spec: {
|
||||||
|
...spec,
|
||||||
|
accessModes: [accessMode],
|
||||||
|
storageClassName: storageClass || undefined,
|
||||||
|
resources: { requests: { storage: size } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label className="text-[var(--dg-text-secondary)] text-xs">名称</Label>
|
||||||
|
<Input value={manifest?.metadata?.name || ''} readOnly className="mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)]" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label className="text-[var(--dg-text-secondary)] text-xs">存储大小</Label>
|
||||||
|
<Input
|
||||||
|
value={size}
|
||||||
|
onChange={e => setSize(e.target.value)}
|
||||||
|
placeholder="如 10Gi"
|
||||||
|
className="mt-1 font-mono text-xs h-8 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-[var(--dg-text-muted)] mt-1">单位:Gi (GiB), Mi (MiB), Ti (TiB)</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[var(--dg-text-secondary)] text-xs">访问模式</Label>
|
||||||
|
<Select value={accessMode} onValueChange={setAccessMode}>
|
||||||
|
<SelectTrigger className="mt-1 h-8 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="ReadWriteOnce">ReadWriteOnce (单节点读写)</SelectItem>
|
||||||
|
<SelectItem value="ReadOnlyMany">ReadOnlyMany (多节点只读)</SelectItem>
|
||||||
|
<SelectItem value="ReadWriteMany">ReadWriteMany (多节点读写)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[var(--dg-text-secondary)] text-xs">StorageClass</Label>
|
||||||
|
<Input
|
||||||
|
value={storageClass}
|
||||||
|
onChange={e => setStorageClass(e.target.value)}
|
||||||
|
placeholder="留空使用默认 StorageClass"
|
||||||
|
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)]">
|
||||||
|
<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
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -239,7 +239,7 @@ export function TopologyView() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteEdge = async (edge: TopologyEdge) => {
|
const handleDeleteEdge = async (edge: TopologyEdge) => {
|
||||||
if (!edge.id.startsWith('custom-edge')) { toast.info('K8s 原生关系不可删除'); return; }
|
if (!edge.id.startsWith('custom-edge')) { toast.info('Kubernetes 原生关系不可删除'); return; }
|
||||||
const ok = await _confirm({ title: '删除连线', description: '确定删除此自定义连线?', variant: 'destructive' });
|
const ok = await _confirm({ title: '删除连线', description: '确定删除此自定义连线?', variant: 'destructive' });
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
setTopology((prev) => ({ ...prev, edges: prev.edges.filter((e) => e.id !== edge.id) }));
|
setTopology((prev) => ({ ...prev, edges: prev.edges.filter((e) => e.id !== edge.id) }));
|
||||||
@@ -370,7 +370,7 @@ export function TopologyView() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-slate-100">架构拓扑</h1>
|
<h1 className="text-2xl font-bold text-slate-100">架构拓扑</h1>
|
||||||
<p className="text-sm text-slate-500 mt-1">
|
<p className="text-sm text-slate-500 mt-1">
|
||||||
K8s 集群资源拓扑可视化 · 拖拽节点调整布局
|
Kubernetes 集群资源拓扑可视化 · 拖拽节点调整布局
|
||||||
{editMode && <span className="text-amber-400 ml-2">· 编辑模式</span>}
|
{editMode && <span className="text-amber-400 ml-2">· 编辑模式</span>}
|
||||||
{connectMode && <span className="text-amber-400 ml-1">· 连线中{connectSource ? '(选择目标节点)' : '(选择源节点)'}</span>}
|
{connectMode && <span className="text-amber-400 ml-1">· 连线中{connectSource ? '(选择目标节点)' : '(选择源节点)'}</span>}
|
||||||
</p>
|
</p>
|
||||||
@@ -552,7 +552,7 @@ export function TopologyView() {
|
|||||||
<DialogContent className="bg-slate-900 border-slate-800 max-w-md">
|
<DialogContent className="bg-slate-900 border-slate-800 max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-slate-100">{editingNode ? '编辑节点' : '添加自定义节点'}</DialogTitle>
|
<DialogTitle className="text-slate-100">{editingNode ? '编辑节点' : '添加自定义节点'}</DialogTitle>
|
||||||
<DialogDescription className="text-slate-500">{editingNode ? '修改节点属性' : '添加数据库、外部服务等非 K8s 资源节点'}</DialogDescription>
|
<DialogDescription className="text-slate-500">{editingNode ? '修改节点属性' : '添加数据库、外部服务等非 Kubernetes 资源节点'}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ export const PrometheusDashboard: React.FC<Props> = ({ dsUrl }) => {
|
|||||||
: (
|
: (
|
||||||
<Tabs value={tab} onValueChange={setTab}>
|
<Tabs value={tab} onValueChange={setTab}>
|
||||||
<TabsList className="bg-[var(--dg-bg-tertiary)] mb-3">
|
<TabsList className="bg-[var(--dg-bg-tertiary)] mb-3">
|
||||||
<TabsTrigger value="kubernetes" className="text-xs"><Container className="w-3 h-3 mr-1" />K8s</TabsTrigger>
|
<TabsTrigger value="kubernetes" className="text-xs"><Container className="w-3 h-3 mr-1" />Kubernetes</TabsTrigger>
|
||||||
<TabsTrigger value="node" className="text-xs"><Server className="w-3 h-3 mr-1" />Node</TabsTrigger>
|
<TabsTrigger value="node" className="text-xs"><Server className="w-3 h-3 mr-1" />Node</TabsTrigger>
|
||||||
<TabsTrigger value="pod" className="text-xs"><Box className="w-3 h-3 mr-1" />Pod</TabsTrigger>
|
<TabsTrigger value="pod" className="text-xs"><Box className="w-3 h-3 mr-1" />Pod</TabsTrigger>
|
||||||
<TabsTrigger value="jvm" className="text-xs"><Zap className="w-3 h-3 mr-1" />JVM</TabsTrigger>
|
<TabsTrigger value="jvm" className="text-xs"><Zap className="w-3 h-3 mr-1" />JVM</TabsTrigger>
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ export const serverApi = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ==================== K8s 集群 API ====================
|
// ==================== Kubernetes 集群 API ====================
|
||||||
export const kubernetesApi = {
|
export const kubernetesApi = {
|
||||||
getClusters: async () => {
|
getClusters: async () => {
|
||||||
return http.get<ListResponse>('/kubernetes-clusters');
|
return http.get<ListResponse>('/kubernetes-clusters');
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export interface KubernetesCluster {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// K8s节点
|
// Kubernetes 节点
|
||||||
export interface KubernetesNode {
|
export interface KubernetesNode {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -57,7 +57,7 @@ export interface KubernetesNode {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// K8s Pod
|
// Kubernetes Pod
|
||||||
export interface KubernetesPod {
|
export interface KubernetesPod {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
Reference in new issue
Block a user