refactor(quality): Q6 前端巨型组件拆分 — ResourceVisualEditor 882L→282L (-68%), 提取 5 个独立资源编辑器文件
将 ResourceVisualEditor.tsx (882L, 项目最大前端组件) 中的 5 个内联资源编辑器 提取为 resource-visual-editors/ 子目录下的独立文件: - ConfigMapEditor.tsx (95L) — ConfigMap 键值对表格 - SecretEditor.tsx (114L) — Secret Base64 编解码 - ServiceEditor.tsx (155L) — Service 端口/类型/选择器 - IngressEditor.tsx (215L) — Ingress Host/Path 路由规则 - PvcEditor.tsx (90L) — PVC 存储配置 主文件仅保留加载/保存逻辑 + YAML/可视化切换 + 资源类型分发。 清理 11 个 unused import。零逻辑变更,纯机械提取。 验证: tsc -b=0, npm build✓ (16.11s), ESLint errors=0, 17/17 测试通过
This commit is contained in:
1 parent
5d4a99b8a9
commit
892cb85676
8 files changed
+711
-607
No files matched your search
@@ -129,7 +129,7 @@
|
|||||||
| ~~`api.ts` 1324 行/352 方法~~ → 已拆分为 12 个域文件 (api/kubernetes.ts, deploy.ts, monitor.ts 等) | ✅ 已解决 | 前端 | ~~P0~~ done |
|
| ~~`api.ts` 1324 行/352 方法~~ → 已拆分为 12 个域文件 (api/kubernetes.ts, deploy.ts, monitor.ts 等) | ✅ 已解决 | 前端 | ~~P0~~ done |
|
||||||
| 前端 **1026 处** `any` 类型 | 类型安全缺失,重构风险高 | 前端全局 | ~~P0~~ **done** (any 1076→40, unused 25→6, console 93→1, tsc --noEmit=0) |
|
| 前端 **1026 处** `any` 类型 | 类型安全缺失,重构风险高 | 前端全局 | ~~P0~~ **done** (any 1076→40, unused 25→6, console 93→1, tsc --noEmit=0) |
|
||||||
| ~~K8s 双路由体系(集群域+Kuboard 域)~~ → code 路由统一为主,ID 路由标记 deprecated 向后兼容 | ✅ 已解决 | 后端 | ~~P1~~ done |
|
| ~~K8s 双路由体系(集群域+Kuboard 域)~~ → code 路由统一为主,ID 路由标记 deprecated 向后兼容 | ✅ 已解决 | 后端 | ~~P1~~ done |
|
||||||
| 25 个 ≥500 行巨型组件 | 拆分困难 | 前端 | P1 |
|
| 25 个 ≥500 行巨型组件 | 拆分困难 | 前端 | P1 → **Q6 done: 最大组件 ResourceVisualEditor 882L→282L (-68%),提取 5 个独立编辑器文件** |
|
||||||
|
|
||||||
### 3.2 🟡 中优先级
|
### 3.2 🟡 中优先级
|
||||||
|
|
||||||
|
|||||||
+35
@@ -477,6 +477,7 @@ services/api.ts (1324L)
|
|||||||
| Q4+ | 后端错误处理治理扩展(Q5: RDP/API Token/AIOps 模块 `_ =` DB 写入) | P0 | **done** |
|
| Q4+ | 后端错误处理治理扩展(Q5: RDP/API Token/AIOps 模块 `_ =` DB 写入) | P0 | **done** |
|
||||||
| U13 | 主机监控大屏挂载(HostMonitorDashboard 死代码复活) | P3 | **done** |
|
| U13 | 主机监控大屏挂载(HostMonitorDashboard 死代码复活) | P3 | **done** |
|
||||||
| U14 | ESLint 冗余 disable 指令清理(unused eslint-disable 6→0) | P3 | **done** |
|
| U14 | ESLint 冗余 disable 指令清理(unused eslint-disable 6→0) | P3 | **done** |
|
||||||
|
| Q6 | 前端巨型组件拆分(ResourceVisualEditor 882L→282L,提取 5 个独立编辑器) | P0 | **done** |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -610,3 +611,37 @@ pending → in_progress → done / blocked
|
|||||||
- [x] `npm run build` 零错误(✓ built in 16.93s)
|
- [x] `npm run build` 零错误(✓ built in 16.93s)
|
||||||
- [x] 17/17 集成回归测试通过(零回归)
|
- [x] 17/17 集成回归测试通过(零回归)
|
||||||
- [x] Docker 部署 healthy(改动不影响运行时)
|
- [x] Docker 部署 healthy(改动不影响运行时)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Q6: 前端巨型组件拆分 — ResourceVisualEditor [done]
|
||||||
|
|
||||||
|
**问题**: `ResourceVisualEditor.tsx`(882 行)是项目最大的前端组件,包含 5 个内联资源编辑器(ConfigMap/Secret/Service/Ingress/PVC),是 PROJECT_EVALUATION.md 3.1 🔴 高优先级技术债"25 个 ≥500 行巨型组件"中最大的一个。单文件过大导致维护困难、代码导航低效。
|
||||||
|
|
||||||
|
**已完成 (2026-07-06)**:
|
||||||
|
|
||||||
|
将 5 个内联资源编辑器提取为 `resource-visual-editors/` 子目录下的独立文件:
|
||||||
|
|
||||||
|
| 新文件 | 行数 | 职责 |
|
||||||
|
|--------|------|------|
|
||||||
|
| `ConfigMapEditor.tsx` | 95 | ConfigMap 键值对表格编辑器 |
|
||||||
|
| `SecretEditor.tsx` | 114 | Secret 键值对表格 (Base64 编码/解码) |
|
||||||
|
| `ServiceEditor.tsx` | 155 | Service 端口/类型/选择器编辑器 |
|
||||||
|
| `IngressEditor.tsx` | 215 | Ingress Host/Path 路由规则编辑器 |
|
||||||
|
| `PvcEditor.tsx` | 90 | PVC 存储大小/访问模式/StorageClass 编辑器 |
|
||||||
|
|
||||||
|
主文件 `ResourceVisualEditor.tsx` 从 **882 行降至 282 行**(-68%),仅保留:加载/保存逻辑、YAML/可视化模式切换、资源类型分发(dispatch)。
|
||||||
|
|
||||||
|
**提取原则**: 每个子编辑器是自包含的函数组件,接收 `{manifest, onSave, saving, isCreate}` props(IngressEditor 额外接收 `clusterCode`),无共享状态。提取为纯机械操作(移动函数 + 添加 import/export),零逻辑变更。
|
||||||
|
|
||||||
|
**验收标准**:
|
||||||
|
- [x] ResourceVisualEditor.tsx 从 882 行降至 **282 行**(-68%)
|
||||||
|
- [x] 5 个提取的编辑器文件全部 < 220 行
|
||||||
|
- [x] `tsc -b` 零错误
|
||||||
|
- [x] `npm run build` 零错误(✓ built in 16.11s)
|
||||||
|
- [x] ESLint errors 保持 **0**(清理 11 个 unused import)
|
||||||
|
- [x] ESLint warnings 保持 **59**(无新增)
|
||||||
|
- [x] `go build` 零错误
|
||||||
|
- [x] 17/17 集成回归测试通过(零回归)
|
||||||
|
- [x] Docker 部署 healthy
|
||||||
|
- [x] K8s 资源 YAML API 链路验证通过(visual editor dispatch 正常)
|
||||||
@@ -2,13 +2,9 @@
|
|||||||
// ResourceVisualEditor — ConfigMap/Secret/Service/Ingress/PVC 可视化编辑 // Logan.Li
|
// ResourceVisualEditor — ConfigMap/Secret/Service/Ingress/PVC 可视化编辑 // Logan.Li
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import type { ComponentType } from 'react';
|
import type { ComponentType } from 'react';
|
||||||
import { X, Save, Loader2, Plus, Trash2, FileText, Network, Shield, Database, ChevronRight} from 'lucide-react';
|
import { X, Loader2, FileText, Network, Shield, Database, ChevronRight} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { ModalOverlay } from '@/components/ui/modal-overlay';
|
import { ModalOverlay } from '@/components/ui/modal-overlay';
|
||||||
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 { useToast } from '@/hooks/use-toast';
|
||||||
import { kubernetesApi } from '@/api';
|
import { kubernetesApi } from '@/api';
|
||||||
import { createEmptyManifest } from './resource-create-templates';
|
import { createEmptyManifest } from './resource-create-templates';
|
||||||
@@ -18,6 +14,11 @@ import { YamlCodeMirror } from './YamlCodeMirror';
|
|||||||
import { getYamlResourceConfig, yamlDownloadFilename } from './k8s-yaml-resource-config';
|
import { getYamlResourceConfig, yamlDownloadFilename } from './k8s-yaml-resource-config';
|
||||||
import { downloadYamlFile } from '@/utils/export';
|
import { downloadYamlFile } from '@/utils/export';
|
||||||
import i18n from '@/i18n';
|
import i18n from '@/i18n';
|
||||||
|
import { ConfigMapEditor } from './resource-visual-editors/ConfigMapEditor';
|
||||||
|
import { SecretEditor } from './resource-visual-editors/SecretEditor';
|
||||||
|
import { ServiceEditor } from './resource-visual-editors/ServiceEditor';
|
||||||
|
import { IngressEditor } from './resource-visual-editors/IngressEditor';
|
||||||
|
import { PvcEditor } from './resource-visual-editors/PvcEditor';
|
||||||
|
|
||||||
interface ResourceVisualEditorProps {
|
interface ResourceVisualEditorProps {
|
||||||
clusterCode: string;
|
clusterCode: string;
|
||||||
@@ -279,604 +280,3 @@ export function ResourceVisualEditor({ clusterCode, namespace, resource, name, o
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ══════════════════════════════════════════════
|
|
||||||
// ConfigMap 编辑器 — 键值对表格
|
|
||||||
// ══════════════════════════════════════════════
|
|
||||||
function ConfigMapEditor({ manifest, onSave, saving, isCreate }: { manifest: any; onSave: (m: any) => void; saving: boolean; isCreate?: boolean }) {
|
|
||||||
const data = manifest?.data || {};
|
|
||||||
const [resName, setResName] = useState(manifest?.metadata?.name || '');
|
|
||||||
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];
|
|
||||||
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, metadata: { ...manifest.metadata, name: resName.trim() }, data: newData });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
<div className="shrink-0">
|
|
||||||
<Label className="text-[var(--dg-text-secondary)] text-xs"> {i18n.t('kubernetes.secret.secretName')} </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="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"> {i18n.t('kubernetes.resourcevisuale.s6bd91c09')} </Label>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => setExpandAll(!expandAll)}>
|
|
||||||
{expandAll ? i18n.t('kubernetes.resourcevisuale.s880e849b') : i18n.t('system.expandAll')}
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={addRow}><Plus className="h-3 w-3 mr-1" /> {i18n.t('monitor.dashboard.custom.addBtn')} </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-tertiary)]">
|
|
||||||
<Input
|
|
||||||
value={entry.key}
|
|
||||||
onChange={e => update(i, 'key', e.target.value)}
|
|
||||||
placeholder={i18n.t('kubernetes.resourcevisuale.s65017dab')}
|
|
||||||
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={i18n.t('k8s.resourceEditor.sfe7509e0')}
|
|
||||||
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 dg-text-red hover:dg-text-red 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"> {i18n.t('kubernetes.resourcevisuale.saf7696c3')} </div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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" />}
|
|
||||||
保存配置
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════
|
|
||||||
// Secret 编辑器 — 键值对表格 (Base64 编码/解码)
|
|
||||||
// ══════════════════════════════════════════════
|
|
||||||
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]) => {
|
|
||||||
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, metadata: { ...manifest.metadata, name: resName.trim() }, data: newData });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<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"> {i18n.t('kubernetes.secret.secretName')} </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"> {i18n.t('system.credentialType')} </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"> {i18n.t('kubernetes.resourcevisuale.se1937033')} </Label>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => setExpandAll(!expandAll)}>
|
|
||||||
{expandAll ? i18n.t('kubernetes.resourcevisuale.s880e849b') : i18n.t('system.expandAll')}
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={addRow}><Plus className="h-3 w-3 mr-1" /> {i18n.t('monitor.dashboard.custom.addBtn')} </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-tertiary)]">
|
|
||||||
<Input
|
|
||||||
value={entry.key}
|
|
||||||
onChange={e => update(i, 'key', e.target.value)}
|
|
||||||
placeholder={i18n.t('kubernetes.resourcevisuale.scdecc460')}
|
|
||||||
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={i18n.t('kubernetes.resourcevisuale.s7f123dd4')}
|
|
||||||
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 dg-text-red hover:dg-text-red 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"> {i18n.t('kubernetes.resourcevisuale.s47ff6900')} </div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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" />}
|
|
||||||
保存密钥
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════
|
|
||||||
// Service 编辑器 — 端口配置 + 类型 + 选择器
|
|
||||||
// ══════════════════════════════════════════════
|
|
||||||
function ServiceEditor({ manifest, onSave, saving, isCreate }: { manifest: any; onSave: (m: any) => void; saving: boolean; isCreate?: boolean }) {
|
|
||||||
const spec = manifest?.spec || {};
|
|
||||||
const [resName, setResName] = useState(manifest?.metadata?.name || '');
|
|
||||||
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: Record<string, any> = { ...spec };
|
|
||||||
newSpec.type = svcType;
|
|
||||||
newSpec.ports = ports.map(p => {
|
|
||||||
const port: Record<string, 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, metadata: { ...manifest.metadata, name: resName.trim() }, spec: newSpec });
|
|
||||||
};
|
|
||||||
|
|
||||||
const svcTypes = [
|
|
||||||
{ value: 'ClusterIP', label: 'ClusterIP' },
|
|
||||||
{ value: 'NodePort', label: 'NodePort' },
|
|
||||||
{ value: 'LoadBalancer', label: 'LoadBalancer' },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
<div className="shrink-0">
|
|
||||||
<Label className="text-[var(--dg-text-secondary)] text-xs"> {i18n.t('agent.agentServiceName')} </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="shrink-0 mt-3">
|
|
||||||
<Label className="text-[var(--dg-text-secondary)] text-xs mb-2 block"> {i18n.t('kubernetes.service.serviceType')} </Label>
|
|
||||||
<div className="flex rounded-lg border border-[var(--dg-border)] overflow-hidden w-fit">
|
|
||||||
{svcTypes.map((t) => (
|
|
||||||
<button
|
|
||||||
key={t.value}
|
|
||||||
type="button"
|
|
||||||
className={`px-3 py-1.5 text-xs transition-colors ${svcType === t.value ? 'bg-blue-600 text-white' : 'text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-tertiary)]'}`}
|
|
||||||
onClick={() => setSvcType(t.value)}
|
|
||||||
>
|
|
||||||
{t.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 端口 + 选择器:统一滚动,避免条目过多时底部被裁切 // 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"> {i18n.t('kubernetes.resourcevisuale.s736788dc')} </Label>
|
|
||||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={addPort}><Plus className="h-3 w-3 mr-1" /> {i18n.t('agent.addPort')} </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="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={i18n.t('kubernetes.secret.secretName')} 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={i18n.t('system.ldapPort')} 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={i18n.t('monitor.alertEvents.col.target')} 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 dg-text-red hover:dg-text-red" onClick={() => removePort(i)}><Trash2 className="h-3 w-3" /></Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{ports.length === 0 && <div className="text-center py-4 text-[var(--dg-text-muted)] text-xs"> {i18n.t('kubernetes.resourcevisuale.sf3ba5e52')} </div>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Selector */}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<Label className="text-[var(--dg-text-secondary)] text-sm"> {i18n.t('kubernetes.resourcevisuale.scecdd712')} </Label>
|
|
||||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={addSelector}><Plus className="h-3 w-3 mr-1" /> {i18n.t('kubernetes.resourcevisuale.s736eaaae')} </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={i18n.t('kubernetes.resourcevisuale.s450c9957')} 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={i18n.t('kubernetes.resourcevisuale.sb873e454')} 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 dg-text-red hover:dg-text-red" 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"> {i18n.t('k8s.resource.noSelectors')} </div>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════
|
|
||||||
// Ingress 编辑器 — Host/Path 路由规则
|
|
||||||
// ══════════════════════════════════════════════
|
|
||||||
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));
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [clusterCode]);
|
|
||||||
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 } } },
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
const newSpec: Record<string, any> = { ...spec, rules: newRules };
|
|
||||||
if (ingressClass.trim()) newSpec.ingressClassName = ingressClass.trim();
|
|
||||||
else delete newSpec.ingressClassName;
|
|
||||||
onSave({ ...manifest, metadata: { ...manifest.metadata, name: resName.trim() }, spec: newSpec });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<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"> {i18n.t('kubernetes.secret.secretName')} </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="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 ? i18n.t('monitor.logPanel.logResults.loading') : i18n.t('kubernetes.resourcevisuale.s25a87dc9')} />
|
|
||||||
</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)]"> {i18n.t('kubernetes.resourcevisuale.s619a36ce')} </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 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"> {i18n.t('kubernetes.ingress.rules')} </Label>
|
|
||||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={addRule}><Plus className="h-3 w-3 mr-1" /> {i18n.t('kubernetes.resourcevisuale.s4f0a3841')} </Button>
|
|
||||||
</div>
|
|
||||||
<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-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={i18n.t('kubernetes.resourcevisuale.s33cde95b')}
|
|
||||||
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 shrink-0 dg-text-red hover:dg-text-red" onClick={() => removeRule(ri)}><Trash2 className="h-3 w-3" /></Button>
|
|
||||||
</div>
|
|
||||||
{rule.paths.map((p, pi) => (
|
|
||||||
<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={i18n.t('kubernetes.resourcevisuale.sdfd5ebd4')} 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={i18n.t('system.ldapPort')} 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 dg-text-red hover:dg-text-red" 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" /> {i18n.t('kubernetes.ingress.addPath')} </Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{rules.length === 0 && <div className="text-center py-4 text-[var(--dg-text-muted)] text-xs"> {i18n.t('kubernetes.resourcevisuale.s0b0794a3')} </div>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<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
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════
|
|
||||||
// PVC 编辑器 — 存储大小/访问模式/StorageClass
|
|
||||||
// ══════════════════════════════════════════════
|
|
||||||
function PvcEditor({ manifest, onSave, saving, isCreate }: { manifest: any; onSave: (m: any) => void; saving: boolean; isCreate?: boolean }) {
|
|
||||||
const spec = manifest?.spec || {};
|
|
||||||
const resources = spec.resources?.requests || {};
|
|
||||||
const [resName, setResName] = useState(manifest?.metadata?.name || '');
|
|
||||||
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,
|
|
||||||
metadata: { ...manifest.metadata, name: resName.trim() },
|
|
||||||
spec: {
|
|
||||||
...spec,
|
|
||||||
accessModes: [accessMode],
|
|
||||||
storageClassName: storageClass || undefined,
|
|
||||||
resources: { requests: { storage: size } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
<div className="shrink-0">
|
|
||||||
<Label className="text-[var(--dg-text-secondary)] text-xs"> {i18n.t('kubernetes.secret.secretName')} </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 mt-3 shrink-0">
|
|
||||||
<div>
|
|
||||||
<Label className="text-[var(--dg-text-secondary)] text-xs"> {i18n.t('kubernetes.resourcevisuale.s61aa8291')} </Label>
|
|
||||||
<Input
|
|
||||||
value={size}
|
|
||||||
onChange={e => setSize(e.target.value)}
|
|
||||||
placeholder={i18n.t('kubernetes.resourcevisuale.s1b915a5e')}
|
|
||||||
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"> {i18n.t('kubernetes.resourcevisuale.s6a1c7a2c')} </p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label className="text-[var(--dg-text-secondary)] text-xs"> {i18n.t('kubernetes.storage.accessModes')} </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 className="shrink-0 mt-1">
|
|
||||||
<Label className="text-[var(--dg-text-secondary)] text-xs">StorageClass</Label>
|
|
||||||
<Input
|
|
||||||
value={storageClass}
|
|
||||||
onChange={e => setStorageClass(e.target.value)}
|
|
||||||
placeholder={i18n.t('kubernetes.resourcevisuale.s06195f6c')}
|
|
||||||
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-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
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
// ConfigMapEditor — ConfigMap 键值对表格可视化编辑器 (从 ResourceVisualEditor.tsx 拆分)
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Plus, Trash2, Loader2, Save } 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 i18n from '@/i18n';
|
||||||
|
|
||||||
|
export interface ConfigMapEditorProps {
|
||||||
|
manifest: any;
|
||||||
|
onSave: (m: any) => void;
|
||||||
|
saving: boolean;
|
||||||
|
isCreate?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfigMapEditor({ manifest, onSave, saving, isCreate }: ConfigMapEditorProps) {
|
||||||
|
const data = manifest?.data || {};
|
||||||
|
const [resName, setResName] = useState(manifest?.metadata?.name || '');
|
||||||
|
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];
|
||||||
|
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, metadata: { ...manifest.metadata, name: resName.trim() }, data: newData });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="shrink-0">
|
||||||
|
<Label className="text-[var(--dg-text-secondary)] text-xs"> {i18n.t('kubernetes.secret.secretName')} </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="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"> {i18n.t('kubernetes.resourcevisuale.s6bd91c09')} </Label>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => setExpandAll(!expandAll)}>
|
||||||
|
{expandAll ? i18n.t('kubernetes.resourcevisuale.s880e849b') : i18n.t('system.expandAll')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={addRow}><Plus className="h-3 w-3 mr-1" /> {i18n.t('monitor.dashboard.custom.addBtn')} </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-tertiary)]">
|
||||||
|
<Input
|
||||||
|
value={entry.key}
|
||||||
|
onChange={e => update(i, 'key', e.target.value)}
|
||||||
|
placeholder={i18n.t('kubernetes.resourcevisuale.s65017dab')}
|
||||||
|
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={i18n.t('k8s.resourceEditor.sfe7509e0')}
|
||||||
|
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 dg-text-red hover:dg-text-red 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"> {i18n.t('kubernetes.resourcevisuale.saf7696c3')} </div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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" />}
|
||||||
|
保存配置
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
// IngressEditor — Ingress Host/Path 路由规则可视化编辑器 (从 ResourceVisualEditor.tsx 拆分)
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Plus, Trash2, Loader2, Save } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { kubernetesApi } from '@/api';
|
||||||
|
import i18n from '@/i18n';
|
||||||
|
|
||||||
|
export interface IngressEditorProps {
|
||||||
|
manifest: any;
|
||||||
|
clusterCode: string;
|
||||||
|
onSave: (m: any) => void;
|
||||||
|
saving: boolean;
|
||||||
|
isCreate?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IngressEditor({ manifest, clusterCode, onSave, saving, isCreate }: IngressEditorProps) {
|
||||||
|
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));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [clusterCode]);
|
||||||
|
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 } } },
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
const newSpec: Record<string, any> = { ...spec, rules: newRules };
|
||||||
|
if (ingressClass.trim()) newSpec.ingressClassName = ingressClass.trim();
|
||||||
|
else delete newSpec.ingressClassName;
|
||||||
|
onSave({ ...manifest, metadata: { ...manifest.metadata, name: resName.trim() }, spec: newSpec });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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"> {i18n.t('kubernetes.secret.secretName')} </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="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 ? i18n.t('monitor.logPanel.logResults.loading') : i18n.t('kubernetes.resourcevisuale.s25a87dc9')} />
|
||||||
|
</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)]"> {i18n.t('kubernetes.resourcevisuale.s619a36ce')} </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 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"> {i18n.t('kubernetes.ingress.rules')} </Label>
|
||||||
|
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={addRule}><Plus className="h-3 w-3 mr-1" /> {i18n.t('kubernetes.resourcevisuale.s4f0a3841')} </Button>
|
||||||
|
</div>
|
||||||
|
<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-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={i18n.t('kubernetes.resourcevisuale.s33cde95b')}
|
||||||
|
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 shrink-0 dg-text-red hover:dg-text-red" onClick={() => removeRule(ri)}><Trash2 className="h-3 w-3" /></Button>
|
||||||
|
</div>
|
||||||
|
{rule.paths.map((p, pi) => (
|
||||||
|
<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={i18n.t('kubernetes.resourcevisuale.sdfd5ebd4')} 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={i18n.t('system.ldapPort')} 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 dg-text-red hover:dg-text-red" 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" /> {i18n.t('kubernetes.ingress.addPath')} </Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{rules.length === 0 && <div className="text-center py-4 text-[var(--dg-text-muted)] text-xs"> {i18n.t('kubernetes.resourcevisuale.s0b0794a3')} </div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
// PvcEditor — PVC 存储大小/访问模式/StorageClass 可视化编辑器 (从 ResourceVisualEditor.tsx 拆分)
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Loader2, Save } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import i18n from '@/i18n';
|
||||||
|
|
||||||
|
export interface PvcEditorProps {
|
||||||
|
manifest: any;
|
||||||
|
onSave: (m: any) => void;
|
||||||
|
saving: boolean;
|
||||||
|
isCreate?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PvcEditor({ manifest, onSave, saving, isCreate }: PvcEditorProps) {
|
||||||
|
const spec = manifest?.spec || {};
|
||||||
|
const resources = spec.resources?.requests || {};
|
||||||
|
const [resName, setResName] = useState(manifest?.metadata?.name || '');
|
||||||
|
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,
|
||||||
|
metadata: { ...manifest.metadata, name: resName.trim() },
|
||||||
|
spec: {
|
||||||
|
...spec,
|
||||||
|
accessModes: [accessMode],
|
||||||
|
storageClassName: storageClass || undefined,
|
||||||
|
resources: { requests: { storage: size } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="shrink-0">
|
||||||
|
<Label className="text-[var(--dg-text-secondary)] text-xs"> {i18n.t('kubernetes.secret.secretName')} </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 mt-3 shrink-0">
|
||||||
|
<div>
|
||||||
|
<Label className="text-[var(--dg-text-secondary)] text-xs"> {i18n.t('kubernetes.resourcevisuale.s61aa8291')} </Label>
|
||||||
|
<Input
|
||||||
|
value={size}
|
||||||
|
onChange={e => setSize(e.target.value)}
|
||||||
|
placeholder={i18n.t('kubernetes.resourcevisuale.s1b915a5e')}
|
||||||
|
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"> {i18n.t('kubernetes.resourcevisuale.s6a1c7a2c')} </p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[var(--dg-text-secondary)] text-xs"> {i18n.t('kubernetes.storage.accessModes')} </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 className="shrink-0 mt-1">
|
||||||
|
<Label className="text-[var(--dg-text-secondary)] text-xs">StorageClass</Label>
|
||||||
|
<Input
|
||||||
|
value={storageClass}
|
||||||
|
onChange={e => setStorageClass(e.target.value)}
|
||||||
|
placeholder={i18n.t('kubernetes.resourcevisuale.s06195f6c')}
|
||||||
|
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-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
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
// SecretEditor — Secret 键值对表格可视化编辑器 (Base64 编码/解码, 从 ResourceVisualEditor.tsx 拆分)
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Plus, Trash2, Loader2, Save } 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 i18n from '@/i18n';
|
||||||
|
|
||||||
|
export interface SecretEditorProps {
|
||||||
|
manifest: any;
|
||||||
|
onSave: (m: any) => void;
|
||||||
|
saving: boolean;
|
||||||
|
isCreate?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SecretEditor({ manifest, onSave, saving, isCreate }: SecretEditorProps) {
|
||||||
|
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]) => {
|
||||||
|
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, metadata: { ...manifest.metadata, name: resName.trim() }, data: newData });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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"> {i18n.t('kubernetes.secret.secretName')} </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"> {i18n.t('system.credentialType')} </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"> {i18n.t('kubernetes.resourcevisuale.se1937033')} </Label>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => setExpandAll(!expandAll)}>
|
||||||
|
{expandAll ? i18n.t('kubernetes.resourcevisuale.s880e849b') : i18n.t('system.expandAll')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={addRow}><Plus className="h-3 w-3 mr-1" /> {i18n.t('monitor.dashboard.custom.addBtn')} </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-tertiary)]">
|
||||||
|
<Input
|
||||||
|
value={entry.key}
|
||||||
|
onChange={e => update(i, 'key', e.target.value)}
|
||||||
|
placeholder={i18n.t('kubernetes.resourcevisuale.scdecc460')}
|
||||||
|
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={i18n.t('kubernetes.resourcevisuale.s7f123dd4')}
|
||||||
|
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 dg-text-red hover:dg-text-red 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"> {i18n.t('kubernetes.resourcevisuale.s47ff6900')} </div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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" />}
|
||||||
|
保存密钥
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
// ServiceEditor — Service 端口配置 + 类型 + 选择器可视化编辑器 (从 ResourceVisualEditor.tsx 拆分)
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Plus, Trash2, Loader2, Save } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import i18n from '@/i18n';
|
||||||
|
|
||||||
|
export interface ServiceEditorProps {
|
||||||
|
manifest: any;
|
||||||
|
onSave: (m: any) => void;
|
||||||
|
saving: boolean;
|
||||||
|
isCreate?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServiceEditor({ manifest, onSave, saving, isCreate }: ServiceEditorProps) {
|
||||||
|
const spec = manifest?.spec || {};
|
||||||
|
const [resName, setResName] = useState(manifest?.metadata?.name || '');
|
||||||
|
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: Record<string, any> = { ...spec };
|
||||||
|
newSpec.type = svcType;
|
||||||
|
newSpec.ports = ports.map(p => {
|
||||||
|
const port: Record<string, 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, metadata: { ...manifest.metadata, name: resName.trim() }, spec: newSpec });
|
||||||
|
};
|
||||||
|
|
||||||
|
const svcTypes = [
|
||||||
|
{ value: 'ClusterIP', label: 'ClusterIP' },
|
||||||
|
{ value: 'NodePort', label: 'NodePort' },
|
||||||
|
{ value: 'LoadBalancer', label: 'LoadBalancer' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="shrink-0">
|
||||||
|
<Label className="text-[var(--dg-text-secondary)] text-xs"> {i18n.t('agent.agentServiceName')} </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="shrink-0 mt-3">
|
||||||
|
<Label className="text-[var(--dg-text-secondary)] text-xs mb-2 block"> {i18n.t('kubernetes.service.serviceType')} </Label>
|
||||||
|
<div className="flex rounded-lg border border-[var(--dg-border)] overflow-hidden w-fit">
|
||||||
|
{svcTypes.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.value}
|
||||||
|
type="button"
|
||||||
|
className={`px-3 py-1.5 text-xs transition-colors ${svcType === t.value ? 'bg-blue-600 text-white' : 'text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-tertiary)]'}`}
|
||||||
|
onClick={() => setSvcType(t.value)}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 端口 + 选择器:统一滚动,避免条目过多时底部被裁切 // 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"> {i18n.t('kubernetes.resourcevisuale.s736788dc')} </Label>
|
||||||
|
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={addPort}><Plus className="h-3 w-3 mr-1" /> {i18n.t('agent.addPort')} </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="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={i18n.t('kubernetes.secret.secretName')} 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={i18n.t('system.ldapPort')} 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={i18n.t('monitor.alertEvents.col.target')} 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 dg-text-red hover:dg-text-red" onClick={() => removePort(i)}><Trash2 className="h-3 w-3" /></Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{ports.length === 0 && <div className="text-center py-4 text-[var(--dg-text-muted)] text-xs"> {i18n.t('kubernetes.resourcevisuale.sf3ba5e52')} </div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selector */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<Label className="text-[var(--dg-text-secondary)] text-sm"> {i18n.t('kubernetes.resourcevisuale.scecdd712')} </Label>
|
||||||
|
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={addSelector}><Plus className="h-3 w-3 mr-1" /> {i18n.t('kubernetes.resourcevisuale.s736eaaae')} </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={i18n.t('kubernetes.resourcevisuale.s450c9957')} 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={i18n.t('kubernetes.resourcevisuale.sb873e454')} 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 dg-text-red hover:dg-text-red" 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"> {i18n.t('k8s.resource.noSelectors')} </div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user