- KubernetesClusterDialog: 新增 code 表单字段 - 名称变更自动生成 code(小写+连字符) - handleSubmit 增加 code 校验 + toast 提示 - payload 增加 code 字段(后端 binding:required)
195 lines
6.9 KiB
TypeScript
195 lines
6.9 KiB
TypeScript
// k8s-yaml-utils.ts — YAML 编辑前清理工具(借鉴 opsx-agent cleanYAML 设计)
|
||
// 不依赖 js-yaml;基于文本行级解析,针对 K8s 标准 manifest 结构清理。
|
||
// 作者: Logan <admin@attacker.club>
|
||
|
||
/**
|
||
* 编辑前清理 YAML 文本:
|
||
* - 移除 status 整块(K8s 服务端字段)
|
||
* - 移除 metadata 中的只读/噪音字段:uid / resourceVersion / managedFields /
|
||
* creationTimestamp / generation / selfLink / ownerReferences / resourceVersion
|
||
* - 移除 annotations 中的 kubectl.kubernetes.io/last-applied-configuration
|
||
* - 规范化行尾,保留可读缩进
|
||
*/
|
||
export function cleanYAML(yaml: string): string {
|
||
if (!yaml) return '';
|
||
const lines = yaml.replace(/\r\n/g, '\n').split('\n');
|
||
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', // 兜底
|
||
]);
|
||
|
||
while (i < lines.length) {
|
||
const line = lines[i];
|
||
const trimmed = line.trimStart();
|
||
const indent = line.length - trimmed.length;
|
||
|
||
// 1) 顶层 status: 整块移除(indent === 0)
|
||
if (indent === 0 && /^status\s*:/.test(trimmed)) {
|
||
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];
|
||
|
||
// 单行移除
|
||
if (METADATA_LINE_KEYS.has(key)) {
|
||
// 若该行值非空(如 "uid: xxx"),直接跳过该行
|
||
// 若该行值为空(如 "managedFields:"),需要跳过其下属缩进块
|
||
const afterColon = trimmed.slice(metaKeyMatch[0].length).trim();
|
||
if (afterColon === '') {
|
||
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++;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// managedFields 块移除(即使有内联内容也整块跳过)
|
||
if (METADATA_BLOCK_KEYS.has(key)) {
|
||
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++;
|
||
}
|
||
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);
|
||
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;
|
||
}
|
||
|
||
out.push(line);
|
||
i++;
|
||
}
|
||
|
||
// 末尾保持一个换行
|
||
let result = out.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd();
|
||
return result + '\n';
|
||
}
|
||
|
||
/** 解析简单 "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 官方蓝
|
||
} as const;
|
||
|
||
/** 根据 Pod/Deployment 状态返回对应色值 */
|
||
export function statusColor(status: string | undefined): string {
|
||
if (!status) return '#64748b';
|
||
const s = status.toLowerCase();
|
||
if (['running', 'available', 'ready', 'active', 'completed', 'succeeded'].includes(s)) return STATUS_COLORS.green;
|
||
if (['pending', 'updating', 'progressing', 'containercreating'].includes(s)) return STATUS_COLORS.orange;
|
||
if (['failed', 'error', 'crashloopbackoff', 'imagepullbackoff', 'errimageneverpull'].includes(s)) return STATUS_COLORS.red;
|
||
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);
|
||
if (existing) {
|
||
existing._count += 1;
|
||
} else {
|
||
map.set(key, { ...it, _count: 1 });
|
||
}
|
||
}
|
||
return Array.from(map.values());
|
||
}
|
||
|
||
/** 格式化 K8s 时间戳(ISO / RFC3339)为本地可读格式 */
|
||
export function formatK8sTime(ts: string | undefined | null): string {
|
||
if (!ts) return '-';
|
||
const d = new Date(ts);
|
||
if (isNaN(d.getTime())) return ts;
|
||
const pad = (n: number) => String(n).padStart(2, '0');
|
||
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();
|
||
const e = new Date(end).getTime();
|
||
if (isNaN(s) || isNaN(e) || e < s) return null;
|
||
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`;
|
||
if (sec < 3600) return `${Math.floor(sec / 60)}m${sec % 60}s`;
|
||
const h = Math.floor(sec / 3600);
|
||
const m = Math.floor((sec % 3600) / 60);
|
||
return `${h}h${m}m`;
|
||
}
|