feat(rdp): 新增 RDP 远程桌面/SMB/账单同步能力 + 清理编译产物

主要变更:
- RDP 远程桌面:新增 backend/pkg/rdp(RDP 引擎 + credssp/display/rle/trace)、
  backend/controller/rdp*(连接/凭证/文件/会话/实时)、frontend RDP 组件与 API
- Windows SMB 文件传输:backend/pkg/winsmb
- 云账单同步:backend/pkg/billingsync + billing_sync/aliyun_billing,前端账单详情
- Agent CLI Kubernetes 工具:kubernetes_action/get、pod_execute/logs
- 服务器导入助手:server_import_helpers、ImportCredentialSettings
- 国际化与组件重构(cloud/deploy/monitor/server/system 等多模块)
- 清理编译产物二进制:移除 backend/server、backend/agent/bin/aiops-agent-*
- .gitignore:新增 backend/agent/ 与 .cache/(避免 334MB go-build 缓存误入库)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
LoganandClaude committed 2026-07-04 01:50:36 +08:00
1 parent 777d9e4e71
commit 76ec9387f0
202 files changed
+16555 -2543

No files matched your search

@@ -10,14 +10,14 @@ import { Badge } from '@/components/ui/badge';
import { Plus, Trash2, Save, Loader2, Tags, FileText, Hash, Lock } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { saveDeploymentManifest } from './deployment-manifest-save';
import i18n from '@/i18n';
import type { JsonData } from '@/types/common';
interface DeploymentEditBasicProps {
clusterCode: string;
namespace: string;
deploymentName: string;
manifest: any | null;
onSaved: (updated?: any) => void;
manifest: JsonData | null;
onSaved: (updated?: JsonData) => void;
onCancel: () => void;
isCreate?: boolean;
}
@@ -42,7 +42,7 @@ export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, ma
const { toast } = useToast();
const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(!manifest);
const [rawManifest, setRawManifest] = useState<any>(manifest || {});
const [rawManifest, setRawManifest] = useState<JsonData>(manifest || {});
const [resName, setResName] = useState(manifest?.metadata?.name || '');
const [replicas, setReplicas] = useState(1);
const [labels, setLabels] = useState<Array<{ key: string; value: string }>>([]);
@@ -94,7 +94,7 @@ export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, ma
const merged = { ...kvToObj(prev), ...sel };
return Object.entries(merged).map(([key, value]) => ({ key, value }));
});
toast({ title: i18n.t('kubernetes.deploymenteditb.s8bf97a5b'), description: i18n.t('kubernetes.deploymenteditb.s841d0da6') });
toast({ title: '已同步', description: 'Selector 标签已合并到 Pod 模板标签' });
};
const save = async () => {
@@ -107,7 +107,7 @@ export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, ma
base.metadata = base.metadata || {};
if (isCreate) {
const trimmed = resName.trim();
if (!trimmed) throw new Error(i18n.t('k8s.hpa.nameRequired'));
if (!trimmed) throw new Error('名称不能为空');
base.metadata.name = trimmed;
base.metadata.namespace = namespace;
}
@@ -128,10 +128,10 @@ export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, ma
delete base.spec.template.metadata.labels;
}
const savedName = await saveDeploymentManifest(clusterCode, namespace, deploymentName, base);
toast({ title: isCreate ? i18n.t('monitor.dashboard.custom.toastCreateSuccess') : i18n.t('kubernetes.deploymenteditb.sa59c4d53'), description: isCreate ? `${savedName} 已创建` : `${savedName} 更新已提交` });
toast({ title: isCreate ? '创建成功' : '基本信息已保存', description: isCreate ? `${savedName} 已创建` : `${savedName} 更新已提交` });
onSaved(base);
} catch (e) {
toast({ title: i18n.t('kubernetes.msg.saveFailed'), description: e?.message || String(e), variant: 'destructive' });
toast({ title: '保存失败', description: e?.message || String(e), variant: 'destructive' });
} finally { setSaving(false); }
};
@@ -140,7 +140,7 @@ export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, ma
<Card className="dg-card">
<CardContent className="p-12 text-center">
<Loader2 className="h-6 w-6 animate-spin mx-auto dg-text-muted" />
<p className="dg-text-muted mt-3 text-sm"> {i18n.t('monitor.logPanel.logResults.loading')} </p>
<p className="dg-text-muted mt-3 text-sm">加载中...</p>
</CardContent>
</Card>
);
@@ -175,7 +175,7 @@ export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, ma
onChange={e => setReplicas(Math.max(0, Number(e.target.value) || 0))}
className="w-32 dg-input"
/>
<span className="text-xs dg-text-muted"> {i18n.t('kubernetes.deploymenteditb.s6e47fc82')} </span>
<span className="text-xs dg-text-muted">期望运行的 Pod 实例数量</span>
</div>
</CardContent>
</Card>
@@ -185,12 +185,12 @@ export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, ma
<CardHeader className="pb-3">
<CardTitle className="text-sm text-[var(--dg-text-primary)] flex items-center gap-2">
<Lock className="h-4 w-4 dg-text-muted" />
{isCreate ? i18n.t('kubernetes.deploymenteditb.sc8de32ed') : i18n.t('kubernetes.deploymenteditb.sc4d9eb56')}
{isCreate ? '基本标识' : '基本标识(只读)'}
</CardTitle>
</CardHeader>
<CardContent className="pt-0 grid grid-cols-2 gap-4">
<div>
<Label className="dg-text-secondary text-xs"> {i18n.t('kubernetes.deploymenteditb.sab724785')} </Label>
<Label className="dg-text-secondary text-xs">名称 (name)</Label>
<Input
value={isCreate ? resName : deploymentName}
readOnly={!isCreate}
@@ -199,7 +199,7 @@ export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, ma
/>
</div>
<div>
<Label className="dg-text-secondary text-xs"> {i18n.t('kubernetes.deploymenteditb.s00e648c6')} </Label>
<Label className="dg-text-secondary text-xs">命名空间 (namespace)</Label>
<Input value={namespace} readOnly
className="mt-1 bg-[var(--dg-bg-primary)] border-[var(--dg-border)] dg-text-muted font-mono text-sm cursor-not-allowed" />
</div>
@@ -221,7 +221,7 @@ export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, ma
</div>
</CardHeader>
<CardContent className="pt-0 space-y-1.5">
{labels.length === 0 && <p className="text-[11px] dg-text-muted"> {i18n.t('monitor.logPanel.logResults.noLabels')} </p>}
{labels.length === 0 && <p className="text-[11px] dg-text-muted">无标签</p>}
{labels.map((l, i) => (
<div key={i} className="grid grid-cols-[1fr_1.4fr_auto] gap-2">
<Input value={l.key} placeholder="app"
@@ -253,7 +253,7 @@ export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, ma
</CardHeader>
<CardContent className="pt-0 space-y-1.5">
<p className="text-[11px] text-amber-500/70 mb-1">Selector 决定哪些 Pod 归属此 Deployment。修改时必须确保与 Pod Template Labels 匹配,否则 Deployment 将无法管理 Pod。</p>
{selectorLabels.length === 0 && <p className="text-[11px] dg-text-muted"> {i18n.t('kubernetes.service.noSelector')} </p>}
{selectorLabels.length === 0 && <p className="text-[11px] dg-text-muted">无选择器</p>}
{selectorLabels.map((s, i) => (
<div key={i} className="grid grid-cols-[1fr_1.4fr_auto] gap-2">
<Input value={s.key} placeholder="app"
@@ -289,7 +289,7 @@ export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, ma
</CardHeader>
<CardContent className="pt-0 space-y-1.5">
<p className="text-[11px] dg-text-muted mb-1">Pod 模板标签必须包含 Selector 全部键值,否则 Deployment 无法管理 Pod</p>
{templateLabels.length === 0 && <p className="text-[11px] dg-text-muted"> {i18n.t('kubernetes.deploymenteditb.se9c9f60d')} </p>}
{templateLabels.length === 0 && <p className="text-[11px] dg-text-muted">无模板标签</p>}
{templateLabels.map((l, i) => (
<div key={i} className="grid grid-cols-[1fr_1.4fr_auto] gap-2">
<Input value={l.key} placeholder="app"
@@ -320,7 +320,7 @@ export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, ma
</div>
</CardHeader>
<CardContent className="pt-0 space-y-1.5">
{annotations.length === 0 && <p className="text-[11px] dg-text-muted"> {i18n.t('kubernetes.deploymenteditb.sbe1e7c07')} </p>}
{annotations.length === 0 && <p className="text-[11px] dg-text-muted">无注解</p>}
{annotations.map((a, i) => (
<div key={i} className="grid grid-cols-[1fr_1.4fr_auto] gap-2">
<Input value={a.key} placeholder="deployment.kubernetes.io/revision"
@@ -342,7 +342,7 @@ export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, ma
保存将通过 <code className="dg-text-secondary">PUT application/yaml</code> 提交(自动剥离 status/managedFields 等只读字段)
</p>
<div className="flex gap-2">
<Button variant="outline" className="border-[var(--dg-border)] dg-text-secondary" onClick={onCancel}> {i18n.t('rbac.jobRole.cancel')} </Button>
<Button variant="outline" className="border-[var(--dg-border)] dg-text-secondary" onClick={onCancel}>取消</Button>
<Button className="bg-blue-600 hover:bg-blue-700" disabled={saving} onClick={save}>
{saving ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Save className="h-4 w-4 mr-1" />}
保存
@@ -10,8 +10,9 @@ import { FitAddon } from 'xterm-addon-fit';
import 'xterm/css/xterm.css';
import { Terminal, Maximize2, Minimize2, X, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { attachTerminalResize, mountXterm } from '@/lib/xterm-utils';
import { useUserStore } from '@/store';
import i18n from '@/i18n';
import { TerminalLoadingOverlay } from '@/components/common/TerminalLoadingOverlay';
interface KubectlTerminalProps {
open: boolean;
@@ -28,6 +29,7 @@ export function KubectlTerminal({ open, onOpenChange, clusterId, clusterName }:
const [connected, setConnected] = useState(false);
const [fullscreen, setFullscreen] = useState(false);
const [error, setError] = useState('');
const [hasOutput, setHasOutput] = useState(false);
// 重置全屏状态
useEffect(() => {
@@ -37,6 +39,7 @@ export function KubectlTerminal({ open, onOpenChange, clusterId, clusterName }:
// 连接终端
useEffect(() => {
if (!open || !clusterId) return;
setHasOutput(false);
const term = new XTerm({
cursorBlink: true,
@@ -79,40 +82,33 @@ export function KubectlTerminal({ open, onOpenChange, clusterId, clusterName }:
try {
const msg = JSON.parse(event.data);
if (msg.status === 0 && typeof msg.data === 'string') {
setHasOutput(true);
term.write(msg.data);
}
} catch {
if (typeof event.data === 'string') term.write(event.data);
if (typeof event.data === 'string') {
setHasOutput(true);
term.write(event.data);
}
}
};
ws.onopen = () => {
setConnected(true);
setError('');
if (containerRef.current) {
term.open(containerRef.current);
requestAnimationFrame(() => {
try {
fit.fit();
} catch { /* noop */ }
});
// 延迟 focus:确保 Dialog 渲染完成后再聚焦终端
setTimeout(() => {
try {
term.focus();
} catch { /* noop */ }
}, 150);
}
mountXterm(term, fit, () => containerRef.current, ws);
};
ws.onclose = () => {
setConnected(false);
term.write(i18n.t('kubernetes.kubectlterminal.sf4ee00d1'));
setHasOutput(true);
term.write('\r\n\x1b[31m[连接已断开]\x1b[0m\r\n');
};
ws.onerror = () => {
setError(i18n.t('aiops.wsConnectFailed'));
term.write(i18n.t('kubernetes.podexecterminal.sc8107726'));
setError('WebSocket 连接失败');
setHasOutput(true);
term.write('\r\n\x1b[31m[连接发生错误]\x1b[0m\r\n');
};
return () => {
@@ -128,35 +124,24 @@ export function KubectlTerminal({ open, onOpenChange, clusterId, clusterName }:
};
}, [open, clusterId]);
// ResizeObserver 监听容器变化自动 fit
// ResizeObserver 监听容器变化自动 fit,并同步远端 PTY 尺寸。 // Logan.Li
useEffect(() => {
if (!open) return;
const container = containerRef.current;
if (!container) return;
let resizeTimer: ReturnType<typeof setTimeout>;
const ro = new ResizeObserver(() => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
if (fitRef.current && termRef.current && wsRef.current) {
try {
fitRef.current.fit();
termRef.current.focus();
const { cols, rows } = termRef.current;
if (wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ status: 1, cols, rows }));
}
} catch { /* noop */ }
}
}, 60);
});
ro.observe(container);
return () => {
clearTimeout(resizeTimer);
ro.disconnect();
let cleanup: (() => void) | undefined;
let retry = 0;
const attach = () => {
const container = containerRef.current;
const fit = fitRef.current;
const term = termRef.current;
if (!container || !fit || !term) {
if (retry++ < 30) requestAnimationFrame(attach);
return;
}
cleanup = attachTerminalResize(container, fit, term, wsRef.current);
};
}, [open]);
attach();
return () => cleanup?.();
}, [open, fullscreen]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -202,11 +187,10 @@ export function KubectlTerminal({ open, onOpenChange, clusterId, clusterName }:
</div>
{/* 终端容器 */}
<div
ref={containerRef}
className="bg-[#0f172a]"
style={{ height: fullscreen ? 'calc(100vh - 49px)' : '480px' }}
/>
<div className="relative bg-[#0f172a]" style={{ height: fullscreen ? 'calc(100vh - 49px)' : '480px' }}>
<div ref={containerRef} className="xterm-terminal-host h-full" />
<TerminalLoadingOverlay show={!hasOutput && !error} label="正在连接集群..." />
</div>
</DialogContent>
</Dialog>
);
@@ -67,7 +67,7 @@ export function KubernetesClusterDetail({ cluster, onClose }: { cluster?: JsonDa
{ time: t('common.duration.min', { count: 2 }), cpu: Math.max(0, cpu * 0.96), memory: Math.max(0, mem * 0.97) },
{ time: t('k8s.cluster.now'), cpu, memory: mem },
];
}, [clusterMetrics, t]); // eslint-disable-line react-hooks/exhaustive-deps
}, [clusterMetrics, t]);
const loadClusterDetail = async (clusterCode: string) => {
try {
@@ -20,9 +20,10 @@ import {
Terminal, Maximize2, Minimize2, X, Loader2, RotateCw, Box,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { attachTerminalResize, mountXterm } from '@/lib/xterm-utils';
import { TerminalLoadingOverlay } from '@/components/common/TerminalLoadingOverlay';
import { kubernetesApi } from '@/api';
import { useToast } from '@/hooks/use-toast';
import i18n from '@/i18n';
export interface PodExecTerminalProps {
open: boolean;
@@ -62,6 +63,7 @@ export function PodExecTerminal({
const [fullscreen, setFullscreen] = useState(false);
const [container, setContainer] = useState<string>(initialContainer || containers[0] || '');
const [shell, setShell] = useState<string>('sh');
const [hasOutput, setHasOutput] = useState(false);
// ─── 重置 ───
useEffect(() => {
@@ -92,6 +94,7 @@ export function PodExecTerminal({
}
setConnState('connecting');
setHasOutput(false);
term.clear();
term.writeln(`\x1b[36m[正在连接 ${podName}${container ? '/' + container : ''} shell=${shell} ...]\x1b[0m`);
@@ -115,32 +118,39 @@ export function PodExecTerminal({
try {
const msg = JSON.parse(event.data);
if (msg.status === 0 && typeof msg.data === 'string') {
setHasOutput(true);
term.write(msg.data);
} else if (msg.status === 2 || msg.error) {
const errMsg = msg.data || msg.message || msg.error || i18n.t('kubernetes.podexecterminal.s05f6e923');
setHasOutput(true);
const errMsg = msg.data || msg.message || msg.error || '执行错误';
if (errMsg.includes('not found') && shell === 'bash') {
term.writeln(i18n.t('kubernetes.podexecterminal.s14481be5'));
term.writeln('\r\n\x1b[33m⚠ bash 不可用,请切换 /bin/sh 后重连。\x1b[0m');
errorShownRef.current = true;
return;
}
term.write(errMsg.includes('\x1b') ? errMsg : `\r\n\x1b[31m${errMsg}\x1b[0m\r\n`);
}
} catch {
if (typeof event.data === 'string') term.write(event.data);
if (typeof event.data === 'string') {
setHasOutput(true);
term.write(event.data);
}
}
};
ws.onclose = (_ev) => {
setConnState((s) => (s === 'error' ? s : 'closed'));
setHasOutput(true);
if (!errorShownRef.current) {
term.write(i18n.t('kubernetes.podexecterminal.s123d9435'));
term.write('\r\n\x1b[33m[连接已关闭]\x1b[0m\r\n');
}
};
ws.onerror = (ev) => {
console.error('[PodExec:connect] WS error event fired, readyState=', ws.readyState, 'type=', ev.type);
setConnState('error');
term.write(i18n.t('kubernetes.podexecterminal.sc8107726'));
setHasOutput(true);
term.write('\r\n\x1b[31m[连接发生错误]\x1b[0m\r\n');
};
}, [clusterId, namespace, podName, container, shell]);
@@ -200,8 +210,7 @@ export function PodExecTerminal({
}
});
term.open(el);
requestAnimationFrame(() => { try { fit.fit(); } catch { /* noop */ } });
mountXterm(term, fit, () => el, wsRef.current);
mountedRef.current = true;
connect();
@@ -232,45 +241,37 @@ export function PodExecTerminal({
useEffect(() => {
if (open && termRef.current && mountedRef.current) {
connect();
} else {
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [container, shell]);
// ─── ResizeObserver ───
// ─── ResizeObserver:同步容器尺寸和远端 PTY 尺寸 // Logan.Li ───
useEffect(() => {
if (!open) return;
const el = containerRef.current;
if (!el) return;
let timer: ReturnType<typeof setTimeout>;
const ro = new ResizeObserver(() => {
clearTimeout(timer);
timer = setTimeout(() => {
if (fitRef.current && termRef.current) {
try {
fitRef.current.fit();
termRef.current.focus();
const { cols, rows } = termRef.current;
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ status: 1, cols, rows }));
}
} catch { /* noop */ }
}
}, 60);
});
ro.observe(el);
return () => { clearTimeout(timer); ro.disconnect(); };
let cleanup: (() => void) | undefined;
let retry = 0;
const attach = () => {
const container = containerRef.current;
const fit = fitRef.current;
const term = termRef.current;
if (!container || !fit || !term) {
if (retry++ < 30) requestAnimationFrame(attach);
return;
}
cleanup = attachTerminalResize(container, fit, term, wsRef.current);
};
attach();
return () => cleanup?.();
}, [open, fullscreen]);
// ─── UI ───
const statusColor = connState === 'connected' ? 'bg-emerald-400'
: connState === 'error' ? 'bg-red-400'
: connState === 'closed' ? 'bg-[var(--dg-bg-active)]' : 'bg-amber-400';
const statusText = connState === 'connected' ? i18n.t('agent.agentConnected')
: connState === 'connecting' ? i18n.t('common.connecting')
: connState === 'error' ? i18n.t('kubernetes.podexecterminal.sd5235918')
: connState === 'closed' ? i18n.t('agent.agentDisconnected') : i18n.t('common.idle');
const statusText = connState === 'connected' ? '已连接'
: connState === 'connecting' ? '连接中'
: connState === 'error' ? '连接错误'
: connState === 'closed' ? '已断开' : '空闲';
const handleTerminalClick = useCallback(() => {
try { termRef.current?.focus(); } catch { /* noop */ }
@@ -290,7 +291,7 @@ export function PodExecTerminal({
style={fullscreen ? { maxWidth: '100vw', width: '100vw', height: '100vh' } : { maxWidth: '960px', width: '92vw' }}
>
<DialogTitle className="sr-only">exec — {podName}</DialogTitle>
<DialogDescription className="sr-only"> {i18n.t('kubernetes.podexecterminal.s1a62d44d')} </DialogDescription>
<DialogDescription className="sr-only">进入 Pod 容器执行命令</DialogDescription>
{/* 工具栏 */}
<div className="flex items-center justify-between gap-2 px-4 py-2 border-b border-[var(--dg-border)] flex-wrap">
@@ -306,7 +307,7 @@ export function PodExecTerminal({
<Select value={container} onValueChange={setContainer}>
<SelectTrigger className="h-8 w-[150px] text-xs bg-[var(--dg-bg-hover)] border-[var(--dg-border)]">
<Box className="h-3 w-3 mr-1 text-[var(--dg-text-muted)]" />
<SelectValue placeholder={i18n.t('kubernetes.container.title')} />
<SelectValue placeholder="容器" />
</SelectTrigger>
<SelectContent>{containers.map((c) => <SelectItem key={c} value={c}>{c}</SelectItem>)}</SelectContent>
</Select>
@@ -318,25 +319,27 @@ export function PodExecTerminal({
<SelectContent>{SHELLS.map(s => <SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>)}</SelectContent>
</Select>
<Button variant="ghost" size="sm" className="h-8 px-2 text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]"
onClick={() => { connect(); toast({ title: i18n.t('kubernetes.podexecterminal.sad127ce8') }); }} title={i18n.t('kubernetes.podexecterminal.s114d0e25')}>
onClick={() => { connect(); toast({ title: '正在重连…' }); }} title="重连">
<RotateCw className="h-3.5 w-3.5" />
</Button>
<button onClick={() => setFullscreen(!fullscreen)} className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]" title={fullscreen ? i18n.t('common.exitFullscreen2') : i18n.t('monitor.fullscreen')}>
<button onClick={() => setFullscreen(!fullscreen)} className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]" title={fullscreen ? '退出全屏' : '全屏'}>
{fullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</button>
<button onClick={() => onOpenChange(false)} className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]" title={i18n.t('agent.portClosed')}>
<button onClick={() => onOpenChange(false)} className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]" title="关闭">
<X className="h-4 w-4" />
</button>
</div>
</div>
{/* 终端容器 */}
<div
ref={containerRef}
className="bg-[#0f172a] cursor-text"
style={{ height: fullscreen ? 'calc(100vh - 49px)' : '480px' }}
onClick={handleTerminalClick}
/>
<div className="relative bg-[#0f172a]" style={{ height: fullscreen ? 'calc(100vh - 49px)' : '480px' }}>
<div
ref={containerRef}
className="xterm-terminal-host h-full cursor-text"
onClick={handleTerminalClick}
/>
<TerminalLoadingOverlay show={connState === 'connecting' && !hasOutput} label="正在进入容器..." />
</div>
</DialogContent>
</Dialog>
);
@@ -6,7 +6,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { kubernetesApi } from '@/api';
import { useToast } from '@/hooks/use-toast';
import { useTranslation } from 'react-i18next';
import { getErrorMessage } from '@/lib/utils';
export interface CreateHPADialogProps {
clusterCode: string;
@@ -16,7 +16,6 @@ export interface CreateHPADialogProps {
}
export function CreateHPADialog({ clusterCode, namespace, deploymentName, onCreated }: CreateHPADialogProps) {
const { t } = useTranslation();
const { toast } = useToast();
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
@@ -30,7 +29,7 @@ export function CreateHPADialog({ clusterCode, namespace, deploymentName, onCrea
const handleCreate = async () => {
if (!form.name.trim()) {
toast({ title: t('k8s.hpa.nameRequired'), variant: 'destructive' });
toast({ title: '名称不能为空', variant: 'destructive' });
return;
}
setLoading(true);
@@ -45,46 +44,46 @@ export function CreateHPADialog({ clusterCode, namespace, deploymentName, onCrea
cpuPercent: form.cpuPercent,
memPercent: form.memPercent,
});
toast({ title: t('k8s.hpa.createSuccess') });
toast({ title: 'HPA 创建成功' });
setOpen(false);
onCreated();
setForm({ name: '', minReplicas: 1, maxReplicas: 10, cpuPercent: 80, memPercent: 0 });
} catch (e: any) {
toast({ title: t('k8s.hpa.createFailed'), description: e?.message || String(e), variant: 'destructive' });
} catch (e: unknown) {
toast({ title: '创建失败', description: getErrorMessage(e), variant: 'destructive' });
} finally { setLoading(false); }
};
return (
<>
<Button variant="outline" size="sm" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]" onClick={() => setOpen(true)}>
<Plus className="h-3.5 w-3.5 mr-1" />{t('k8s.hpa.create')}
<Plus className="h-3.5 w-3.5 mr-1" />创建 HPA
</Button>
{open && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={() => setOpen(false)}>
<div className="bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl w-[480px] max-w-[92vw] max-h-[85vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
<h2 className="text-lg font-bold text-[var(--dg-text-primary)] px-6 pt-6 pb-4 shrink-0">{t('k8s.hpa.create')}</h2>
<h2 className="text-lg font-bold text-[var(--dg-text-primary)] px-6 pt-6 pb-4 shrink-0">创建 HPA</h2>
<div className="flex-1 min-h-0 overflow-y-auto px-6 space-y-3">
<div>
<Label className="dg-text-secondary text-sm">{t('common.name')}</Label>
<Label className="dg-text-secondary text-sm">名称</Label>
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder={`hpa-${deploymentName}`}
className="dg-input mt-1" />
</div>
<div>
<Label className="dg-text-secondary text-sm">{t('k8s.hpa.target')}</Label>
<p className="text-[var(--dg-text-secondary)] text-sm mt-1 bg-[var(--dg-bg-tertiary)] rounded px-3 py-2 truncate" title={t('k8s.hpa.targetSummary', { deployment: deploymentName, namespace })}>
{t('k8s.hpa.targetSummary', { deployment: deploymentName, namespace })}
<Label className="dg-text-secondary text-sm">目标</Label>
<p className="text-[var(--dg-text-secondary)] text-sm mt-1 bg-[var(--dg-bg-tertiary)] rounded px-3 py-2 truncate" title={`Deployment: ${deploymentName} · 命名空间: ${namespace}`}>
Deployment: {deploymentName} · 命名空间: {namespace}
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="dg-text-secondary text-sm">{t('k8s.hpa.minReplicas')}</Label>
<Label className="dg-text-secondary text-sm">最小副本数</Label>
<Input type="number" value={form.minReplicas} min={1}
onChange={(e) => setForm({ ...form, minReplicas: Number(e.target.value) })}
className="dg-input mt-1" />
</div>
<div>
<Label className="dg-text-secondary text-sm">{t('k8s.hpa.maxReplicas')}</Label>
<Label className="dg-text-secondary text-sm">最大副本数</Label>
<Input type="number" value={form.maxReplicas} min={1}
onChange={(e) => setForm({ ...form, maxReplicas: Number(e.target.value) })}
className="dg-input mt-1" />
@@ -92,13 +91,13 @@ export function CreateHPADialog({ clusterCode, namespace, deploymentName, onCrea
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="dg-text-secondary text-sm">{t('k8s.hpa.cpuTarget')}</Label>
<Label className="dg-text-secondary text-sm">CPU 目标 (%)</Label>
<Input type="number" value={form.cpuPercent} min={0} max={100}
onChange={(e) => setForm({ ...form, cpuPercent: Number(e.target.value) })}
className="dg-input mt-1" />
</div>
<div>
<Label className="dg-text-secondary text-sm">{t('k8s.hpa.memTarget')} <span className="dg-text-muted">{t('common.optional')}</span></Label>
<Label className="dg-text-secondary text-sm">内存目标 (%) <span className="dg-text-muted">可选</span></Label>
<Input type="number" value={form.memPercent} min={0} max={100}
onChange={(e) => setForm({ ...form, memPercent: Number(e.target.value) })}
className="dg-input mt-1" />
@@ -106,9 +105,9 @@ export function CreateHPADialog({ clusterCode, namespace, deploymentName, onCrea
</div>
</div>
<div className="flex justify-end gap-2 px-6 py-4 border-t border-[var(--dg-border)] shrink-0">
<Button variant="outline" className="border-[var(--dg-border)] dg-text-secondary" onClick={() => setOpen(false)}>{t('common.cancel')}</Button>
<Button variant="outline" className="border-[var(--dg-border)] dg-text-secondary" onClick={() => setOpen(false)}>取消</Button>
<Button className="bg-blue-600 hover:bg-blue-700" disabled={loading} onClick={handleCreate}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : t('common.create')}
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : '创建'}
</Button>
</div>
</div>
@@ -116,4 +115,4 @@ export function CreateHPADialog({ clusterCode, namespace, deploymentName, onCrea
)}
</>
);
}
}
@@ -2,16 +2,16 @@
import { kubernetesApi } from '@/api';
import { prepareManifestForSave } from './k8s-manifest-utils';
import { isCreateDeployment } from './deployment-constants';
import i18n from '@/i18n';
import type { JsonData } from '@/types/common';
export async function saveDeploymentManifest(
clusterCode: string,
namespace: string,
deploymentName: string,
manifest: Record<string, any>,
manifest: JsonData,
): Promise<string> {
const resName = String(manifest?.metadata?.name || '').trim();
if (!resName) throw new Error(i18n.t('kubernetes.deploymentmanif.scb497faa'));
if (!resName) throw new Error('Deployment 名称不能为空');
const yaml = await prepareManifestForSave(manifest);
if (isCreateDeployment(deploymentName)) {
await kubernetesApi.applyYAML(clusterCode, yaml);
@@ -19,4 +19,4 @@ export async function saveDeploymentManifest(
await kubernetesApi.updateResourceYaml(clusterCode, namespace, 'deployments', resName, yaml);
}
return resName;
}
}
@@ -7,9 +7,10 @@ import { ArrowRight, Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { joinImageRef, parseImageRef } from '../image-ref-utils';
import { ModalShell } from './ModalShell';
import type { JsonData } from '@/types/common';
export interface UpdateImageModalProps {
deployment: any;
deployment: JsonData;
newImage: string;
setNewImage: (value: string) => void;
actionLoading: string;
@@ -153,4 +154,4 @@ export function UpdateImageModal({
</div>
</ModalShell>
);
}
}
@@ -378,7 +378,7 @@ export function cleanYAML(yaml: string): string {
// 系统 volumeMount 条目
if (inVolumeMounts && trimmed.startsWith('- name:')) {
// 检查这个 volume mount 块内是否有系统路径
let blockLines: string[] = [line];
const blockLines: string[] = [line];
let j = i + 1;
let isSystem = false;
while (j < lines.length) {
@@ -427,7 +427,7 @@ export function cleanYAML(yaml: string): string {
}
// 末尾清理
let result = out.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd();
const result = out.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd();
return result + '\n';
}