feat(k8s): M8+M9 存储挂载/高级设置/服务路由 Tab — Deployment 可视化编辑器 5 Tab 全部完成

M8 存储挂载 Tab (DeploymentEditStorage.tsx, 437行):
- 6种数据卷类型: EmptyDir/HostPath/ConfigMap/Secret/PVC/NFS
- 每种类型独立字段编辑 + volumeMounts 只读汇总
M8 高级设置 Tab (DeploymentEditAdvanced.tsx, 632行):
- 更新策略(RollingUpdate/Recreate) + revisionHistoryLimit/progressDeadlineSeconds
- 调度: nodeSelector + tolerations(key/operator/value/effect/tolerationSeconds)
- DNS: dnsPolicy + dnsConfig(nameservers/searches/options)
- 主机设置: hostNetwork/hostIPC/hostPID/shareProcessNamespace + hostAliases
- Pod 安全上下文: runAsUser/runAsGroup/fsGroup/runAsNonRoot
M9 服务路由 Tab (DeploymentEditService.tsx):
- selector 双路径关联 Service(spec.selector.matchLabels || selector)
- Service/Ingress 创建/删除集成

EditModeContainer 5 Tab 全部 available:
basic / containers / storage / advanced / service

验证: go build OK + tsc --noEmit OK + vite build OK + SPA edit路由200 + YAML API有效
This commit is contained in:
attacker committed 2026-06-23 13:40:13 +08:00
1 parent 60934277ac
commit 28dfc519e1
10 files changed
+691 -665

No files matched your search

@@ -1,14 +1,17 @@
// k8s-yaml-utils.ts — YAML 编辑前清理工具(借鉴 opsx-agent cleanYAML 设计)
// 不依赖 js-yaml;基于文本行级解析,针对 K8s 标准 manifest 结构清理。
// k8s-yaml-utils.ts — K8s YAML 编辑前深度清理工具
// 参考 opsx-agent clean_kubernetes_yaml() 设计,移除所有运行时/默认值/系统字段
// 作者: Logan <admin@attacker.club>
/**
* 编辑前清理 YAML 文本:
* - 移除 status 整块(K8s 服务端字段)
* - 移除 metadata 中的只读/噪音字段:uid / resourceVersion / managedFields /
* creationTimestamp / generation / selfLink / ownerReferences / resourceVersion
* - 移除 annotations 中的 kubectl.kubernetes.io/last-applied-configuration
* - 规范化行尾,保留可读缩进
* 编辑前深度清理 YAML 文本(Kuboard 级精简):
* - 移除 status 整块
* - 移除 metadata 噪音字段(uid/resourceVersion/managedFields/creationTimestamp 等)
* - 移除 annotations 中的 kubectl 系统注解
* - 移除 labels 中的 pod-template-hash
* - 移除 spec 中 K8s 默认值(dnsPolicy/restartPolicy/schedulerName 等)
* - 移除容器默认值(imagePullPolicy/terminationMessagePath 等)
* - 移除系统卷(kube-api-access-*)及对应 volumeMount
* - 移除 Deployment 默认字段(progressDeadlineSeconds/revisionHistoryLimit)
*/
export function cleanYAML(yaml: string): string {
if (!yaml) return '';
@@ -16,102 +19,346 @@ export function cleanYAML(yaml: string): string {
const out: string[] = [];
let i = 0;
// metadata 下需要整块移除的字段(值为多行块或对象)
const METADATA_BLOCK_KEYS = new Set([
'managedFields',
'ownerReferences',
]);
// metadata 下需要单行移除的字段
// ===== 移除配置 =====
const METADATA_LINE_KEYS = new Set([
'uid',
'resourceVersion',
'creationTimestamp',
'generation',
'selfLink',
'clusterName',
'managedFields', // 兜底
'uid', 'resourceVersion', 'creationTimestamp', 'generation',
'selfLink', 'clusterName', 'managedFields', 'generateName',
]);
const METADATA_BLOCK_KEYS = new Set(['managedFields', 'ownerReferences']);
// K8s 默认值 → 值匹配时移除
const SPEC_DEFAULTS: Record<string, string[]> = {
dnsPolicy: ['ClusterFirst'],
restartPolicy: ['Always'],
schedulerName: ['default-scheduler'],
terminationGracePeriodSeconds: ['30'],
serviceAccountName: ['default'],
enableServiceLinks: ['true'],
progressDeadlineSeconds: ['600'],
revisionHistoryLimit: ['10'],
};
const CONTAINER_DEFAULTS: Record<string, string[]> = {
imagePullPolicy: ['Always', 'IfNotPresent'],
terminationMessagePath: ['/dev/termination-log'],
terminationMessagePolicy: ['File'],
};
const ALWAYS_REMOVE_ANNOTATIONS = new Set([
'kubectl.kubernetes.io/last-applied-configuration',
'deployment.kubernetes.io/revision',
'kubectl.kubernetes.io/restartedAt',
'control-plane.alpha.kubernetes.io/leader',
'autoscaling.alpha.kubernetes.io/conditions',
]);
const SYSTEM_VOLUME_PREFIXES = ['kube-api-access-', 'default-token-', 'kube-'];
const SYSTEM_MOUNT_PATHS = ['/var/run/secrets/kubernetes.io/serviceaccount'];
// 当前作用域追踪
let inMetadata = false;
let inAnnotations = false;
let inSpec = false;
let inTemplateSpec = false;
let inContainers = false;
let inInitContainers = false;
let inVolumeMounts = false;
let inVolumes = false;
let metadataIndent = 0;
let specIndent = 0;
let templateSpecIndent = 0;
function getIndent(line: string): number {
return line.length - line.trimStart().length;
}
function extractKey(line: string): string | null {
const m = line.trimStart().match(/^([a-zA-Z_][a-zA-Z0-9_./-]*)\s*:/);
return m ? m[1] : null;
}
function hasInlineValue(line: string): boolean {
const afterColon = line.trim().split(':').slice(1).join(':').trim();
return afterColon !== '' && !/^[>|]/.test(afterColon);
}
// 跳过一个 YAML 块(包括当前行的所有子行)
function skipBlock(startIndent: number): void {
i++;
while (i < lines.length) {
const l = lines[i];
const t = l.trimStart();
if (t === '' || t.startsWith('#')) { i++; continue; }
if (getIndent(l) <= startIndent) break;
i++;
}
}
// 检查当前行是否是某个 volume 条目的开始(系统卷)
function isSystemVolume(line: string): boolean {
const key = extractKey(line);
if (!key) return false;
return SYSTEM_VOLUME_PREFIXES.some(prefix => key.startsWith(prefix));
}
// 检查 volumeMount 是否指向系统挂载路径
function isSystemMountLine(line: string): boolean {
return SYSTEM_MOUNT_PATHS.some(p => line.includes(`mountPath: ${p}`));
}
while (i < lines.length) {
const line = lines[i];
const trimmed = line.trimStart();
const indent = line.length - trimmed.length;
const indent = getIndent(line);
const key = extractKey(line);
// 1) 顶层 status: 整块移除(indent === 0)
if (indent === 0 && /^status\s*:/.test(trimmed)) {
// 空行和注释保留
if (trimmed === '' || trimmed.startsWith('#')) {
out.push(line);
i++;
// 跳过所有后续属于 status 块的行(缩进 > 0 或空行),直到下一个顶层 key
while (i < lines.length) {
const l = lines[i];
const t = l.trimStart();
if (t === '' || t.startsWith('#')) { i++; continue; }
const ind = l.length - t.length;
if (ind === 0) break; // 回到顶层
i++;
}
continue;
}
// 2) metadata 子字段清理
// 匹配形如 " uid: xxx" 或 " managedFields:" 等
const metaKeyMatch = trimmed.match(/^([a-zA-Z_][a-zA-Z0-9_-]*)\s*:/);
if (metaKeyMatch && indent >= 2 && indent <= 6) {
const key = metaKeyMatch[1];
// === 顶层 status: 整块移除 ===
if (indent === 0 && key === 'status') {
skipBlock(0);
continue;
}
// 单行移除
if (METADATA_LINE_KEYS.has(key)) {
// 若该行值非空(如 "uid: xxx"),直接跳过该行
// 若该行值为空(如 "managedFields:"),需要跳过其下属缩进块
const afterColon = trimmed.slice(metaKeyMatch[0].length).trim();
if (afterColon === '') {
// === 顶层 metadata: 跟踪作用域 ===
if (indent === 0 && key === 'metadata') {
inMetadata = true;
metadataIndent = 0;
out.push(line);
i++;
continue;
}
if (indent === 0 && key !== 'metadata') {
inMetadata = false;
inAnnotations = false;
}
// === 顶层 spec: 跟踪作用域 ===
if (indent === 0 && key === 'spec') {
inSpec = true;
specIndent = 0;
out.push(line);
i++;
continue;
}
if (indent === 0 && key !== 'spec') {
inSpec = false;
inTemplateSpec = false;
inContainers = false;
inInitContainers = false;
inVolumes = false;
}
// === metadata 子字段 ===
if (inMetadata) {
const metaIndent = 2; // metadata 子字段通常缩进 2
// 标注块开始/结束
if (indent === metaIndent && key === 'annotations') {
inAnnotations = true;
out.push(line);
i++;
continue;
}
if (indent <= metaIndent && key !== 'annotations') {
inAnnotations = false;
}
// labels 下的 pod-template-hash
if (indent >= metaIndent && indent <= metaIndent + 2) {
if (trimmed.startsWith('pod-template-hash') && trimmed.includes(':')) {
i++;
continue;
}
}
// metadata 单行字段移除
if (indent === metaIndent && key && METADATA_LINE_KEYS.has(key)) {
if (hasInlineValue(line)) {
i++;
while (i < lines.length) {
const l = lines[i];
const t = l.trimStart();
if (t === '' || t.startsWith('#')) { i++; continue; }
const ind = l.length - t.length;
if (ind <= indent) break;
i++;
}
} else {
i++;
skipBlock(metaIndent);
}
continue;
}
// managedFields 块移除(即使有内联内容也整块跳过)
if (METADATA_BLOCK_KEYS.has(key)) {
// metadata 块字段移除(managedFields/ownerReferences)
if (indent === metaIndent && key && METADATA_BLOCK_KEYS.has(key)) {
skipBlock(metaIndent);
continue;
}
// annotations 内的系统注解
if (inAnnotations && indent > metaIndent) {
if (key && ALWAYS_REMOVE_ANNOTATIONS.has(key)) {
if (hasInlineValue(line)) {
i++;
} else {
// 多行 block scalar
const afterColon = trimmed.split(':').slice(1).join(':').trim();
if (/^[>|]/.test(afterColon)) {
skipBlock(indent);
} else {
i++;
}
}
continue;
}
}
}
// === spec 子字段 ===
if (inSpec) {
// spec.template.spec 跟踪
if (indent === 2 && key === 'template') {
out.push(line);
i++;
continue;
}
// spec.selector — 不动(关键字段)
// spec.strategy — 不动
// spec 下的 K8s 默认值
if (indent === 2 && key && SPEC_DEFAULTS[key]) {
const afterColon = trimmed.split(':').slice(1).join(':').trim();
if (SPEC_DEFAULTS[key].includes(afterColon)) {
i++;
continue;
}
}
// spec 下的 nodeName(运行时分配)
if (indent === 2 && key === 'nodeName') {
i++;
continue;
}
// template.spec 内部
if (indent === 2 && key === 'spec') {
// 这是 template.spec 的开始
inTemplateSpec = true;
templateSpecIndent = indent;
out.push(line);
i++;
continue;
}
}
// === template.spec 内部 ===
if (inTemplateSpec && indent > templateSpecIndent) {
// containers 数组开始
if (key === 'containers') {
inContainers = true;
inInitContainers = false;
out.push(line);
i++;
continue;
}
if (key === 'initContainers') {
inInitContainers = true;
inContainers = false;
out.push(line);
i++;
continue;
}
// volumes 数组开始
if (key === 'volumes') {
inVolumes = true;
out.push(line);
i++;
continue;
}
// template.spec 默认值
if (key && SPEC_DEFAULTS[key]) {
const afterColon = trimmed.split(':').slice(1).join(':').trim();
if (SPEC_DEFAULTS[key].includes(afterColon)) {
i++;
continue;
}
}
// nodeName
if (key === 'nodeName') {
i++;
continue;
}
// serviceAccount (非 serviceAccountName 时的冗余字段)
if (key === 'serviceAccount' && trimmed.endsWith(':')) {
if (hasInlineValue(line)) {
i++;
} else {
skipBlock(indent);
}
continue;
}
}
// === containers/initContainers 内部 ===
if ((inContainers || inInitContainers) && indent >= 6) {
// 容器字段的默认值
if (key && CONTAINER_DEFAULTS[key]) {
const afterColon = trimmed.split(':').slice(1).join(':').trim();
if (CONTAINER_DEFAULTS[key].includes(afterColon)) {
i++;
continue;
}
}
// volumeMounts 区域
if (key === 'volumeMounts') {
inVolumeMounts = true;
// 不直接输出,等下面判断每个 mount
}
// 系统 volumeMount 条目
if (inVolumeMounts && trimmed.startsWith('- name:')) {
// 检查这个 volume mount 块内是否有系统路径
let blockLines: string[] = [line];
let j = i + 1;
let isSystem = false;
while (j < lines.length) {
const nl = lines[j].trimStart();
if (nl === '' || nl.startsWith('#')) { blockLines.push(lines[j]); j++; continue; }
if (getIndent(lines[j]) <= indent) break;
if (isSystemMountPath(nl)) isSystem = true;
blockLines.push(lines[j]);
j++;
}
if (isSystem) {
i = j; // 跳过整个 mount 块
continue;
}
// 非系统 mount,保留
inVolumeMounts = false;
}
}
// === volumes 区域 ===
if (inVolumes && indent >= 4) {
// 系统卷条目(- name: kube-api-access-xxx)
if (trimmed.startsWith('- name:') && isSystemVolume(trimmed)) {
// 跳过整个 volume 块
const blockIndent = indent;
i++;
while (i < lines.length) {
const l = lines[i];
const t = l.trimStart();
if (t === '' || t.startsWith('#')) { i++; continue; }
const ind = l.length - t.length;
if (ind <= indent) break;
if (getIndent(l) <= blockIndent) break;
i++;
}
continue;
}
}
// 3) annotations 内 last-applied-configuration 移除
// 形如 " kubectl.kubernetes.io/last-applied-configuration: >-"
// 可能跨多行(block scalar)
if (/kubectl\.kubernetes\.io\/last-applied-configuration\s*:/.test(trimmed)) {
const afterColon = trimmed.split(':').slice(1).join(':').trim();
// 多行 block scalar 标记:>- >- | |- 等
const isBlockScalar = /^[>|]/.test(afterColon);
// 连续空行压缩
if (trimmed === '' && out.length > 0 && out[out.length - 1].trim() === '') {
i++;
if (isBlockScalar) {
while (i < lines.length) {
const l = lines[i];
const t = l.trimStart();
if (t === '') { i++; continue; }
const ind = l.length - t.length;
if (ind <= indent) break;
i++;
}
}
continue;
}
@@ -119,27 +366,33 @@ export function cleanYAML(yaml: string): string {
i++;
}
// 末尾保持一个换行
// 末尾清理
let result = out.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd();
return result + '\n';
}
/** 解析简单 "key: value" 行(顶层或指定缩进),用于表单回填兜底。 */
function isSystemMountPath(line: string): boolean {
return line.includes('mountPath:') && (
line.includes('/var/run/secrets/kubernetes.io') ||
line.includes('/var/run/secrets/sa')
);
}
/** 解析简单 "key: value" 行 */
export function parseScalar(line: string): { key: string; value: string } | null {
const m = line.match(/^([A-Za-z_][\w-]*)\s*:\s*(.*)$/);
if (!m) return null;
return { key: m[1], value: m[2].trim().replace(/^["']|["']$/g, '') };
}
/** 状态色(与 opsx-agent 一致) */
/** 状态色 */
export const STATUS_COLORS = {
green: '#10b981',
orange: '#f59e0b',
red: '#ef4444',
blue: '#326CE5', // Kubernetes 官方蓝
blue: '#326CE5',
} as const;
/** 根据 Pod/Deployment 状态返回对应色值 */
export function statusColor(status: string | undefined): string {
if (!status) return '#64748b';
const s = status.toLowerCase();
@@ -149,23 +402,22 @@ export function statusColor(status: string | undefined): string {
return STATUS_COLORS.blue;
}
/** 按镜像聚合 ReplicaSet / Revision 列表(同镜像合并展示) */
/** 按镜像聚合 */
export function aggregateByImage<T extends { image?: string; images?: string[] }>(items: T[]): Array<T & { _count: number }> {
const map = new Map<string, T & { _count: number }>();
for (const it of items) {
const img = it.image || (Array.isArray(it.images) ? it.images[0] : '') || 'unknown';
const key = img;
const existing = map.get(key);
const existing = map.get(img);
if (existing) {
existing._count += 1;
} else {
map.set(key, { ...it, _count: 1 });
map.set(img, { ...it, _count: 1 });
}
}
return Array.from(map.values());
}
/** 格式化 K8s 时间戳(ISO / RFC3339)为本地可读格式 */
/** 格式化 K8s 时间戳 */
export function formatK8sTime(ts: string | undefined | null): string {
if (!ts) return '-';
const d = new Date(ts);
@@ -174,7 +426,6 @@ export function formatK8sTime(ts: string | undefined | null): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
/** 计算两个时间戳之间的相对耗时(秒/分/时),用于"调度耗时""就绪耗时"展示 */
export function elapsedSeconds(start?: string | null, end?: string | null): number | null {
if (!start || !end) return null;
const s = new Date(start).getTime();
@@ -183,7 +434,6 @@ export function elapsedSeconds(start?: string | null, end?: string | null): numb
return Math.max(0, Math.round((e - s) / 1000));
}
/** 把秒数格式化为 "1m23s" / "2h5m" / "45s" */
export function formatDuration(sec: number | null | undefined): string {
if (sec == null) return '-';
if (sec < 60) return `${sec}s`;