chore: 统一本地服务器与远程仓库源码

- 合并 main 与 master 历史并以服务器结构为基线
- 保留迁移、数据保留、AWS 账单与部署安全能力
- 建立 SDD 规格和 CI 验证闭环
This commit is contained in:
Logan.Li committed 2026-08-05 20:03:25 +08:00
commit fd60361d93
460 files changed
+37492 -23871

No files matched your search

+54 -3
View File
@@ -1,4 +1,4 @@
import { Suspense, lazy } from 'react';
import { Suspense, lazy, useEffect, useRef } from 'react';
import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from '@/hooks/useTheme';
@@ -10,6 +10,7 @@ import { PageTransition } from '@/components/layout/PageTransition';
import { ErrorBoundary } from '@/components/layout/ErrorBoundary';
import { Toaster } from '@/components/ui/sonner';
import i18n from '@/i18n';
import { toast } from 'sonner';
// Lazy load page components for code splitting
const Dashboard = lazy(() => import('@/components/dashboard/Dashboard').then(m => ({ default: m.Dashboard })));
@@ -85,7 +86,7 @@ function LazyPage({ children }: { children: React.ReactNode }) {
const location = useLocation();
return (
<ErrorBoundary>
<PageTransition key={location.pathname}>
<PageTransition transitionKey={location.pathname}>
<Suspense fallback={<PageLoader />}>
{children}
</Suspense>
@@ -104,12 +105,63 @@ const queryClient = new QueryClient({
},
});
function AssetVersionGuard() {
const currentVersionRef = useRef<string>('');
const notifiedRef = useRef(false);
useEffect(() => {
const readVersion = async () => {
try {
const resp = await fetch(`/version?t=${Date.now()}`, { cache: 'no-store' });
if (!resp.ok) return;
const data = await resp.json();
const nextVersion = [data.version, data.git_commit, data.build_time].filter(Boolean).join(':');
if (!nextVersion) return;
if (!currentVersionRef.current) {
currentVersionRef.current = nextVersion;
return;
}
if (currentVersionRef.current !== nextVersion && !notifiedRef.current) {
notifiedRef.current = true;
toast.info(i18n.t('common.appUpdated'), {
description: i18n.t('common.appUpdatedDesc'),
action: {
label: i18n.t('common.reloadPage'),
onClick: () => window.location.reload(),
},
duration: Infinity,
});
}
} catch {
// 版本检查失败不影响业务页面,后续定时器会重试。 // Logan.Li
}
};
void readVersion();
const timer = window.setInterval(readVersion, 60_000);
const handleFocus = () => void readVersion();
const handleVisibility = () => {
if (document.visibilityState === 'visible') void readVersion();
};
window.addEventListener('focus', handleFocus);
document.addEventListener('visibilitychange', handleVisibility);
return () => {
window.clearInterval(timer);
window.removeEventListener('focus', handleFocus);
document.removeEventListener('visibilitychange', handleVisibility);
};
}, []);
return null;
}
function App() {
return (
<ThemeProvider>
<QueryClientProvider client={queryClient}>
<ConfirmProvider>
<Toaster position="top-right" richColors closeButton />
<AssetVersionGuard />
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
@@ -186,7 +238,6 @@ function App() {
<Route path="alerts" element={<LazyPage><AlertManager /></LazyPage>} />
<Route path="alert-center" element={<LazyPage><AlertCenter /></LazyPage>} />
<Route path="monitor-center" element={<LazyPage><MonitorCenter /></LazyPage>} />
<Route path="host-dashboard" element={<Navigate to="/dashboards" replace />} />
<Route path="log-center" element={<Navigate to="/log-center/loki" replace />} />
<Route path="log-center/:section" element={<LazyPage><LogCenter /></LazyPage>} />
<Route path="logs" element={<Navigate to="/log-center/loki" replace />} />
+19 -16
View File
@@ -2,44 +2,47 @@
import { http } from '@/utils/request';
import { useUserStore } from '@/store';
// 会话与消息类型(与后端 model.AgentCLISession/Message 对应) // Logan.Li
// 会话与消息类型(与后端 model.AIRun/AIRunMessage 对应) // Logan.Li
export interface AgentCliSession {
id: number;
run_id: string;
parent_run_id: string;
user_id: number;
feature: string;
mode: string; // host | kubernetes
server_id: number;
server_name: string;
server_host: string;
cluster_id: number;
cluster_name: string;
namespace: string;
pod_name: string;
container: string;
subject_type: string;
subject_id: string;
subject_name: string;
subject_scope: string;
title: string;
status: string; // running/done/aborted/error
provider: string;
model: string;
safety_mode: string;
context_pack_id: string;
prompt_tokens: number;
completion_tokens: number;
rounds: number;
total_tokens: number;
duration: number;
error?: string;
started_at: string;
finished_at?: string;
created_at: string;
updated_at: string;
}
export interface AgentCliMessage {
id: number;
session_id: number;
run_id: string;
round: number;
seq: number;
type: string; // thought/tool_call/tool_result/done/error/user
role: string;
content: string;
tool_name: string;
tool_args: string;
tool_output: string;
risk: string;
success: boolean;
duration: string;
tool_call_id: string;
span_id: string;
tokens: number;
created_at: string;
}
@@ -96,4 +99,4 @@ export function buildAgentCliWsUrl(params: AgentCliWsParams | number | string):
}
return `${base}?mode=host&id=${params.serverId}&token=${token}`;
}
}
+3
View File
@@ -47,4 +47,7 @@ export const cloudApi = {
deleteDomain: async (id: number | string) => {
return http.delete(`/domains/${id}`);
},
checkDomainSSL: async (id: number | string) => {
return http.post<{ ssl_expire_date: string; ssl_days_remaining: number; ssl_issuer: string }>(`/domains/${id}/check-ssl`);
},
};
+12 -5
View File
@@ -3,13 +3,20 @@
import { http, type ListResponse } from '@/utils/request';
export const billingApi = {
getStats: async (currency = 'CNY') => {
return http.get('/cloud-billings/stats', { currency });
getSettings: async () => {
return http.get<{ base_currency: string; usd_cny_rate: number; updated_at?: string; updated_by?: string }>('/cloud-billings/settings');
},
saveSettings: async (usdCnyRate: number) => {
return http.put<{ base_currency: string; usd_cny_rate: number }>('/cloud-billings/settings', { usd_cny_rate: usdCnyRate });
},
getStats: async (params?: { currency?: string }) => {
return http.get('/cloud-billings/stats', params);
},
getCostAnalysis: async () => {
return http.get('/cloud-billings/cost-analysis');
/** 获取按月聚合的多云费用分析数据。 // Logan.Li */
getCostAnalysis: async (params?: { start_month?: string; end_month?: string; dimension?: string; currency?: string; provider?: string; cloud_account_id?: number }) => {
return http.get('/cloud-billings/cost-analysis', params);
},
list: async (params?: { provider?: string; cloud_account_id?: number; currency?: string; start_date?: string; end_date?: string; page?: number; page_size?: number }) => {
list: async (params?: { provider?: string; cloud_account_id?: number; start_date?: string; end_date?: string; page?: number; page_size?: number }) => {
return http.get<ListResponse>('/cloud-billings', params);
},
create: async (data: Record<string, unknown>) => {
+1 -13
View File
@@ -3,18 +3,6 @@
import { http, type ListResponse } from '@/utils/request';
export const deployApi = {
// Git 仓库认证
getRepos: () => http.get<ListResponse>('/deploy/repos'),
getRepo: (id: number | string) => http.get(`/deploy/repos/${id}`),
createRepo: ( data: Record<string, unknown>) => http.post('/deploy/repos', data),
updateRepo: (id: number | string, data: Record<string, unknown>) => http.put(`/deploy/repos/${id}`, data),
deleteRepo: (id: number | string) => http.delete(`/deploy/repos/${id}`),
// 镜像仓库认证
getRegistries: () => http.get<ListResponse>('/deploy/registries'),
getRegistry: (id: number | string) => http.get(`/deploy/registries/${id}`),
createRegistry: ( data: Record<string, unknown>) => http.post('/deploy/registries', data),
updateRegistry: (id: number | string, data: Record<string, unknown>) => http.put(`/deploy/registries/${id}`, data),
deleteRegistry: (id: number | string) => http.delete(`/deploy/registries/${id}`),
// 环境管理
getEnvironments: () => http.get<ListResponse>('/deploy/environments'),
createEnvironment: ( data: Record<string, unknown>) => http.post('/deploy/environments', data),
@@ -45,4 +33,4 @@ export const deployApi = {
uninstallTool: (id: number | string) => http.post(`/deploy/tools/${id}/uninstall`),
checkTool: (id: number | string) => http.post(`/deploy/tools/${id}/check`),
getToolVersions: (id: number | string) => http.get(`/deploy/tools/${id}/versions`),
};
};
+9 -2
View File
@@ -49,7 +49,7 @@ export const pipelineApi = {
getByCode: (code: string) => http.get(`/pipelines/${code}`),
getRunsByCode: (code: string, pageSize = 50) =>
http.get<ListResponse>(`/pipelines/${code}/runs`, { page_size: pageSize }),
triggerByCode: (code: string) => http.post(`/pipelines/${code}/trigger`, {}),
triggerByCode: (code: string, data?: Record<string, unknown>) => http.post(`/pipelines/${code}/trigger`, data || {}),
stopRunByCode: (code: string, runId: number | string) =>
http.post(`/pipelines/${code}/runs/${runId}/stop`, {}),
batchDeleteRuns: (code: string, ids: number[]) =>
@@ -61,4 +61,11 @@ export const pipelineApi = {
getWorkspace: (runId: number | string) => http.get(`/pipelines/runs/${runId}/workspace`),
listRunFiles: (runId: number | string, path?: string) =>
http.get(`/pipelines/runs/${runId}/files`, path ? { path } : undefined),
};
getJenkinsConfig: () => http.get('/deploy/jenkins/config'),
saveJenkinsConfig: (data: Record<string, unknown>) => http.put('/deploy/jenkins/config', data),
testJenkinsConfig: () => http.post('/deploy/jenkins/test', {}),
syncJenkinsJobs: (data?: { prefix?: string; overwrite?: boolean }) =>
http.post('/deploy/jenkins/sync', data || {}),
getJenkinsRefs: (code: string, type: 'branch' | 'tag') =>
http.get('/deploy/jenkins/refs', { code, type }),
};
+7 -3
View File
@@ -3,7 +3,7 @@
import { http, type ListResponse } from '@/utils/request';
export const serverApi = {
getServers: async (params?: { page?: number; page_size?: number; keyword?: string; status?: string; sort?: 'hostname'; order?: 'asc' | 'desc' }) => {
getServers: async (params?: { page?: number; page_size?: number; keyword?: string; status?: string }) => {
return http.get<ListResponse>('/servers', params);
},
getServerDetail: async (id: number | string) => {
@@ -39,12 +39,16 @@ export const serverApi = {
return http.get('/dashboard/metrics');
},
installAgent: async (id: number | string, token: string, creds?: { username?: string; password?: string; ssh_key?: string; auth_type?: string }) => {
const body: Record<string, unknown> = { token };
// 浏览器当前 origin 是用户真实访问域名,避免反向代理把上游内网地址传给 Agent。 // Logan.Li
const serverUrl = typeof window !== 'undefined' ? window.location.origin : '';
const body: Record<string, unknown> = { token, server_url: serverUrl };
if (creds) Object.assign(body, creds);
return http.post<{ installed: boolean; arch: string; output: string }>(`/servers/${id}/install-agent`, body);
},
batchInstallAgent: async (ids: number[], token: string, credentials?: Record<number, { username?: string; password?: string; ssh_key?: string; auth_type?: string }>) => {
const body: Record<string, unknown> = { server_ids: ids };
// 批量异步任务同样显式携带公网 origin,后台 goroutine 不再依赖代理头推断。 // Logan.Li
const serverUrl = typeof window !== 'undefined' ? window.location.origin : '';
const body: Record<string, unknown> = { server_ids: ids, server_url: serverUrl };
if (credentials) body.credentials = credentials;
return http.post<{ total: number; success: number; results: Record<string, unknown>[] }>(`/servers/install-agent?token=${encodeURIComponent(token)}`, body);
},
+20 -20
View File
@@ -53,7 +53,7 @@ export function AgentAlerts() {
setRules(asList(rulesRes));
} catch (err) {
console.error(err);
toast.error(i18n.t('agent.alerts.loadFailed'));
toast.error(i18n.t('agent.agentalertstsx.s7ffaa448'));
} finally {
setLoading(false);
}
@@ -65,11 +65,11 @@ export function AgentAlerts() {
// ============ 告警事件 ============
const handleResolve = async (id: number) => {
const ok = await confirm({ title: i18n.t('agent.alerts.resolveAlert'), description: i18n.t('agent.alerts.resolveConfirm'), confirmText: i18n.t('kubernetes.deployment.resume') });
const ok = await confirm({ title: i18n.t('agent.agentalertstsx.sd56d01fa'), description: i18n.t('agent.agentalertstsx.s99f0fb65'), confirmText: i18n.t('kubernetes.deployment.resume') });
if (!ok) return;
try {
await agentApi.resolveAlert(id);
toast.success(i18n.t('agent.alerts.resolved'));
toast.success(i18n.t('agent.agentalertstsx.s0e23522e'));
loadData();
} catch (err) {
toast.error(err?.message || i18n.t('cloud.operationFailed'));
@@ -88,11 +88,11 @@ export function AgentAlerts() {
};
const handleDeleteRule = async (id: number) => {
const ok = await confirm({ title: i18n.t('monitor.deleteRule'), description: i18n.t('agent.alerts.deleteRule'), confirmText: i18n.t('kubernetes.storage.delete2'), variant: 'destructive' });
const ok = await confirm({ title: i18n.t('monitor.deleteRule'), description: i18n.t('monitor.alertrulestabts.sd301558d'), confirmText: i18n.t('kubernetes.storage.delete2'), variant: 'destructive' });
if (!ok) return;
try {
await agentApi.deleteRule(id);
toast.success(i18n.t('agent.alerts.resolved'));
toast.success(i18n.t('agent.agentalertstsx.s8b2798c4'));
loadData();
} catch (err) {
toast.error(err?.message || i18n.t('kubernetes.msg.deleteFailed2'));
@@ -113,18 +113,18 @@ export function AgentAlerts() {
{/* 页头 */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold text-[var(--dg-text-primary)]">{i18n.t('agent.alerts.title')}</h2>
<p className="text-sm dg-text-secondary mt-0.5"> {i18n.t('agent.alerts.thresholdTriggered')} </p>
<h2 className="text-xl font-semibold text-[var(--dg-text-primary)]">Agent 告警</h2>
<p className="text-sm dg-text-secondary mt-0.5"> {i18n.t('agent.agentalertstsx.s03a08cd6')} </p>
</div>
<Button variant="outline" size="sm" onClick={loadData} disabled={loading} className="dg-input text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]">
<RefreshCw className={`h-4 w-4 mr-1.5 ${loading ? 'animate-spin' : ''}`} />
{i18n.t('common.refresh')}
刷新
</Button>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)]">
<TabsTrigger value="events" className="data-[state=active]:bg-[var(--dg-bg-active)]"> {i18n.t('kubernetes.eventsPage.alertEvents')} </TabsTrigger>
<TabsTrigger value="events" className="data-[state=active]:bg-[var(--dg-bg-active)]"> {i18n.t('kubernetes.eventspagetsx.s0e5b83cc')} </TabsTrigger>
<TabsTrigger value="rules" className="data-[state=active]:bg-[var(--dg-bg-active)]"> {i18n.t('monitor.alertRules')} </TabsTrigger>
</TabsList>
@@ -137,7 +137,7 @@ export function AgentAlerts() {
onClick={() => setResolvedView(false)}
className={!resolvedView ? 'bg-blue-600 hover:bg-blue-700 text-white' : 'dg-input text-[var(--dg-text-secondary)]'}
>
{i18n.t('agent.alerts.unresolved')}
未恢复
</Button>
<Button
size="sm"
@@ -145,7 +145,7 @@ export function AgentAlerts() {
onClick={() => setResolvedView(true)}
className={resolvedView ? 'bg-blue-600 hover:bg-blue-700 text-white' : 'dg-input text-[var(--dg-text-secondary)]'}
>
{i18n.t('agent.alerts.resolved')}
已恢复
</Button>
</div>
@@ -160,7 +160,7 @@ export function AgentAlerts() {
<Card className="dg-card">
<CardContent className="flex flex-col items-center justify-center py-16">
<CheckCircle className="h-10 w-10 text-emerald-500 mb-3" />
<p className="dg-text-secondary">{resolvedView ? i18n.t('agent.alerts.noResolved') : i18n.t('agent.alerts.noActive')}</p>
<p className="dg-text-secondary">{resolvedView ? i18n.t('agent.agentalertstsx.se459f480') : i18n.t('agent.agentalertstsx.s778455b9')}</p>
</CardContent>
</Card>
) : (
@@ -178,18 +178,18 @@ export function AgentAlerts() {
{severityBadge(alert.severity)}
<span className="font-medium text-[var(--dg-text-primary)]">{alert.rule_name || alert.name || alert.title || i18n.t('monitor.hostDashboard.alert')}</span>
</div>
<p className="text-sm dg-text-secondary mt-1">{alert.message || alert.description || i18n.t('agent.alerts.thresholdTriggered')}</p>
<p className="text-sm dg-text-secondary mt-1">{alert.message || alert.description || i18n.t('agent.agentalertstsx.sddab4a17')}</p>
<div className="flex items-center gap-4 mt-2 text-xs dg-text-muted flex-wrap">
<span className="inline-flex items-center gap-1">
<Cpu className="h-3 w-3" />
{alert.hostname || '--'}
</span>
<span className="inline-flex items-center gap-1">
<span className="dg-text-secondary"> {i18n.t('agent.agentAlerts.metric')} </span> {getMetricLabel(alert.metric)}
<span className="dg-text-secondary"> {i18n.t('agent.agentalertstsx.sbfdcf242')} </span> {getMetricLabel(alert.metric)}
</span>
{alert.value != null && (
<span className="inline-flex items-center gap-1">
<span className="dg-text-secondary"> {i18n.t('agent.agentAlerts.currentValue')} </span> {alert.value}
<span className="dg-text-secondary"> {i18n.t('agent.agentalertstsx.s2d13763b')} </span> {alert.value}
</span>
)}
<span className="inline-flex items-center gap-1">
@@ -207,7 +207,7 @@ export function AgentAlerts() {
className="shrink-0 h-8 dg-input dg-text-green hover:bg-emerald-500/10 hover:dg-text-green"
>
<CheckCircle className="h-3.5 w-3.5 mr-1" />
{i18n.t('agent.alerts.resolve')}
恢复
</Button>
)}
</div>
@@ -223,7 +223,7 @@ export function AgentAlerts() {
<div className="flex justify-end">
<Button size="sm" onClick={openCreateRule} className="bg-blue-600 hover:bg-blue-700 text-white">
<Plus className="h-4 w-4 mr-1.5" />
{i18n.t('agent.alerts.newRule')}
新建规则
</Button>
</div>
@@ -241,7 +241,7 @@ export function AgentAlerts() {
<p className="dg-text-secondary"> {i18n.t('monitor.noRules')} </p>
<Button size="sm" onClick={openCreateRule} className="mt-4 bg-blue-600 hover:bg-blue-700 text-white">
<Plus className="h-4 w-4 mr-1.5" />
{i18n.t('agent.alerts.createFirstRule')}
创建第一条规则
</Button>
</CardContent>
</Card>
@@ -273,9 +273,9 @@ export function AgentAlerts() {
</span>
)}
{rule.metric === 'port_down' && rule.port ? (
<span className="dg-text-secondary">{i18n.t("common.port")} {rule.port}</span>
<span className="dg-text-secondary">端口 {rule.port}</span>
) : null}
{rule.duration ? <span className="dg-text-secondary">{i18n.t("common.duration")} {rule.duration}s</span> : null}
{rule.duration ? <span className="dg-text-secondary">持续 {rule.duration}s</span> : null}
</div>
</div>
</div>
+10 -10
View File
@@ -71,7 +71,7 @@ export function AgentMonitor() {
if (fresh) setSelectedHost(fresh);
}
} catch (err) {
if (!silent) toast.error(i18n.t('agent.monitor.loadHostFailed'));
if (!silent) toast.error(i18n.t('agent.agentmonitortsx.s5760f667'));
console.error(err);
} finally {
setLoading(false);
@@ -100,7 +100,7 @@ export function AgentMonitor() {
// 提交远程命令(直连 SSH 即时返回)
const submitCommand = async () => {
if (!cmdCommand.trim()) {
toast.error(i18n.t('agent.monitor.enterCommand'));
toast.error(i18n.t('agent.agentmonitortsx.s9358aada'));
return;
}
setCmdSubmitting(true);
@@ -114,7 +114,7 @@ export function AgentMonitor() {
const resData = (res?.data ?? {}) as Record<string, unknown>;
setCmdResult({
status: res?.code === 200 ? 'completed' : 'failed',
result: (resData.output as string) || (res?.message as string) || i18n.t('agent.monitor.noOutput'),
result: (resData.output as string) || (res?.message as string) || i18n.t('agent.agentmonitortsx.s26d45bea'),
error: resData.error as string | undefined,
});
} catch (err) {
@@ -142,8 +142,8 @@ export function AgentMonitor() {
{/* 页头 */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold text-[var(--dg-text-primary)]">{t("agent.agentMonitor.title")}</h2>
<p className="text-sm dg-text-secondary mt-0.5"> {i18n.t('agent.agentMonitor.subtitle')} </p>
<h2 className="text-xl font-semibold text-[var(--dg-text-primary)]">{t("agent.agentmonitortsx.s1a2b3c4d")}</h2>
<p className="text-sm dg-text-secondary mt-0.5"> {i18n.t('agent.agentmonitortsx.sa75d7519')} </p>
</div>
<div className="flex items-center gap-2">
<Button
@@ -154,7 +154,7 @@ export function AgentMonitor() {
className="dg-input text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] hover:text-[var(--dg-text-primary)]"
>
<RefreshCw className={`h-4 w-4 mr-1.5 ${refreshing ? 'animate-spin' : ''}`} />
{t('agent.monitor.commandPlaceholder')}
{t('agent.agentmonitortsx.s2b3c4d5e')}
</Button>
<Button
size="sm"
@@ -162,7 +162,7 @@ export function AgentMonitor() {
className="bg-blue-600 hover:bg-blue-700 text-white"
>
<Rocket className="h-4 w-4 mr-1.5" />
{t('agent.monitor.commandPlaceholder')}
{t('agent.agentmonitortsx.s3c4d5e6f')}
</Button>
</div>
</div>
@@ -303,7 +303,7 @@ export function AgentMonitor() {
{t("agent.agentmonitortsx.s8h9i0j1k")}
</DialogTitle>
<DialogDescription className="dg-text-secondary">
{t('common.executeOn', { host: cmdHost })}
在 {cmdHost} 上执行命令
</DialogDescription>
</DialogHeader>
@@ -325,7 +325,7 @@ export function AgentMonitor() {
<Textarea
value={cmdCommand}
onChange={(e) => setCmdCommand(e.target.value)}
placeholder={i18n.t('agent.monitor.commandPlaceholder')}
placeholder={i18n.t('agent.agentmonitortsx.s6e62a5b0')}
rows={3}
className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] font-mono text-sm resize-none"
/>
@@ -359,7 +359,7 @@ export function AgentMonitor() {
</Badge>
</div>
<pre className="text-xs text-[var(--dg-text-secondary)] font-mono whitespace-pre-wrap break-all bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg p-3 max-h-48 overflow-auto">
{cmdResult.result || cmdResult.output || cmdResult.error || i18n.t('agent.monitor.noOutput')}
{cmdResult.result || cmdResult.output || cmdResult.error || i18n.t('agent.agentmonitortsx.s26d45bea')}
</pre>
</div>
)}
@@ -70,7 +70,7 @@ export function AgentRuleDialog({ open, onOpenChange, editingRule, onSuccess }:
const handleSave = async () => {
if (!ruleForm.name.trim()) {
toast.error(i18n.t('agent.ruleDialog.enterName'));
toast.error(i18n.t('agent.agentruledialog.s40f3f17d'));
return;
}
setSaving(true);
@@ -107,8 +107,8 @@ export function AgentRuleDialog({ open, onOpenChange, editingRule, onSuccess }:
<Dialog open={open} onOpenChange={(v) => { if (!v) onOpenChange(false); }}>
<DialogContent className="max-w-md dg-card text-[var(--dg-text-primary)]">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)]">{editingRule ? i18n.t('monitor.editRule') : i18n.t('agent.ruleDialog.editRule')}</DialogTitle>
<DialogDescription className="dg-text-secondary"> {i18n.t('agent.ruleDialog.description')} </DialogDescription>
<DialogTitle className="text-[var(--dg-text-primary)]">{editingRule ? i18n.t('monitor.editRule') : i18n.t('agent.agentruledialog.sf10ed468')}</DialogTitle>
<DialogDescription className="dg-text-secondary"> {i18n.t('agent.agentruledialog.s0cc0f608')} </DialogDescription>
</DialogHeader>
<div className="space-y-3">
@@ -117,7 +117,7 @@ export function AgentRuleDialog({ open, onOpenChange, editingRule, onSuccess }:
<Input
value={ruleForm.name}
onChange={(e) => setRuleForm({ ...ruleForm, name: e.target.value })}
placeholder={i18n.t('agent.ruleDialog.namePlaceholder')}
placeholder={i18n.t('agent.agentruledialog.s5a0f7e3e')}
className="h-9 dg-input"
/>
</div>
@@ -154,7 +154,7 @@ export function AgentRuleDialog({ open, onOpenChange, editingRule, onSuccess }:
{ruleForm.metric !== 'offline' && (
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)] text-xs"> {i18n.t('agent.ruleDialog.operator')} </Label>
<Label className="text-[var(--dg-text-secondary)] text-xs"> {i18n.t('agent.agentruledialog.sd52f2cb3')} </Label>
<Select value={ruleForm.operator} onValueChange={(v) => setRuleForm({ ...ruleForm, operator: v })}>
<SelectTrigger className="h-9 dg-input">
<SelectValue />
@@ -167,7 +167,7 @@ export function AgentRuleDialog({ open, onOpenChange, editingRule, onSuccess }:
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)] text-xs">{i18n.t('common.threshold')} {ruleForm.metric !== 'port_down' ? '(%)' : ''}</Label>
<Label className="text-[var(--dg-text-secondary)] text-xs">阈值 {ruleForm.metric !== 'port_down' ? '(%)' : ''}</Label>
<Input
type="number"
value={ruleForm.threshold}
@@ -180,24 +180,24 @@ export function AgentRuleDialog({ open, onOpenChange, editingRule, onSuccess }:
{ruleForm.metric === 'port_down' && (
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)] text-xs"> {i18n.t('agent.ruleDialog.port')} </Label>
<Input
type="number"
value={ruleForm.port}
<Label className="text-[var(--dg-text-secondary)] text-xs"> {i18n.t('agent.agentruledialog.s4d134841')} </Label>
<Input
type="number"
value={ruleForm.port}
onChange={(e) => setRuleForm({ ...ruleForm, port: e.target.value })}
placeholder={i18n.t('agent.ruleDialog.portPlaceholder')}
placeholder={i18n.t('agent.agentruledialog.s77565e5d')}
className="h-9 dg-input"
/>
</div>
)}
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)] text-xs"> {i18n.t('agent.ruleDialog.duration')} </Label>
<Label className="text-[var(--dg-text-secondary)] text-xs"> {i18n.t('agent.agentruledialog.sb1551d7f')} </Label>
<Input
type="number"
value={ruleForm.duration}
onChange={(e) => setRuleForm({ ...ruleForm, duration: e.target.value })}
placeholder={i18n.t('agent.ruleDialog.namePlaceholder')}
placeholder={i18n.t('agent.agentruledialog.sd140c78d')}
className="h-9 dg-input"
/>
</div>
+50 -53
View File
@@ -1,5 +1,4 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useUserStore } from '@/store';
import {
Dialog,
@@ -15,6 +14,7 @@ import { Label } from '@/components/ui/label';
import { Copy, Check, Terminal, Download, Settings, Play, Cpu, Zap, Loader2, Server, Shield } from 'lucide-react';
import { serverApi } from '@/api';
import { toast } from 'sonner';
import i18n from '@/i18n';
interface AgentWizardProps {
open: boolean;
@@ -34,14 +34,10 @@ interface AgentWizardProps {
* 不再 hardcode :8080,避免生产环境生成无法访问的地址。// Logan.Li
*/
function detectServerAddress() {
if (typeof window !== 'undefined' && window.location) {
return window.location.origin;
}
return 'http://127.0.0.1:8080';
return window.location.origin;
}
export function AgentWizard({ open, onOpenChange, server, onStart, onInstalled, onFailed }: AgentWizardProps) {
const { t } = useTranslation();
const token = useUserStore((s) => s.token) || '<YOUR_TOKEN>';
const origin = detectServerAddress();
const isWindowsServer = Boolean(server?.os && server.os.toLowerCase().includes('windows'));
@@ -109,10 +105,10 @@ export function AgentWizard({ open, onOpenChange, server, onStart, onInstalled,
if (success) {
setCopied(true);
toast.success(t('agent.wizard.copySuccess'));
toast.success(i18n.t('agent.agentwizardtsx.s19ee0048'));
setTimeout(() => setCopied(false), 3000);
} else {
toast.error(t('agent.wizard.copyFailed'));
toast.error(i18n.t('agent.agentwizardtsx.s7ac41b1c'));
setShowManualInput(true);
}
};
@@ -123,19 +119,19 @@ export function AgentWizard({ open, onOpenChange, server, onStart, onInstalled,
const manualSteps = [
{
icon: Download,
title: t('agent.wizard.stepDownload'),
title: i18n.t('agent.agentwizardtsx.s8c59e054'),
commands: [
`curl -fsSL ${binaryUrl} -o /usr/local/bin/aiops-agent`,
],
},
{
icon: Settings,
title: t('agent.wizard.stepChmod'),
title: i18n.t('agent.agentwizardtsx.s2f17bf61'),
commands: ['chmod +x /usr/local/bin/aiops-agent'],
},
{
icon: Cpu,
title: t('agent.wizard.stepService'),
title: i18n.t('agent.agentwizardtsx.s03fa0002'),
commands: [
`cat > /etc/systemd/system/aiops-agent.service << 'EOF'`,
`[Unit]`,
@@ -155,7 +151,7 @@ export function AgentWizard({ open, onOpenChange, server, onStart, onInstalled,
},
{
icon: Play,
title: t('agent.wizard.stepStart'),
title: i18n.t('agent.agentwizardtsx.s84bd81f4'),
commands: [
'systemctl daemon-reload',
'systemctl enable --now aiops-agent',
@@ -166,7 +162,7 @@ export function AgentWizard({ open, onOpenChange, server, onStart, onInstalled,
const windowsManualSteps = [
{
icon: Download,
title: t('agent.wizard.stepDownloadWindows'),
title: '下载 Windows Agent',
commands: [
`New-Item -ItemType Directory -Force "C:\\Program Files\\AIOps"`,
`Invoke-WebRequest -Uri ${windowsBinaryUrl} -OutFile "C:\\Program Files\\AIOps\\aiops-agent.exe" -UseBasicParsing`,
@@ -174,14 +170,14 @@ export function AgentWizard({ open, onOpenChange, server, onStart, onInstalled,
},
{
icon: Cpu,
title: t('agent.wizard.stepRegisterWindows'),
title: '注册 Windows 服务',
commands: [
`New-Service -Name AIOpsAgent -DisplayName "AIOps Agent" -BinaryPathName '"C:\\Program Files\\AIOps\\aiops-agent.exe" -server ${origin} -interval 10 -token ${token}' -StartupType Automatic`,
],
},
{
icon: Play,
title: t('agent.wizard.stepStartCheck'),
title: '启动并检查',
commands: [
'Start-Service AIOpsAgent',
'Get-Service AIOpsAgent',
@@ -194,11 +190,11 @@ export function AgentWizard({ open, onOpenChange, server, onStart, onInstalled,
const handleSshInstall = async () => {
if (!server || !token) return;
if (isWindowsServer) {
toast.error(t('agent.wizard.windowsSSHNotSupported'));
toast.error('Windows Agent 请使用 PowerShell 安装命令,SSH 一键安装仅支持 Linux/macOS');
return;
}
if (needsCreds && !sshPassword && !sshKey) {
toast.error(t('agent.wizard.credsRequired'));
toast.error(i18n.t('agent.agentwizardtsx.s6cfe961c'));
return;
}
setSshInstalling(true);
@@ -217,11 +213,11 @@ export function AgentWizard({ open, onOpenChange, server, onStart, onInstalled,
: undefined;
// 优先尝试异步批量接口,即使单台也后台执行
await serverApi.batchInstallAgent([server.id], token, credentials);
toast.success(t('agent.wizard.installSubmitted', { hostname: server.hostname }));
toast.success(`${server.hostname}: 已提交 Agent 安装任务,后台执行中…`);
onInstalled?.(server.id);
onOpenChange(false);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
const msg = e?.message || i18n.t('agent.installFailed');
toast.error(`${server.hostname} ${msg}`);
onFailed?.(server.id, msg);
} finally {
@@ -231,46 +227,47 @@ export function AgentWizard({ open, onOpenChange, server, onStart, onInstalled,
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl dg-card text-[var(--dg-text-primary)]">
{/* 限制向导尺寸并允许内部滚动,避免长文案或窄屏突破弹窗边框。 // Logan.Li */}
<DialogContent className="w-[calc(100%-2rem)] max-w-2xl max-h-[calc(100vh-2rem)] overflow-y-auto dg-card text-[var(--dg-text-primary)]">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)]">
{server ? t('agent.wizard.titleWithHost', { hostname: server.hostname }) : t('agent.wizard.title')}
{server ? `部署 Agent → ${server.hostname}` : i18n.t('agent.agentwizardtsx.s812cc1a5')}
</DialogTitle>
<DialogDescription className="dg-text-secondary">
{server
? t('agent.wizard.descWithHost', { hostname: server.hostname, ip: server.ip })
: t('agent.wizard.desc')
? `目标主机 ${server.hostname} (${server.ip}),选择安装方式:`
: i18n.t('agent.agentwizardtsx.sed0c1b63')
}
</DialogDescription>
</DialogHeader>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)]">
<TabsTrigger value="auto" className="data-[state=active]:bg-[var(--dg-bg-active)]">
<Terminal className="h-4 w-4 mr-1.5" />
{isWindowsServer ? t('agent.wizard.tabPowerShell') : t('agent.wizard.tabAuto')}
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full min-w-0">
<TabsList className="grid h-auto w-full grid-cols-3 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)]">
<TabsTrigger value="auto" className="min-w-0 justify-center px-2 text-xs sm:text-sm data-[state=active]:bg-[var(--dg-bg-active)]">
<Terminal className="h-4 w-4 mr-1 shrink-0 sm:mr-1.5" />
<span className="truncate">{isWindowsServer ? 'PowerShell 安装' : i18n.t('agent.agentwizardtsx.s1a2b3c4d')}</span>
</TabsTrigger>
{server && !isWindowsServer && (
<TabsTrigger value="ssh" className="data-[state=active]:bg-[var(--dg-bg-active)]">
<Zap className="h-4 w-4 mr-1.5" />
{t('agent.wizard.tabSSH')}
<TabsTrigger value="ssh" className="min-w-0 justify-center px-2 text-xs sm:text-sm data-[state=active]:bg-[var(--dg-bg-active)]">
<Zap className="h-4 w-4 mr-1 shrink-0 sm:mr-1.5" />
<span className="truncate">{i18n.t('agent.agentwizardtsx.s2b3c4d5e')}</span>
</TabsTrigger>
)}
{!server && (
<TabsTrigger value="ssh" className="data-[state=active]:bg-[var(--dg-bg-active)] opacity-50 cursor-not-allowed" disabled>
<Zap className="h-4 w-4 mr-1.5" />
{t('agent.wizard.tabSSH')}
<TabsTrigger value="ssh" className="min-w-0 justify-center px-2 text-xs sm:text-sm data-[state=active]:bg-[var(--dg-bg-active)] opacity-50 cursor-not-allowed" disabled>
<Zap className="h-4 w-4 mr-1 shrink-0 sm:mr-1.5" />
<span className="truncate">SSH一键安装</span>
</TabsTrigger>
)}
<TabsTrigger value="manual" className="data-[state=active]:bg-[var(--dg-bg-active)]">
{t('agent.wizard.tabManual')}
<TabsTrigger value="manual" className="min-w-0 justify-center px-2 text-xs sm:text-sm data-[state=active]:bg-[var(--dg-bg-active)]">
<span className="truncate">{i18n.t('agent.agentwizardtsx.s3c4d5e6f')}</span>
</TabsTrigger>
</TabsList>
{/* 自动安装 */}
<TabsContent value="auto" className="mt-4 space-y-3">
<p className="text-sm dg-text-secondary">
{isWindowsServer ? t('agent.wizard.autoHintWindows') : t('agent.wizard.autoHint')}
{isWindowsServer ? '请在目标 Windows 主机的管理员 PowerShell 中执行以下命令。' : i18n.t('agent.agentwizardtsx.s4d5e6f7g')}
</p>
<div className="relative rounded-lg bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] p-4">
<pre className="text-xs dg-text-green font-mono whitespace-pre-wrap break-all pr-10">
@@ -281,7 +278,7 @@ export function AgentWizard({ open, onOpenChange, server, onStart, onInstalled,
variant="ghost"
onClick={handleCopy}
className="absolute top-2 right-2 h-8 w-8 dg-text-secondary hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]"
title={t('agent.wizard.copyBtn')}
title={i18n.t('agent.agentwizardtsx.s564be0b1')}
>
{copied ? <Check className="h-4 w-4 dg-text-green" /> : <Copy className="h-4 w-4" />}
</Button>
@@ -291,7 +288,7 @@ export function AgentWizard({ open, onOpenChange, server, onStart, onInstalled,
{showManualInput && (
<div className="rounded-lg bg-amber-950/30 border border-amber-800/50 p-4">
<p className="text-xs dg-text-yellow mb-2">
{t('agent.wizard.manualCopyHint')}
{i18n.t('agent.agentwizardtsx.s5e6f7g8h')}
</p>
<textarea
readOnly
@@ -304,7 +301,7 @@ export function AgentWizard({ open, onOpenChange, server, onStart, onInstalled,
<div className="flex items-start gap-2 text-xs dg-text-muted">
<Terminal className="h-3.5 w-3.5 mt-0.5 shrink-0" />
<span>
{t('agent.wizard.addressDetected', { origin })}
服务器地址已自动检测 — 安装脚本与 Agent 上报均使用 {origin}(通过 Nginx/Vite proxy 转发到后端)。安装成功后将自动连接并开始上报指标。
</span>
</div>
</TabsContent>
@@ -314,8 +311,8 @@ export function AgentWizard({ open, onOpenChange, server, onStart, onInstalled,
{!server ? (
<div className="text-center py-12 dg-text-secondary">
<Server className="h-10 w-10 mx-auto mb-3 opacity-50" />
<p className="text-sm font-medium mb-1"> {t('agent.wizard.noServer')} </p>
<p className="text-xs"> {t('agent.wizard.noServerHint')} </p>
<p className="text-sm font-medium mb-1"> {i18n.t('agent.agentwizardtsx.sc9b7eec2')} </p>
<p className="text-xs"> {i18n.t('agent.agentwizardtsx.s12a11150')} </p>
</div>
) : (
<>
@@ -333,11 +330,11 @@ export function AgentWizard({ open, onOpenChange, server, onStart, onInstalled,
<div className="mb-4 p-3 rounded-lg bg-amber-950/30 border border-amber-800/40 space-y-3">
<div className="flex items-center gap-2 text-xs text-amber-300/80">
<Shield className="h-3.5 w-3.5" />
<span> {t('agent.wizard.credsHint')} </span>
<span> {i18n.t('agent.agentwizardtsx.seb55dff9')} </span>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-xs dg-text-secondary"> {t('system.userName')} </Label>
<Label className="text-xs dg-text-secondary"> {i18n.t('system.userName')} </Label>
<Input
value={sshUsername}
onChange={e => setSshUsername(e.target.value)}
@@ -347,7 +344,7 @@ export function AgentWizard({ open, onOpenChange, server, onStart, onInstalled,
</div>
<div className="flex items-end gap-1">
<div className="flex-1">
<Label className="text-xs dg-text-secondary"> {t('system.passwordLabel')} </Label>
<Label className="text-xs dg-text-secondary"> {i18n.t('system.passwordLabel')} </Label>
<Input
type="password"
value={sshPassword}
@@ -356,17 +353,17 @@ export function AgentWizard({ open, onOpenChange, server, onStart, onInstalled,
className="h-8 text-sm dg-input text-[var(--dg-text-primary)] mt-1"
/>
</div>
<span className="text-xs dg-text-muted px-1 pb-1">{t("agent.wizard.or")}</span>
<span className="text-xs dg-text-muted px-1 pb-1">{i18n.t("agent.agentwizardtsx.s1k2l3m4n")}</span>
<Button variant="ghost" size="sm"
onClick={() => { setAuthType('key'); setSshPassword(''); }}
className={`text-xs h-7 px-2 ${authType==='key'?'dg-text-blue':'dg-text-muted'}`}>
{t('agent.wizard.keyAuth')}
{i18n.t('agent.agentwizardtsx.s2l3m4n5o')}
</Button>
</div>
</div>
{authType === 'key' && (
<div>
<Label className="text-xs dg-text-secondary">{t("agent.wizard.privateKey")}</Label>
<Label className="text-xs dg-text-secondary">{i18n.t("agent.agentwizardtsx.s3m4n5o6p")}</Label>
<textarea
value={sshKey}
onChange={e => setSshKey(e.target.value)}
@@ -380,7 +377,7 @@ export function AgentWizard({ open, onOpenChange, server, onStart, onInstalled,
)}
<p className="text-sm dg-text-secondary mb-4">
{t("agent.wizard.sshHint")}
{i18n.t("agent.agentwizardtsx.s7g8h9i0j")}
</p>
<Button
onClick={handleSshInstall}
@@ -390,12 +387,12 @@ export function AgentWizard({ open, onOpenChange, server, onStart, onInstalled,
{sshInstalling ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
{t('agent.wizard.sshInstalling')}
{i18n.t('agent.agentwizardtsx.s8h9i0j1k')}
</>
) : (
<>
<Zap className="h-4 w-4 mr-2" />
{needsCreds ? t('agent.wizard.installWithCreds') : t('agent.wizard.installNow')}
{needsCreds ? i18n.t('agent.agentwizardtsx.sce2f5317') : i18n.t('agent.agentwizardtsx.s9866eb4f')}
</>
)}
</Button>
@@ -403,7 +400,7 @@ export function AgentWizard({ open, onOpenChange, server, onStart, onInstalled,
<div className="flex items-start gap-2 text-xs dg-text-muted">
<Terminal className="h-3.5 w-3.5 mt-0.5 shrink-0" />
<span>
{t('agent.wizard.sshConnectHint', { ip: server.ip })}
系统将通过 SSH 连接 {server.ip},执行安装脚本。请确保服务器 SSH 凭据已正确配置(编辑服务器可修改密码/密钥)。
</span>
</div>
</>
@@ -433,7 +430,7 @@ export function AgentWizard({ open, onOpenChange, server, onStart, onInstalled,
))}
</div>
<p className="text-xs dg-text-muted">
{t('agent.wizard.manualHint')}
{i18n.t('agent.agentwizardtsx.s0j1k2l3m')}
</p>
</TabsContent>
</Tabs>
@@ -65,7 +65,6 @@ export function HostDetailDialog({
const ports: JsonData[] = Array.isArray(host.ports) ? host.ports : [];
const chartData = history.map((h) => ({
// eslint-disable-next-line react-hooks/purity
time: new Date(h.timestamp || h.time || h.created_at || Date.now()).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
cpu: Number(h.cpu_percent ?? h.cpu ?? 0),
mem: Number(h.mem_percent ?? h.mem ?? 0),
@@ -85,7 +84,7 @@ export function HostDetailDialog({
<Badge className="bg-red-500/15 dg-text-red border border-red-500/30"> {i18n.t('agent.agentOffline')} </Badge>
)}
</DialogTitle>
<DialogDescription className="sr-only"> {i18n.t('agent.hostDetailDialog.desc')} </DialogDescription>
<DialogDescription className="sr-only"> {i18n.t('agent.hostdetaildialo.sffa4f720')} </DialogDescription>
</DialogHeader>
<div className="space-y-4">
@@ -94,7 +93,7 @@ export function HostDetailDialog({
<InfoCell label={i18n.t('agent.agentIp')} value={host.ip || '--'} />
<InfoCell label={i18n.t('agent.agentOs')} value={host.os || host.platform || '--'} />
<InfoCell label={i18n.t('agent.agentLastSeen')} value={timeAgo(host.last_report || host.last_reported_at || host.updated_at)} />
<InfoCell label={i18n.t('agent.portCount')} value={String(ports.length)} />
<InfoCell label={i18n.t('agent.hostdetaildialo.s6ad4fc6e')} value={String(ports.length)} />
</div>
{/* 趋势图 */}
@@ -102,16 +101,16 @@ export function HostDetailDialog({
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-[var(--dg-text-primary)] flex items-center gap-1.5">
<Activity className="h-4 w-4 dg-text-blue" />
{i18n.t('common.metricTrend')}
指标趋势
</span>
<Select value={String(hours)} onValueChange={(v) => setHours(Number(v))}>
<SelectTrigger size="sm" className="h-7 w-28 dg-input text-[var(--dg-text-secondary)] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent className="dg-input">
<SelectItem value="1"> {i18n.t('agent.hostDetailDialog.last1h')} </SelectItem>
<SelectItem value="6"> {i18n.t('agent.hostDetailDialog.last6h')} </SelectItem>
<SelectItem value="24"> {i18n.t('agent.hostDetailDialog.last24h')} </SelectItem>
<SelectItem value="1"> {i18n.t('agent.hostdetaildialo.s3851af2c')} </SelectItem>
<SelectItem value="6"> {i18n.t('agent.hostdetaildialo.s2d59b172')} </SelectItem>
<SelectItem value="24"> {i18n.t('agent.hostdetaildialo.s768a1b34')} </SelectItem>
</SelectContent>
</Select>
</div>
@@ -121,7 +120,7 @@ export function HostDetailDialog({
<Loader2 className="h-5 w-5 animate-spin dg-text-muted" />
</div>
) : chartData.length === 0 ? (
<div className="h-full flex items-center justify-center text-sm dg-text-muted"> {i18n.t('agent.hostDetailDialog.noHistoryData')} </div>
<div className="h-full flex items-center justify-center text-sm dg-text-muted"> {i18n.t('agent.hostdetaildialo.sdc73dbe9')} </div>
) : (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData} margin={{ top: 5, right: 5, bottom: 0, left: -20 }}>
@@ -158,10 +157,10 @@ export function HostDetailDialog({
<div>
<span className="text-sm font-medium text-[var(--dg-text-primary)] flex items-center gap-1.5 mb-2">
<Network className="h-4 w-4 dg-text-blue" />
{i18n.t('common.portDiscovery', { count: ports.length })}
端口发现 ({ports.length})
</span>
{ports.length === 0 ? (
<p className="text-xs dg-text-muted"> {i18n.t('agent.hostDetailDialog.noPortData')} </p>
<p className="text-xs dg-text-muted"> {i18n.t('agent.hostdetaildialo.se7887d60')} </p>
) : (
<div className="flex flex-wrap gap-1.5 max-h-32 overflow-y-auto">
{ports.map((p, idx) => (
@@ -181,10 +180,10 @@ export function HostDetailDialog({
className="bg-blue-600 hover:bg-blue-700 text-white"
>
<Terminal className="h-4 w-4 mr-1.5" />
{i18n.t('common.remoteCommand')}
远程命令
</Button>
<Button variant="outline" onClick={onClose} className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">
{i18n.t('common.close')}
关闭
</Button>
</DialogFooter>
</DialogContent>
@@ -10,23 +10,23 @@ import i18n from '@/i18n';
// 主机监控指标(与后端 alert_engine 对齐)// Logan.Li
export const METRIC_OPTIONS = [
{ value: 'cpu', label: i18n.t('kubernetes.cluster.cpuUsage'), icon: Cpu },
{ value: 'cpu_iowait', label: i18n.t('agent.alertHelper.netInBps'), icon: Cpu },
{ value: 'cpu_iowait', label: i18n.t('agent.agentalerthelpe.sc1eec3e5'), icon: Cpu },
{ value: 'mem', label: i18n.t('kubernetes.cluster.memoryUsage'), icon: MemoryStick },
{ value: 'mem_available', label: i18n.t('agent.alertHelper.memAvailable'), icon: MemoryStick },
{ value: 'swap', label: i18n.t('agent.alerts.metricSwap'), icon: MemoryStick },
{ value: 'load1', label: i18n.t('agent.alerts.metricLoad1'), icon: Gauge },
{ value: 'mem_available', label: i18n.t('monitor.hostmonitortsx.sa23fcda0'), icon: MemoryStick },
{ value: 'swap', label: i18n.t('agent.agentalerthelpe.sa2f9101c'), icon: MemoryStick },
{ value: 'load1', label: i18n.t('agent.agentalerthelpe.sf23ce82f'), icon: Gauge },
{ value: 'disk', label: i18n.t('monitor.hostDisk'), icon: HardDrive },
{ value: 'disk_inode', label: i18n.t('agent.alerts.metricInode'), icon: HardDrive },
{ value: 'disk_io_util', label: i18n.t('agent.alertHelper.diskIOUtil'), icon: HardDrive },
{ value: 'tcp_established', label: i18n.t('agent.alerts.metricTcpEst'), icon: Network },
{ value: 'disk_inode', label: i18n.t('agent.agentalerthelpe.sf7eadeca'), icon: HardDrive },
{ value: 'disk_io_util', label: i18n.t('agent.agentalerthelpe.s565a0e4d'), icon: HardDrive },
{ value: 'tcp_established', label: i18n.t('agent.agentalerthelpe.s6d9639e8'), icon: Network },
{ value: 'tcp_time_wait', label: 'TCP TIME_WAIT', icon: Network },
{ value: 'tcp_close_wait', label: 'TCP CLOSE_WAIT', icon: Network },
{ value: 'net_rx_bps', label: i18n.t('monitor.hostNetIn'), icon: Network },
{ value: 'net_tx_bps', label: i18n.t('monitor.hostNetOut'), icon: Network },
{ value: 'net_rx_errors', label: i18n.t('agent.alertHelper.netInErrors'), icon: Network },
{ value: 'net_rx_errors', label: i18n.t('agent.agentalerthelpe.s8d73bfe3'), icon: Network },
{ value: 'fd', label: i18n.t('monitor.hostFileDescriptor'), icon: Activity },
{ value: 'offline', label: i18n.t('agent.alertHelper.hostOffline'), icon: WifiOff },
{ value: 'port_down', label: i18n.t('agent.alertHelper.portDown'), icon: Network },
{ value: 'offline', label: i18n.t('agent.agentalerthelpe.s8ed49aa6'), icon: WifiOff },
{ value: 'port_down', label: i18n.t('agent.agentalerthelpe.sf62ce662'), icon: Network },
{ value: 'health_probe', label: i18n.t('kubernetes.container.probes'), icon: Timer },
];
+554 -92
View File
@@ -2,31 +2,131 @@
// 关联主机后,用自然语言描述任务,AI 通过 SSH 工具自主排障/部署,循环重试直到解决。
// 通过 WebSocket 实时展示思考、执行的命令、输出与结果;高危命令弹框确认。
import { useState, useRef, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { serverApi } from '@/api';
import { kubernetesApi } from '@/api/kubernetes';
import { buildAgentCliWsUrl, type AgentCliMode } from '@/api/aiops/agent-cli';
import { toast } from 'sonner';
import {
Bot,
Send, Square, Terminal, Shield, Brain, Cpu, Database, Loader2, Bot, Lightbulb, Server,
PanelLeftClose, History, ChevronUp, ChevronDown, Boxes, Cloud,
} from 'lucide-react';
import { Input } from '@/components/ui/input';
import AgentCLIHistory from './AgentCLIHistory';
import { AgentChatTimeline, type ChatItem } from './AgentCLIMessages';
import AgentCLIToolbar from './AgentCLIToolbar';
import AgentCLIEmptyState from './AgentCLIEmptyState';
import ScrollButtons from '@/components/ScrollButtons';
import type { AgentCliSession } from '@/api/aiops/agent-cli';
import { PageShell } from '@/components/layout/PageShell';
import { POD_NONE, type WsEvent } from './agent-cli-constants';
import { createWsEventHandler } from './agent-cli/event-handler';
import ChatInputArea from './agent-cli/ChatInputArea';
import { useTranslation } from 'react-i18next';
// WS 事件类型(后端 wsEvent 对应) // Logan.Li
interface WsEvent {
type: string;
phase?: string;
summary?: string;
round?: number;
session_id?: number;
thought?: string;
tool_name?: string;
tool_args?: Record<string, unknown>;
risk?: string;
output?: string;
success?: boolean;
duration?: string;
output_size?: number;
tokens?: number;
answer?: string;
message?: string;
confirm_id?: string;
command?: string;
}
// 四大核心模块说明(灯泡 Popover) // Logan.Li
const POD_NONE = '__none__';
type Translate = (key: string) => string;
function getHostTips(t: Translate) {
return [
{ icon: Cpu, title: t('agentcli.s1a2b3c4d'), color: 'text-sky-400', desc: t('agentcli.s2b3c4d5e') },
{ icon: Shield, title: t('agentcli.s3c4d5e6f'), color: 'dg-text-yellow', desc: t('agentcli.s4d5e6f7g') },
{ icon: Brain, title: t('agentcli.s5e6f7g8h'), color: 'dg-text-purple', desc: t('agentcli.s6f7g8h9i') },
{ icon: Database, title: t('agentcli.s7g8h9i0j'), color: 'dg-text-green', desc: t('agentcli.s8h9i0j1k') },
];
}
function getKubernetesTips(t: Translate) {
return [
{ icon: Cloud, title: t('agentcli.s9i0j1k2l'), color: 'text-sky-400', desc: t('agentcli.s0j1k2l3m') },
{ icon: Terminal, title: t('agentcli.s1k2l3m4n'), color: 'dg-text-purple', desc: t('agentcli.s2l3m4n5o') },
{ icon: Shield, title: t('agentcli.s3c4d5e6f'), color: 'dg-text-yellow', desc: t('agentcli.s3m4n5o6p') },
{ icon: Database, title: t('agentcli.s7g8h9i0j'), color: 'dg-text-green', desc: t('agentcli.s4n5o6p7q') },
];
}
// 安全模式说明(灯泡 Popover 展示) // Logan.Li
function getSafetyModes(t: Translate) {
return [
{
value: 'strict',
label: t('agentcli.s5o6p7q8r'),
short: t('agentcli.s6p7q8r9s'),
desc: t('agentcli.s7q8r9s0t'),
examples: t('agentcli.s4x5y6z7a'),
},
{
value: 'balanced',
label: t('agentcli.s8r9s0t1u'),
short: t('agentcli.s9s0t1u2v'),
desc: t('agentcli.s0t1u2v3w'),
examples: t('agentcli.s5y6z7a8b'),
recommended: true,
},
{
value: 'permissive',
label: t('agentcli.s1u2v3w4x'),
short: t('agentcli.s2v3w4x5y'),
desc: t('agentcli.s3w4x5y6z'),
examples: t('agentcli.s6z7a8b9c'),
},
] as const;
}
// formatServerLabel 主机下拉展示文案 // Logan.Li
function formatServerLabel(s: { hostname: string; ip: string }) {
return `${s.hostname}(${s.ip})`;
}
function formatAgentPhase(phase: string, t: Translate) {
const map: Record<string, string> = {
idle: t('common.idle'),
accepted: t('agentcli.s5s6t7u8v'),
context: t('agentcli.s4r5s6t7u'),
thinking: t('aiops.thinking'),
planning: t('common.planning'),
safety_check: t('common.safetyCheck'),
approval_required: t('common.waitingApproval'),
tool_running: t('common.executing'),
observing: t('common.observing'),
validating: t('common.verify'),
summarizing: t('common.summary'),
done: t('agentcli.s2d3e4f5g'),
blocked: t('common.blocked'),
error: t('common.failed'),
aborted: t('agentcli.s1s2t3u4v'),
};
return map[phase] || phase || t('common.running');
}
export default function AgentCLI() {
const { t } = useTranslation();
const confirm = useConfirm();
const { t } = useTranslation();
const [cliMode, setCliMode] = useState<AgentCliMode>('host');
const [servers, setServers] = useState<{ id: number; hostname: string; ip: string }[]>([]);
@@ -42,6 +142,7 @@ export default function AgentCLI() {
const [items, setItems] = useState<ChatItem[]>([]);
const [input, setInput] = useState('');
const [running, setRunning] = useState(false);
const [phase, setPhase] = useState('idle');
const [round, setRound] = useState(0);
const [tokens, setTokens] = useState(0);
const [connected, setConnected] = useState(false);
@@ -62,15 +163,15 @@ export default function AgentCLI() {
setItems((prev) => [...prev, { ...it, id: nextId() }]);
}, []);
// WebSocket 事件处理(提取到 event-handler.ts) // Logan.Li
const { handleEvent } = createWsEventHandler({
push, nextId, setRound, setTokens, setRunning, setCanContinue,
setItems, setHistoryRefresh, setViewMode,
confirm, wsRef,
});
const safetyModes = getSafetyModes(t);
const currentSafety = safetyModes.find((m) => m.value === safetyMode) ?? safetyModes[1];
const currentTips = cliMode === 'kubernetes' ? getKubernetesTips(t) : getHostTips(t);
const selectedCluster = clusters.find((c) => c.code === clusterCode);
const wsReady = cliMode === 'host' ? Boolean(serverId) : Boolean(clusterCode && namespace);
const filteredPods = pods.filter((p) => {
const q = podSearch.trim().toLowerCase();
return !q || p.name.toLowerCase().includes(q);
});
// 加载主机列表 // Logan.Li
useEffect(() => {
@@ -136,7 +237,7 @@ export default function AgentCLI() {
if (cliMode === 'host' && !serverId) return;
if (cliMode === 'kubernetes' && (!clusterCode || !namespace)) return;
setItems([]);
setRound(0); setTokens(0); setRunning(false);
setRound(0); setTokens(0); setRunning(false); setPhase('idle');
setConnected(false);
setViewMode(false);
setViewSession(null);
@@ -157,8 +258,8 @@ export default function AgentCLI() {
const connectTimer = window.setTimeout(() => {
if (active && !everConnected && ws.readyState !== WebSocket.OPEN) {
push({ kind: 'error', content: cliMode === 'kubernetes'
? t('aiops.cli.error.connectTimeoutK8s')
: t('aiops.cli.error.connectTimeoutHost') });
? t('agentcli.s5m6n7o8p')
: t('agentcli.s6n7o8p9q') });
try { ws.close(); } catch { /* noop */ }
}
}, 8000);
@@ -169,12 +270,13 @@ export default function AgentCLI() {
window.clearTimeout(connectTimer);
setConnected(false);
setRunning(false);
setPhase('idle');
if (!everConnected) {
push({ kind: 'error', content: cliMode === 'kubernetes'
? t('aiops.cli.error.connectRejectedK8s')
: t('aiops.cli.error.connectRejectedHost') });
? t('agentcli.s7o8p9q0r')
: t('agentcli.s8p9q0r1s') });
} else if (!viewMode) {
push({ kind: 'info', content: t('aiops.cli.info.connectionClosed') });
push({ kind: 'info', content: t('agentcli.s9q0r1s2t') });
}
};
ws.onerror = () => { setConnected(false); };
@@ -225,14 +327,151 @@ export default function AgentCLI() {
return () => ro.disconnect();
}, [items.length, updateScrollState]);
// 处理后端事件 // Logan.Li
const handleEvent = (ev: WsEvent) => {
const type = ev.type;
switch (type) {
case 'session':
push({ kind: 'info', content: ev.message || '' });
// 仅「恢复历史会话」时进入可追问(新任务开始的 session 事件文案不同) // Logan.Li
if (ev.message?.includes('已恢复历史会话')) {
setViewMode(false);
setCanContinue(true);
}
break;
case 'status':
if (ev.phase) setPhase(ev.phase);
if (ev.round) setRound(ev.round);
if (ev.tokens) setTokens(ev.tokens);
push({
kind: 'status',
round: ev.round,
phase: ev.phase,
content: ev.summary || ev.message || ev.phase || '',
toolName: ev.tool_name,
risk: ev.risk,
});
break;
case 'progress':
if (ev.round) setRound(ev.round);
if (ev.tokens) setTokens(ev.tokens);
break;
case 'thought_delta':
setPhase('thinking');
setItems((prev) => {
const next = [...prev];
const last = next[next.length - 1];
if (last?.kind === 'thought' && last.streaming && last.round === ev.round) {
next[next.length - 1] = { ...last, content: (last.content || '') + (ev.thought || '') };
} else {
next.push({ id: nextId(), kind: 'thought', round: ev.round, content: ev.thought || '', streaming: true });
}
return next;
});
break;
case 'thought':
setPhase('planning');
setItems((prev) => {
const next = [...prev];
const last = next[next.length - 1];
if (last?.kind === 'thought' && last.streaming && last.round === ev.round) {
next[next.length - 1] = { ...last, content: ev.thought || last.content, streaming: false };
} else {
next.push({ id: nextId(), kind: 'thought', round: ev.round, content: ev.thought || '' });
}
return next;
});
break;
case 'tool_call': {
setPhase(ev.phase || 'tool_running');
const args = ev.tool_args || {};
push({
kind: 'tool', round: ev.round, toolName: ev.tool_name,
command: String(args.command || (args.action ? `${args.action} ${args.name || ''}` : '')), risk: ev.risk, pending: true,
});
break;
}
case 'tool_result':
setPhase(ev.phase || 'observing');
setItems((prev) => {
const next = [...prev];
for (let i = next.length - 1; i >= 0; i--) {
if (next[i].kind === 'tool' && next[i].pending && next[i].toolName === ev.tool_name) {
next[i] = {
...next[i], output: ev.output, success: ev.success,
duration: ev.duration, outputSize: ev.output_size, pending: false,
};
break;
}
}
return next;
});
break;
case 'confirm':
setPhase(ev.phase || 'approval_required');
handleConfirm(ev);
break;
case 'done': {
setPhase('done');
const text = (ev.answer || ev.message || '').trim();
setItems((prev) => {
const norm = (s?: string) => (s || '').trim().replace(/\s+/g, ' ');
// 移除与 done 同文的 thought,避免终局总结显示两遍 // Logan.Li
const next = prev.filter((it) => {
if (it.kind !== 'thought' || !text) return true;
return norm(it.content) !== norm(text);
});
const last = next[next.length - 1];
if (last?.kind === 'done') {
if (text) {
next[next.length - 1] = { ...last, content: text, round: ev.round ?? last.round };
}
return next;
}
return [...next, {
id: nextId(),
kind: 'done',
content: text || t('agentcli.s0r1s2t3u'),
round: ev.round,
}];
});
setRunning(false);
setCanContinue(true);
setHistoryRefresh((k) => k + 1);
break;
}
case 'error':
case 'aborted':
setPhase(type === 'aborted' ? 'aborted' : 'error');
push({ kind: type === 'aborted' ? 'info' : 'error', content: ev.message || (type === 'aborted' ? t('agentcli.s1s2t3u4v') : t('agentcli.s2t3u4v5w')) });
setRunning(false);
setCanContinue(type !== 'aborted');
setHistoryRefresh((k) => k + 1);
break;
}
};
// 高危命令确认(后端驱动 → 弹框 → 回传) // Logan.Li
const handleConfirm = async (ev: WsEvent) => {
const ok = await confirm({
title: t('agentcli.s3u4v5w6x'),
description: t('agentcli.s4v5w6x7y', { tool: ev.tool_name, risk: ev.risk, cmd: ev.command || '-' }),
confirmText: t('agentcli.s5w6x7y8z'),
cancelText: t('agentcli.s6x7y8z9a'),
variant: 'destructive',
});
wsRef.current?.send(JSON.stringify({ type: ok ? 'confirm' : 'deny', confirm_id: ev.confirm_id }));
if (!ok) push({ kind: 'info', content: t('agentcli.s7y8z9a0b', { name: ev.command || ev.tool_name }) });
};
// 发送任务 // Logan.Li
const send = () => {
const q = input.trim();
if (!q) return;
if (cliMode === 'host' && !serverId) { toast.error(t('aiops.cli.error.selectServer')); return; }
if (cliMode === 'kubernetes' && (!clusterCode || !namespace)) { toast.error(t('aiops.cli.error.selectCluster')); return; }
if (cliMode === 'host' && !serverId) { toast.error(t('agentcli.s8z9a0b1c')); return; }
if (cliMode === 'kubernetes' && (!clusterCode || !namespace)) { toast.error(t('agentcli.s9a0b1c2d')); return; }
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
toast.error(t('aiops.cli.error.notReady'));
toast.error(t('agentcli.s0b1c2d3e'));
return;
}
if (running) return;
@@ -243,6 +482,7 @@ export default function AgentCLI() {
wsRef.current.send(JSON.stringify({ type: msgType, query: q, safety_mode: safetyMode }));
setInput('');
setRunning(true);
setPhase('accepted');
if (msgType === 'start') setCanContinue(false);
};
@@ -256,25 +496,23 @@ export default function AgentCLI() {
setItems([
{
id: nextId(), kind: 'info',
content: t('aiops.cli.info.viewHistory', {
title: sess.title,
status: sess.status === 'done' ? t('aiops.cli.statusDone') : sess.status,
}),
content: t('agentcli.s1c2d3e4f', { title: sess.title, status: sess.status === 'done' ? t('agentcli.s2d3e4f5g') : sess.status }),
},
...replayItems,
]);
setRunning(false);
setPhase('idle');
};
// resumeHistorySession 恢复历史会话上下文,进入可追问状态 // Logan.Li
const resumeHistorySession = () => {
if (!viewSession) return;
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
toast.error(t('aiops.cli.error.notReadyRetry'));
toast.error(t('agentcli.s4f5g6h7i'));
return;
}
wsRef.current.send(JSON.stringify({ type: 'resume', session_id: viewSession.id }));
push({ kind: 'info', content: t('aiops.cli.info.resuming', { title: viewSession.title }) });
push({ kind: 'info', content: t('agentcli.s3e4f5g6h', { title: viewSession.title }) });
};
const handleNewTask = () => {
@@ -284,47 +522,218 @@ export default function AgentCLI() {
setItems([]);
setRound(0);
setTokens(0);
setPhase('idle');
};
const selectedServer = servers.find((s) => String(s.id) === serverId);
const canResumeView = viewSession && ['done', 'aborted', 'error'].includes(viewSession.status);
return (
<PageShell
variant="workspace"
title="Agent CLI"
subtitle={cliMode === 'kubernetes' ? t('aiops.cli.subtitleK8s') : t('aiops.cli.subtitleHost')}
subtitle={cliMode === 'kubernetes' ? t('agentcli.s8b9c0d1e') : t('agentcli.s9c0d1e2f')}
icon={<Bot className="h-5 w-5" />}
className="h-full"
contentClassName="h-full flex flex-col min-h-0 gap-2"
>
<AgentCLIToolbar
cliMode={cliMode}
setCliMode={setCliMode}
servers={servers}
serverId={serverId}
setServerId={setServerId}
clusters={clusters}
clusterCode={clusterCode}
setClusterCode={setClusterCode}
namespaces={namespaces}
namespace={namespace}
setNamespace={setNamespace}
pods={pods}
podName={podName}
setPodName={setPodName}
podSearch={podSearch}
setPodSearch={setPodSearch}
safetyMode={safetyMode}
setSafetyMode={setSafetyMode}
connected={connected}
viewMode={viewMode}
canContinue={canContinue}
running={running}
round={round}
tokens={tokens}
historyOpen={historyOpen}
setHistoryOpen={setHistoryOpen}
/>
{/* 工作区控制栏:主机 + 安全模式 + 状态,不挤占对话区宽度 // Logan.Li */}
<div className="flex items-center gap-2 flex-wrap shrink-0 px-0.5">
<div className="flex rounded-md border border-[var(--dg-border)] overflow-hidden h-8 shrink-0">
<button
type="button"
className={`px-2.5 text-xs flex items-center gap-1 ${cliMode === 'host' ? 'bg-blue-500/15 dg-text-blue' : 'dg-text-secondary hover:bg-[var(--dg-bg-hover)]'}`}
onClick={() => setCliMode('host')}
>
<Server className="w-3.5 h-3.5" />{t('agentcli.s0d1e2f3g')}
</button>
<button
type="button"
className={`px-3 text-xs flex items-center gap-1 border-l border-[var(--dg-border)] ${cliMode === 'kubernetes' ? 'bg-blue-500/15 dg-text-blue' : 'dg-text-secondary hover:bg-[var(--dg-bg-hover)]'}`}
onClick={() => setCliMode('kubernetes')}
>
<Boxes className="w-3.5 h-3.5 shrink-0" />Kubernetes
</button>
</div>
{cliMode === 'host' ? (
<Select value={serverId} onValueChange={setServerId}>
<SelectTrigger className="w-80 max-w-[22rem] h-8 text-xs dg-input min-w-0">
<Server className="w-3.5 h-3.5 mr-1.5 dg-text-secondary shrink-0" />
<SelectValue placeholder={t('agentcli.s1e2f3g4h')} className="truncate">
{selectedServer ? formatServerLabel(selectedServer) : undefined}
</SelectValue>
</SelectTrigger>
<SelectContent className="dg-input max-h-72 min-w-[var(--radix-select-trigger-width)]">
{servers.map((s) => (
<SelectItem key={s.id} value={String(s.id)} title={formatServerLabel(s)}>
<span className="truncate block max-w-[20rem]">{formatServerLabel(s)}</span>
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<>
<Select value={clusterCode} onValueChange={setClusterCode}>
<SelectTrigger className="w-44 h-8 text-xs dg-input">
<Cloud className="w-3.5 h-3.5 mr-1 dg-text-secondary shrink-0" />
<SelectValue placeholder={t('agentcli.s2f3g4h5i')} />
</SelectTrigger>
<SelectContent className="dg-input max-h-72">
{clusters.map((c) => (
<SelectItem key={c.id} value={c.code}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={namespace} onValueChange={setNamespace}>
<SelectTrigger className="w-36 h-8 text-xs dg-input">
<SelectValue placeholder={t('agentcli.s3g4h5i6j')} />
</SelectTrigger>
<SelectContent className="dg-input max-h-72">
{namespaces.map((ns) => (
<SelectItem key={ns} value={ns}>{ns}</SelectItem>
))}
</SelectContent>
</Select>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="h-8 text-xs dg-input min-w-[10rem] justify-start font-normal">
<Terminal className="w-3.5 h-3.5 mr-1 dg-text-secondary shrink-0" />
{podName === POD_NONE ? t('agentcli.s4h5i6j7k') : podName}
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-80 dg-input p-2 space-y-2">
<Input
value={podSearch}
onChange={(e) => setPodSearch(e.target.value)}
placeholder={t('agentcli.s5i6j7k8l')}
className="h-8 text-xs dg-input"
/>
<div className="max-h-56 overflow-y-auto space-y-0.5">
<button
type="button"
className={`w-full text-left text-xs px-2 py-1.5 rounded ${podName === POD_NONE ? 'bg-blue-500/15' : 'hover:bg-[var(--dg-bg-hover)]'}`}
onClick={() => setPodName(POD_NONE)}
>
{t('agentcli.s6j7k8l9m')}
</button>
{filteredPods.map((p) => (
<button
key={p.name}
type="button"
className={`w-full text-left text-xs px-2 py-1.5 rounded ${podName === p.name ? 'bg-blue-500/15' : 'hover:bg-[var(--dg-bg-hover)]'}`}
onClick={() => setPodName(p.name)}
>
<div className="font-medium truncate">{p.name}</div>
<div className="text-[10px] dg-text-muted truncate">{p.status} · restarts {p.restarts}</div>
</button>
))}
{filteredPods.length === 0 && (
<div className="text-[10px] dg-text-muted text-center py-2">{t('agentcli.s7k8l9m0n')}</div>
)}
</div>
</PopoverContent>
</Popover>
</>
)}
<div className="flex items-center gap-1">
<Select value={safetyMode} onValueChange={setSafetyMode}>
<SelectTrigger className="w-24 h-8 text-xs dg-input">
<Shield className="w-3.5 h-3.5 mr-1 dg-text-secondary shrink-0" />
<SelectValue />
</SelectTrigger>
<SelectContent className="dg-input">
{safetyModes.map((m) => (
<SelectItem key={m.value} value={m.value}>{m.label}</SelectItem>
))}
</SelectContent>
</Select>
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 dg-text-yellow hover:bg-[var(--dg-bg-hover)]" title={t('agentcli.s8l9m0n1o')}>
<Lightbulb className="w-4 h-4" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-80 dg-input p-3 space-y-2.5">
<div className="text-sm font-medium text-[var(--dg-text-primary)]">{t('agentcli.s8l9m0n1o')}</div>
<p className="text-[11px] dg-text-muted leading-relaxed">
{t('agentcli.s9m0n1o2p')}
</p>
{safetyModes.map((m) => (
<div
key={m.value}
className={`rounded-md p-2.5 border text-xs ${
m.value === safetyMode
? 'border-blue-500/40 bg-blue-500/10'
: 'border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]/40'
}`}
>
<div className="flex items-center gap-2">
<span className="font-medium text-[var(--dg-text-primary)]">{m.label}</span>
<span className="text-[10px] dg-text-muted">{m.short}</span>
{'recommended' in m && m.recommended && (
<Badge className="text-[9px] px-1 py-0 bg-violet-500/15 dg-text-purple border-violet-500/30">{t('agentcli.s7a8b9c0d')}</Badge>
)}
</div>
<p className="dg-text-secondary leading-relaxed mt-1">{m.desc}</p>
<p className="text-[10px] dg-text-muted mt-1">{t('agentcli.s0n1o2p3q')}{m.examples}</p>
</div>
))}
</PopoverContent>
</Popover>
</div>
<Button
variant="ghost"
size="sm"
className="h-8 px-2 text-xs dg-text-secondary"
onClick={() => setHistoryOpen((v) => !v)}
title={historyOpen ? t('agentcli.s1o2p3q4r') : t('agentcli.s2p3q4r5s')}
>
{historyOpen ? <PanelLeftClose className="w-3.5 h-3.5 mr-1" /> : <History className="w-3.5 h-3.5 mr-1" />}
{t('agentcli.s3q4r5s6t')}
</Button>
<div className="flex-1 min-w-[1rem]" />
{connected && (
<Badge className="h-6 text-[10px] bg-emerald-500/15 dg-text-green border-emerald-500/30">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 mr-1" />{t('agentcli.s4r5s6t7u')}
</Badge>
)}
{viewMode && (
<Badge className="h-6 text-[10px] bg-amber-500/15 dg-text-yellow border-amber-500/30">{t('agentcli.s5s6t7u8v')}</Badge>
)}
{canContinue && !running && !viewMode && (
<Badge className="h-6 text-[10px] bg-violet-500/15 dg-text-purple border-violet-500/30">{t('agentcli.s6t7u8v9w')}</Badge>
)}
{running && (
<Badge className="h-6 text-[10px] bg-blue-500/15 dg-text-blue border-blue-500/30">
<Loader2 className="w-3 h-3 mr-1 animate-spin" />{formatAgentPhase(phase, t)} · {t('agentcli.s7u8v9w0x', { round, tokens })}
</Badge>
)}
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 dg-text-muted hover:dg-text-secondary" title={t('agentcli.s8v9w0x1y')}>
<Lightbulb className="w-4 h-4" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-80 dg-input p-3 space-y-2">
<div className="text-sm font-medium text-[var(--dg-text-primary)]">{t('agentcli.s8v9w0x1y')}</div>
{currentTips.map((t) => (
<div key={t.title} className="flex gap-2 p-2 rounded-md bg-[var(--dg-bg-tertiary)]/40 border border-[var(--dg-border)]">
<t.icon className={`w-4 h-4 mt-0.5 shrink-0 ${t.color}`} />
<div>
<div className="text-xs font-medium text-[var(--dg-text-primary)]">{t.title}</div>
<div className="text-[10px] dg-text-secondary leading-relaxed mt-0.5">{t.desc}</div>
</div>
</div>
))}
</PopoverContent>
</Popover>
</div>
{/* 主工作区:可折叠历史 + 对话区全宽 // Logan.Li */}
<div className="flex flex-1 min-h-0 gap-2">
@@ -345,15 +754,15 @@ export default function AgentCLI() {
{viewMode && viewSession && (
<div className="flex items-center gap-2 px-3 py-2 border-b border-[var(--dg-border)] bg-amber-500/5 shrink-0 flex-wrap">
<span className="text-xs dg-text-secondary flex-1 min-w-[12rem]">
{t('aiops.cli.viewMode.readOnly', { title: viewSession.title })}
{t('agentcli.s3q4r5s6t')} ({t('common.readOnly')}) · {viewSession.title}
</span>
{canResumeView && (
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={resumeHistorySession}>
{t('aiops.cli.viewMode.continueBtn')}
{t('common.continue')}
</Button>
)}
<Button size="sm" variant="ghost" className="h-7 text-xs" onClick={handleNewTask}>
{t('aiops.cli.viewMode.newTask')}
{t('common.newTask')}
</Button>
</div>
)}
@@ -364,40 +773,93 @@ export default function AgentCLI() {
className="flex-1 min-h-0 overflow-y-auto p-4 space-y-3"
>
{items.length === 0 && (
<AgentCLIEmptyState
cliMode={cliMode}
wsReady={wsReady}
connected={connected}
safetyMode={safetyMode}
podName={podName}
/>
<div className="h-full flex flex-col items-center justify-center dg-text-muted gap-3">
<Terminal className="w-10 h-10 opacity-40" />
{!wsReady ? (
<>
<div className="text-sm">
{cliMode === 'kubernetes' ? t('agentcli.s1y2z3a4b') : t('agentcli.s2z3a4b5c')}
</div>
</>
) : !connected ? (
<>
<Loader2 className="w-6 h-6 animate-spin dg-text-blue" />
<div className="text-sm">{cliMode === 'kubernetes' ? t('agentcli.s3a4b5c6d') : t('agentcli.s4b5c6d7e')}</div>
</>
) : (
<div className="text-sm text-center max-w-md">
{cliMode === 'kubernetes'
? t('agentcli.s5c6d7e8f')
: t('agentcli.s6d7e8f9g')}
<div className="text-xs dg-text-muted mt-2">
{t('agentcli.s7e8f9g0h', { label: currentSafety.label, short: currentSafety.short })}
{cliMode === 'kubernetes' && podName === POD_NONE && ` · ${t('agentcli.s8f9g0h1i')}`}
</div>
</div>
)}
</div>
)}
<AgentChatTimeline items={items} activeRound={round} />
</div>
<ScrollButtons
show={items.length > 0}
atTop={scrollAtTop}
atBottom={scrollAtBottom}
onScrollTop={scrollToTop}
onScrollBottom={scrollToBottom}
/>
{items.length > 0 && (!scrollAtTop || !scrollAtBottom) && (
<div className="absolute right-3 bottom-3 z-10 flex flex-col gap-1.5">
{!scrollAtTop && (
<Button
type="button"
variant="secondary"
size="icon"
className="h-8 w-8 rounded-full shadow-md border border-[var(--dg-border)] bg-[var(--surface-raised)]/95 opacity-90 hover:opacity-100"
onClick={scrollToTop}
title={t('agentcli.s9w0x1y2z')}
>
<ChevronUp className="h-4 w-4" />
</Button>
)}
{!scrollAtBottom && (
<Button
type="button"
variant="secondary"
size="icon"
className="h-8 w-8 rounded-full shadow-md border border-[var(--dg-border)] bg-[var(--surface-raised)]/95 opacity-90 hover:opacity-100"
onClick={scrollToBottom}
title={t('agentcli.s0x1y2z3a')}
>
<ChevronDown className="h-4 w-4" />
</Button>
)}
</div>
)}
</div>
{/* 输入区(提取到 ChatInputArea.tsx) // Logan.Li */}
<ChatInputArea
input={input}
setInput={setInput}
onSend={send}
onStop={stop}
running={running}
viewMode={viewMode}
placeholder={
viewMode ? t('aiops.cli.placeholder.viewMode')
: canContinue ? t('aiops.cli.placeholder.continueInput')
: wsReady ? t('aiops.cli.placeholder.inputTask')
: (cliMode === 'kubernetes' ? t('aiops.cli.placeholder.selectClusterNs') : t('aiops.cli.placeholder.selectServerShort'))
}
/>
{/* 加宽输入区:多行 + 独立操作栏 // Logan.Li */}
<div className="border-t border-[var(--dg-border)] p-3 space-y-2 shrink-0">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }}
placeholder={
viewMode ? t('agentcli.s1i2j3k4l')
: canContinue ? t('agentcli.s2j3k4l5m')
: wsReady ? t('agentcli.s3k4l5m6n')
: (cliMode === 'kubernetes' ? t('agentcli.s1y2z3a4b') : t('agentcli.s2z3a4b5c'))
}
rows={3}
disabled={viewMode}
className="w-full resize-none dg-input min-h-[5rem] max-h-40 text-sm leading-relaxed disabled:opacity-50"
/>
<div className="flex items-center justify-between gap-2">
<span className="text-[10px] dg-text-muted">{t('agentcli.s4l5m6n7o')}</span>
{running ? (
<Button variant="destructive" size="sm" onClick={stop} className="bg-red-600 hover:bg-red-700">
<Square className="w-3.5 h-3.5 mr-1" /> {t('agentcli.s9g0h1i2j')}
</Button>
) : (
<Button size="sm" onClick={send} disabled={!input.trim() || viewMode} className="bg-blue-600 hover:bg-blue-700">
<Send className="w-3.5 h-3.5 mr-1" /> {t('agentcli.s0h1i2j3k')}
</Button>
)}
</div>
</div>
</Card>
</div>
</PageShell>
@@ -12,7 +12,8 @@ import i18n from '@/i18n';
// 对话流条目模型 // Logan.Li
export interface ChatItem {
id: number;
kind: 'user' | 'thought' | 'tool' | 'done' | 'error' | 'info';
kind: 'user' | 'thought' | 'tool' | 'done' | 'error' | 'info' | 'status';
phase?: string;
round?: number;
content?: string;
toolName?: string;
@@ -21,6 +22,7 @@ export interface ChatItem {
output?: string;
success?: boolean;
duration?: string;
outputSize?: number;
pending?: boolean;
streaming?: boolean;
}
@@ -51,15 +53,18 @@ export function messagesToChatItems(msgs: AgentCliMessage[]): ChatItem[] {
case 'thought':
items.push({ id: nextId(), kind: 'thought', round: m.round, content: m.content });
break;
case 'status':
items.push({ id: nextId(), kind: 'status', round: m.round, content: m.content, toolName: m.tool_name });
break;
case 'tool_call': {
let command = '';
try {
const args = JSON.parse(m.tool_args || '{}') as Record<string, unknown>;
command = String(args.command || '');
const args = JSON.parse(m.content || '{}') as Record<string, unknown>;
command = String(args.command || (args.action ? `${args.action} ${args.name || ''}` : ''));
} catch { /* 忽略解析失败 */ }
items.push({
id: nextId(), kind: 'tool', round: m.round,
toolName: m.tool_name, command, risk: m.risk, pending: true,
toolName: m.tool_name, command, pending: true,
});
break;
}
@@ -68,9 +73,8 @@ export function messagesToChatItems(msgs: AgentCliMessage[]): ChatItem[] {
if (items[i].kind === 'tool' && items[i].pending && items[i].toolName === m.tool_name) {
items[i] = {
...items[i],
output: m.content || m.tool_output,
success: m.success,
duration: m.duration,
output: m.content,
success: true,
pending: false,
};
break;
@@ -124,12 +128,12 @@ function groupChatItems(items: ChatItem[]): TimelineSegment[] {
let i = 0;
while (i < items.length) {
const it = items[i];
if (it.round != null && (it.kind === 'thought' || it.kind === 'tool')) {
if (it.round != null && (it.kind === 'thought' || it.kind === 'tool' || it.kind === 'status')) {
const r = it.round;
const group: ChatItem[] = [];
while (i < items.length) {
const cur = items[i];
if (cur.round !== r || (cur.kind !== 'thought' && cur.kind !== 'tool')) break;
if (cur.round !== r || (cur.kind !== 'thought' && cur.kind !== 'tool' && cur.kind !== 'status')) break;
group.push(cur);
i++;
}
@@ -235,6 +239,9 @@ export function MessageView({ item, inRoundGroup = false }: { item: ChatItem; in
if (item.kind === 'tool') {
return <ToolBlock item={item} inRoundGroup={inRoundGroup} />;
}
if (item.kind === 'status') {
return <StatusLine item={item} />;
}
if (item.kind === 'done') {
const text = item.content?.trim() || i18n.t('aiops.taskDone');
return (
@@ -258,6 +265,44 @@ export function MessageView({ item, inRoundGroup = false }: { item: ChatItem; in
);
}
function phaseLabel(phase?: string) {
const map: Record<string, string> = {
accepted: '已接收',
context: '上下文',
thinking: '思考中',
planning: '规划',
safety_check: '安全检查',
approval_required: '等待审批',
tool_running: '工具执行中',
observing: '观察结果',
validating: '验证',
summarizing: '总结',
done: '完成',
blocked: '已阻断',
error: '失败',
};
return map[phase || ''] || phase || '状态';
}
function StatusLine({ item }: { item: ChatItem }) {
const danger = item.phase === 'blocked' || item.phase === 'error';
return (
<div className={`flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-xs ${danger ? 'border-red-500/25 bg-red-500/10' : 'border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]/35'}`}>
{item.phase === 'thinking' || item.phase === 'tool_running' ? (
<Loader2 className="w-3.5 h-3.5 dg-text-blue animate-spin shrink-0" />
) : danger ? (
<AlertTriangle className="w-3.5 h-3.5 dg-text-red shrink-0" />
) : (
<CheckCircle2 className="w-3.5 h-3.5 dg-text-secondary shrink-0" />
)}
<span className="font-medium text-[var(--dg-text-primary)]">{phaseLabel(item.phase)}</span>
{item.toolName && <span className="dg-text-muted">tool={item.toolName}</span>}
{item.content && <span className="dg-text-muted truncate">{item.content}</span>}
{item.risk && <Badge className={`ml-auto text-[10px] border ${riskClass(item.risk)}`}>{item.risk}</Badge>}
</div>
);
}
// ThoughtBlock AI 思考(可折叠,灰底斜体,支持流式) // Logan.Li
function ThoughtBlock({
text, round, streaming, hideRoundLabel,
@@ -306,7 +351,13 @@ function ToolBlock({ item, inRoundGroup }: { item: ChatItem; inRoundGroup?: bool
)}
{!item.pending && item.output != null && (
<>
{item.duration && <div className="text-[10px] dg-text-muted">{i18n.t('aiops.durationLabel', { duration: item.duration })}</div>}
{(item.duration || item.outputSize != null) && (
<div className="text-[10px] dg-text-muted">
{item.duration && i18n.t('aiops.durationLabel', { duration: item.duration })}
{item.duration && item.outputSize != null ? ' · ' : ''}
{item.outputSize != null ? `输出 ${formatBytes(item.outputSize)}` : ''}
</div>
)}
{(item.toolName === 'analyze_output' || looksLikeMarkdown(item.output)) ? (
<div className="bg-[var(--dg-bg-primary)]/40 rounded px-2 py-1.5 max-h-80 overflow-y-auto">
<AgentMarkdown content={item.output || i18n.t('aiops.agentCliNoOutput')} />
@@ -320,4 +371,10 @@ function ToolBlock({ item, inRoundGroup }: { item: ChatItem; inRoundGroup?: bool
)}
</div>
);
}
}
function formatBytes(n: number) {
if (n >= 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)}MB`;
if (n >= 1024) return `${(n / 1024).toFixed(1)}KB`;
return `${n}B`;
}
+2 -2
View File
@@ -129,7 +129,7 @@ export function Login() {
</div>
{/* 登录卡片 */}
<div className="relative z-10 w-full max-w-md dg-card rounded-xl p-10 animate-fade-in">
<div className="relative z-10 w-full max-w-md dg-card rounded-xl p-6 sm:p-10 animate-fade-in">
<div className="text-center mb-8">
<div className="dg-icon-box w-14 h-14 rounded-xl mx-auto mb-4">
<Activity className="size-7" />
@@ -223,4 +223,4 @@ export function Login() {
</p>
</div>
);
}
}
@@ -0,0 +1,347 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Bar,
BarChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { ArrowDownRight, ArrowUpRight, CalendarRange, Loader2, Sparkles } from 'lucide-react';
import { toast } from 'sonner';
import { billingApi, aiApi } from '@/api';
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 { ContentPanel } from '@/components/layout/ContentPanel';
import { PageEmpty } from '@/components/layout/PageEmpty';
import { PageLoading } from '@/components/layout/PageLoading';
import { AgentMarkdown } from '@/components/aiops/AgentMarkdown';
interface BillingAnalysisItem {
name: string;
cost: number;
percent: number;
}
interface BillingAnalysisMonth {
month: string;
total: number;
items: BillingAnalysisItem[];
}
interface BillingAttribution {
name: string;
previous_cost: number;
current_cost: number;
delta: number;
contribution: number;
}
interface BillingAnalysisData {
start_month: string;
end_month: string;
currency: string;
exchange_rates: Record<string, number>;
rate_source: string;
dimension: string;
data_through: string;
incomplete_period: boolean;
categories: string[];
months: BillingAnalysisMonth[];
summary: {
total_cost: number;
average_cost: number;
peak_cost: number;
peak_month: string;
mom_percent: number;
};
attributions: BillingAttribution[];
}
interface BillingChartRow {
month: string;
total: number;
[category: string]: string | number;
}
interface BillingTooltipProps {
active?: boolean;
label?: string;
currency?: string;
payload?: Array<{
name?: string;
value?: number;
color?: string;
payload?: BillingChartRow;
}>;
}
const chartColors = [
'var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)',
'var(--chart-5)', 'var(--chart-6)', 'var(--chart-7)', 'var(--text-tertiary)',
];
// monthOffset 返回相对当前月份的 YYYY-MM。 // Logan.Li
function monthOffset(offset: number): string {
const date = new Date();
date.setDate(1);
date.setMonth(date.getMonth() + offset);
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
}
// formatAnalysisCurrency 按账单币种展示金额,避免多币种混算。 // Logan.Li
function formatAnalysisCurrency(amount: number, currency = 'CNY'): string {
const prefix = currency === 'CNY' ? '¥' : currency === 'USD' ? '$' : `${currency} `;
return `${prefix}${amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
function BillingTooltip({ active, label, payload, currency }: BillingTooltipProps) {
if (!active || !payload?.length) return null;
const visible = payload.filter((item) => Number(item.value) !== 0);
const total = Number(payload[0]?.payload?.total || 0);
return (
<div className="min-w-60 rounded-md border border-[var(--chart-tooltip-border)] bg-[var(--chart-tooltip-bg)] p-3 shadow-lg">
<div className="mb-2 flex items-center justify-between gap-4 text-sm font-medium text-[var(--text-primary)]">
<span>{label}</span>
<span>{formatAnalysisCurrency(total, currency)}</span>
</div>
<div className="space-y-1.5">
{visible.map((item) => (
<div key={item.name} className="grid grid-cols-[10px_minmax(100px,1fr)_auto_auto] items-center gap-2 text-xs">
<span className="h-2 w-2 rounded-sm" style={{ backgroundColor: item.color }} />
<span className="truncate text-[var(--text-secondary)]">{item.name}</span>
<span className="font-medium text-[var(--text-primary)]">{formatAnalysisCurrency(Number(item.value), currency)}</span>
<span className="w-12 text-right text-[var(--text-tertiary)]">
{total ? `${(Number(item.value) / total * 100).toFixed(1)}%` : '0%'}
</span>
</div>
))}
</div>
</div>
);
}
export function BillingAnalysisPanel() {
const { t } = useTranslation();
const [startMonth, setStartMonth] = useState(monthOffset(-5));
const [endMonth, setEndMonth] = useState(monthOffset(0));
const [dimension, setDimension] = useState('service');
const [currency, setCurrency] = useState('CNY');
const [provider, setProvider] = useState('__all');
const [providers, setProviders] = useState<Array<{ value: string; label: string }>>([]);
const [data, setData] = useState<BillingAnalysisData | null>(null);
const [loading, setLoading] = useState(true);
const [analyzing, setAnalyzing] = useState(false);
const [insight, setInsight] = useState('');
const load = useCallback(async () => {
if (startMonth > endMonth) {
toast.error(t('cloud.analysisInvalidRange'));
return;
}
setLoading(true);
try {
const result = await billingApi.getCostAnalysis({
start_month: startMonth,
end_month: endMonth,
dimension,
currency,
provider: provider === '__all' ? undefined : provider,
});
setData(result as BillingAnalysisData);
setInsight('');
} catch (error) {
toast.error(t('cloud.analysisLoadFailed'), { description: error instanceof Error ? error.message : '' });
} finally {
setLoading(false);
}
}, [currency, dimension, endMonth, provider, startMonth, t]);
useEffect(() => {
void Promise.all([billingApi.getProviders(), billingApi.getSettings()]).then(([result, settings]) => {
setProviders(Array.isArray(result) ? result as Array<{ value: string; label: string }> : []);
setCurrency(settings.base_currency || 'CNY');
}).catch(() => setProviders([]));
}, []);
useEffect(() => {
const timer = window.setTimeout(load, 0);
return () => window.clearTimeout(timer);
}, [load]);
const chartData = useMemo<BillingChartRow[]>(() => (data?.months || []).map((month) => {
const row: BillingChartRow = { month: month.month, total: month.total };
month.items.forEach((item) => { row[item.name] = item.cost; });
return row;
}), [data]);
const analyze = async () => {
if (!data || data.summary.total_cost === 0) return;
setAnalyzing(true);
try {
const prompt = [
'你是企业 FinOps 专家。请只依据以下已经计算好的账单汇总进行分析,不要重新计算或虚构数据。',
'输出四段:成本概览、波动趋势、归因分析、可执行优化建议。每段使用加粗标题,建议需指明优先级和验证方法。',
`分析维度:${dimension};报告币种:${data.currency};汇率:${JSON.stringify(data.exchange_rates)};数据截止:${data.data_through || '未知'};末月未完整出账:${data.incomplete_period ? '是' : '否'}`,
`汇总:${JSON.stringify(data.summary)}`,
`月度数据:${JSON.stringify(data.months)}`,
`变化归因:${JSON.stringify(data.attributions)}`,
].join('\n');
const result = await aiApi.chat([{ role: 'user', content: prompt }], `billing-analysis-${Date.now()}`);
setInsight(result.content || '');
} catch (error) {
toast.error(t('cloud.analysisAIFailed'), { description: error instanceof Error ? error.message : '' });
} finally {
setAnalyzing(false);
}
};
return (
<div className="space-y-4">
<div className="flex flex-wrap items-end gap-3 rounded-md border border-[var(--border-default)] bg-[var(--surface-raised)] px-4 py-3">
<div>
<Label className="mb-1 block text-xs text-[var(--text-tertiary)]">{t('cloud.analysisPeriod')}</Label>
<div className="flex items-center gap-2">
<Input type="month" value={startMonth} onChange={(event) => setStartMonth(event.target.value)} className="h-9 w-[118px] min-w-0 sm:w-36 dg-input" />
<span className="text-[var(--text-tertiary)]">-</span>
<Input type="month" value={endMonth} onChange={(event) => setEndMonth(event.target.value)} className="h-9 w-[118px] min-w-0 sm:w-36 dg-input" />
</div>
</div>
<div>
<Label className="mb-1 block text-xs text-[var(--text-tertiary)]">{t('cloud.platformLabel')}</Label>
<Select value={provider} onValueChange={setProvider}>
<SelectTrigger className="h-9 w-36 dg-input"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="__all">{t('cloud.allProviders')}</SelectItem>
{providers.map((item) => <SelectItem key={item.value} value={item.value}>{item.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div>
<Label className="mb-1 block text-xs text-[var(--text-tertiary)]">{t('cloud.analysisDimension')}</Label>
<Select value={dimension} onValueChange={setDimension}>
<SelectTrigger className="h-9 w-36 dg-input"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="service">{t('cloud.dimensionService')}</SelectItem>
<SelectItem value="provider">{t('cloud.dimensionProvider')}</SelectItem>
<SelectItem value="account">{t('cloud.dimensionAccount')}</SelectItem>
<SelectItem value="region">{t('cloud.dimensionRegion')}</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="mb-1 block text-xs text-[var(--text-tertiary)]">{t('cloud.analysisCurrency')}</Label>
<Select value={currency} onValueChange={setCurrency}>
<SelectTrigger className="h-9 w-28 dg-input"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="CNY">CNY</SelectItem>
<SelectItem value="USD">USD</SelectItem>
</SelectContent>
</Select>
</div>
<Button variant="outline" size="sm" onClick={load} disabled={loading} className="dg-btn-secondary">
{loading ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <CalendarRange className="mr-1 h-4 w-4" />}
{t('cloud.query')}
</Button>
<Button size="sm" onClick={analyze} disabled={analyzing || !data || data.summary.total_cost === 0} className="dg-btn-primary ml-auto">
{analyzing ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Sparkles className="mr-1 h-4 w-4" />}
{t('cloud.aiAnalysis')}
</Button>
</div>
{loading ? <PageLoading /> : !data || data.summary.total_cost === 0 ? (
<PageEmpty title={t('cloud.analysisNoData')} />
) : (
<>
<ContentPanel>
<div className="grid grid-cols-2 gap-x-6 gap-y-4 lg:grid-cols-4">
<div><p className="text-xs text-[var(--text-tertiary)]">{t('cloud.analysisTotal')}</p><p className="mt-1 text-xl font-semibold text-[var(--text-primary)]">{formatAnalysisCurrency(data.summary.total_cost, data.currency)}</p></div>
<div><p className="text-xs text-[var(--text-tertiary)]">{t('cloud.analysisAverage')}</p><p className="mt-1 text-xl font-semibold text-[var(--text-primary)]">{formatAnalysisCurrency(data.summary.average_cost, data.currency)}</p></div>
<div><p className="text-xs text-[var(--text-tertiary)]">{t('cloud.analysisPeak')}</p><p className="mt-1 text-xl font-semibold text-[var(--text-primary)]">{formatAnalysisCurrency(data.summary.peak_cost, data.currency)}</p><p className="text-xs text-[var(--text-tertiary)]">{data.summary.peak_month}</p></div>
<div>
<p className="text-xs text-[var(--text-tertiary)]">{t('cloud.analysisMom')}</p>
<p className={`mt-1 flex items-center text-xl font-semibold ${data.summary.mom_percent > 0 ? 'dg-text-red' : data.summary.mom_percent < 0 ? 'dg-text-green' : 'text-[var(--text-primary)]'}`}>
{data.summary.mom_percent > 0 ? <ArrowUpRight className="mr-1 h-5 w-5" /> : data.summary.mom_percent < 0 ? <ArrowDownRight className="mr-1 h-5 w-5" /> : null}
{Math.abs(data.summary.mom_percent).toFixed(1)}%
</p>
{data.incomplete_period && <p className="text-xs text-[var(--text-tertiary)]">{t('cloud.analysisIncomplete')}</p>}
</div>
</div>
</ContentPanel>
{insight && (
<ContentPanel>
<div className="mb-3 flex items-center gap-2">
<Sparkles className="h-4 w-4 text-[var(--brand)]" />
<h3 className="text-sm font-semibold text-[var(--text-primary)]">{t('cloud.aiAnalysisResult')}</h3>
</div>
<AgentMarkdown content={insight} />
</ContentPanel>
)}
<ContentPanel>
<div className="mb-4 flex flex-wrap items-start justify-between gap-2">
<div>
<h3 className="text-sm font-semibold text-[var(--text-primary)]">{t('cloud.analysisTrendTitle')}</h3>
<p className="mt-0.5 text-xs text-[var(--text-tertiary)]">{t('cloud.analysisDataThrough', { date: data.data_through || '-' })}</p>
<p className="mt-0.5 text-xs text-[var(--text-tertiary)]">{t('cloud.analysisConversionNote', { currency: data.currency, rates: Object.entries(data.exchange_rates || {}).map(([source, rate]) => `1 ${source} = ${rate} ${data.currency}`).join(' · ') })}</p>
</div>
<div className="flex max-w-full flex-wrap justify-end gap-x-3 gap-y-1">
{data.categories.map((category, index) => (
<span key={category} className="inline-flex items-center gap-1.5 text-xs text-[var(--text-secondary)]">
<span className="h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: chartColors[index % chartColors.length] }} />{category}
</span>
))}
</div>
</div>
<div className="h-80 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} margin={{ top: 8, right: 8, left: 8, bottom: 0 }}>
<CartesianGrid vertical={false} stroke="var(--chart-grid)" strokeDasharray="3 3" />
<XAxis dataKey="month" tickLine={false} axisLine={{ stroke: 'var(--chart-axis)' }} tick={{ fill: 'var(--text-tertiary)', fontSize: 12 }} />
<YAxis tickLine={false} axisLine={false} width={66} tick={{ fill: 'var(--text-tertiary)', fontSize: 12 }} tickFormatter={(value: number) => value >= 10000 ? `${(value / 10000).toFixed(1)}万` : value.toLocaleString('zh-CN')} />
<Tooltip content={<BillingTooltip currency={data.currency} />} cursor={{ fill: 'var(--surface-hover)' }} />
{data.categories.map((category, index) => (
<Bar key={category} dataKey={category} name={category} stackId="cost" fill={chartColors[index % chartColors.length]} maxBarSize={88} radius={index === data.categories.length - 1 ? [3, 3, 0, 0] : 0} />
))}
</BarChart>
</ResponsiveContainer>
</div>
</ContentPanel>
<ContentPanel noPadding>
<div className="overflow-x-auto">
<table className="w-full min-w-[720px] text-sm">
<thead className="bg-[var(--surface-inset)] text-[var(--text-secondary)]">
<tr>
<th className="sticky left-0 z-10 bg-[var(--surface-inset)] px-4 py-3 text-left font-medium">{t('cloud.analysisCategory')}</th>
<th className="px-4 py-3 text-right font-medium">{t('cloud.totalCost')}</th>
{data.months.map((month) => <th key={month.month} className="px-4 py-3 text-right font-medium">{month.month}</th>)}
</tr>
</thead>
<tbody>
<tr className="border-t border-[var(--border-default)] font-semibold text-[var(--text-primary)]">
<td className="sticky left-0 bg-[var(--surface-raised)] px-4 py-3">{t('cloud.analysisTotalRow')}</td>
<td className="px-4 py-3 text-right">{formatAnalysisCurrency(data.summary.total_cost, data.currency)}</td>
{data.months.map((month) => <td key={month.month} className="px-4 py-3 text-right">{formatAnalysisCurrency(month.total, data.currency)}</td>)}
</tr>
{data.categories.map((category, index) => (
<tr key={category} className="border-t border-[var(--border-default)] text-[var(--text-secondary)] hover:bg-[var(--surface-hover)]">
<td className="sticky left-0 bg-[var(--surface-raised)] px-4 py-3"><span className="inline-flex items-center gap-2"><span className="h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: chartColors[index % chartColors.length] }} />{category}</span></td>
<td className="px-4 py-3 text-right font-medium text-[var(--text-primary)]">{formatAnalysisCurrency(data.months.reduce((sum, month) => sum + (month.items.find((item) => item.name === category)?.cost || 0), 0), data.currency)}</td>
{data.months.map((month) => <td key={month.month} className="px-4 py-3 text-right">{formatAnalysisCurrency(month.items.find((item) => item.name === category)?.cost || 0, data.currency)}</td>)}
</tr>
))}
</tbody>
</table>
</div>
</ContentPanel>
</>
)}
</div>
);
}
@@ -66,7 +66,7 @@ export function BillingRecordDialog({ open, onOpenChange, editingRecord, onSucce
const handleSave = async () => {
if (!form.provider || !form.billing_date || !form.cost) {
toast.error(i18n.t('cloud.billingRecordDialog.fillFields'));
toast.error(i18n.t('cloud.billingrecorddi.sd8a97c17'));
return;
}
setSubmitting(true);
@@ -80,7 +80,7 @@ export function BillingRecordDialog({ open, onOpenChange, editingRecord, onSucce
}
onOpenChange(false);
onSuccess();
toast(i18n.t("common.saveSuccess"));
toast('保存成功');
} catch (e) {
toast.error(i18n.t('kubernetes.msg.saveFailed'), { description: (e as JsonData).message });
} finally {
@@ -92,13 +92,13 @@ export function BillingRecordDialog({ open, onOpenChange, editingRecord, onSucce
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{editingRecord ? i18n.t('cloud.billingRecordDialog.editRecord') : i18n.t('cloud.recordCost')}</DialogTitle>
<DialogDescription> {i18n.t('cloud.billingRecordDialog.desc')} </DialogDescription>
<DialogTitle>{editingRecord ? i18n.t('cloud.billingrecorddi.s3c91e420') : i18n.t('cloud.recordCost')}</DialogTitle>
<DialogDescription> {i18n.t('cloud.billingrecorddi.s09e541fd')} </DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-sm text-[var(--dg-text-secondary)]"> {i18n.t('cloud.billingRecordDialog.providerLabel')} </Label>
<Label className="text-sm text-[var(--dg-text-secondary)]"> {i18n.t('cloud.billingrecorddi.sb11d8416')} </Label>
<Select value={form.provider} onValueChange={(v) => setForm({ ...form, provider: v })}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
@@ -109,13 +109,13 @@ export function BillingRecordDialog({ open, onOpenChange, editingRecord, onSucce
</Select>
</div>
<div>
<Label className="text-sm text-[var(--dg-text-secondary)]"> {i18n.t('cloud.billingRecordDialog.dateLabel')} </Label>
<Label className="text-sm text-[var(--dg-text-secondary)]"> {i18n.t('cloud.billingrecorddi.s3abdefe2')} </Label>
<Input type="date" className="mt-1" value={form.billing_date} onChange={(e) => setForm({ ...form, billing_date: e.target.value })} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-sm text-[var(--dg-text-secondary)]"> {i18n.t('cloud.billingRecordDialog.costLabel')} </Label>
<Label className="text-sm text-[var(--dg-text-secondary)]"> {i18n.t('cloud.billingrecorddi.s6f252687')} </Label>
<Input type="number" step="0.01" className="mt-1" placeholder="0.00" value={form.cost} onChange={(e) => setForm({ ...form, cost: e.target.value })} />
</div>
<div>
@@ -130,7 +130,7 @@ export function BillingRecordDialog({ open, onOpenChange, editingRecord, onSucce
</div>
<div>
<Label className="text-sm text-[var(--dg-text-secondary)]"> {i18n.t('cloud.accountName')} </Label>
<Input className="mt-1" placeholder={i18n.t('cloud.billingRecordDialog.mainAccount')} value={form.account_name} onChange={(e) => setForm({ ...form, account_name: e.target.value })} />
<Input className="mt-1" placeholder={i18n.t('cloud.billingrecorddi.s0010795e')} value={form.account_name} onChange={(e) => setForm({ ...form, account_name: e.target.value })} />
</div>
</div>
<div>
@@ -58,7 +58,7 @@ export function BillingRuleDialog({ open, onOpenChange, editingRule, onSuccess }
const handleSave = async () => {
if (!form.name || !form.threshold) {
toast.error(i18n.t('cloud.billingRuleDialog.fillFields'));
toast.error(i18n.t('cloud.billingruledial.sd36855b1'));
return;
}
setSubmitting(true);
@@ -71,7 +71,7 @@ export function BillingRuleDialog({ open, onOpenChange, editingRule, onSuccess }
}
onOpenChange(false);
onSuccess();
toast(i18n.t("common.saveSuccess"));
toast('保存成功');
} catch (e) {
toast.error(i18n.t('kubernetes.msg.saveFailed'), { description: (e as JsonData).message });
} finally {
@@ -83,13 +83,13 @@ export function BillingRuleDialog({ open, onOpenChange, editingRule, onSuccess }
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{editingRule ? i18n.t('cloud.billingRuleDialog.editRule') : i18n.t('cloud.billingRuleDialog.addRule')}</DialogTitle>
<DialogDescription> {i18n.t('cloud.billingRuleDialog.desc')} </DialogDescription>
<DialogTitle>{editingRule ? i18n.t('cloud.billingruledial.sb8cfa0c9') : i18n.t('cloud.billingruledial.sd58c549a')}</DialogTitle>
<DialogDescription> {i18n.t('cloud.billingruledial.s75227f20')} </DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<Label className="text-sm text-[var(--dg-text-secondary)]"> {i18n.t('monitor.silenceDialog.ruleNameRequired')} </Label>
<Input className="mt-1" placeholder={i18n.t('cloud.billingRuleDialog.namePlaceholder')} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
<Input className="mt-1" placeholder={i18n.t('cloud.billingruledial.sc147ac4f')} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
@@ -97,7 +97,7 @@ export function BillingRuleDialog({ open, onOpenChange, editingRule, onSuccess }
<Select value={form.provider || 'all'} onValueChange={(v) => setForm({ ...form, provider: v === 'all' ? '' : v })}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all"> {i18n.t('cloud.billingRuleDialog.allProviders')} </SelectItem>
<SelectItem value="all"> {i18n.t('cloud.billingruledial.sc356f198')} </SelectItem>
{Object.entries(providerLabels).map(([k, v]) => (
<SelectItem key={k} value={k}>{v}</SelectItem>
))}
@@ -117,19 +117,19 @@ export function BillingRuleDialog({ open, onOpenChange, editingRule, onSuccess }
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-sm text-[var(--dg-text-secondary)]"> {i18n.t('cloud.billingRuleDialog.compare')} </Label>
<Label className="text-sm text-[var(--dg-text-secondary)]"> {i18n.t('cloud.billingruledial.sd7650ece')} </Label>
<Select value={form.operator} onValueChange={(v) => setForm({ ...form, operator: v })}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value=">"> {i18n.t('cloud.billingRuleDialog.gt')} </SelectItem>
<SelectItem value=">="> {i18n.t('cloud.billingRuleDialog.gte')} </SelectItem>
<SelectItem value="<"> {i18n.t('cloud.billingRuleDialog.lt')} </SelectItem>
<SelectItem value="<="> {i18n.t('cloud.billingRuleDialog.lte')} </SelectItem>
<SelectItem value=">"> {i18n.t('cloud.billingruledial.s3dba9756')} </SelectItem>
<SelectItem value=">="> {i18n.t('cloud.billingruledial.s8e1e26d1')} </SelectItem>
<SelectItem value="<"> {i18n.t('cloud.billingruledial.s0196c18c')} </SelectItem>
<SelectItem value="<="> {i18n.t('cloud.billingruledial.s45c301a8')} </SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-sm text-[var(--dg-text-secondary)]"> {i18n.t('cloud.billingRuleDialog.thresholdLabel')} </Label>
<Label className="text-sm text-[var(--dg-text-secondary)]"> {i18n.t('cloud.billingruledial.s11f9fb4c')} </Label>
<Input type="number" step="0.01" className="mt-1" placeholder="500.00" value={form.threshold} onChange={(e) => setForm({ ...form, threshold: e.target.value })} />
</div>
</div>
@@ -0,0 +1,92 @@
import { useEffect, useState } from 'react';
import { Settings } from 'lucide-react';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { billingApi } from '@/api';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
interface BillingSettingsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSaved: () => void;
}
// BillingSettingsDialog 提供轻量 USD/CNY 汇率设置。 // Logan.Li
export function BillingSettingsDialog({ open, onOpenChange, onSaved }: BillingSettingsDialogProps) {
const { t } = useTranslation();
const [rate, setRate] = useState('7.2');
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!open) return;
const timer = window.setTimeout(() => {
setLoading(true);
void billingApi.getSettings()
.then((settings) => setRate(String(settings.usd_cny_rate || 7.2)))
.catch(() => toast.error(t('cloud.exchangeRateLoadFailed')))
.finally(() => setLoading(false));
}, 0);
return () => window.clearTimeout(timer);
}, [open, t]);
const save = async () => {
const value = Number(rate);
if (!Number.isFinite(value) || value < 0.1 || value > 100) {
toast.error(t('cloud.exchangeRateInvalid'));
return;
}
setLoading(true);
try {
await billingApi.saveSettings(value);
toast.success(t('cloud.exchangeRateSaved'));
onOpenChange(false);
onSaved();
} catch (error) {
toast.error(t('cloud.exchangeRateSaveFailed'), { description: error instanceof Error ? error.message : '' });
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Settings className="h-4 w-4" />
{t('cloud.exchangeRateSettings')}
</DialogTitle>
<DialogDescription>{t('cloud.exchangeRateSettingsDesc')}</DialogDescription>
</DialogHeader>
<div className="space-y-2 py-2">
<Label htmlFor="usd-cny-rate">USD/CNY</Label>
<Input
id="usd-cny-rate"
type="number"
min="0.1"
max="100"
step="0.0001"
value={rate}
onChange={(event) => setRate(event.target.value)}
className="dg-input"
/>
<p className="text-xs text-[var(--text-tertiary)]">{t('cloud.exchangeRateExample', { rate: rate || '-' })}</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>{t('common.cancel')}</Button>
<Button onClick={save} disabled={loading}>{t('common.save')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -25,7 +25,6 @@ import { cloudApi, billingApi } from '@/api';
import { CloudBrandIcon, getProviderColor } from '@/components/icons/CloudBrandIcon';
import type { JsonData } from '@/types/common';
import { formatCurrencyByCode } from './CloudBillingHelpers';
interface CloudAccountDetailSheetProps {
account: JsonData | null;
@@ -142,7 +141,6 @@ export function CloudAccountDetailSheet({
const serverCount = account.server_count ?? account.serverCount ?? 0;
const regionCount = account.region_count ?? account.regionCount ?? regions.length;
const monthlyCost = account.monthly_cost ?? account.monthlyCost ?? 0;
const billingCurrency = account.billing_currency || account.billingCurrency || 'CNY';
const lastSync = account.last_sync_at || account.lastSyncAt || account.last_sync;
return (
@@ -187,7 +185,7 @@ export function CloudAccountDetailSheet({
<div className="grid grid-cols-3 gap-3">
<div className="rounded-lg p-3 bg-[var(--dg-bg-tertiary)]">
<p className="text-xs dg-text-secondary">{t('cloud.monthlyFee')}</p>
<p className="text-lg font-medium text-[var(--dg-green)]">{formatCurrencyByCode(Number(monthlyCost), billingCurrency)}</p>
<p className="text-lg font-medium text-[var(--dg-green)]">¥{Number(monthlyCost).toLocaleString()}</p>
</div>
<div className="rounded-lg p-3 bg-[var(--dg-bg-tertiary)]">
<p className="text-xs dg-text-secondary">{t('cloud.serverCount')}</p>
@@ -264,7 +262,7 @@ export function CloudAccountDetailSheet({
<p className="text-xs dg-text-muted">{b.billing_date} · {b.region || '-'}</p>
</div>
<span className="text-[var(--dg-green)] font-medium">
{formatCurrencyByCode(Number(b.cost || 0), b.currency)}
¥{Number(b.cost || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</span>
</div>
))}
@@ -275,4 +273,4 @@ export function CloudAccountDetailSheet({
</SheetContent>
</Sheet>
);
}
}
@@ -2,9 +2,35 @@ import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Plus, Search, Key, RefreshCw } from 'lucide-react';
import { Label } from '@/components/ui/label';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Plus,
Search,
Trash2,
Pencil,
Key,
Cloud,
CheckCircle2,
XCircle,
RefreshCw,
Loader2,
MapPin,
Wallet,
CloudDownload,
} from 'lucide-react';
import { toast } from 'sonner';
import { cloudApi } from '@/api';
import { CloudBrandIcon, getProviderColor } from '@/components/icons/CloudBrandIcon';
import { CloudAccountDetailSheet } from './CloudAccountDetailSheet';
import { CloudServerImport } from '@/components/server/CloudServerImport';
import { PageShell } from '@/components/layout/PageShell';
@@ -12,17 +38,28 @@ import { PageLoading } from '@/components/layout/PageLoading';
import { PageEmpty } from '@/components/layout/PageEmpty';
import { StatStrip } from '@/components/ui/stat-strip';
import type { JsonData } from '@/types/common';
import {
type AccountFormData,
type ActionLoading,
getInitialFormData,
buildFormDataFromAccount,
computeAccountStats,
filterAccounts,
getProviderLabels,
} from './cloud-account/types';
import { AccountCard } from './cloud-account/AccountCard';
import { AccountFormDialog } from './cloud-account/AccountFormDialog';
function getProviderLabels(t: (key: string) => string): Record<string, string> {
return {
aws: 'AWS',
aliyun: t('cloud.aliyun'),
tencent: t('cloud.tencent'),
huawei: t('cloud.huawei'),
azure: 'Azure',
gcp: 'GCP',
};
}
// getCredentialHints 按厂商提示凭据格式,Azure 使用服务主体三段标识。 // Logan.Li
function getCredentialHints(provider: string) {
if (provider === 'azure') {
return { accessKey: '租户ID:订阅ID:客户端ID', secretKey: '客户端密钥' };
}
if (provider === 'aws') return { accessKey: 'AKIA...', secretKey: 'AWS Secret Access Key' };
if (provider === 'tencent') return { accessKey: '腾讯云 SecretId', secretKey: '腾讯云 SecretKey' };
if (provider === 'huawei') return { accessKey: '华为云 Access Key', secretKey: '华为云 Secret Access Key' };
return { accessKey: 'LTAI...', secretKey: '阿里云 AccessKey Secret' };
}
export function CloudAccountManager() {
const { t } = useTranslation();
@@ -34,15 +71,23 @@ export function CloudAccountManager() {
// 创建/编辑账号表单状态
const [formOpen, setFormOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [editingAccount, setEditingAccount] = useState<JsonData | null>(null);
const [formData, setFormData] = useState<AccountFormData>(getInitialFormData());
const [detailAccount, setDetailAccount] = useState<JsonData | null>(null);
const [editingAccount, setEditingAccount] = useState<JsonData>(null);
const [formData, setFormData] = useState({
name: '',
provider: 'aliyun',
access_key: '',
secret_key: '',
monthly_cost: '',
status: 'active',
});
const [detailAccount, setDetailAccount] = useState<JsonData>(null);
const [detailOpen, setDetailOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [importAccountId, setImportAccountId] = useState<number | string | undefined>();
const [actionLoading, setActionLoading] = useState<ActionLoading>({});
const [actionLoading, setActionLoading] = useState<Record<string, string>>({});
const providerLabels = getProviderLabels(t);
const credentialHints = getCredentialHints(formData.provider);
const setCardAction = (accountId: string | number, action: string | null) => {
setActionLoading((prev) => {
@@ -71,9 +116,7 @@ export function CloudAccountManager() {
toast.success(t('cloud.syncRegionsSuccess'));
loadData();
} catch (e) {
toast.error(t('cloud.syncRegionsFailed'), {
description: e instanceof Error ? e.message : String(e),
});
toast.error(t('cloud.syncRegionsFailed'), { description: (e as JsonData).message });
} finally {
setCardAction(account.id, null);
}
@@ -97,9 +140,7 @@ export function CloudAccountManager() {
}
loadData();
} catch (e) {
toast.error(t('cloud.syncBillingFailed'), {
description: e instanceof Error ? e.message : String(e),
});
toast.error(t('cloud.syncBillingFailed'), { description: (e as JsonData).message });
} finally {
setCardAction(account.id, null);
}
@@ -107,7 +148,14 @@ export function CloudAccountManager() {
const resetForm = () => {
setEditingAccount(null);
setFormData(getInitialFormData());
setFormData({
name: '',
provider: 'aliyun',
access_key: '',
secret_key: '',
monthly_cost: '',
status: 'active',
});
};
const handleOpenCreate = () => {
@@ -117,7 +165,14 @@ export function CloudAccountManager() {
const handleOpenEdit = (account: JsonData) => {
setEditingAccount(account);
setFormData(buildFormDataFromAccount(account));
setFormData({
name: account.name || '',
provider: account.provider || 'aliyun',
access_key: '',
secret_key: '',
monthly_cost: String(account.monthly_cost ?? 0),
status: account.status || 'active',
});
setFormOpen(true);
};
@@ -172,8 +227,20 @@ export function CloudAccountManager() {
}
};
const filteredAccounts = filterAccounts(accounts, searchQuery, selectedProvider);
const stats = computeAccountStats(accounts);
const filteredAccounts = accounts.filter(account => {
const accessKey = account.access_key || account.accessKey || '';
const matchesSearch = (account.name || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
accessKey.toLowerCase().includes(searchQuery.toLowerCase());
const matchesProvider = selectedProvider === 'all' || account.provider === selectedProvider;
return matchesSearch && matchesProvider;
});
const stats = {
total: accounts.length,
active: accounts.filter(a => a.status === 'active').length,
inactive: accounts.filter(a => a.status === 'inactive').length,
error: accounts.filter(a => a.status === 'error').length,
};
const toolbar = (
<div className="flex items-center gap-4 flex-wrap">
@@ -214,17 +281,13 @@ export function CloudAccountManager() {
{ label: t('common.total'), value: stats.total, color: 'blue' },
{ label: t('cloud.normal'), value: stats.active, color: 'green' },
{ label: t('cloud.disabled'), value: stats.inactive, color: 'muted' },
{
label: t('cloud.error'),
value: stats.error,
color: stats.error > 0 ? 'red' : 'muted',
},
{ label: t('cloud.error'), value: stats.error, color: stats.error > 0 ? 'red' : 'muted' },
]}
/>
) : undefined
}
toolbar={toolbar}
actions={
actions={(
<>
<Button variant="outline" size="sm" onClick={loadData} className="dg-btn-secondary">
<RefreshCw className={`h-4 w-4 mr-1 ${loading ? 'animate-spin' : ''}`} />
@@ -235,7 +298,7 @@ export function CloudAccountManager() {
{t('cloud.addAccount')}
</Button>
</>
}
)}
>
{loading ? (
<PageLoading />
@@ -243,41 +306,258 @@ export function CloudAccountManager() {
<PageEmpty
icon={<Key className="h-12 w-12" />}
title={t('cloud.noAccountData')}
action={
action={(
<Button size="sm" className="dg-btn-primary" onClick={handleOpenCreate}>
<Plus className="h-4 w-4 mr-1" />
{t('cloud.addAccount')}
</Button>
}
)}
/>
) : (
<div className="grid grid-cols-2 gap-4">
{filteredAccounts.map((account) => (
<AccountCard
key={account.id}
account={account}
providerLabels={providerLabels}
actionLoading={actionLoading}
onEdit={handleOpenEdit}
onDelete={handleDelete}
onDetail={openDetail}
onSyncRegions={handleQuickSyncRegions}
onSyncBilling={handleQuickSyncBilling}
onImport={openImport}
/>
))}
{filteredAccounts.map((account) => {
const accessKey = account.access_key || account.accessKey || '';
const serverCount = account.server_count ?? account.serverCount ?? 0;
const lastSync = account.last_sync || account.lastSync || account.created_at || '-';
const monthlyCost = account.monthly_cost ?? account.monthlyCost ?? 0;
return (
<Card key={account.id} className="dg-card dg-card-hover">
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3 min-w-0">
<div className="w-10 h-10 rounded-lg bg-[var(--dg-bg-tertiary)] flex items-center justify-center shrink-0" style={{ borderBottom: `2px solid ${getProviderColor(account.provider)}` }}>
<CloudBrandIcon provider={account.provider} className="h-6 w-6" />
</div>
<div className="min-w-0">
<button
type="button"
className="font-medium text-[var(--dg-text-primary)] hover:text-[var(--dg-blue)] text-left truncate block max-w-full"
onClick={() => openDetail(account)}
>
{account.name}
</button>
<p className="text-sm dg-text-secondary mt-0.5">{providerLabels[account.provider] || account.provider}</p>
<div className="flex items-center gap-3 mt-2">
{accessKey && (
<Badge variant="secondary" className="dg-badge-muted">
<Key className="h-3 w-3 mr-1" />
{accessKey.slice(0, 8)}...
</Badge>
)}
{account.status === 'active' && (
<Badge className="dg-status-running border-0">
<CheckCircle2 className="h-3 w-3 mr-1" />
{t('cloud.normal')}
</Badge>
)}
{account.status === 'inactive' && (
<Badge className="dg-status-stopped border-0">
<XCircle className="h-3 w-3 mr-1" />
{t('cloud.stopped')}
</Badge>
)}
{account.status === 'error' && (
<Badge className="dg-status-error border-0">
<Key className="h-3 w-3 mr-1" />
{t('cloud.error')}
</Badge>
)}
</div>
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 dg-text-muted hover:text-[var(--dg-blue)]"
onClick={() => handleOpenEdit(account)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 dg-text-muted hover:text-[var(--dg-red)]"
onClick={() => handleDelete(account.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<div className="grid grid-cols-3 gap-4 mt-4 pt-4 border-t border-[var(--dg-border)]">
<div>
<p className="text-xs dg-text-secondary">{t('cloud.monthlyFee')}</p>
<p className="text-lg font-medium text-[var(--dg-green)]">¥{Number(monthlyCost).toLocaleString()}</p>
</div>
<div>
<p className="text-xs dg-text-secondary">{t('cloud.serverCount')}</p>
<p className="text-lg font-medium text-[var(--dg-text-primary)]">{serverCount}</p>
</div>
<div>
<p className="text-xs dg-text-secondary">{t('cloud.syncTime')}</p>
<p className="text-sm text-[var(--dg-text-secondary)]">
{lastSync !== '-' ? new Date(lastSync).toLocaleString() : '-'}
</p>
</div>
</div>
<div className="flex flex-wrap gap-2 mt-3 pt-3 border-t border-[var(--dg-border)]">
<Button
variant="outline"
size="sm"
className="h-7 text-xs dg-btn-secondary"
disabled={actionLoading[String(account.id)] === 'regions'}
onClick={() => handleQuickSyncRegions(account)}
>
{actionLoading[String(account.id)] === 'regions'
? <Loader2 className="h-3 w-3 mr-1 animate-spin" />
: <MapPin className="h-3 w-3 mr-1" />}
{t('cloud.syncRegions')}
</Button>
<Button
variant="outline"
size="sm"
className="h-7 text-xs dg-btn-secondary"
disabled={actionLoading[String(account.id)] === 'billing'}
onClick={() => handleQuickSyncBilling(account)}
>
{actionLoading[String(account.id)] === 'billing'
? <Loader2 className="h-3 w-3 mr-1 animate-spin" />
: <Wallet className="h-3 w-3 mr-1" />}
{t('cloud.syncBilling')}
</Button>
<Button
size="sm"
className="h-7 text-xs dg-btn-primary"
onClick={() => openImport(account)}
>
<CloudDownload className="h-3 w-3 mr-1" />
{t('cloud.importHosts')}
</Button>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
<AccountFormDialog
open={formOpen}
onOpenChange={setFormOpen}
editingAccount={editingAccount}
formData={formData}
onFormDataChange={setFormData}
submitting={submitting}
onSubmit={handleSubmit}
/>
{/* 创建账号 Dialog */}
<Dialog open={formOpen} onOpenChange={setFormOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Cloud className="h-5 w-5 text-[var(--dg-blue)]" />
{editingAccount ? t('cloud.editAccount') : t('cloud.addAccount')}
</DialogTitle>
<DialogDescription>
{editingAccount ? t('cloud.editAccountDesc') : t('cloud.addAccountDesc')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label>
{t('cloud.accountName')} <span className="text-[var(--dg-red)]">*</span>
</Label>
<Input
placeholder={t('cloud.accountNamePlaceholder')}
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="dg-input"
/>
</div>
<div className="space-y-1.5">
<Label>
{t('cloud.providerLabel')} <span className="text-[var(--dg-red)]">*</span>
</Label>
<select
value={formData.provider}
onChange={(e) => setFormData({ ...formData, provider: e.target.value })}
className="w-full px-3 py-2 rounded-md text-sm dg-input"
>
<option value="aliyun">{t('cloud.aliyun')}</option>
<option value="tencent">{t('cloud.tencent')}</option>
<option value="aws">AWS</option>
<option value="huawei">{t('cloud.huawei')}</option>
<option value="azure">Azure</option>
</select>
</div>
</div>
<div className="space-y-1.5">
<Label>
{formData.provider === 'azure' ? 'Azure 标识' : 'AccessKey'}
{editingAccount && <span className="text-xs dg-text-muted ml-2">{t('cloud.keepBlank')}</span>}
</Label>
<Input
placeholder={credentialHints.accessKey}
value={formData.access_key}
onChange={(e) => setFormData({ ...formData, access_key: e.target.value })}
className="dg-input font-mono text-sm"
/>
</div>
<div className="space-y-1.5">
<Label>
{formData.provider === 'azure' ? 'Azure 客户端密钥' : 'SecretKey'}
{editingAccount && <span className="text-xs dg-text-muted ml-2">{t('cloud.keepBlank')}</span>}
</Label>
<Input
type="password"
placeholder={credentialHints.secretKey}
value={formData.secret_key}
onChange={(e) => setFormData({ ...formData, secret_key: e.target.value })}
className="dg-input font-mono text-sm"
/>
</div>
{formData.provider === 'azure' && (
<p className="text-xs dg-text-muted">Azure 同步使用服务主体:标识填写“租户ID:订阅ID:客户端ID”,密钥填写客户端密钥。</p>
)}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label>{t('cloud.monthlyFeeUnit')}</Label>
<Input
type="number"
min="0"
placeholder="0"
value={formData.monthly_cost}
onChange={(e) => setFormData({ ...formData, monthly_cost: e.target.value })}
className="dg-input"
/>
</div>
<div className="space-y-1.5">
<Label>{t('common.status')}</Label>
<select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value })}
className="w-full px-3 py-2 rounded-md text-sm dg-input"
>
<option value="active">{t('cloud.normal')}</option>
<option value="inactive">{t('cloud.stopped')}</option>
<option value="error">{t('cloud.error')}</option>
</select>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setFormOpen(false)} className="dg-btn-secondary">
{t('common.cancel')}
</Button>
<Button
className="dg-btn-primary"
disabled={!formData.name.trim() || submitting}
onClick={handleSubmit}
>
{submitting ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : editingAccount ? <Pencil className="h-4 w-4 mr-2" /> : <Plus className="h-4 w-4 mr-2" />}
{editingAccount ? t('cloud.saveChanges') : t('cloud.createAccount')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<CloudAccountDetailSheet
account={detailAccount}
+290 -35
View File
@@ -1,25 +1,58 @@
import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Plus, RefreshCw, Wallet, CloudDownload, Loader2 } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Plus,
Trash2,
Pencil,
RefreshCw,
Wallet,
Bell,
CloudDownload,
Loader2,
Info,
Settings,
} from 'lucide-react';
import { toast } from 'sonner';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { billingApi } from '@/api';
import { formatCurrency } from './CloudBillingHelpers';
import {
formatCurrency,
TrendChart,
providerLabels,
providerHex,
ProviderDot,
} from './CloudBillingHelpers';
import { StatStrip } from '@/components/ui/stat-strip';
import { BillingRecordDialog } from './BillingRecordDialog';
import { BillingRuleDialog } from './BillingRuleDialog';
import { BillingAnalysisPanel } from './BillingAnalysisPanel';
import { BillingSettingsDialog } from './BillingSettingsDialog';
import { PageShell } from '@/components/layout/PageShell';
import { OverviewTab } from './cloud-billing/OverviewTab';
import { RecordsTab, RecordsFilterToolbar } from './cloud-billing/RecordsTab';
import { AlertRulesTab } from './cloud-billing/AlertRulesTab';
import { ContentPanel } from '@/components/layout/ContentPanel';
import { PageLoading } from '@/components/layout/PageLoading';
import { PageEmpty } from '@/components/layout/PageEmpty';
import type { JsonData } from '@/types/common';
import { useUserStore } from '@/store';
export function CloudBilling() {
const { t } = useTranslation();
const confirm = useConfirm();
const isAdmin = useUserStore((state) => state.user?.is_admin === true);
const [activeTab, setActiveTab] = useState<'overview' | 'records' | 'alerts'>('overview');
const [activeTab, setActiveTab] = useState<'overview' | 'analysis' | 'records' | 'alerts'>('overview');
const [stats, setStats] = useState<JsonData>(null);
const [statsLoading, setStatsLoading] = useState(true);
@@ -38,6 +71,7 @@ export function CloudBilling() {
const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
const [editingRule, setEditingRule] = useState<JsonData>(null);
const [syncing, setSyncing] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const loadStats = useCallback(async () => {
setStatsLoading(true);
@@ -97,7 +131,7 @@ export function CloudBilling() {
if (activeTab === 'alerts') loadRules();
};
// 从活跃云账号同步各厂商昨日账单。 // Logan.Li
// 从云账号 AK 同步昨日账单(当前支持阿里云和腾讯云) // Logan.Li
const handleSyncYesterday = async () => {
setSyncing(true);
try {
@@ -185,10 +219,39 @@ export function CloudBilling() {
const pageTabs = [
{ id: 'overview', label: t('cloud.overviewStats') },
{ id: 'analysis', label: t('cloud.costAnalysis') },
{ id: 'records', label: t('cloud.costRecords') },
{ id: 'alerts', label: t('cloud.billingAlert'), badge: rules.length > 0 ? rules.length : undefined },
];
const recordsToolbar = (
<div className="flex flex-wrap items-end gap-3">
<div>
<Label className="text-xs dg-text-secondary mb-1">{t('cloud.vendor')}</Label>
<Select value={filterProvider || 'all'} onValueChange={(v) => setFilterProvider(v === 'all' ? '' : v)}>
<SelectTrigger className="w-32 h-9 dg-input"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('common.all')}</SelectItem>
{Object.entries(providerLabels).map(([k, v]) => (
<SelectItem key={k} value={k}>{v}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs dg-text-secondary mb-1">{t('cloud.startDate')}</Label>
<Input type="date" className="w-40 h-9 dg-input" value={filterStart} onChange={(e) => setFilterStart(e.target.value)} />
</div>
<div>
<Label className="text-xs dg-text-secondary mb-1">{t('cloud.endDate')}</Label>
<Input type="date" className="w-40 h-9 dg-input" value={filterEnd} onChange={(e) => setFilterEnd(e.target.value)} />
</div>
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={loadRecords}>
<RefreshCw className="h-4 w-4 mr-1" />{t('cloud.query')}
</Button>
</div>
);
return (
<PageShell
variant="list"
@@ -198,17 +261,7 @@ export function CloudBilling() {
tabs={pageTabs}
activeTab={activeTab}
onTabChange={(id) => setActiveTab(id as typeof activeTab)}
toolbar={activeTab === 'records' ? (
<RecordsFilterToolbar
filterProvider={filterProvider}
filterStart={filterStart}
filterEnd={filterEnd}
onProviderChange={setFilterProvider}
onStartChange={setFilterStart}
onEndChange={setFilterEnd}
onQuery={loadRecords}
/>
) : undefined}
toolbar={activeTab === 'records' ? recordsToolbar : undefined}
metrics={
stats && !statsLoading ? (
<StatStrip
@@ -223,6 +276,11 @@ export function CloudBilling() {
}
actions={(
<>
{isAdmin && (
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={() => setSettingsOpen(true)}>
<Settings className="h-4 w-4 mr-1" />{t('cloud.exchangeRateSettings')}
</Button>
)}
<Button
variant="outline"
size="sm"
@@ -252,30 +310,226 @@ export function CloudBilling() {
>
{/* Tab 1:概览统计 */}
{activeTab === 'overview' && (
<OverviewTab stats={stats} statsLoading={statsLoading} />
statsLoading ? (
<PageLoading />
) : stats ? (
<div className="space-y-4">
<div className="rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]/40 p-3">
<div className="flex items-start gap-2 text-sm text-[var(--dg-text-secondary)]">
<Info className="h-4 w-4 dg-text-blue shrink-0 mt-0.5" />
<div className="space-y-1">
<p>{t('cloud.syncBillingInfo')}</p>
<p className="text-xs dg-text-muted">{t('cloud.syncBillingTiming')}</p>
</div>
</div>
</div>
{stats.alerts?.length > 0 && (
<div className="dg-alert-banner">
<div className="flex items-center gap-2 mb-3">
<Bell className="h-4 w-4 dg-text-yellow" />
<span className="dg-alert-banner-title">{t('cloud.costAlert')}</span>
</div>
<div className="space-y-2">
{stats.alerts.map((a: JsonData, i: number) => (
<div key={i} className="flex items-center justify-between text-sm flex-wrap gap-2">
<div className="flex items-center gap-2">
<Badge variant="outline" className="dg-badge-yellow">{a.rule_name}</Badge>
<span className="dg-text-secondary">
{a.provider} · {a.metric === 'daily' ? t('cloud.dailyCost') : t('cloud.monthlyAccumulated')}
</span>
</div>
<span className="text-[var(--text-secondary)]">
{t('cloud.current')} <span className="dg-text-yellow font-medium">{formatCurrency(a.current)}</span>
{' '}{a.operator}{' '}
{t('cloud.alertThreshold')} <span className="dg-text-red font-medium">{formatCurrency(a.threshold)}</span>
</span>
</div>
))}
</div>
</div>
)}
<Card className="dg-card">
<CardContent className="pt-4">
<h3 className="dg-section-title">{t('cloud.trend30d')}</h3>
<TrendChart data={stats.month_trend || []} />
</CardContent>
</Card>
<Card className="dg-card">
<CardContent className="pt-4">
<h3 className="dg-section-title">{t('cloud.monthVendorDistribution')}</h3>
{(stats.provider_dist || []).length === 0 ? (
<PageEmpty title={t('cloud.noDistributionData')} className="py-8" />
) : (
<div className="space-y-3">
<div className="flex h-8 rounded-lg overflow-hidden">
{stats.provider_dist.map((p: JsonData, i: number) => (
<div
key={i}
className="flex items-center justify-center text-xs text-white font-medium"
style={{
width: `${p.percent}%`,
backgroundColor: providerHex[p.provider] || 'var(--surface-active)',
}}
title={`${providerLabels[p.provider] || p.provider}: ${formatCurrency(p.cost)} (${p.percent.toFixed(1)}%)`}
>
{p.percent > 8 ? (providerLabels[p.provider] || p.provider) : ''}
</div>
))}
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{stats.provider_dist.map((p: JsonData, i: number) => (
<div key={i} className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<ProviderDot provider={p.provider} className="w-3 h-3" />
<span className="text-[var(--text-secondary)]">{providerLabels[p.provider] || p.provider}</span>
</div>
<div className="text-right">
<span className="text-[var(--text-primary)] font-medium">{formatCurrency(p.cost)}</span>
<span className="dg-text-muted ml-2">{p.percent.toFixed(1)}%</span>
</div>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
</div>
) : (
<PageEmpty icon={<Wallet className="h-12 w-12" />} title={t('cloud.noStatsData')} />
)
)}
{/* Tab 2:成本记录 */}
{activeTab === 'analysis' && <BillingAnalysisPanel />}
{/* Tab 3:成本记录 */}
{activeTab === 'records' && (
<RecordsTab
records={records}
loading={recordsLoading}
onEdit={openEditRecord}
onDelete={handleDeleteRecord}
onAdd={openAddRecord}
/>
<ContentPanel noPadding>
{recordsLoading ? (
<PageLoading />
) : records.length === 0 ? (
<PageEmpty
icon={<Wallet className="h-12 w-12" />}
title={t('cloud.noCostRecords')}
action={(
<Button size="sm" className="dg-btn-primary" onClick={openAddRecord}>
<Plus className="h-4 w-4 mr-1" />{t('cloud.recordCost')}
</Button>
)}
/>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-[var(--surface-raised)] dg-text-secondary">
<tr>
<th className="px-4 py-2.5 text-left font-medium">{t('cloud.colDate')}</th>
<th className="px-4 py-2.5 text-left font-medium">{t('cloud.vendor')}</th>
<th className="px-4 py-2.5 text-left font-medium">{t('cloud.colService')}</th>
<th className="px-4 py-2.5 text-left font-medium">{t('cloud.colRegion')}</th>
<th className="px-4 py-2.5 text-left font-medium">{t('cloud.colAccount')}</th>
<th className="px-4 py-2.5 text-right font-medium">{t('cloud.colCost')}</th>
<th className="px-4 py-2.5 text-left font-medium">{t('cloud.colDescription')}</th>
<th className="px-4 py-2.5 text-center font-medium">{t('common.actions')}</th>
</tr>
</thead>
<tbody>
{records.map((r) => (
<tr key={r.id} className="dg-table-row border-t border-[var(--border-default)]">
<td className="px-4 py-2.5 text-[var(--text-secondary)]">{r.billing_date}</td>
<td className="px-4 py-2.5">
<span className="inline-flex items-center gap-1.5">
<ProviderDot provider={r.provider} />
{providerLabels[r.provider] || r.provider}
</span>
</td>
<td className="px-4 py-2.5 dg-text-secondary">{r.service || '-'}</td>
<td className="px-4 py-2.5 dg-text-secondary">{r.region || '-'}</td>
<td className="px-4 py-2.5 dg-text-secondary">{r.account_name || '-'}</td>
<td className="px-4 py-2.5 text-right text-[var(--text-primary)] font-medium">{formatCurrency(r.cost)}</td>
<td className="px-4 py-2.5 dg-text-muted max-w-xs truncate">{r.description || '-'}</td>
<td className="px-4 py-2.5">
<div className="flex items-center justify-center gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => openEditRecord(r)}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 dg-text-red hover:dg-text-red" onClick={() => handleDeleteRecord(r.id)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</ContentPanel>
)}
{/* Tab 3:预警规则 */}
{/* Tab 4:预警规则 */}
{activeTab === 'alerts' && (
<AlertRulesTab
rules={rules}
loading={rulesLoading}
onEdit={openEditRule}
onDelete={handleDeleteRule}
onToggle={handleToggleRule}
onAdd={openAddRule}
/>
rulesLoading ? (
<PageLoading />
) : rules.length === 0 ? (
<PageEmpty
icon={<Bell className="h-12 w-12" />}
title={t('cloud.noAlertRules')}
description={t('cloud.alertRuleHint')}
action={(
<Button size="sm" className="dg-btn-primary" onClick={openAddRule}>
<Plus className="h-4 w-4 mr-1" />{t('cloud.addAlertRule')}
</Button>
)}
/>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{rules.map((r) => (
<Card key={r.id} className="dg-card">
<CardContent className="pt-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Bell className={`h-4 w-4 ${r.enabled ? 'dg-text-yellow' : 'dg-text-muted'}`} />
<span className="text-sm font-medium text-[var(--text-primary)]">{r.name}</span>
</div>
<Switch checked={r.enabled} onCheckedChange={() => handleToggleRule(r.id)} />
</div>
<div className="grid grid-cols-2 gap-2 text-sm">
<div>
<span className="dg-text-muted">{t('cloud.vendor')}</span>
<span className="ml-2 text-[var(--text-secondary)]">{r.provider ? (providerLabels[r.provider] || r.provider) : t('common.all')}</span>
</div>
<div>
<span className="dg-text-muted">{t('cloud.colMetric')}</span>
<span className="ml-2 text-[var(--text-secondary)]">{r.metric === 'daily' ? t('cloud.dailyCost') : t('cloud.monthlyAccumulated')}</span>
</div>
<div>
<span className="dg-text-muted">{t('cloud.alertThreshold')}</span>
<span className="ml-2 dg-text-yellow font-medium">{r.operator} {formatCurrency(r.threshold)}</span>
</div>
{r.last_triggered && (
<div>
<span className="dg-text-muted">{t('cloud.lastTriggered')}</span>
<span className="ml-2 dg-text-secondary">{new Date(r.last_triggered).toLocaleString('zh-CN')}</span>
</div>
)}
</div>
<div className="flex justify-end gap-1 mt-3">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => openEditRule(r)}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 dg-text-red hover:dg-text-red" onClick={() => handleDeleteRule(r.id)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)
)}
<BillingRecordDialog
@@ -290,6 +544,7 @@ export function CloudBilling() {
editingRule={editingRule}
onSuccess={() => { loadRules(); loadStats(); }}
/>
<BillingSettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} onSaved={refreshAll} />
</PageShell>
);
}
@@ -39,22 +39,7 @@ export function ProviderDot({ provider, className = 'w-2 h-2' }: { provider: str
}
export function formatCurrency(n: number) {
return formatCurrencyByCode(n, 'CNY');
}
// 按账单原始币种格式化,避免 USD 等费用被错误显示成人民币。 // Logan.Li
export function formatCurrencyByCode(n: number, currency = 'CNY') {
try {
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: currency || 'CNY',
currencyDisplay: 'narrowSymbol',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(Number(n) || 0);
} catch {
return `${currency || 'CNY'} ${(Number(n) || 0).toFixed(2)}`;
}
return `¥${n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
export function StatCard({ icon, label, value, trend, trendLabel, alert }: {
@@ -121,4 +106,4 @@ export function TrendChart({ data }: { data: { date: string; cost: number }[] })
</div>
</div>
);
}
}
+65 -39
View File
@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -24,6 +25,7 @@ import {
Clock,
RefreshCw,
Loader2,
ShieldCheck,
} from 'lucide-react';
import { cloudApi } from '@/api';
import { PageShell } from '@/components/layout/PageShell';
@@ -38,11 +40,13 @@ export function DomainManager() {
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [selectedStatus, setSelectedStatus] = useState<string>('all');
const [referenceTime] = useState(Date.now);
// 创建/编辑域名表单状态
const [formOpen, setFormOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [editingDomain, setEditingDomain] = useState<JsonData>(null);
const [checkingDomainId, setCheckingDomainId] = useState<number | null>(null);
const [formData, setFormData] = useState({
name: '',
registrar: '',
@@ -104,15 +108,17 @@ export function DomainManager() {
ssl_enabled: formData.ssl_enabled,
ssl_expire_date: formData.ssl_expire_date,
};
if (editingDomain) {
await cloudApi.updateDomain(editingDomain.id, payload);
} else {
await cloudApi.createDomain(payload);
const saved: JsonData = editingDomain
? await cloudApi.updateDomain(editingDomain.id, payload)
: await cloudApi.createDomain(payload);
if (formData.ssl_enabled && saved?.id) {
await cloudApi.checkDomainSSL(saved.id).catch(() => undefined);
}
setFormOpen(false);
loadData();
} catch (_e) {
// silently ignored
await loadData();
toast.success(t('common.savedSuccessfully'));
} catch (error) {
toast.error(t('common.saveFailed'), { description: error instanceof Error ? error.message : '' });
} finally {
setSubmitting(false);
}
@@ -143,6 +149,22 @@ export function DomainManager() {
}
};
// handleCheckSSL 调用与站点拨测相同的 TLS 检测能力。 // Logan.Li
const handleCheckSSL = async (domain: JsonData) => {
setCheckingDomainId(Number(domain.id));
try {
const result = await cloudApi.checkDomainSSL(domain.id);
toast.success(t('cloud.sslCheckSuccess'), {
description: t('cloud.sslCheckResult', { date: String(result.ssl_expire_date).substring(0, 10), days: result.ssl_days_remaining }),
});
await loadData();
} catch (error) {
toast.error(t('cloud.sslCheckFailed'), { description: error instanceof Error ? error.message : '' });
} finally {
setCheckingDomainId(null);
}
};
const filteredDomains = domains.filter(domain => {
const matchesSearch = (domain.name || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
(domain.registrar || '').toLowerCase().includes(searchQuery.toLowerCase());
@@ -150,18 +172,17 @@ export function DomainManager() {
return matchesSearch && matchesStatus;
});
// getDaysUntilExpiry 统一计算续费和证书剩余天数。 // Logan.Li
function getDaysUntilExpiry(expireDate: string) {
if (!expireDate) return Infinity;
return Math.ceil((new Date(expireDate).getTime() - referenceTime) / (1000 * 60 * 60 * 24));
}
const stats = {
total: domains.length,
active: domains.filter(d => d.status === 'active').length,
expired: domains.filter(d => d.status === 'expired').length,
pending: domains.filter(d => d.status === 'pending').length,
sslEnabled: domains.filter(d => d.ssl_enabled || d.sslEnabled).length,
};
const getDaysUntilExpiry = (expireDate: string) => {
if (!expireDate) return Infinity;
const days = Math.ceil((new Date(expireDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24));
return days;
renewSoon: domains.filter((d) => getDaysUntilExpiry(String(d.expire_date || '')) <= 30).length,
sslSoon: domains.filter((d) => getDaysUntilExpiry(String(d.ssl_expire_date || '')) <= 30).length,
sslEnabled: domains.filter((d) => d.ssl_enabled || d.sslEnabled).length,
};
const toolbar = (
@@ -199,9 +220,9 @@ export function DomainManager() {
<StatStrip
items={[
{ label: t('common.total'), value: stats.total, color: 'blue' },
{ label: t('cloud.normal'), value: stats.active, color: 'green' },
{ label: t('cloud.domainExpired'), value: stats.expired, color: stats.expired > 0 ? 'red' : 'muted' },
{ label: 'SSL', value: stats.sslEnabled, color: 'cyan' },
{ label: t('cloud.renewSoon'), value: stats.renewSoon, color: stats.renewSoon > 0 ? 'amber' : 'muted' },
{ label: t('cloud.sslExpiringSoon'), value: stats.sslSoon, color: stats.sslSoon > 0 ? 'red' : 'muted' },
{ label: t('cloud.sslEnabledCount'), value: stats.sslEnabled, color: 'cyan' },
]}
/>
) : undefined
@@ -240,10 +261,11 @@ export function DomainManager() {
const expireDate = domain.expire_date || domain.expireDate || '';
const sslExpireDate = domain.ssl_expire_date || domain.sslExpireDate || '';
const daysUntilExpiry = getDaysUntilExpiry(expireDate);
const sslDaysUntilExpiry = getDaysUntilExpiry(sslExpireDate);
return (
<Card key={domain.id} className="dg-card dg-card-hover">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-center gap-4">
<div className="dg-icon-box">
<Globe className="h-5 w-5" />
@@ -283,17 +305,30 @@ export function DomainManager() {
</div>
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<p className="text-sm dg-text-secondary">{t('cloud.domainExpireDate')}</p>
<div className="flex flex-wrap items-center gap-4 lg:justify-end">
<div className="min-w-36 lg:text-right">
<p className="text-xs dg-text-secondary">{t('cloud.domainRenewalExpiry')}</p>
<p className={`text-sm font-medium ${daysUntilExpiry < 30 ? 'text-[var(--dg-red)]' : 'text-[var(--dg-text-primary)]'}`}>
{expireDate ? expireDate.substring(0, 10) : '-'}{expireDate && ` (${t('cloud.daysLeft', { count: daysUntilExpiry })})`}
{sslEnabled && sslExpireDate && (
<span className="dg-text-muted"> · SSL: {sslExpireDate.substring(0, 10)}</span>
)}
</p>
</div>
<div className="min-w-36 lg:text-right">
<p className="text-xs dg-text-secondary">{t('cloud.domainSslExpire')}</p>
<p className={`text-sm font-medium ${sslDaysUntilExpiry < 30 ? 'text-[var(--dg-red)]' : 'text-[var(--dg-text-primary)]'}`}>
{sslExpireDate ? `${sslExpireDate.substring(0, 10)} (${t('cloud.daysLeft', { count: sslDaysUntilExpiry })})` : t('cloud.sslNotChecked')}
</p>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 dg-text-muted hover:text-[var(--dg-cyan)]"
onClick={() => handleCheckSSL(domain)}
disabled={checkingDomainId === Number(domain.id)}
title={t('cloud.checkSsl')}
>
{checkingDomainId === Number(domain.id) ? <Loader2 className="h-4 w-4 animate-spin" /> : <ShieldCheck className="h-4 w-4" />}
</Button>
<Button
variant="ghost"
size="icon"
@@ -400,18 +435,8 @@ export function DomainManager() {
/>
</div>
<div className="grid grid-cols-2 gap-4 items-end">
<div className="space-y-2">
<Label>{t('cloud.domainSslExpire')}</Label>
<Input
type="date"
value={formData.ssl_expire_date}
onChange={(e) => setFormData({ ...formData, ssl_expire_date: e.target.value })}
disabled={!formData.ssl_enabled}
className="dg-input disabled:opacity-40"
/>
</div>
<label className="flex items-center gap-2 cursor-pointer pb-2">
<div className="rounded-md bg-[var(--surface-inset)] px-3 py-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={formData.ssl_enabled}
@@ -420,6 +445,7 @@ export function DomainManager() {
/>
<span className="text-sm text-[var(--dg-text-secondary)]">{t('cloud.enableSsl')}</span>
</label>
<p className="mt-1 text-xs text-[var(--text-tertiary)]">{t('cloud.sslAutoCheckHint')}</p>
</div>
</div>
@@ -39,7 +39,7 @@ import type { JsonData } from '@/types/common';
import i18n from '@/i18n';
const typeLabels: Record<string, string> = {
public: i18n.t('cloud.platform.publicCloud'),
public: i18n.t('server.cloudserverimpo.s7ef31abb'),
private: i18n.t('cloud.privateCloud'),
hybrid: i18n.t('cloud.hybridCloud'),
};
@@ -138,7 +138,7 @@ export function PlatformManager() {
<div className="relative w-72">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 dg-text-muted" />
<Input
placeholder={i18n.t('cloud.platform.searchPlaceholder')}
placeholder={i18n.t('cloud.platformmanager.s783a513f')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 h-9 dg-input"
@@ -150,7 +150,7 @@ export function PlatformManager() {
</SelectTrigger>
<SelectContent>
<SelectItem value="all"> {i18n.t('k8s.topology.allTypes')} </SelectItem>
<SelectItem value="public"> {i18n.t('cloud.platform.publicCloud')} </SelectItem>
<SelectItem value="public"> {i18n.t('server.cloudserverimpo.s7ef31abb')} </SelectItem>
<SelectItem value="private"> {i18n.t('cloud.privateCloud')} </SelectItem>
<SelectItem value="hybrid"> {i18n.t('cloud.hybridCloud')} </SelectItem>
</SelectContent>
@@ -162,7 +162,7 @@ export function PlatformManager() {
<PageShell
variant="list"
title={i18n.t('cloud.title')}
subtitle={i18n.t('cloud.platform.subtitle')}
subtitle={i18n.t('cloud.platformmanager.s7012045f')}
icon={<CloudCog className="h-5 w-5" />}
metrics={
!loading ? (
@@ -170,7 +170,7 @@ export function PlatformManager() {
items={[
{ label: i18n.t('system.auditTotal'), value: stats.total, color: 'blue' },
{ label: i18n.t('system.userActive'), value: stats.active, color: 'green' },
{ label: i18n.t('cloud.platform.publicCloud'), value: stats.public, color: 'cyan' },
{ label: i18n.t('server.cloudserverimpo.s7ef31abb'), value: stats.public, color: 'cyan' },
]}
/>
) : undefined
@@ -180,11 +180,11 @@ export function PlatformManager() {
<>
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={loadData}>
<RefreshCw className={`h-4 w-4 mr-1 ${loading ? 'animate-spin' : ''}`} />
{i18n.t('common.refresh')}
刷新
</Button>
<Button size="sm" className="dg-btn-primary" onClick={openCreate}>
<Plus className="h-4 w-4 mr-1" />
{i18n.t('cloud.platform.addPlatform')}
添加平台
</Button>
</>
)}
@@ -194,11 +194,11 @@ export function PlatformManager() {
) : filtered.length === 0 ? (
<PageEmpty
icon={<CloudCog className="h-12 w-12" />}
title={i18n.t('cloud.platform.noData')}
title={i18n.t('cloud.platformmanager.sb9f478da')}
action={(
<Button size="sm" className="dg-btn-primary" onClick={openCreate}>
<Plus className="h-4 w-4 mr-1" />
{i18n.t('cloud.platform.addPlatform')}
添加平台
</Button>
)}
/>
@@ -244,11 +244,11 @@ export function PlatformManager() {
</Badge>
{p.status === 'active' ? (
<Badge className="dg-status-running gap-1 border-0">
<CheckCircle2 className="h-3 w-3" /> {i18n.t('cloud.platform.enabled')}
<CheckCircle2 className="h-3 w-3" /> 启用
</Badge>
) : (
<Badge className="dg-status-stopped gap-1 border-0">
<XCircle className="h-3 w-3" /> {i18n.t('cloud.platform.disabled')}
<XCircle className="h-3 w-3" /> 禁用
</Badge>
)}
</div>
@@ -261,26 +261,26 @@ export function PlatformManager() {
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingItem ? i18n.t('cloud.platform.editPlatform') : i18n.t('cloud.platform.addPlatform')}</DialogTitle>
<DialogTitle>{editingItem ? i18n.t('cloud.platformmanager.sf83734ec') : i18n.t('cloud.platformmanager.s3f6f0cf2')}</DialogTitle>
<DialogDescription className="sr-only"> {i18n.t('aiops.llmModelDesc')} </DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label> {i18n.t('common.name')} </Label>
<Label> {i18n.t('cloud.platformmanager.seac7fc68')} </Label>
<Input
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder={i18n.t('cloud.platform.namePlaceholder')}
placeholder={i18n.t('cloud.platformmanager.sa39d9635')}
className="dg-input"
/>
</div>
<div className="space-y-1.5">
<Label> {i18n.t('common.code')} </Label>
<Label> {i18n.t('cloud.platformmanager.se21e278f')} </Label>
<Input
value={formData.code}
onChange={(e) => setFormData({ ...formData, code: e.target.value })}
placeholder={i18n.t('cloud.platform.namePlaceholder')}
placeholder={i18n.t('cloud.platformmanager.s7b09b049')}
className="dg-input"
disabled={!!editingItem}
/>
@@ -294,7 +294,7 @@ export function PlatformManager() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="public"> {i18n.t('cloud.platform.publicCloud')} </SelectItem>
<SelectItem value="public"> {i18n.t('server.cloudserverimpo.s7ef31abb')} </SelectItem>
<SelectItem value="private"> {i18n.t('cloud.privateCloud')} </SelectItem>
<SelectItem value="hybrid"> {i18n.t('cloud.hybridCloud')} </SelectItem>
</SelectContent>
@@ -314,7 +314,7 @@ export function PlatformManager() {
</div>
</div>
<div className="space-y-1.5">
<Label> {i18n.t('cloud.platform.namePlaceholder')} </Label>
<Label> {i18n.t('cloud.platformmanager.saf587eb5')} </Label>
<Input
value={formData.website}
onChange={(e) => setFormData({ ...formData, website: e.target.value })}
@@ -327,7 +327,7 @@ export function PlatformManager() {
<Input
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder={i18n.t('cloud.platform.descPlaceholder')}
placeholder={i18n.t('cloud.platformmanager.sc44dd421')}
className="dg-input"
/>
</div>
@@ -349,7 +349,7 @@ export function PlatformManager() {
<DialogTitle> {i18n.t('k8s.actionModal.confirmDelete')} </DialogTitle>
<DialogDescription className="sr-only"> {i18n.t('aiops.llmModelDesc')} </DialogDescription>
</DialogHeader>
<p className="text-sm dg-text-secondary"> {i18n.t('cloud.platformManager.deleteConfirm')} </p>
<p className="text-sm dg-text-secondary"> {i18n.t('cloud.platformmanager.s452abbb1')} </p>
<DialogFooter>
<Button variant="ghost" onClick={() => setDeleteId(null)}> {i18n.t('rbac.jobRole.cancel')} </Button>
<Button variant="destructive" onClick={confirmDelete}> {i18n.t('kubernetes.storage.delete2')} </Button>
@@ -21,7 +21,8 @@ export function SessionWatermark({ className = "" }: SessionWatermarkProps) {
const backgroundImage = useMemo(() => {
const text = escapeSvgText(username);
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="260" height="150" viewBox="0 0 260 150"><g transform="rotate(-24 130 75)"><text x="130" y="78" text-anchor="middle" font-family="Menlo, Monaco, Consolas, monospace" font-size="22" font-weight="700" fill="rgba(255,255,255,0.26)">${text}</text></g></svg>`;
// 常规桌面视口维持约 4 至 6 个标识,降低密度以兼顾截图追责与表格、图表可读性。 // Logan.Li
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="640" height="400" viewBox="0 0 640 400"><g transform="rotate(-24 320 200)"><text x="320" y="206" text-anchor="middle" font-family="Menlo, Monaco, Consolas, monospace" font-size="18" font-weight="600" fill="rgba(255,255,255,0.20)">${text}</text></g></svg>`;
return `url("data:image/svg+xml;utf8,${encodeURIComponent(svg)}")`;
}, [username]);
@@ -34,8 +35,8 @@ export function SessionWatermark({ className = "" }: SessionWatermarkProps) {
style={{
backgroundImage,
backgroundRepeat: "repeat",
backgroundSize: "260px 150px",
opacity: 0.78,
backgroundSize: "640px 400px",
opacity: 0.55,
}}
/>
);
@@ -1,6 +1,5 @@
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
interface TerminalLoadingOverlayProps {
show: boolean;
@@ -8,15 +7,14 @@ interface TerminalLoadingOverlayProps {
className?: string;
}
export function TerminalLoadingOverlay({ show, label, className }: TerminalLoadingOverlayProps) {
const { t } = useTranslation();
export function TerminalLoadingOverlay({ show, label = '连接中...', className }: TerminalLoadingOverlayProps) {
if (!show) return null;
return (
<div className={cn('absolute inset-0 z-10 flex items-center justify-center bg-black/35', className)}>
<div className="flex flex-col items-center gap-2 text-white/85">
<Loader2 className="h-7 w-7 animate-spin" />
{label && <span className="text-xs">{label || t('common.connectingEllipsis')}</span>}
{label && <span className="text-xs">{label}</span>}
</div>
</div>
);
@@ -0,0 +1,38 @@
// 高敏业务路由统一水印策略:凭据、资源拓扑、日志、命令与权限数据需要截图追责。 // Logan.Li
const SENSITIVE_PATH_PREFIXES = [
'/servers',
'/cloud-accounts',
'/domains',
'/cloud-billing',
'/kubernetes',
'/workloads',
'/nodes',
'/topology',
'/pipelines',
'/deploy',
'/metrics',
'/datasources',
'/trace',
'/alerts',
'/alert-center',
'/log-center',
'/cronjobs',
'/scripts',
'/remote',
'/ai-',
'/approval',
'/audit',
'/profile',
'/users',
'/roles',
'/credentials',
'/approval-workflow',
'/settings',
] as const;
// shouldProtectPage 判断当前页面是否展示高敏运维数据,首页与大屏等低干扰页面不加水印。 // Logan.Li
export function shouldProtectPage(pathname: string): boolean {
return SENSITIVE_PATH_PREFIXES.some(
(prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`),
);
}
@@ -1,25 +1,517 @@
/**
* DashboardRecent — barrel re-export
*
* 原始 518L 单文件拆分为 dashboard-recent/ 目录下独立文件:
* - types.ts: DashboardRelease / DashboardCluster / DashboardAutomationStats / DashboardOpsStats / DashboardPipelineStats
* - shared-components.tsx: StatusDot / SeverityBadge
* - AlertMessagesCard.tsx: 告警消息卡片
* - CertificateExpiryCard.tsx: 证书到期卡片
* - ReleaseHistoryCard.tsx: 发布历史卡片
* - OpsOverviewCard.tsx: 运维概览卡片
* - KubernetesClusterCard.tsx: Kubernetes 集群卡片
*/
export { AlertMessagesCard } from './dashboard-recent/AlertMessagesCard';
export { CertificateExpiryCard } from './dashboard-recent/CertificateExpiryCard';
export { ReleaseHistoryCard } from './dashboard-recent/ReleaseHistoryCard';
export { OpsOverviewCard } from './dashboard-recent/OpsOverviewCard';
export { KubernetesClusterCard } from './dashboard-recent/KubernetesClusterCard';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
Rocket,
AlertTriangle,
ArrowRight,
Cloud,
Shield,
Clock3,
Globe,
Database,
Server,
Monitor,
Activity,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import type { AlertEventRecord, CertExpiryRecord } from '@/types';
export type {
DashboardRelease,
DashboardCluster,
DashboardAutomationStats,
DashboardOpsStats,
DashboardPipelineStats,
} from './dashboard-recent/types';
/** 仪表盘卡片数据类型 — 与 Dashboard API 响应对齐 */
export interface DashboardRelease {
id: number | string;
status: string;
app_name: string;
version: string;
environment?: string;
commit_id?: string;
[key: string]: unknown;
}
export interface DashboardCluster {
id: number | string;
code?: string;
name: string;
status: string;
cpu_usage?: number;
memory_usage?: number;
node_count?: number;
pod_count?: number;
ingress_count?: number;
deployment_count?: number;
service_count?: number;
[key: string]: unknown;
}
export interface DashboardAutomationStats {
cron_job_count?: number;
script_count?: number;
today_executions?: number;
success_rate?: number;
}
export interface DashboardOpsStats {
domain_count?: number;
site_task_count?: number;
middleware_count?: number;
snmp_device_count?: number;
monitor_target_count?: number;
}
export interface DashboardPipelineStats {
today_count?: number;
success?: number;
failed?: number;
running?: number;
}
/**
* DashboardRecent - 优化后的仪表盘底部卡片组件
*
* 优化点:
* 1. 使用 dg-card 工具类替代硬编码的 slate 颜色
* 2. 统一的状态指示器样式
* 3. 更好的 hover 效果和过渡动画
* 4. ScrollArea 使用 scrollbar-thin 类
* 5. 空状态显示更友好
*/
// Status dot component
function StatusDot({ severity }: { severity: string }) {
return (
<div
className={cn(
'w-2 h-2 rounded-full flex-shrink-0 mt-1.5',
severity === 'critical' && 'bg-[var(--dg-red)]',
severity === 'warning' && 'bg-[var(--dg-yellow)]',
severity === 'info' && 'bg-[var(--dg-blue)]',
)}
/>
);
}
// Severity badge
function SeverityBadge({ severity }: { severity: string }) {
const { t } = useTranslation();
return (
<Badge
variant="outline"
className={cn(
'text-xs font-medium',
severity === 'critical' && 'dg-badge-red border',
severity === 'warning' && 'dg-badge-yellow border',
severity === 'info' && 'dg-badge-blue border',
)}
>
{severity === 'critical' ? t('common.error') : severity === 'warning' ? t('common.warning') : t('common.info')}
</Badge>
);
}
export function AlertMessagesCard({ alerts }: { alerts: AlertEventRecord[] }) {
const { t } = useTranslation();
const navigate = useNavigate();
const recentAlerts = alerts.filter((a) => a.status === 'firing').slice(0, 8);
return (
<Card className="dg-card dg-card-hover">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-[var(--dg-text-primary)]">{t('dashboard.alerts.title')}</CardTitle>
<CardDescription className="text-[var(--dg-text-muted)]">{t('dashboard.alerts.description')}</CardDescription>
</div>
<Button
variant="ghost"
size="sm"
className="dg-btn-ghost text-xs"
onClick={() => navigate('/alerts')}
>
{t('dashboard.alerts.viewAll')}
<ArrowRight className="h-3.5 w-3.5 ml-1" />
</Button>
</div>
</CardHeader>
<CardContent>
<ScrollArea className="h-[320px] scrollbar-thin">
{recentAlerts.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-[var(--dg-text-muted)]">
<AlertTriangle className="h-10 w-10 mb-3 opacity-30" />
<p className="text-sm">{t('dashboard.alerts.empty')}</p>
<p className="text-xs mt-1 opacity-60">{t('dashboard.alerts.emptyHint')}</p>
</div>
) : (
<div className="space-y-1.5 pr-2">
{recentAlerts.map((alert) => (
<div
key={alert.id}
className="flex items-start gap-3 p-3 rounded-lg bg-[var(--dg-bg-tertiary)]/50 hover:bg-[var(--dg-bg-hover)] transition-all duration-200 cursor-pointer group"
onClick={() => navigate('/alert-center')}
>
<StatusDot severity={alert.severity || 'info'} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-[var(--dg-text-primary)] truncate">
{alert.rule_name}
</span>
<SeverityBadge severity={alert.severity || 'info'} />
</div>
<p className="text-xs text-[var(--dg-text-muted)] mt-1">{alert.target}</p>
<p className="text-xs text-[var(--dg-text-muted)]/70 line-clamp-1">{alert.message}</p>
</div>
<span className="text-[11px] text-[var(--dg-text-muted)]/50 whitespace-nowrap flex-shrink-0">
{alert.created_at ? new Date(alert.created_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) : ''}
</span>
</div>
))}
</div>
)}
</ScrollArea>
</CardContent>
</Card>
);
}
export function CertificateExpiryCard({ expCerts }: { expCerts: CertExpiryRecord[] }) {
const { t } = useTranslation();
const expiringDomains = (expCerts ?? [])
.map((d) => ({
...d,
daysUntilExpiry: d.days_until_expiry ?? Math.ceil((new Date(d.expire_date).getTime() - Date.now()) / (1000 * 60 * 60 * 24)),
}))
.sort((a, b) => a.daysUntilExpiry - b.daysUntilExpiry);
const criticalCount = expiringDomains.filter((d) => d.daysUntilExpiry < 30).length;
const warningCount = expiringDomains.filter((d) => d.daysUntilExpiry >= 30 && d.daysUntilExpiry < 60).length;
return (
<Card className="dg-card dg-card-hover">
<CardHeader className="pb-2">
<div className="flex items-center justify-between flex-wrap gap-2">
<div>
<CardTitle className="text-[var(--dg-text-primary)]">{t('dashboard.certificates.title')}</CardTitle>
<CardDescription className="text-[var(--dg-text-muted)]">{t('dashboard.certificates.description')}</CardDescription>
</div>
<div className="flex items-center gap-2">
{criticalCount > 0 && (
<Badge className="dg-badge-red border">
<AlertTriangle className="h-3 w-3 mr-1" />
{criticalCount} {t('dashboard.certificates.expiringSoon')}
</Badge>
)}
{warningCount > 0 && (
<Badge className="dg-badge-yellow border">
{warningCount} {t('dashboard.certificates.expiringIn30Days')}
</Badge>
)}
</div>
</div>
</CardHeader>
<CardContent>
<ScrollArea className="h-[280px] scrollbar-thin">
{expiringDomains.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 text-[var(--dg-text-muted)]">
<Shield className="h-10 w-10 mb-3 opacity-30" />
<p className="text-sm">{t('dashboard.certificates.empty')}</p>
</div>
) : (
<div className="space-y-1.5 pr-2">
{expiringDomains.map((domain) => (
<div
key={domain.id}
className="flex items-center justify-between p-3 rounded-lg bg-[var(--dg-bg-tertiary)]/50 hover:bg-[var(--dg-bg-hover)] transition-all duration-200"
>
<div className="flex items-center gap-3 min-w-0">
<div
className={cn(
'w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0',
domain.daysUntilExpiry < 30 ? 'bg-[var(--dg-red)]/10' :
domain.daysUntilExpiry < 60 ? 'bg-[var(--dg-yellow)]/10' :
'bg-[var(--dg-green)]/10'
)}
>
<Shield className={cn(
'h-4 w-4',
domain.daysUntilExpiry < 30 ? 'text-[var(--dg-red)]' :
domain.daysUntilExpiry < 60 ? 'text-[var(--dg-yellow)]' :
'text-[var(--dg-green)]'
)} />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-[var(--dg-text-primary)] truncate">{domain.name}</p>
<p className="text-xs text-[var(--dg-text-muted)]">{t('dashboard.certificates.expireDate')}: {domain.expire_date?.substring(0, 10)}</p>
</div>
</div>
<Badge
variant="outline"
className={cn(
'text-xs flex-shrink-0',
domain.daysUntilExpiry < 30 ? 'dg-badge-red border' :
domain.daysUntilExpiry < 60 ? 'dg-badge-yellow border' :
'dg-badge-green border'
)}
>
{domain.daysUntilExpiry < 0 ? t('dashboard.certificates.expired') : `${domain.daysUntilExpiry} ${t('dashboard.certificates.days')}`}
</Badge>
</div>
))}
</div>
)}
</ScrollArea>
</CardContent>
</Card>
);
}
export function ReleaseHistoryCard({ releases }: { releases: DashboardRelease[] }) {
const { t } = useTranslation();
const navigate = useNavigate();
const releaseList = releases?.slice(0, 5) ?? [];
return (
<Card className="dg-card dg-card-hover">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-[var(--dg-text-primary)]">{t('dashboard.releases.title')}</CardTitle>
<CardDescription className="text-[var(--dg-text-muted)]">{t('dashboard.releases.description')}</CardDescription>
</div>
<Button
variant="ghost"
size="sm"
className="dg-btn-ghost text-xs"
onClick={() => navigate('/pipelines')}
>
{t('dashboard.releases.viewAll')}
<ArrowRight className="h-3.5 w-3.5 ml-1" />
</Button>
</div>
</CardHeader>
<CardContent>
{releaseList.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 text-[var(--dg-text-muted)]">
<Rocket className="h-10 w-10 mb-3 opacity-30" />
<p className="text-sm">{t('dashboard.releases.empty')}</p>
</div>
) : (
<div className="space-y-2">
{releaseList.map((release) => (
<div
key={release.id}
className="flex items-center gap-3 p-3 rounded-lg bg-[var(--dg-bg-tertiary)]/50 hover:bg-[var(--dg-bg-hover)] transition-all duration-200"
>
<div
className={cn(
'w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0',
release.status === 'success' ? 'bg-[var(--dg-green)]/10' :
release.status === 'failed' ? 'bg-[var(--dg-red)]/10' :
release.status === 'deploying' ? 'bg-[var(--dg-blue)]/10' :
'bg-[var(--dg-yellow)]/10'
)}
>
<Rocket className={cn(
'h-4 w-4',
release.status === 'success' ? 'text-[var(--dg-green)]' :
release.status === 'failed' ? 'text-[var(--dg-red)]' :
release.status === 'deploying' ? 'text-[var(--dg-blue)]' :
'text-[var(--dg-yellow)]'
)} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-[var(--dg-text-primary)] truncate">{release.app_name}</span>
<Badge variant="outline" className="text-xs border-[var(--dg-border)] text-[var(--dg-text-muted)] flex-shrink-0">
{release.version}
</Badge>
</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-[var(--dg-text-muted)]">{release.environment}</span>
<span className="text-xs text-[var(--dg-border)]">·</span>
<span className="text-xs text-[var(--dg-text-muted)] truncate">{release.commit_id}</span>
</div>
</div>
<Badge
variant="outline"
className={cn(
'text-xs flex-shrink-0',
release.status === 'success' ? 'dg-badge-green border' :
release.status === 'failed' ? 'dg-badge-red border' :
release.status === 'deploying' ? 'dg-badge-blue border' :
'dg-badge-yellow border'
)}
>
{release.status === 'success' ? t('dashboard.releases.success') :
release.status === 'failed' ? t('dashboard.releases.failed') :
release.status === 'deploying' ? t('dashboard.releases.deploying') :
release.status === 'rolled_back' ? t('dashboard.releases.rolledBack') : t('dashboard.releases.building')}
</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
/** 运维概览 — 资产 / 监控 / 任务快捷入口 // Logan.Li */
export function OpsOverviewCard({
automation,
ops,
serverCount,
hostMonitorCount,
firingAlerts,
}: {
automation?: DashboardAutomationStats;
ops?: DashboardOpsStats;
serverCount?: number;
hostMonitorCount?: number;
firingAlerts?: number;
}) {
const { t } = useTranslation();
const navigate = useNavigate();
const tiles = [
{ key: 'servers', label: t('dashboard.ops.servers'), value: serverCount ?? 0, icon: Server, path: '/servers', color: 'text-[var(--dg-blue)]' },
{ key: 'domains', label: t('dashboard.ops.domains'), value: ops?.domain_count ?? 0, icon: Globe, path: '/domains', color: 'text-[var(--dg-cyan)]' },
{ key: 'host', label: t('dashboard.ops.hostMonitor'), value: hostMonitorCount ?? serverCount ?? 0, icon: Monitor, path: '/host-monitor', color: 'text-[var(--dg-green)]' },
{ key: 'mw', label: t('dashboard.ops.middleware'), value: ops?.middleware_count ?? 0, icon: Database, path: '/monitor-center?tab=middleware', color: 'text-[var(--dg-red)]' },
{ key: 'site', label: t('dashboard.ops.siteProbes'), value: ops?.site_task_count ?? 0, icon: Activity, path: '/monitor-center?tab=site', color: 'text-[var(--dg-yellow)]' },
{ key: 'cron', label: t('dashboard.ops.cronJobs'), value: automation?.cron_job_count ?? 0, icon: Clock3, path: '/cronjobs', color: 'text-[var(--dg-purple)]' },
];
return (
<Card className="dg-card dg-card-hover">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-[var(--dg-text-primary)]">{t('dashboard.ops.title')}</CardTitle>
<CardDescription className="text-[var(--dg-text-muted)]">{t('dashboard.ops.description')}</CardDescription>
</div>
<Button variant="ghost" size="sm" className="dg-btn-ghost text-xs" onClick={() => navigate('/ai-agent-cli')}>
{t('menu.aiAgentCli')}
<ArrowRight className="h-3.5 w-3.5 ml-1" />
</Button>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mb-3">
{tiles.map((tile) => (
<button
key={tile.key}
type="button"
onClick={() => navigate(tile.path)}
className="flex items-center gap-2.5 p-2.5 rounded-xl bg-[var(--dg-bg-tertiary)]/50 hover:bg-[var(--dg-bg-hover)] transition-all text-left"
>
<tile.icon className={cn('h-4 w-4 shrink-0', tile.color)} />
<div className="min-w-0">
<div className="text-base font-bold text-[var(--dg-text-primary)] leading-none">{tile.value}</div>
<div className="text-[10px] text-[var(--dg-text-muted)] mt-0.5 truncate">{tile.label}</div>
</div>
</button>
))}
</div>
<div className="flex items-center justify-between text-xs px-1 pt-1 border-t border-[var(--dg-border)]">
<span className="text-[var(--dg-text-muted)]">
{t('dashboard.ops.todayExecutions')}: <span className="text-[var(--dg-text-primary)] font-medium">{automation?.today_executions ?? 0}</span>
</span>
<button type="button" className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]" onClick={() => navigate('/alert-center')}>
{t('dashboard.ops.firingAlerts')}: <span className={cn('font-medium', (firingAlerts ?? 0) > 0 ? 'text-[var(--dg-red)]' : 'text-[var(--dg-text-primary)]')}>{firingAlerts ?? 0}</span>
</button>
</div>
</CardContent>
</Card>
);
}
export function KubernetesClusterCard({ clusters }: { clusters: DashboardCluster[] }) {
const { t } = useTranslation();
const navigate = useNavigate();
return (
<Card className="dg-card dg-card-hover">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-[var(--dg-text-primary)]">{t('dashboard.clusters.title')}</CardTitle>
<CardDescription className="text-[var(--dg-text-muted)]">{t('dashboard.clusters.description')}</CardDescription>
</div>
<Button
variant="ghost"
size="sm"
className="dg-btn-ghost text-xs"
onClick={() => navigate('/kubernetes')}
>
{t('dashboard.clusters.viewAll')}
<ArrowRight className="h-3.5 w-3.5 ml-1" />
</Button>
</div>
</CardHeader>
<CardContent>
{clusters.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 text-[var(--dg-text-muted)]">
<Cloud className="h-10 w-10 mb-3 opacity-30" />
<p className="text-sm">{t('dashboard.clusters.empty')}</p>
</div>
) : (
<div className="space-y-3">
{clusters.map((cluster) => {
const cpuUsage = Number(cluster.cpu_usage ?? 0);
const memoryUsage = Number(cluster.memory_usage ?? 0);
const hasUsage = cpuUsage > 0 || memoryUsage > 0;
const metrics = hasUsage
? [
{ label: t('dashboard.clusters.nodes'), value: cluster.node_count ?? 0 },
{ label: t('dashboard.clusters.pods'), value: cluster.pod_count ?? 0 },
{ label: t('dashboard.clusters.cpu'), value: `${cpuUsage.toFixed(1)}%` },
{ label: t('dashboard.clusters.memory'), value: `${memoryUsage.toFixed(1)}%` },
]
: [
{ label: t('dashboard.clusters.nodes'), value: cluster.node_count ?? 0 },
{ label: t('dashboard.clusters.pods'), value: cluster.pod_count ?? 0 },
{ label: t('dashboard.clusters.ingress'), value: cluster.ingress_count ?? 0 },
{ label: t('dashboard.clusters.deployments'), value: cluster.deployment_count ?? 0 },
];
return (
<div
key={cluster.id}
className="p-3.5 rounded-xl bg-[var(--dg-bg-tertiary)]/50 hover:bg-[var(--dg-bg-hover)] transition-all duration-200 cursor-pointer"
onClick={() => navigate(`/kubernetes/${cluster.code || cluster.id}`)}
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2.5">
<Cloud className="h-4.5 w-4.5 text-[var(--dg-blue)]" />
<span className="text-sm font-semibold text-[var(--dg-text-primary)]">{cluster.name}</span>
</div>
<Badge
variant="outline"
className={cn(
'text-xs',
cluster.status === 'healthy' ? 'dg-badge-green border' :
cluster.status === 'warning' ? 'dg-badge-yellow border' :
'dg-badge-red border'
)}
>
{cluster.status === 'healthy' ? t('dashboard.clusters.healthy') : cluster.status === 'warning' ? t('dashboard.clusters.warning') : t('dashboard.clusters.error')}
</Badge>
</div>
<div className="grid grid-cols-4 gap-2 text-center">
{metrics.map((item) => (
<div key={item.label} className="p-1.5 rounded-lg bg-[var(--dg-bg-primary)]/50">
<div className="text-sm font-semibold text-[var(--dg-text-primary)]">{item.value}</div>
<div className="text-[10px] text-[var(--dg-text-muted)] mt-0.5">{item.label}</div>
</div>
))}
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
);
}
@@ -100,7 +100,7 @@ export function AppReleaseManager() {
</div>
<Select value={envFilter} onValueChange={setEnvFilter}>
<SelectTrigger className="w-32 h-9 dg-input">
<SelectValue placeholder={t('deploy.placeholderEnv')} />
<SelectValue placeholder="环境" />
</SelectTrigger>
<SelectContent className="dg-input">
<SelectItem value="all">{t('deploy.filterAllEnv')}</SelectItem>
@@ -112,7 +112,7 @@ export function AppReleaseManager() {
</Select>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-32 h-9 dg-input">
<SelectValue placeholder={t('deploy.placeholderStatus')} />
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent className="dg-input">
<SelectItem value="all">{t('deploy.filterAllStatus')}</SelectItem>
@@ -59,7 +59,7 @@ export default function BuildNodesManager({ embedded = false }: { embedded?: boo
const srvList = (srvRes as { list?: ServerItem[] })?.list || [];
setServers(srvList);
} catch {
toast.error(i18n.t('deploy.buildNodes.loadFailed'));
toast.error(i18n.t('pipeline.buildnodesmanag.sb9faa2a3'));
} finally {
setLoading(false);
}
@@ -91,7 +91,7 @@ export default function BuildNodesManager({ embedded = false }: { embedded?: boo
const handleSubmit = async () => {
if (!name.trim() || !serverId) {
toast.error(i18n.t('deploy.buildNodes.loadFailed'));
toast.error(i18n.t('pipeline.buildnodesmanag.sda51a736'));
return;
}
setSubmitting(true);
@@ -121,14 +121,14 @@ export default function BuildNodesManager({ embedded = false }: { embedded?: boo
};
const handleDelete = async (node: BuildNode) => {
const ok = await confirm({ title: i18n.t('kubernetes.topology.deleteNode'), description: i18n.t('deploy.buildNodes.deleteConfirm', { name: node.name }) });
const ok = await confirm({ title: i18n.t('kubernetes.topology.deleteNode'), description: `确定删除构建节点「${node.name}」?` });
if (!ok) return;
try {
await buildNodeApi.delete(node.id);
toast.success(i18n.t('monitor.silenceRules.deleted'));
load();
} catch {
toast.error(i18n.t('deploy.buildNodes.loadFailed'));
toast.error(i18n.t('pipeline.buildnodesmanag.s577b185d'));
}
};
@@ -137,7 +137,7 @@ export default function BuildNodesManager({ embedded = false }: { embedded?: boo
try {
const res = await buildNodeApi.test(node.id) as { success?: boolean; message?: string };
if (res?.success) {
toast.success(i18n.t('deploy.buildNodes.testSuccess') + (res.message ? `: ${res.message}` : ''));
toast.success(`连通成功: ${res.message || ''}`);
} else {
toast.error(res?.message || i18n.t('aiops.llmTestFailed'));
}
@@ -160,7 +160,7 @@ export default function BuildNodesManager({ embedded = false }: { embedded?: boo
const handleSaveMaster = async () => {
const n = Number(masterMax);
if (!n || n < 1) {
toast.error(i18n.t('deploy.buildNodes.loadFailed'));
toast.error(i18n.t('pipeline.buildnodesmanag.s5f25f49a'));
return;
}
setSavingMaster(true);
@@ -172,7 +172,7 @@ export default function BuildNodesManager({ embedded = false }: { embedded?: boo
max_executors: m?.max_executors ?? n,
running_count: m?.running_count ?? prev.running_count,
}));
toast.success(i18n.t('deploy.buildNodes.deleted'));
toast.success(i18n.t('pipeline.buildnodesmanag.scb1ab6ab'));
} catch {
toast.error(i18n.t('kubernetes.deployment.imageUpdateFailed'));
} finally {
@@ -188,10 +188,10 @@ export default function BuildNodesManager({ embedded = false }: { embedded?: boo
<span className="text-sm font-medium text-[var(--dg-text-primary)]"> {i18n.t('pipeline.buildNodes')} </span>
<div className="flex gap-2">
<Button variant="ghost" size="sm" onClick={load} disabled={loading}>
<RefreshCw className={`h-3.5 w-3.5 mr-1${loading ? ' animate-spin' : ''}`} />{i18n.t("common.refresh")}
<RefreshCw className={`h-3.5 w-3.5 mr-1${loading ? ' animate-spin' : ''}`} />刷新
</Button>
<Button size="sm" onClick={openCreate}>
<Plus className="h-3.5 w-3.5 mr-1" />{i18n.t("common.add")}
<Plus className="h-3.5 w-3.5 mr-1" />添加
</Button>
</div>
</div>
@@ -201,14 +201,14 @@ export default function BuildNodesManager({ embedded = false }: { embedded?: boo
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Cpu className="h-4 w-4 text-[var(--dg-text-muted)]" />
<span className="text-sm font-medium text-[var(--dg-text-primary)]">{i18n.t('deploy.buildNodes.masterBuiltin')}</span>
<Badge variant="outline" className="text-[10px]"> {i18n.t('pipeline.buildNodesManage.defaultSchedule')} </Badge>
<span className="text-sm font-medium text-[var(--dg-text-primary)]">Master(内置)</span>
<Badge variant="outline" className="text-[10px]"> {i18n.t('pipeline.buildnodesmanag.sa419fe03')} </Badge>
<span className="text-xs text-[var(--dg-text-muted)]">
{i18n.t('deploy.buildNodes.executorUsage', { running: master.running_count, max: master.max_executors })}
占用 {master.running_count}/{master.max_executors}
</span>
</div>
<div className="flex items-center gap-2">
<Label className="text-xs text-[var(--dg-text-muted)] shrink-0"> {i18n.t('pipeline.buildNodesManage.maxConcurrency')} </Label>
<Label className="text-xs text-[var(--dg-text-muted)] shrink-0"> {i18n.t('pipeline.buildnodesmanag.s8e408e28')} </Label>
<Input
type="number"
min={1}
@@ -218,19 +218,19 @@ export default function BuildNodesManager({ embedded = false }: { embedded?: boo
className="h-7 w-16 text-xs dg-input"
/>
<Button size="sm" className="h-7 text-xs" onClick={handleSaveMaster} disabled={savingMaster}>
{savingMaster ? i18n.t('deploy.buildNodes.saving') : i18n.t('task.script.save')}
{savingMaster ? i18n.t('pipeline.buildnodesmanag.s7cbf2d09') : i18n.t('task.script.save')}
</Button>
</div>
</div>
<p className="text-[11px] text-[var(--dg-text-muted)] mt-2">
{i18n.t('deploy.buildNodes.schedulingHint')}
未指定节点时优先在 Master 执行;并发设为 1 时按触发顺序串行排队;满载后自动调度到空闲远程节点
</p>
</div>
{nodes.length === 0 ? (
<PageEmpty
title={i18n.t('deploy.buildNodes.noRemoteNodes')}
description={i18n.t('deploy.buildNodes.noRemoteDesc')}
title={i18n.t('pipeline.buildnodesmanag.s2795bd4a')}
description={i18n.t('pipeline.buildnodesmanag.s90fb5e18')}
action={<Button size="sm" onClick={openCreate}><Plus className="h-3.5 w-3.5 mr-1" /> {i18n.t('kubernetes.topology.addNode')} </Button>}
/>
) : (
@@ -251,14 +251,14 @@ export default function BuildNodesManager({ embedded = false }: { embedded?: boo
</span>
</div>
<div className="flex items-center gap-3 mt-1 text-[11px] text-[var(--dg-text-muted)]">
<span>{i18n.t('deploy.buildNodes.executorLabel', { running: node.running_count || 0, max: node.max_executors })}</span>
{node.labels && <span>{i18n.t('deploy.buildNodes.labelsLabel', { labels: node.labels })}</span>}
<span>执行器 {node.running_count || 0}/{node.max_executors}</span>
{node.labels && <span>标签 {node.labels}</span>}
<span className="truncate">{node.workspace_path}</span>
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button variant="ghost" size="sm" className="h-7 text-xs" disabled={testingId === node.id} onClick={() => handleTest(node)}>
{testingId === node.id ? i18n.t('deploy.buildNodes.testing') : i18n.t('pipeline.testTool')}
{testingId === node.id ? i18n.t('pipeline.buildnodesmanag.sf85549cd') : i18n.t('pipeline.testTool')}
</Button>
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => handleToggle(node)}>
{node.enabled ? i18n.t('system.userInactive') : i18n.t('system.userActive')}
@@ -293,9 +293,9 @@ export default function BuildNodesManager({ embedded = false }: { embedded?: boo
<Input value={name} onChange={(e) => setName(e.target.value)} className="h-8 text-xs dg-input" placeholder="build-node-01" />
</div>
<div className="space-y-1">
<Label className="text-xs"> {i18n.t('common.tag')} </Label>
<Label className="text-xs"> {i18n.t('pipeline.buildnodesmanag.sdfdaf1c5')} </Label>
<Select value={serverId || '__none'} onValueChange={(v) => setServerId(v === '__none' ? '' : v)}>
<SelectTrigger className="h-8 text-xs dg-input"><SelectValue placeholder={i18n.t('deploy.buildNodes.selectServer')} /></SelectTrigger>
<SelectTrigger className="h-8 text-xs dg-input"><SelectValue placeholder={i18n.t('server.webshelltsx.s030235d1')} /></SelectTrigger>
<SelectContent>
<SelectItem value="__none"> {i18n.t('k8s.storage.pleaseSelect')} </SelectItem>
{servers.map((s) => (
@@ -308,16 +308,16 @@ export default function BuildNodesManager({ embedded = false }: { embedded?: boo
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs"> {i18n.t('pipeline.buildNodesManage.maxConcurrency')} </Label>
<Label className="text-xs"> {i18n.t('pipeline.buildnodesmanag.s8e408e28')} </Label>
<Input value={maxExecutors} onChange={(e) => setMaxExecutors(e.target.value)} type="number" min={1} className="h-8 text-xs dg-input" />
</div>
<div className="space-y-1">
<Label className="text-xs"> {i18n.t('pipeline.buildNodesManage.workspacePath')} </Label>
<Label className="text-xs"> {i18n.t('pipeline.buildnodesmanag.sbcfcd088')} </Label>
<Input value={workspacePath} onChange={(e) => setWorkspacePath(e.target.value)} className="h-8 text-xs font-mono dg-input" />
</div>
</div>
<div className="space-y-1">
<Label className="text-xs"> {i18n.t('kubernetes.namespace.labels')} <span className="text-[var(--dg-text-muted)]">{i18n.t('common.optional')}</span></Label>
<Label className="text-xs"> {i18n.t('kubernetes.namespace.labels')} <span className="text-[var(--dg-text-muted)]">(可选)</span></Label>
<Input value={labels} onChange={(e) => setLabels(e.target.value)} className="h-8 text-xs dg-input" placeholder="linux,docker,high-mem" />
</div>
<div className="space-y-1">
@@ -326,7 +326,7 @@ export default function BuildNodesManager({ embedded = false }: { embedded?: boo
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setDialogOpen(false)}> {i18n.t('rbac.jobRole.cancel')} </Button>
<Button onClick={handleSubmit} disabled={submitting}>{submitting ? i18n.t('deploy.buildNodes.saving') : i18n.t('task.script.save')}</Button>
<Button onClick={handleSubmit} disabled={submitting}>{submitting ? i18n.t('pipeline.buildnodesmanag.s7cbf2d09') : i18n.t('task.script.save')}</Button>
</div>
</div>
</DialogContent>
@@ -343,7 +343,7 @@ export default function BuildNodesManager({ embedded = false }: { embedded?: boo
}
return (
<PageShell title={i18n.t('pipeline.buildNodes')} subtitle={i18n.t('deploy.pipeline.buildNodes')}>
<PageShell title={i18n.t('pipeline.buildNodes')} subtitle={i18n.t('pipeline.buildnodesmanag.sb2b825e4')}>
{panel}
{dialog}
</PageShell>
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus, Pencil, Trash2, RefreshCw, GitBranch, Container, Globe, Settings2 } from 'lucide-react';
import { Plus, Pencil, Trash2, RefreshCw, Globe, Settings2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
@@ -12,24 +12,12 @@ import { useSearchParams } from 'react-router-dom';
import BuildNodesManager from './BuildNodesManager';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { toast } from 'sonner';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { deployApi } from '@/api';
import CredentialFormDialog from './CredentialFormDialog';
import ToolConfigTab from './ToolConfigTab';
import type { JsonData } from '@/types/common';
interface Repo {
id: number; name: string; description: string; url: string;
auth_type: string; credential_id: number | null; enabled: boolean;
created_by: string; updated_at: string;
}
interface Registry {
id: number; name: string; description: string; registry_url: string; namespace: string;
auth_type: string; credential_id: number | null; enabled: boolean;
created_by: string; updated_at: string;
}
interface Environment {
id: number; name: string; display_name: string; type: string; color: string;
sort_order: number; description: string; enabled: boolean;
@@ -74,7 +62,10 @@ function EnvFormDialog({ open, onClose, onSaved, editData }: { open: boolean; on
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent>
<DialogHeader><DialogTitle>{editData ? t('deploy.env.editEnv') : t('deploy.env.addEnv')}</DialogTitle></DialogHeader>
<DialogHeader>
<DialogTitle>{editData ? t('deploy.env.editEnv') : t('deploy.env.addEnv')}</DialogTitle>
<DialogDescription>{t('deploy.label.envManage')}</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1"><Label>{t('deploy.env.envLabel')}</Label><Input value={name} onChange={e => setName(e.target.value)} placeholder="production" /></div>
<div className="space-y-1"><Label>{t('deploy.table.displayName')}</Label><Input value={displayName} onChange={e => setDisplayName(e.target.value)} placeholder={t('deploy.env.prodEnv')} /></div>
@@ -102,47 +93,42 @@ function EnvFormDialog({ open, onClose, onSaved, editData }: { open: boolean; on
);
}
const CONFIG_TABS = ['repos', 'registries', 'environments', 'tools', 'build-nodes'] as const;
const CONFIG_TABS = ['environments', 'build-nodes', 'tools'] as const;
export default function DeployCredentials() {
const { t } = useTranslation();
const _confirm = useConfirm();
const confirm = useConfirm();
const [searchParams] = useSearchParams();
const tabFromUrl = searchParams.get('tab');
const [repos, setRepos] = useState<Repo[]>([]);
const [registries, setRegistries] = useState<Registry[]>([]);
const [environments, setEnvironments] = useState<Environment[]>([]);
const [loading, setLoading] = useState(false);
const [editorOpen, setEditorOpen] = useState(false);
const [editType, setEditType] = useState<'repo' | 'registry'>('repo');
const [editingId, setEditingId] = useState<number | null>(null);
const [envDialogOpen, setEnvDialogOpen] = useState(false);
const [editEnv, setEditEnv] = useState<Environment | null>(null);
const [activeTab, setActiveTab] = useState(
tabFromUrl && (CONFIG_TABS as readonly string[]).includes(tabFromUrl) ? tabFromUrl : 'repos',
tabFromUrl && (CONFIG_TABS as readonly string[]).includes(tabFromUrl) ? tabFromUrl : 'environments',
);
const fetchAll = async () => {
setLoading(true);
try {
const [rR, rReg, rEnv] = await Promise.all([deployApi.getRepos(), deployApi.getRegistries(), deployApi.getEnvironments()]);
setRepos((rR as JsonData)?.list || []);
setRegistries((rReg as JsonData)?.list || []);
setEnvironments((rEnv as JsonData)?.list || []);
const rEnv = await deployApi.getEnvironments();
setEnvironments((rEnv as { list?: Environment[] })?.list || []);
} catch { toast.error(t('deploy.toast.getDataFailed')); }
finally { setLoading(false); }
};
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => { fetchAll(); }, []);
const handleDelete = async (type: 'repo' | 'registry' | 'env', item: JsonData) => {
const ok = await _confirm({ title: t('common.delete'), description: t('deploy.toast.deleteConfirm', { name: item.name || item.display_name }), confirmText: t('common.delete'), variant: 'destructive' });
const handleDeleteEnv = async (item: Environment) => {
const ok = await confirm({
title: t('common.delete'),
description: t('deploy.toast.deleteConfirm', { name: item.display_name || item.name }),
confirmText: t('common.delete'),
variant: 'destructive',
});
if (!ok) return;
try {
if (type === 'repo') await deployApi.deleteRepo(item.id);
else if (type === 'registry') await deployApi.deleteRegistry(item.id);
else await deployApi.deleteEnvironment(item.id);
await deployApi.deleteEnvironment(item.id);
toast.success(t('deploy.toast.deleted'));
fetchAll();
} catch { toast.error(t('deploy.toast.deleteFailed')); }
@@ -151,8 +137,6 @@ export default function DeployCredentials() {
const envTypeLabel: Record<string, string> = { production: t('deploy.env.production'), staging: t('deploy.env.staging'), testing: t('deploy.env.testing'), development: t('deploy.env.development') };
const pageTabs = [
{ id: 'repos', label: t('deploy.credForm.gitRepo'), badge: repos.length || undefined },
{ id: 'registries', label: t('deploy.credForm.imageRegistry'), badge: registries.length || undefined },
{ id: 'environments', label: t('deploy.label.envManage'), badge: environments.length || undefined },
{ id: 'build-nodes', label: t('deploy.deployNav.buildNodes') },
{ id: 'tools', label: t('deploy.tool.title') },
@@ -169,117 +153,33 @@ export default function DeployCredentials() {
activeTab={activeTab}
onTabChange={setActiveTab}
actions={activeTab === 'build-nodes' ? undefined : (
<>
activeTab === 'environments' ? (
<>
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={fetchAll}>
<RefreshCw className="w-4 h-4 mr-1" />{t('common.refresh')}
</Button>
<Button size="sm" className="dg-btn-primary" onClick={() => { setEditEnv(null); setEnvDialogOpen(true); }}>
<Plus className="w-4 h-4 mr-1" />新增环境
</Button>
</>
) : (
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={fetchAll}>
<RefreshCw className="w-4 h-4 mr-1" />{t('common.refresh')}
</Button>
<Button size="sm" className="dg-btn-primary" onClick={() => { setEditType('repo'); setEditingId(null); setEditorOpen(true); }}>
<Plus className="w-4 h-4 mr-1" />{t('deploy.credForm.newRepo')}
</Button>
<Button size="sm" variant="secondary" onClick={() => { setEditType('registry'); setEditingId(null); setEditorOpen(true); }}>
<Plus className="w-4 h-4 mr-1" />{t('deploy.credForm.newRegistry')}
</Button>
</>
)
)}
contentClassName="space-y-4"
>
{activeTab === 'repos' && (
repos.length === 0 ? (
<PageEmpty icon={<GitBranch className="h-10 w-10" />} title={t('deploy.credForm.noRepo')} />
) : (
<ContentPanel noPadding>
<div className="overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
<tr>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">{t('deploy.table.name')}</th>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)] hidden md:table-cell">{t('deploy.table.repoUrl')}</th>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">{t('deploy.cred.authMethod')}</th>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">{t('deploy.table.status')}</th>
<th className="px-3 py-2 text-right font-medium text-[var(--dg-text-secondary)]">{t('common.actions')}</th>
</tr>
</thead>
<tbody>
{repos.map(r => (
<tr key={r.id} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50">
<td className="px-3 py-2 font-medium text-[var(--dg-text-primary)]">{r.name}</td>
<td className="px-3 py-2 text-[var(--dg-text-secondary)] hidden md:table-cell font-mono text-xs max-w-[300px] truncate">{r.url}</td>
<td className="px-3 py-2"><Badge variant="outline" className="text-xs">{r.auth_type}</Badge></td>
<td className="px-3 py-2">
<Badge className={r.enabled ? 'bg-emerald-500/20 dg-text-green border-emerald-500/30' : 'bg-[var(--dg-bg-active)]/20 dg-text-secondary border-[var(--dg-border)]'}>{r.enabled ? t('common.enable') : t('common.disable')}</Badge>
</td>
<td className="px-3 py-2 text-right">
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditType('repo'); setEditingId(r.id); setEditorOpen(true); }}><Pencil className="w-3.5 h-3.5" /></Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-500" onClick={() => handleDelete('repo', r)}><Trash2 className="w-3.5 h-3.5" /></Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</ContentPanel>
)
)}
{activeTab === 'registries' && (
registries.length === 0 ? (
<PageEmpty icon={<Container className="h-10 w-10" />} title={t('deploy.credForm.noRegistry')} />
) : (
<ContentPanel noPadding>
<div className="overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
<tr>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">{t('deploy.table.name')}</th>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)] hidden md:table-cell">Registry URL</th>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)] hidden sm:table-cell">{t('deploy.credForm.namespace')}</th>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">{t('deploy.cred.authMethod')}</th>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">{t('deploy.table.status')}</th>
<th className="px-3 py-2 text-right font-medium text-[var(--dg-text-secondary)]">{t('common.actions')}</th>
</tr>
</thead>
<tbody>
{registries.map(r => (
<tr key={r.id} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50">
<td className="px-3 py-2 font-medium text-[var(--dg-text-primary)]">{r.name}</td>
<td className="px-3 py-2 text-[var(--dg-text-secondary)] hidden md:table-cell font-mono text-xs max-w-[250px] truncate">{r.registry_url}</td>
<td className="px-3 py-2 text-[var(--dg-text-secondary)] hidden sm:table-cell">{r.namespace || '-'}</td>
<td className="px-3 py-2"><Badge variant="outline" className="text-xs">{r.auth_type}</Badge></td>
<td className="px-3 py-2">
<Badge className={r.enabled ? 'bg-emerald-500/20 dg-text-green border-emerald-500/30' : 'bg-[var(--dg-bg-active)]/20 dg-text-secondary border-[var(--dg-border)]'}>{r.enabled ? t('common.enable') : t('common.disable')}</Badge>
</td>
<td className="px-3 py-2 text-right">
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditType('registry'); setEditingId(r.id); setEditorOpen(true); }}><Pencil className="w-3.5 h-3.5" /></Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-500" onClick={() => handleDelete('registry', r)}><Trash2 className="w-3.5 h-3.5" /></Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</ContentPanel>
)
)}
{activeTab === 'environments' && (
<>
<div className="flex items-center justify-end mb-3">
<Button size="sm" className="dg-btn-primary" onClick={() => { setEditEnv(null); setEnvDialogOpen(true); }}>
<Plus className="w-4 h-4 mr-1" />{t("common.newEnv")}
</Button>
</div>
{environments.length === 0 ? (
<PageEmpty
icon={<Globe className="h-10 w-10" />}
title={t('deploy.noEnvConfig')}
description={t('deploy.clickEnvConfigHint')}
title="暂无环境配置"
description='点击"新增环境"创建'
action={(
<Button size="sm" onClick={() => { setEditEnv(null); setEnvDialogOpen(true); }}>
<Plus className="w-4 h-4 mr-1" />{t("common.newEnv")}
<Plus className="w-4 h-4 mr-1" />新增环境
</Button>
)}
/>
@@ -289,13 +189,13 @@ export default function DeployCredentials() {
<table className="w-full text-sm">
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
<tr>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">{t("common.envLabel")}</th>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">{t("common.displayName")}</th>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">{t("common.type")}</th>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">{t("common.color")}</th>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">{t("common.sort")}</th>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">{t("common.status")}</th>
<th className="px-3 py-2 text-right font-medium text-[var(--dg-text-secondary)]">{t("common.actions")}</th>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">环境标识</th>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">显示名称</th>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">类型</th>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">颜色</th>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">排序</th>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">状态</th>
<th className="px-3 py-2 text-right font-medium text-[var(--dg-text-secondary)]">操作</th>
</tr>
</thead>
<tbody>
@@ -312,12 +212,12 @@ export default function DeployCredentials() {
<td className="px-3 py-2"><div className="w-6 h-6 rounded border" style={{ backgroundColor: env.color }} /></td>
<td className="px-3 py-2 text-[var(--dg-text-secondary)]">{env.sort_order}</td>
<td className="px-3 py-2">
<Badge className={env.enabled ? 'bg-emerald-500/20 dg-text-green border-emerald-500/30' : 'bg-[var(--dg-bg-active)]/20 dg-text-secondary border-[var(--dg-border)]'}>{env.enabled ? t('common.enable') : t('common.disable')}</Badge>
<Badge className={env.enabled ? 'bg-emerald-500/20 dg-text-green border-emerald-500/30' : 'bg-[var(--dg-bg-active)]/20 dg-text-secondary border-[var(--dg-border)]'}>{env.enabled ? '启用' : '禁用'}</Badge>
</td>
<td className="px-3 py-2 text-right">
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditEnv(env); setEnvDialogOpen(true); }}><Pencil className="w-3.5 h-3.5" /></Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-500" onClick={() => handleDelete('env', env)}><Trash2 className="w-3.5 h-3.5" /></Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-500" onClick={() => handleDeleteEnv(env)}><Trash2 className="w-3.5 h-3.5" /></Button>
</div>
</td>
</tr>
@@ -334,11 +234,6 @@ export default function DeployCredentials() {
{activeTab === 'tools' && <ToolConfigTab />}
<CredentialFormDialog
open={editorOpen} onOpenChange={setEditorOpen} type={editType} editId={editingId}
repos={repos} registries={registries} onSave={fetchAll}
/>
<EnvFormDialog open={envDialogOpen} onClose={() => setEnvDialogOpen(false)} onSaved={fetchAll} editData={editEnv} />
</PageShell>
);
+15 -16
View File
@@ -99,7 +99,6 @@ export default function DeployRBAC() {
} catch { setAssignments([]); }
};
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => { loadAssignments(); }, [selectedUserId]);
const matchCount = useMemo(() => {
@@ -112,7 +111,7 @@ export default function DeployRBAC() {
const handleCreate = async () => {
if (!newName.trim() || !newCode.trim() || !newPattern.trim()) {
toast.error(i18n.t('deploy.rbac.policy.fillFields')); return;
toast.error(i18n.t('monitor.logrbactsx.sf6e80bf0')); return;
}
setSaving(true);
try {
@@ -147,8 +146,8 @@ export default function DeployRBAC() {
const handleDelete = async (role: ItemRole) => {
const ok = await _confirm({
title: i18n.t('deploy.rbac.policy.deletePolicy'),
description: i18n.t("deploy.rbac.deleteRoleDesc", { name: role.name, count: matchCount[role.id]?.length || 0 }),
title: i18n.t('monitor.logrbactsx.s5d79e436'),
description: `确定删除「${role.name}」?将影响 ${matchCount[role.id]?.length || 0} 个发布任务`,
variant: 'destructive',
});
if (!ok) return;
@@ -165,7 +164,7 @@ export default function DeployRBAC() {
};
const handleAssign = async () => {
if (!selectedUserId || !assignRoleId) { toast.error(i18n.t('deploy.rbac.policy.selectUserPermission')); return; }
if (!selectedUserId || !assignRoleId) { toast.error(i18n.t('monitor.logrbactsx.s0fc53985')); return; }
setAssignLoading(true);
try {
await deployApi.assignItemRole(Number(selectedUserId), {
@@ -190,8 +189,8 @@ export default function DeployRBAC() {
const selectedUser = users.find((u) => String(u.id) === selectedUserId);
const pageTabs = [
{ id: 'roles', label: i18n.t('deploy.rbac.policy.policyStrategy'), badge: roles.length || undefined },
{ id: 'users', label: i18n.t('deploy.rbac.policy.userAuth') },
{ id: 'roles', label: i18n.t('monitor.logrbactsx.s93ef07cd'), badge: roles.length || undefined },
{ id: 'users', label: i18n.t('monitor.logrbactsx.s2de89571') },
{ id: 'groups', label: i18n.t('pipeline.groupLabel') },
{ id: 'preview', label: i18n.t('pipeline.matchPreview') },
];
@@ -200,7 +199,7 @@ export default function DeployRBAC() {
<PageShell
variant="list"
title={i18n.t('deploy.deployNav.jobPermission')}
subtitle={i18n.t('deploy.rbac.policy.subtitle')}
subtitle={i18n.t('pipeline.deployrbactsx.sa596d659')}
icon={<Shield className="h-5 w-5" />}
loading={loading}
tabs={pageTabs}
@@ -208,7 +207,7 @@ export default function DeployRBAC() {
onTabChange={setActiveTab}
actions={activeTab === 'groups' ? undefined : (
<Button className="dg-btn-primary" onClick={() => setCreateOpen(true)}>
<Plus className="w-4 h-4 mr-1" />{i18n.t("common.newPermission")}
<Plus className="w-4 h-4 mr-1" />新建权限
</Button>
)}
contentClassName="space-y-4"
@@ -217,9 +216,9 @@ export default function DeployRBAC() {
roles.length === 0 ? (
<PageEmpty
icon={<Shield className="h-10 w-10" />}
title={i18n.t('deploy.rbac.policy.noPolicy')}
description={i18n.t('deploy.rbac.policy.createHint')}
action={<Button onClick={() => setCreateOpen(true)}><Plus className="h-4 w-4 mr-1" /> {i18n.t('monitor.logRbac.newPermission')} </Button>}
title={i18n.t('pipeline.deployrbactsx.sca578c70')}
description={i18n.t('pipeline.deployrbactsx.s5ac38262')}
action={<Button onClick={() => setCreateOpen(true)}><Plus className="h-4 w-4 mr-1" /> {i18n.t('monitor.logrbactsx.s6058845c')} </Button>}
/>
) : (
<div className="space-y-2">
@@ -296,7 +295,7 @@ export default function DeployRBAC() {
</Select>
</div>
<div className="space-y-1 w-48">
<Label className="text-xs"> {i18n.t('deploy.rbac.policy.policyStrategy')} </Label>
<Label className="text-xs"> {i18n.t('monitor.logrbactsx.s93ef07cd')} </Label>
<Select value={assignRoleId} onValueChange={setAssignRoleId}>
<SelectTrigger className="h-8 text-sm dg-input"><SelectValue placeholder={i18n.t('kubernetes.hpa.selectPolicy')} /></SelectTrigger>
<SelectContent>
@@ -313,7 +312,7 @@ export default function DeployRBAC() {
</div>
{selectedUser ? (
assignments.length === 0 ? (
<p className="text-xs text-[var(--dg-text-muted)]"> {i18n.t('pipeline.deployRbac.noJobPermission')} </p>
<p className="text-xs text-[var(--dg-text-muted)]"> {i18n.t('pipeline.deployrbactsx.sef9548ee')} </p>
) : (
<div className="space-y-1.5">
{assignments.map((a) => {
@@ -334,7 +333,7 @@ export default function DeployRBAC() {
</div>
)
) : (
<p className="text-xs text-[var(--dg-text-muted)] flex items-center gap-1"><Users className="w-3.5 h-3.5" /> {i18n.t('monitor.logRbac.selectUserHint')} </p>
<p className="text-xs text-[var(--dg-text-muted)] flex items-center gap-1"><Users className="w-3.5 h-3.5" /> {i18n.t('monitor.logrbactsx.s9be00c95')} </p>
)}
</CardContent>
</Card>
@@ -369,7 +368,7 @@ export default function DeployRBAC() {
</Badge>
))}
{previewRoleId !== null && previewMatches.length === 0 && (
<span className="text-xs text-[var(--dg-text-muted)]"> {i18n.t('monitor.logRbac.noMatchHint')} </span>
<span className="text-xs text-[var(--dg-text-muted)]"> {i18n.t('monitor.logrbactsx.s64096f4d')} </span>
)}
</div>
</CardContent>
@@ -20,6 +20,7 @@ import {
import { cn } from '@/lib/utils';
import { pipelineApi } from '@/api';
import { useUserStore } from '@/store';
import { buildApiUrl } from '@/utils/request';
interface FileItem {
name: string;
@@ -87,13 +88,13 @@ export function FileBrowserDialog({
setCurrentPath('');
loadDir('');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, runId]);
// 下载文件
const downloadFile = async (file: FileItem) => {
const token = useUserStore.getState().token || '';
const url = `${location.protocol}//${location.host}/api/v1/pipelines/runs/${runId}/files/download?path=${encodeURIComponent(file.path)}&token=${encodeURIComponent(token)}`;
const params = new URLSearchParams({ path: file.path, token });
const url = buildApiUrl(`/pipelines/runs/${runId}/files/download`, params);
window.open(url, '_blank');
};
+294 -24
View File
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react';
import { useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
Plus,
Trash2,
ChevronUp,
@@ -9,12 +10,26 @@ import {
ChevronRight,
Save,
Play,
Loader2,
GitBranch,
Wrench,
Server,
FlaskConical,
Zap,
Terminal,
FileCode,
Container,
Clock,
Webhook,
Hand,
Check,
X,
StopCircle,
ScrollText,
Users,
UserPlus,
Shield,
Server,
FileText,
} from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
@@ -23,8 +38,10 @@ import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent } from '@/components/ui/tabs';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { toast } from 'sonner';
import { pipelineApi, deployApi, buildNodeApi, type BuildNode } from '@/api';
import { useConfirm } from '@/components/ui/confirm-dialog';
@@ -32,13 +49,124 @@ import { PageShell } from '@/components/layout/PageShell';
import { PageEmpty } from '@/components/layout/PageEmpty';
import type { JsonData } from '@/types/common';
import type { Task, Stage, LogEntry } from './pipeline-editor/types';
import { stageTypeLabelKeys, uid } from './pipeline-editor/types';
import { TaskCard } from './pipeline-editor/TaskCard';
import { TaskEditor } from './pipeline-editor/TaskEditorPanel';
import { PipelineLogDialog } from './pipeline-editor/PipelineLogDialog';
import { RunsHistoryTab } from './pipeline-editor/RunsHistoryTab';
import { PipelineMembersTab } from './pipeline-editor/PipelineMembersTab';
// ---- types ----
interface Task {
id: string; name: string; type: string; icon: string;
config: Record<string, string>; script: string; script_type: string;
}
interface Stage {
id: string; name: string; type: string; tasks: Task[];
}
interface LogEntry {
time: string; level: string; message: string; stage: string;
}
const stageTypeLabelKeys: Record<string, string> = {
source: 'deploy.stage.checkoutDesc',
build: 'deploy.stage.build',
deploy: 'deploy.stage.deploy',
test: 'deploy.stage.test',
custom: 'deploy.stage.custom',
};
const taskIcons: JsonData = { GitBranch, Wrench, Server, FlaskConical, Zap, Terminal, FileCode, Container };
function uid() { return `tsk-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; }
function fmtDuration(s: number | undefined): string {
if (!s) return '-';
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60); const sec = s % 60;
return sec > 0 ? `${m}分${sec}秒` : `${m}分`;
}
// ---- Task Card ----
function TaskCard({ task, selected, onSelect, onDelete }: { task: Task; selected: boolean; onSelect: () => void; onDelete: () => void }) {
const Icon = taskIcons[task.icon] || Wrench;
return (
<div
onClick={onSelect}
className={`flex items-center gap-3 p-3 rounded-md border cursor-pointer transition-all group ${
selected ? 'border-[var(--dg-blue)] bg-[color-mix(in_srgb,var(--dg-blue)_12%,transparent)]' : 'border-[var(--dg-border)] hover:border-[var(--dg-border-hover)] bg-[var(--dg-bg-secondary)]'
}`}
>
<div className={`p-1.5 rounded ${selected ? 'bg-[color-mix(in_srgb,var(--dg-blue)_15%,transparent)]' : 'bg-[var(--dg-bg-tertiary)]'}`}>
<Icon className={`w-4 h-4 ${selected ? 'text-blue-600' : 'text-[var(--dg-text-secondary)]'}`} />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-[var(--dg-text-primary)] truncate">{task.name}</div>
<div className="text-xs text-[var(--dg-text-secondary)]">{task.type}</div>
</div>
<Button variant="ghost" size="icon" className="h-6 w-6 opacity-0 group-hover:opacity-100" onClick={e => { e.stopPropagation(); onDelete(); }}>
<Trash2 className="w-3.5 h-3.5 text-red-500" />
</Button>
</div>
);
}
// ---- Task Editor Panel ----
function TaskEditor({ task, onUpdate }: { task: Task | null; onUpdate: (t: Task) => void; onClose: () => void }) {
const { t } = useTranslation();
if (!task) return <div className="p-6 text-center text-[var(--dg-text-secondary)] text-sm">{t('deploy.pipeline.selectTaskHint')}</div>;
return (
<div className="space-y-4">
<div className="space-y-1"><Label className="text-xs">{t('pipeline.taskName')}</Label><Input value={task.name} onChange={e => onUpdate({ ...task, name: e.target.value })} className="h-8 text-sm" /></div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1"><Label className="text-xs">{t('common.type')}</Label>
<Select value={task.type} onValueChange={v => onUpdate({ ...task, type: v })}><SelectTrigger className="h-8 text-sm"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="build">{t('pipeline.build')}</SelectItem><SelectItem value="deploy">{t('pipeline.deploy')}</SelectItem><SelectItem value="test">{t('pipeline.test')}</SelectItem><SelectItem value="custom">{t('pipeline.custom')}</SelectItem></SelectContent></Select>
</div>
<div className="space-y-1"><Label className="text-xs">{t('pipeline.icon')}</Label>
<Select value={task.icon} onValueChange={v => onUpdate({ ...task, icon: v })}><SelectTrigger className="h-8 text-sm"><SelectValue /></SelectTrigger>
<SelectContent>{Object.entries(taskIcons).map(([k]) => (<SelectItem key={k} value={k}>{k}</SelectItem>))}</SelectContent></Select>
</div>
</div>
<div className="space-y-1"><Label className="text-xs">{t('automation.scriptType')}</Label>
<div className="flex gap-2">{['shell', 'python'].map(s => (<Badge key={s} variant={task.script_type === s ? 'default' : 'outline'} className="cursor-pointer text-xs" onClick={() => onUpdate({ ...task, script_type: s })}>{s}</Badge>))}</div>
</div>
<div className="space-y-1"><Label className="text-xs">{t('automation.scriptContent')}</Label>
<Textarea value={task.script} onChange={e => onUpdate({ ...task, script: e.target.value })} className="font-mono text-xs min-h-[200px] bg-[var(--dg-bg-primary)] dg-text-green" placeholder="#!/bin/bash\nset -e\necho 'hello'" />
</div>
</div>
);
}
// ====== Log Dialog ======
function LogDialog({ open, onClose, run, logs }: { open: boolean; onClose: () => void; run?: JsonData; logs: LogEntry[] }) {
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
<DialogHeader><DialogTitle className="flex items-center gap-2"><ScrollText className="w-5 h-5" />{t('automation.execLog')} #{run?.build_number || run?.id}</DialogTitle></DialogHeader>
<div className="space-y-3">
<div className="flex flex-wrap gap-3 text-sm text-[var(--dg-text-secondary)]">
<span>{t('pipeline.triggerMode')}: {run?.triggered_by || 'manual'}</span>
<span>{t('pipeline.branch')}: {run?.branch || '-'}</span>
<span>{t('pipeline.duration')}: {fmtDuration(run?.duration)}</span>
<span>{run?.created_at ? new Date(run.created_at).toLocaleString('zh-CN') : '-'}</span>
</div>
<div className="bg-[var(--dg-bg-primary)] rounded-md p-3 max-h-[50vh] overflow-y-auto font-mono text-xs">
{logs.length === 0 ? (
<div className="dg-text-muted py-4 text-center">{t('pipeline.noLog')}</div>
) : (
logs.map((l, i) => (
<div key={i} className="flex gap-2 py-0.5">
<span className="dg-text-muted shrink-0">[{l.time}]</span>
<span className={
l.level === 'error' ? 'dg-text-red' :
l.level === 'warn' ? 'dg-text-yellow' :
l.level === 'success' ? 'dg-text-green' :
'text-[var(--dg-text-secondary)]'
}>
{l.level === 'success' ? '✓' : l.level === 'error' ? '✗' : l.level === 'warn' ? '!' : '>'} {l.message}
</span>
</div>
))
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
// ====== Main Component ======
export default function PipelineEditor() {
@@ -54,7 +182,7 @@ export default function PipelineEditor() {
const [saving, setSaving] = useState(false);
const [runs, setRuns] = useState<JsonData[]>([]);
const [environments, setEnvironments] = useState<JsonData[]>([]);
const [members, setMembers] = useState<JsonData[]>([]);
const [members, setMembers] = useState<JsonData>([]);
const [loadingMembers, setLoadingMembers] = useState(false);
// trigger config
@@ -106,6 +234,7 @@ export default function PipelineEditor() {
setLoading(true);
try {
const p = await pipelineApi.getPipelineDetail(pipelineCode) as JsonData;
// http.get 已自动解包 json.data,p 即为 pipeline 对象
if (!p || !p.id) { setPipeline(null); setLoading(false); return; }
setPipeline(p);
setTriggerMode(p.trigger_mode || 'manual');
@@ -122,6 +251,7 @@ export default function PipelineEditor() {
const s = p.stages ? JSON.parse(p.stages) : [];
const stageList = Array.isArray(s) ? s : [];
setStages(stageList);
// Auto-expand all stages initially
const expanded: Record<string, boolean> = {};
stageList.forEach((stg: Stage) => { expanded[stg.id] = true; });
setExpandedStages(expanded);
@@ -304,7 +434,7 @@ export default function PipelineEditor() {
<div className="text-center py-12 border-2 border-dashed rounded-lg text-[var(--dg-text-secondary)]">{t('deploy.pipeline.clickToAddStage')}</div>
) : (
stages.map((stage, idx) => {
const isExpanded = expandedStages[stage.id] !== false;
const isExpanded = expandedStages[stage.id] !== false; // default expanded
return (
<div key={stage.id} className="space-y-1">
<div
@@ -340,7 +470,7 @@ export default function PipelineEditor() {
</div>
<div className="w-[360px] flex-shrink-0 border border-[var(--dg-border)] rounded-lg p-4 bg-[var(--dg-bg-secondary)]">
<div className="flex items-center justify-between mb-3"><span className="text-sm font-medium text-[var(--dg-text-primary)]">{t('pipeline.taskEdit')}</span>{selectedTask && <Button variant="ghost" size="sm" className="h-6 text-xs" onClick={() => setSelectedTaskId(null)}>{t('common.close')}</Button>}</div>
<TaskEditor task={selectedTask} onUpdate={handleUpdateTask} />
<TaskEditor task={selectedTask} onUpdate={handleUpdateTask} onClose={() => setSelectedTaskId(null)} />
</div>
</div>
</TabsContent>
@@ -381,26 +511,166 @@ export default function PipelineEditor() {
{/* ====== Runs Tab ====== */}
<TabsContent value="runs" className="mt-3">
<RunsHistoryTab runs={runs} onViewLog={handleViewLog} onStop={handleStop} />
{runs.length === 0 ? (
<div className="text-center py-12 text-[var(--dg-text-secondary)]">{t('pipeline.noRecords')}</div>
) : (
<div className="rounded-md border border-[var(--dg-border)] overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
<tr>
<th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">#</th>
<th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">{t('common.status')}</th>
<th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">{t('pipeline.triggerMode')}</th>
<th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">{t('pipeline.triggerBy')}</th>
<th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">{t('pipeline.buildNode')}</th>
<th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">{t('pipeline.branch')}</th>
<th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">{t('pipeline.duration')}</th>
<th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">{t('common.createdAt')}</th>
<th className="px-3 py-2 text-right text-[var(--dg-text-secondary)]">{t('common.actions')}</th>
</tr>
</thead>
<tbody>
{runs.map((r: JsonData) => (
<tr key={r.id} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50">
<td className="px-3 py-2 font-medium text-[var(--dg-text-primary)]">#{r.build_number || r.id}</td>
<td className="px-3 py-2">
{r.status === 'success' ? <Badge className="bg-emerald-500/20 dg-text-green border-emerald-500/30"><Check className="w-3 h-3 mr-0.5" />{t('common.success')}</Badge> :
r.status === 'failed' ? <Badge className="bg-red-500/20 dg-text-red border-red-500/30"><X className="w-3 h-3 mr-0.5" />{t('common.failed')}</Badge> :
r.status === 'running' ? <Badge className="bg-blue-500/20 dg-text-blue border-blue-500/30"><Loader2 className="w-3 h-3 animate-spin mr-0.5" />{t('common.executing')}</Badge> :
r.status === 'cancelled' ? <Badge className="bg-yellow-500/20 dg-text-yellow border-yellow-500/30"><StopCircle className="w-3 h-3 mr-0.5" />{t('pipeline.stopped')}</Badge> :
<Badge variant="secondary" className="text-[var(--dg-text-secondary)]">{r.status}</Badge>}
</td>
<td className="px-3 py-2 text-[var(--dg-text-secondary)] text-xs">
{r.trigger_mode === 'manual' && <span className="flex items-center gap-1"><Hand className="w-3 h-3" />{t('deploy.trigger.manual')}</span>}
{r.trigger_mode === 'cron' && <span className="flex items-center gap-1"><Clock className="w-3 h-3" />{t('deploy.trigger.cron')}</span>}
{r.trigger_mode === 'webhook' && <span className="flex items-center gap-1"><Webhook className="w-3 h-3" />Webhook</span>}
{r.trigger_mode === 'git_push' && <span className="flex items-center gap-1"><GitBranch className="w-3 h-3" />Git Push</span>}
{!r.trigger_mode && (r.triggered_by || t('deploy.trigger.manual'))}
</td>
<td className="px-3 py-2 text-[var(--dg-text-secondary)] text-xs">{r.triggered_by || 'admin'}</td>
<td className="px-3 py-2 text-[var(--dg-text-secondary)] text-xs">{r.build_node_name || 'Master'}</td>
<td className="px-3 py-2 text-[var(--dg-text-secondary)] font-mono text-xs">{r.branch || '-'}</td>
<td className="px-3 py-2 text-[var(--dg-text-secondary)]">{fmtDuration(r.duration)}</td>
<td className="px-3 py-2 text-[var(--dg-text-secondary)] text-xs">{r.created_at ? new Date(r.created_at).toLocaleString('zh-CN') : '-'}</td>
<td className="px-3 py-2 text-right">
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="sm" className="h-7 text-xs dg-text-blue hover:text-blue-300" onClick={() => handleViewLog(r)}><FileText className="w-3.5 h-3.5 mr-1" />{t('pipeline.log')}</Button>
{(r.status === 'running' || r.status === 'pending') && (
<Button variant="ghost" size="sm" className="h-7 text-xs dg-text-red hover:dg-text-red" onClick={() => handleStop(r.id)}><StopCircle className="w-3.5 h-3.5 mr-1" />{t('common.stop')}</Button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</TabsContent>
{/* ====== Members Tab ====== */}
<TabsContent value="members" className="mt-3">
<PipelineMembersTab
members={members}
loadingMembers={loadingMembers}
newMemberUser={newMemberUser}
setNewMemberUser={setNewMemberUser}
newMemberRole={newMemberRole}
setNewMemberRole={setNewMemberRole}
onAddMember={handleAddMember}
onRemoveMember={handleRemoveMember}
/>
<div className="max-w-xl space-y-4">
{/* Add member form */}
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<UserPlus className="w-4 h-4 dg-text-blue" />
<span className="text-sm font-medium text-[var(--dg-text-primary)]">{t('pipeline.addUser')}</span>
</div>
<div className="flex items-end gap-3 mt-3">
<div className="flex-1 space-y-1">
<Label className="text-xs">{t('pipeline.username')}</Label>
<Input value={newMemberUser} onChange={e => setNewMemberUser(e.target.value)} placeholder={t('pipeline.usernamePlaceholder')} className="h-9" />
</div>
<div className="space-y-1">
<Label className="text-xs">{t('pipeline.role')}</Label>
<Select value={newMemberRole} onValueChange={setNewMemberRole}>
<SelectTrigger className="h-9 w-28"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="owner">{t('pipeline.owner')}</SelectItem>
<SelectItem value="editor">{t('pipeline.editor')}</SelectItem>
<SelectItem value="viewer">{t('pipeline.viewer')}</SelectItem>
</SelectContent>
</Select>
</div>
<Button onClick={handleAddMember} className="h-9"><Plus className="w-4 h-4 mr-1" />{t('common.add')}</Button>
</div>
</CardContent>
</Card>
{/* Members list — split direct users vs group assignments */}
{(() => {
const directUsers = members.filter((m: JsonData) => m.username);
const groupAssignments = members.filter((m: JsonData) => m.group_id);
return (
<>
{/* Direct users */}
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-0">
<div className="px-4 py-2 text-xs font-medium text-[var(--dg-text-secondary)] border-b border-[var(--dg-border)]">
👤 {t('pipeline.directUserAuth')} ({directUsers.length})
</div>
{loadingMembers ? (
<div className="flex items-center justify-center py-6"><Loader2 className="w-5 h-5 animate-spin text-[var(--dg-text-secondary)]" /></div>
) : directUsers.length === 0 ? (
<div className="py-4 text-center text-[var(--dg-text-secondary)] text-xs">{t('pipeline.noDirectUser')}</div>
) : (
<div className="divide-y divide-[var(--dg-border)]">
{directUsers.map((m: JsonData) => (
<div key={m.id} className="flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-blue-500/20 flex items-center justify-center"><Users className="w-4 h-4 dg-text-blue" /></div>
<div>
<div className="text-sm font-medium text-[var(--dg-text-primary)]">{m.username}</div>
<div className="text-xs text-[var(--dg-text-secondary)]">{m.role === 'owner' ? <span className="dg-text-yellow">{t('pipeline.owner')}</span> : m.role === 'editor' ? <span className="dg-text-blue">{t('pipeline.editor')}</span> : <span className="dg-text-secondary">{t('pipeline.viewer')}</span>}</div>
</div>
</div>
<Button variant="ghost" size="sm" className="h-7 text-xs dg-text-red hover:dg-text-red" onClick={() => handleRemoveMember(m.id)}><Trash2 className="w-3.5 h-3.5 mr-1" />{t('pipeline.removeMember')}</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Group assignments */}
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-0">
<div className="px-4 py-2 text-xs font-medium text-[var(--dg-text-secondary)] border-b border-[var(--dg-border)]">
👥 {t('pipeline.groupAuth')} ({groupAssignments.length})
</div>
{groupAssignments.length === 0 ? (
<div className="py-4 text-center text-[var(--dg-text-secondary)] text-xs">
{t('pipeline.noGroupAuthHint', { defaultValue: t('pipeline.noGroupAuthHint') })}
</div>
) : (
<div className="divide-y divide-[var(--dg-border)]">
{groupAssignments.map((m: JsonData) => (
<div key={m.id} className="flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-purple-500/20 flex items-center justify-center"><Shield className="w-4 h-4 dg-text-purple" /></div>
<div>
<div className="text-sm font-medium text-[var(--dg-text-primary)]">{m.group?.name || `${t('pipeline.groupLabel')} #${m.group_id}`}</div>
<div className="text-xs text-[var(--dg-text-secondary)]">{m.role === 'owner' ? <span className="dg-text-yellow">{t('pipeline.owner')}</span> : m.role === 'editor' ? <span className="dg-text-blue">{t('pipeline.editor')}</span> : <span className="dg-text-secondary">{t('pipeline.viewer')}</span>} · {t('pipeline.allGroupMembers')}</div>
</div>
</div>
<Button variant="ghost" size="sm" className="h-7 text-xs dg-text-red hover:dg-text-red" onClick={() => handleRemoveMember(m.id)}><Trash2 className="w-3.5 h-3.5 mr-1" />{t('pipeline.revokeAuth')}</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
</>
);
})()}
</div>
</TabsContent>
</Tabs>
{/* Log Dialog */}
<PipelineLogDialog open={logOpen} onClose={() => setLogOpen(false)} run={logRun} logs={logs} />
<LogDialog open={logOpen} onClose={() => setLogOpen(false)} run={logRun} logs={logs} />
</PageShell>
);
}
@@ -1,15 +1,28 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Plus,
Play,
Search,
Clock,
Layers,
Workflow,
Server,
Network,
PanelsTopLeft,
Loader2,
} from 'lucide-react';
import { pipelineApi, deployApi } from '@/api';
import { usePipelines, usePipelineMutations } from '@/hooks/api';
@@ -17,9 +30,11 @@ import { useConfirm } from '@/components/ui/confirm-dialog';
import { toast } from 'sonner';
import { PipelineCard } from './pipeline-manager/PipelineCard';
import { PipelineCreateDialog } from './pipeline-manager/PipelineCreateDialog';
import { JenkinsSettingsDialog } from './pipeline-manager/JenkinsSettingsDialog';
import { PageShell } from '@/components/layout/PageShell';
import { PageEmpty } from '@/components/layout/PageEmpty';
import type { JsonData } from '@/types/common';
import { TechnologyBrandIcon } from '@/components/icons/TechnologyBrandIcon';
interface DeployEnvironment {
id: number;
@@ -30,9 +45,138 @@ interface DeployEnvironment {
enabled: boolean;
}
interface JenkinsViewInfo {
name: string;
url?: string;
count: number;
}
interface JenkinsTriggerState {
open: boolean;
pipeline: Record<string, unknown> | null;
mode: string;
param: string;
refType: 'branch' | 'tag';
values: Record<string, string>;
choices: Record<string, string[]>;
source: string;
loadingChoices: Record<string, boolean>;
}
interface JenkinsParameterDefinition {
name?: string;
type?: string;
choices?: string[];
default?: unknown;
defaultParameterValue?: { value?: unknown };
description?: string;
}
// 解析 Jenkins 视图列表,兼容旧数据只有 jenkins_view 的情况。 // Logan.Li
function parseJenkinsViews(pipeline: Record<string, unknown>): JenkinsViewInfo[] {
const rawViews = pipeline.jenkins_views;
if (typeof rawViews === 'string' && rawViews.trim()) {
try {
const parsed = JSON.parse(rawViews) as unknown;
if (Array.isArray(parsed)) {
return parsed
.map((item) => {
const view = item as Record<string, unknown>;
return {
name: String(view.name || '').trim(),
url: view.url ? String(view.url) : undefined,
count: 0,
};
})
.filter((view) => view.name);
}
} catch {
return [];
}
}
const name = String(pipeline.jenkins_view || '').trim();
if (!name) return [];
return [{ name, url: pipeline.jenkins_view_url ? String(pipeline.jenkins_view_url) : undefined, count: 0 }];
}
// 解析 Jenkins 参数定义,优先用于触发弹框里的分支/Tag 候选。 // Logan.Li
function parseJenkinsParameters(pipeline: Record<string, unknown>): JenkinsParameterDefinition[] {
const raw = pipeline.jenkins_params;
if (typeof raw !== 'string' || !raw.trim()) return [];
try {
const parsed = JSON.parse(raw) as unknown;
return Array.isArray(parsed) ? parsed as JenkinsParameterDefinition[] : [];
} catch {
return [];
}
}
function jenkinsRefChoices(pipeline: Record<string, unknown> | null, paramName: string): string[] {
if (!pipeline) return [];
const params = parseJenkinsParameters(pipeline);
const target = params.find((item) => String(item.name || '') === paramName)
|| params.find((item) => ['PT_BRANCH', 'PT_TAG', 'PT_BRANCH_TAG', 'GitParameterDefinition'].some((key) => String(item.type || '').includes(key)));
return Array.from(new Set((target?.choices || []).map((item) => String(item)).filter(Boolean)));
}
function preferredJenkinsParam(pipeline: Record<string, unknown>, fallback: string): string {
const params = parseJenkinsParameters(pipeline);
const sameName = params.find((item) => String(item.name || '').toLowerCase() === fallback.toLowerCase());
if (sameName?.name) return String(sameName.name);
const gitParam = params.find((item) => String(item.type || '').includes('GitParameterDefinition') || String(item.type || '').startsWith('PT_'));
return gitParam?.name ? String(gitParam.name) : fallback;
}
function jenkinsParamDefault(param: JenkinsParameterDefinition): string {
if (param.default !== undefined && param.default !== null) return String(param.default);
if (param.defaultParameterValue?.value !== undefined && param.defaultParameterValue.value !== null) {
return String(param.defaultParameterValue.value);
}
return param.choices?.[0] || '';
}
function isJenkinsGitParam(param: JenkinsParameterDefinition): boolean {
const type = String(param.type || '');
const name = String(param.name || '').toLowerCase();
return type.includes('GitParameterDefinition') || type.startsWith('PT_') || name.includes('branch') || name.includes('tag');
}
function jenkinsParamKind(param: JenkinsParameterDefinition): 'choice' | 'multi' | 'boolean' | 'git' | 'text' {
const type = String(param.type || '').toLowerCase();
const name = String(param.name || '').toLowerCase();
if (isJenkinsGitParam(param)) return 'git';
if (type.includes('boolean')) return 'boolean';
if (type.includes('checkbox') || type.includes('multi') || name.includes('host')) return 'multi';
if ((param.choices || []).length > 0) return 'choice';
return 'text';
}
function buildJenkinsInitialValues(pipeline: Record<string, unknown>, params: JenkinsParameterDefinition[], refParam: string): Record<string, string> {
const values: Record<string, string> = {};
params.forEach((param) => {
const name = String(param.name || '');
if (!name) return;
values[name] = jenkinsParamDefault(param);
});
if (refParam && !values[refParam]) {
values[refParam] = String(pipeline.branch || 'main');
}
return values;
}
function buildJenkinsInitialChoices(params: JenkinsParameterDefinition[]): Record<string, string[]> {
const choices: Record<string, string[]> = {};
params.forEach((param) => {
const name = String(param.name || '');
if (name) choices[name] = Array.from(new Set((param.choices || []).map((item) => String(item)).filter(Boolean)));
});
return choices;
}
// 流水线列表页编排器 // Logan.Li
export function PipelineManager() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [searchQuery, setSearchQuery] = useState('');
const { data: pipelines = [], isLoading: loading, isFetching, refetch: refetchPipelines } = usePipelines();
const { triggerMutation, deleteMutation, stopRunMutation } = usePipelineMutations();
@@ -41,11 +185,30 @@ export function PipelineManager() {
const [loadingRuns, setLoadingRuns] = useState<Record<number, boolean>>({});
const [envFilter, setEnvFilter] = useState<string>('all');
const [environments, setEnvironments] = useState<DeployEnvironment[]>([]);
const [viewMode, setViewMode] = useState<'default' | 'jenkins'>('default');
const [jenkinsViewFilter, setJenkinsViewFilter] = useState<string>('all');
const [runningRunMap, setRunningRunMap] = useState<Record<number, Record<string, unknown>>>({});
const [newOpen, setNewOpen] = useState(false);
const [jenkinsOpen, setJenkinsOpen] = useState(false);
const [jenkinsTrigger, setJenkinsTrigger] = useState<JenkinsTriggerState>({
open: false,
pipeline: null,
mode: 'both',
param: 'SELECT',
refType: 'branch',
values: {},
choices: {},
source: '',
loadingChoices: {},
});
const confirm = useConfirm();
const refreshPipelines = async () => {
await queryClient.invalidateQueries({ queryKey: ['pipelines'] });
await refetchPipelines();
};
useEffect(() => {
deployApi.getEnvironments()
.then((res) => {
@@ -95,7 +258,91 @@ export function PipelineManager() {
await triggerMutation.mutateAsync(code);
toast.success(t('deploy.toast.triggered'));
loadRuns(pipelineId, code);
refetchPipelines();
refreshPipelines();
} catch {
toast.error(t('deploy.toast.triggerFailed'));
}
};
const openJenkinsTrigger = async (pipeline: Record<string, unknown>) => {
try {
const cfg = await pipelineApi.getJenkinsConfig() as Record<string, unknown>;
const mode = String(cfg.ref_mode || 'both');
const defaultType = mode === 'tag' ? 'tag' : 'branch';
const param = preferredJenkinsParam(pipeline, String(cfg.ref_param || 'SELECT'));
const params = parseJenkinsParameters(pipeline);
setJenkinsTrigger({
open: true,
pipeline,
mode,
param,
refType: defaultType,
values: buildJenkinsInitialValues(pipeline, params, param),
choices: buildJenkinsInitialChoices(params),
source: String(pipeline.source || ''),
loadingChoices: {},
});
params.filter(isJenkinsGitParam).forEach((item) => {
void loadJenkinsRefs(pipeline, String(item.name || param), defaultType);
});
} catch {
toast.error('读取 Jenkins 构建配置失败');
}
};
const loadJenkinsRefs = async (pipeline: Record<string, unknown> | null, paramName: string, refType: 'branch' | 'tag') => {
if (!pipeline) return;
const code = String(pipeline.code || pipeline.id || '');
if (!code) return;
setJenkinsTrigger((prev) => ({ ...prev, loadingChoices: { ...prev.loadingChoices, [paramName]: true } }));
try {
const data = await pipelineApi.getJenkinsRefs(code, refType) as Record<string, unknown>;
const refs = Array.isArray(data.refs) ? data.refs.map((item) => String(item)).filter(Boolean) : [];
const fallback = jenkinsRefChoices(pipeline, paramName);
const currentParam = parseJenkinsParameters(pipeline).find((item) => String(item.name || '') === paramName);
const currentValue = String(jenkinsTrigger.values[paramName] || (currentParam ? jenkinsParamDefault(currentParam) : ''));
const normalizedRefs = currentValue.startsWith('origin/')
? refs.map((item) => item.startsWith('origin/') ? item : `origin/${item}`)
: refs;
const choices = Array.from(new Set([...normalizedRefs, ...fallback]));
setJenkinsTrigger((prev) => ({
...prev,
choices: { ...prev.choices, [paramName]: choices },
source: String(data.source || prev.source || pipeline.source || ''),
values: {
...prev.values,
[paramName]: prev.values[paramName] || (refType === 'branch' ? String(pipeline.branch || choices[0] || 'main') : choices[0] || ''),
},
loadingChoices: { ...prev.loadingChoices, [paramName]: false },
}));
} catch {
setJenkinsTrigger((prev) => ({
...prev,
choices: { ...prev.choices, [paramName]: jenkinsRefChoices(pipeline, paramName) },
loadingChoices: { ...prev.loadingChoices, [paramName]: false },
}));
}
};
const submitJenkinsTrigger = async () => {
const pipeline = jenkinsTrigger.pipeline;
if (!pipeline) return;
const refValue = String(jenkinsTrigger.values[jenkinsTrigger.param] || '').trim();
if (!refValue) {
toast.error('请填写 Jenkins 构建参数');
return;
}
const id = pipeline.id as number;
const code = (pipeline.code || pipeline.id) as string;
const data = jenkinsTrigger.refType === 'tag'
? { tag: refValue, variables: jenkinsTrigger.values }
: { branch: refValue, variables: jenkinsTrigger.values };
try {
await triggerMutation.mutateAsync({ code, data });
toast.success(t('deploy.toast.triggered'));
setJenkinsTrigger((prev) => ({ ...prev, open: false }));
loadRuns(id, code);
refreshPipelines();
} catch {
toast.error(t('deploy.toast.triggerFailed'));
}
@@ -146,18 +393,34 @@ export function PipelineManager() {
try {
await deleteMutation.mutateAsync(pipeline.id as number | string);
toast.success(t('deploy.toast.deleted'));
refetchPipelines();
refreshPipelines();
} catch {
toast.error(t('deploy.toast.deleteFailed'));
}
};
const jenkinsPipelines = pipelines.filter((p) => String(p.ci_provider || 'aiops') === 'jenkins');
const jenkinsViewTabs = Array.from(
jenkinsPipelines.reduce((acc, pipeline) => {
parseJenkinsViews(pipeline).filter((view) => view.name.toLowerCase() !== 'all').forEach((view) => {
const existing = acc.get(view.name) || { ...view, count: 0 };
acc.set(view.name, { ...existing, url: existing.url || view.url, count: existing.count + 1 });
});
return acc;
}, new Map<string, JenkinsViewInfo>()),
).map(([, view]) => view).sort((a, b) => a.name.localeCompare(b.name));
const filtered = pipelines.filter((p) => {
const matchSearch = String(p.name || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
String(p.description || '').toLowerCase().includes(searchQuery.toLowerCase());
const envId = p.environment_id as number | undefined;
const matchEnv = envFilter === 'all' || String(envId || '') === envFilter;
return matchSearch && matchEnv;
const provider = String(p.ci_provider || 'aiops');
const matchProvider = viewMode === 'default' || provider === 'jenkins';
const views = parseJenkinsViews(p);
const matchJenkinsView = viewMode !== 'jenkins' || jenkinsViewFilter === 'all' ||
views.some((view) => view.name === jenkinsViewFilter);
return matchSearch && matchEnv && matchProvider && matchJenkinsView;
});
const envTabs = [
@@ -168,6 +431,10 @@ export function PipelineManager() {
color: e.color,
})),
];
const jenkinsParams = jenkinsTrigger.pipeline ? parseJenkinsParameters(jenkinsTrigger.pipeline) : [];
const updateJenkinsValue = (name: string, value: string) => {
setJenkinsTrigger((prev) => ({ ...prev, values: { ...prev.values, [name]: value } }));
};
const toolbar = (
<div className="flex items-center gap-4 flex-wrap">
@@ -214,14 +481,36 @@ export function PipelineManager() {
return (
<PageShell
variant="list"
title={t('pipeline.title')}
subtitle={t('pipeline.subtitle')}
title="流水线"
subtitle="CI/CD 构建、镜像打包与双通道部署"
icon={<Workflow className="h-5 w-5" />}
loading={loading}
toolbar={toolbar}
actions={(
<>
<Button variant="outline" className="dg-btn-secondary" onClick={() => refetchPipelines()} disabled={isFetching}>
<div className="flex items-center gap-1 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] p-1">
{[
{ key: 'default', label: '默认视图' },
{ key: 'jenkins', label: 'Jenkins 视图' },
].map((item) => (
<button
key={item.key}
type="button"
onClick={() => setViewMode(item.key as 'default' | 'jenkins')}
className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
viewMode === item.key
? 'bg-[var(--dg-bg-primary)] text-[var(--dg-text-primary)] shadow-sm'
: 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]'
}`}
>
{item.label}
</button>
))}
</div>
<Button variant="outline" className="dg-btn-secondary" onClick={() => setJenkinsOpen(true)}>
<TechnologyBrandIcon brand="jenkins" className="mr-2 h-4 w-4" />Jenkins 接入
</Button>
<Button variant="outline" className="dg-btn-secondary" onClick={() => refreshPipelines()} disabled={isFetching}>
<Clock className="h-4 w-4 mr-2" />{t('common.refresh')}
</Button>
<Button className="dg-btn-primary" onClick={() => setNewOpen(true)}>
@@ -232,17 +521,64 @@ export function PipelineManager() {
contentClassName="space-y-4"
>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 px-4 py-2.5 rounded-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] text-xs text-[var(--dg-text-muted)]">
<span className="flex items-center gap-1.5">
<Server className="h-3.5 w-3.5" />
主机发布:构建 → SSH 关联主机
</span>
<span className="hidden sm:inline text-[var(--dg-border)]">|</span>
<span className="flex items-center gap-1.5">
<Network className="h-3.5 w-3.5" />
Kubernetes 发布:构建 → 镜像 → 关联 Deployment 滚动更新
</span>
{viewMode === 'jenkins' ? (
<>
<span className="flex items-center gap-1.5">
<PanelsTopLeft className="h-3.5 w-3.5" />
Jenkins 兼容视图:按 View、Job、Build 和最近构建状态展示
</span>
<span className="hidden sm:inline text-[var(--dg-border)]">|</span>
<span className="flex items-center gap-1.5">
<TechnologyBrandIcon brand="jenkins" className="h-3.5 w-3.5" />
通过 Jenkins API 触发构建,日志和状态回填到 AIOps
</span>
</>
) : (
<>
<span className="flex items-center gap-1.5">
<Server className="h-3.5 w-3.5" />
主机发布:构建 → SSH 关联主机
</span>
<span className="hidden sm:inline text-[var(--dg-border)]">|</span>
<span className="flex items-center gap-1.5">
<Network className="h-3.5 w-3.5" />
Kubernetes 发布:构建 → 镜像 → 关联 Deployment 滚动更新
</span>
</>
)}
</div>
{viewMode === 'jenkins' && jenkinsViewTabs.length > 0 && (
<div className="flex items-center gap-2 overflow-x-auto rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] p-2">
<button
type="button"
onClick={() => setJenkinsViewFilter('all')}
className={`shrink-0 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
jenkinsViewFilter === 'all'
? 'bg-[var(--dg-bg-primary)] text-[var(--dg-text-primary)] shadow-sm'
: 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]'
}`}
>
全部 View <span className="ml-1 opacity-60">{jenkinsPipelines.length}</span>
</button>
{jenkinsViewTabs.map((view) => (
<button
key={view.name}
type="button"
onClick={() => setJenkinsViewFilter(view.name)}
className={`shrink-0 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
jenkinsViewFilter === view.name
? 'bg-[var(--dg-bg-primary)] text-[var(--dg-text-primary)] shadow-sm'
: 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]'
}`}
title={view.url || view.name}
>
{view.name} <span className="ml-1 opacity-60">{view.count}</span>
</button>
))}
</div>
)}
{!loading && filtered.length === 0 ? (
<PageEmpty
icon={<Workflow className="h-10 w-10" />}
@@ -262,11 +598,12 @@ export function PipelineManager() {
<PipelineCard
key={id}
pipeline={pipeline}
viewMode={viewMode === 'jenkins' ? 'jenkins' : 'default'}
isExpanded={!!expanded[id]}
isLoadingRuns={!!loadingRuns[id]}
pipelineRuns={runs[id] || []}
onToggleExpand={() => toggleExpand(id, code)}
onTrigger={() => handleTrigger(id, code)}
onTrigger={() => String(pipeline.ci_provider || 'aiops') === 'jenkins' ? openJenkinsTrigger(pipeline) : handleTrigger(id, code)}
onStopRunning={() => handleStopRunning(id, code)}
onDelete={() => handleDelete(pipeline)}
onStopRun={(runId) => handleStop(id, code, runId)}
@@ -276,7 +613,181 @@ export function PipelineManager() {
</div>
)}
<PipelineCreateDialog open={newOpen} onOpenChange={setNewOpen} onCreated={refetchPipelines} />
<PipelineCreateDialog open={newOpen} onOpenChange={setNewOpen} onCreated={refreshPipelines} />
<JenkinsSettingsDialog open={jenkinsOpen} onOpenChange={setJenkinsOpen} onSynced={refreshPipelines} />
<Dialog open={jenkinsTrigger.open} onOpenChange={(open) => setJenkinsTrigger((prev) => ({ ...prev, open }))}>
<DialogContent className="w-[min(760px,calc(100vw-32px))] max-w-none p-0 overflow-hidden">
<DialogHeader className="border-b border-[var(--dg-border)] px-6 py-5">
<DialogTitle className="flex items-center gap-2 text-xl">
<TechnologyBrandIcon brand="jenkins" className="h-5 w-5" />
触发 Jenkins 构建
</DialogTitle>
<DialogDescription className="text-sm">
按 Jenkins Job 的 Build with Parameters 定义填写参数,AIOps 仅触发构建并记录日志。
</DialogDescription>
</DialogHeader>
<div className="space-y-4 px-6 py-5">
<div className="grid gap-3 rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] p-4 md:grid-cols-[1fr_auto]">
<div className="min-w-0">
<div className="text-sm font-semibold text-[var(--dg-text-primary)] truncate">
{String(jenkinsTrigger.pipeline?.jenkins_job_name || jenkinsTrigger.pipeline?.name || 'Jenkins Job')}
</div>
{jenkinsTrigger.source && (
<div className="mt-2 flex min-w-0 items-start gap-2 text-xs text-[var(--dg-text-muted)]">
<span className="shrink-0">Git</span>
<span className="min-w-0 break-all font-mono leading-5">{jenkinsTrigger.source}</span>
</div>
)}
</div>
<div className="flex flex-wrap items-start gap-2 md:justify-end">
<span className="rounded border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] px-2 py-1 text-[11px] text-[var(--dg-text-muted)]">
{jenkinsParams.length || 1} 个参数
</span>
<span className="rounded border border-sky-500/30 bg-sky-500/10 px-2 py-1 text-[11px] text-sky-500">
Jenkins
</span>
</div>
</div>
{jenkinsTrigger.mode === 'both' && jenkinsParams.some(isJenkinsGitParam) && (
<div className="grid grid-cols-2 gap-1 rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] p-1">
{[
{ key: 'branch', label: '分支' },
{ key: 'tag', label: 'Tag' },
].map((item) => (
<button
key={item.key}
type="button"
onClick={() => {
const refType = item.key as 'branch' | 'tag';
setJenkinsTrigger((prev) => ({ ...prev, refType }));
jenkinsParams.filter(isJenkinsGitParam).forEach((param) => {
void loadJenkinsRefs(jenkinsTrigger.pipeline, String(param.name || jenkinsTrigger.param), refType);
});
}}
className={`flex-1 rounded px-3 py-1.5 text-sm font-medium transition-colors ${
jenkinsTrigger.refType === item.key
? 'bg-[var(--dg-bg-primary)] text-[var(--dg-text-primary)] shadow-sm ring-1 ring-[var(--dg-border)]'
: 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]'
}`}
>
{item.label}
</button>
))}
</div>
)}
<div className="max-h-[52vh] space-y-3 overflow-y-auto pr-1">
{jenkinsParams.length === 0 && (
<div className="rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] p-4 text-sm text-[var(--dg-text-muted)]">
Jenkins 未返回参数定义,将按默认参数名手动触发。
</div>
)}
{jenkinsParams.map((param, index) => {
const name = String(param.name || '');
if (!name) return null;
const kind = jenkinsParamKind(param);
const choices = jenkinsTrigger.choices[name] || [];
const value = String(jenkinsTrigger.values[name] || '');
const listId = `jenkins-param-${index}`;
const loadingParam = Boolean(jenkinsTrigger.loadingChoices[name]);
const selected = value ? value.split(',').filter(Boolean) : [];
return (
<div key={name} className="space-y-2 rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] p-4">
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<Label className="block truncate font-mono text-sm text-[var(--dg-text-primary)]">{name}</Label>
{param.description && (
<div className="mt-1 whitespace-pre-wrap text-xs leading-5 text-[var(--dg-text-muted)]">{param.description}</div>
)}
</div>
<span className="shrink-0 rounded bg-[var(--dg-bg-primary)] px-2 py-1 text-[10px] text-[var(--dg-text-muted)]">
{String(param.type || 'parameter')}
</span>
</div>
{kind === 'choice' ? (
<select
value={value}
onChange={(e) => updateJenkinsValue(name, e.target.value)}
className="h-10 w-full rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] px-3 text-sm text-[var(--dg-text-primary)] outline-none focus:border-sky-500"
>
{choices.map((choice) => <option key={choice} value={choice}>{choice}</option>)}
</select>
) : kind === 'multi' ? (
<div className="grid gap-2 sm:grid-cols-2">
{choices.map((choice) => {
const checked = selected.includes(choice);
return (
<label key={choice} className="flex min-w-0 items-center gap-2 rounded border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] px-3 py-2 text-sm text-[var(--dg-text-secondary)]">
<input
type="checkbox"
checked={checked}
onChange={(e) => {
const next = e.target.checked
? Array.from(new Set([...selected, choice]))
: selected.filter((item) => item !== choice);
updateJenkinsValue(name, next.join(','));
}}
/>
<span className="truncate" title={choice}>{choice}</span>
</label>
);
})}
{choices.length === 0 && (
<Input value={value} onChange={(e) => updateJenkinsValue(name, e.target.value)} placeholder="value1,value2" />
)}
</div>
) : kind === 'boolean' ? (
<label className="flex items-center gap-2 rounded border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] px-3 py-2 text-sm text-[var(--dg-text-secondary)]">
<input
type="checkbox"
checked={value === 'true'}
onChange={(e) => updateJenkinsValue(name, e.target.checked ? 'true' : 'false')}
/>
启用
</label>
) : kind === 'git' && choices.length > 0 ? (
<select
value={value}
onChange={(e) => updateJenkinsValue(name, e.target.value)}
className="h-10 w-full rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] px-3 text-sm text-[var(--dg-text-primary)] outline-none focus:border-sky-500"
>
{choices.map((choice) => <option key={choice} value={choice}>{choice}</option>)}
</select>
) : (
<>
<Input
value={value}
onChange={(e) => updateJenkinsValue(name, e.target.value)}
placeholder={kind === 'git' ? (jenkinsTrigger.refType === 'tag' ? 'v1.0.0' : 'main') : name}
list={choices.length > 0 ? listId : undefined}
/>
{choices.length > 0 && (
<datalist id={listId}>
{choices.map((choice) => <option key={choice} value={choice} />)}
</datalist>
)}
</>
)}
{loadingParam && (
<div className="flex items-center gap-1.5 text-xs text-[var(--dg-text-muted)]">
<Loader2 className="h-3 w-3 animate-spin" />
正在读取候选...
</div>
)}
</div>
);
})}
</div>
</div>
<DialogFooter className="border-t border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] px-6 py-4">
<Button variant="outline" onClick={() => setJenkinsTrigger((prev) => ({ ...prev, open: false }))}>
取消
</Button>
<Button onClick={submitJenkinsTrigger} disabled={triggerMutation.isPending}>
<Play className="h-4 w-4 mr-2" />构建
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</PageShell>
);
}
}
@@ -1,35 +1,172 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import {
Play, RefreshCw, Trash2, History,
Play, Square, RefreshCw, Clock, User, AlertCircle, CheckCircle2,
Loader2, XCircle, History, FolderOpen, Trash2, ChevronRight as ChevronRightSmall,
GitBranch, Box, Container, Rocket, ShieldCheck,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { toast } from 'sonner';
import { pipelineApi } from '@/api';
import { FileBrowserDialog } from './FileBrowserDialog';
import { PageShell } from '@/components/layout/PageShell';
import { StatStrip } from '@/components/ui/stat-strip';
import type { JsonData } from '@/types/common';
import {
type PipelineRun, type LogEntry,
getStageLogSummary,
} from './pipeline-run-detail/types';
import { StageProgressBar } from './pipeline-run-detail/StageProgressBar';
import { RunsTable } from './pipeline-run-detail/RunsTable';
import { LogConsole } from './pipeline-run-detail/LogConsole';
import { getAuthToken, getWsBase } from '@/utils/request';
// ═══════════ 阶段定义 (与 PipelineManager 共用) ═══════════
const STAGE_DEFS = [
{ key: 'checkout', label: '代码检出', icon: GitBranch, color: '#6366F1', order: 1 },
{ key: 'build', label: '构建', icon: Box, color: '#F59E0B', order: 2 },
{ key: 'test', label: '测试', icon: CheckCircle2,color: '#10B981', order: 3 },
{ key: 'image', label: '镜像打包', icon: Container, color: '#06B6D4', order: 4 },
{ key: 'deploy', label: '部署', icon: Rocket, color: '#EF4444', order: 5 },
{ key: 'verify', label: '验证', icon: ShieldCheck, color: '#3B82F6', order: 6 },
].sort((a, b) => a.order - b.order);
interface PipelineRun {
id: number;
pipeline_id: number;
build_number: number;
status: string;
branch: string;
commit_id: string;
commit_msg: string;
author: string;
triggered_by: string;
image_tag: string;
duration: number;
stages: string;
started_at: string;
finished_at: string;
created_at: string;
}
interface LogEntry {
time: string;
level: string;
stage: string;
message: string;
}
interface StageStatus {
name: string;
status: string; // success, failed, running, pending
}
const STATUS_BADGES: Record<string, string> = {
success: 'bg-emerald-500/20 dg-text-green border-emerald-500/30',
failed: 'bg-red-500/20 dg-text-red border-red-500/30',
running: 'bg-blue-500/20 dg-text-blue border-blue-500/30',
pending: 'bg-[var(--dg-bg-active)]/20 dg-text-secondary border-[var(--dg-border)]',
cancelled: 'bg-amber-500/20 dg-text-yellow border-amber-500/30',
};
const STATUS_ICONS: JsonData = {
success: CheckCircle2,
failed: XCircle,
running: Loader2,
pending: Clock,
cancelled: AlertCircle,
};
function formatDuration(seconds: number): string {
if (!seconds) return '-';
if (seconds < 60) return `${seconds}s`;
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}m${s}s`;
}
function formatTime(ts: string): string {
if (!ts) return '-';
const d = new Date(ts);
return d.toLocaleString('zh-CN', {
month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
});
}
function parseStageStatuses(raw: string): StageStatus[] {
if (!raw) return [];
try {
const s = typeof raw === 'string' ? JSON.parse(raw) : raw;
return Array.isArray(s) ? s : [];
} catch { return []; }
}
/** 从 run.stages (JSON) 推导每个阶段的运行状态 */
function getRunStageStats(run: PipelineRun | null): Map<string, string> {
const map = new Map<string, string>();
if (!run) return map;
// 如果 run.stages 有数据,直接用
const statuses = parseStageStatuses(run.stages);
statuses.forEach(s => {
if (s.name || (s as JsonData).type) {
const key = (s as JsonData).type || (STAGE_DEFS.find(d => d.label === s.name)?.key);
if (key) map.set(key, s.status);
}
});
// 如果 stages 为空但 run 已结束,从整体状态推导
if (map.size === 0 && run.status === 'success') {
STAGE_DEFS.forEach(d => map.set(d.key, 'success'));
} else if (map.size === 0 && run.status === 'failed') {
STAGE_DEFS.forEach((d, i) => map.set(d.key, i === 0 ? 'success' : 'pending'));
} else if (map.size === 0 && run.status === 'running') {
map.set('checkout', 'running');
}
return map;
}
/** 从日志中提取每个阶段的首条日志内容(阶段摘要) */
function getStageLogSummary(logs: LogEntry[], stageKey: string): string {
const first = logs.find(l => l.stage === stageKey);
if (first) return first.message;
const def = STAGE_DEFS.find(d => d.key === stageKey);
if (def) return `${def.label}...`;
return '';
}
// 合并实时日志,避免 WebSocket init 与轮询增量交叠造成重复展示。 // Logan.Li
function mergeLogEntries(prev: LogEntry[], incoming: LogEntry[]): LogEntry[] {
const seen = new Set(prev.map((item) => `${item.time}|${item.level}|${item.stage}|${item.message}`));
const merged = [...prev];
incoming.forEach((item) => {
const key = `${item.time}|${item.level}|${item.stage}|${item.message}`;
if (!seen.has(key)) {
seen.add(key);
merged.push(item);
}
});
return merged;
}
export default function PipelineRunDetail() {
const { t } = useTranslation();
const { code: pipelineCode } = useParams<{ code: string }>();
// 阶段名称国际化映射
const stageLabel = (key: string) => {
const map: Record<string, string> = {
checkout: t('deploy.stage.checkout'),
build: t('deploy.stage.build'),
test: t('deploy.stage.test'),
image: t('deploy.stage.image'),
deploy: t('deploy.stage.deploy'),
verify: t('common.verify'),
};
return map[key] || key;
};
const [pipeline, setPipeline] = useState<JsonData>(null);
const [runs, setRuns] = useState<PipelineRun[]>([]);
const [selectedRun, setSelectedRun] = useState<PipelineRun | null>(null);
const [logs, setLogs] = useState<LogEntry[]>([]);
const [loading, setLoading] = useState(false);
const [selectedStage, setSelectedStage] = useState<string | null>(null);
const [selectedStage, setSelectedStage] = useState<string | null>(null); // null = 全部
const [pageSize, setPageSize] = useState(10);
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [deleting, setDeleting] = useState(false);
@@ -60,60 +197,56 @@ export default function PipelineRunDetail() {
}
} catch { toast.error(t('deploy.runDetail.fetchRunsFailed')); }
finally { setLoading(false); }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pipelineCode, pageSize]);
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => { fetchPipeline(); fetchRuns(); }, []);
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => { fetchRuns(); }, [pageSize]);
// 自动刷新
useEffect(() => {
const hasRunning = runs.some(r => r.status === 'running');
if (!hasRunning) return;
const timer = setInterval(fetchRuns, 5000);
return () => clearInterval(timer);
const t = setInterval(fetchRuns, 5000);
return () => clearInterval(t);
}, [runs, fetchRuns]);
// WebSocket 连接(运行中实时日志)
const connectWS = useCallback((runId: number) => {
if (wsRef.current) wsRef.current.close();
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const token = localStorage.getItem('token');
const ws = new WebSocket(`${protocol}//${window.location.host}/ws/pipeline/${runId}/log?token=${token}`);
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'init' && data.logs) {
const parsed = typeof data.logs === 'string' ? JSON.parse(data.logs) : data.logs;
setLogs(Array.isArray(parsed) ? parsed : []);
} else if (data.type === 'done') {
ws.close();
fetchRuns();
} else if (data.time && data.level) {
setLogs(prev => [...prev, data]);
}
} catch { /* binary frame */ }
};
ws.onclose = () => { wsRef.current = null; };
ws.onerror = () => { wsRef.current = null; };
wsRef.current = ws;
}, [fetchRuns]);
// 加载日志
const fetchLogs = useCallback(async (run: PipelineRun) => {
try {
const data: JsonData = await pipelineApi.getRunLogStream(run.id);
if (data?.logs) {
const parsed = typeof data.logs === 'string' ? JSON.parse(data.logs) : data.logs;
setLogs(Array.isArray(parsed) ? parsed : []);
setLogs(Array.isArray(parsed) ? mergeLogEntries([], parsed) : []);
} else {
setLogs([]);
}
if (run.status === 'running') connectWS(run.id);
} catch { setLogs([]); }
}, [connectWS]);
}, []);
// WebSocket
const connectWS = (runId: number) => {
if (wsRef.current) wsRef.current.close();
const token = getAuthToken();
const ws = new WebSocket(`${getWsBase()}/ws/pipeline/${runId}/log?token=${encodeURIComponent(token)}`);
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'init' && data.logs) {
const parsed = typeof data.logs === 'string' ? JSON.parse(data.logs) : data.logs;
setLogs(Array.isArray(parsed) ? mergeLogEntries([], parsed) : []);
} else if (data.type === 'done') {
ws.close();
fetchRuns();
} else if (data.time && data.level) {
setLogs(prev => mergeLogEntries(prev, [data]));
}
} catch { /* binary frame */ }
};
ws.onclose = () => { wsRef.current = null; };
ws.onerror = () => { wsRef.current = null; };
wsRef.current = ws;
};
useEffect(() => {
if (selectedRun) {
@@ -123,12 +256,12 @@ export default function PipelineRunDetail() {
return () => { if (wsRef.current) wsRef.current.close(); };
}, [selectedRun, fetchLogs]);
// 自动滚动到底部
// 自动滚动
useEffect(() => {
logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [logs]);
// 触发新运行
// 触发
const triggerRun = async () => {
try {
await pipelineApi.triggerByCode(pipelineCode);
@@ -137,7 +270,7 @@ export default function PipelineRunDetail() {
} catch { toast.error(t('deploy.toast.triggerFailed')); }
};
// 停止运行
// 停止
const stopRun = async (runId: number) => {
try {
await pipelineApi.stopRunByCode(pipelineCode, runId);
@@ -171,9 +304,23 @@ export default function PipelineRunDetail() {
else setSelectedIds(new Set(runs.map(r => r.id)));
};
// ═══════════ 派生数据 ═══════════
const filteredLogs = selectedStage ? logs.filter(l => l.stage === selectedStage) : logs;
const triggerLabel = (run: PipelineRun) => {
if (run.triggered_by === 'system') return 'Cron';
if (run.triggered_by === 'git') return 'Git Push';
return run.triggered_by || 'Manual';
};
// ═══════════ 阶段状态映射 ═══════════
const stageStats = getRunStageStats(selectedRun);
// ═══════════ 过滤日志(按阶段) ═══════════
const filteredLogs = selectedStage
? logs.filter(l => l.stage === selectedStage)
: logs;
// 获取当前选中阶段的摘要日志
const stageSummary = selectedStage ? getStageLogSummary(logs, selectedStage) : '';
const successCount = runs.filter(r => r.status === 'success').length;
const runningCount = runs.filter(r => r.status === 'running').length;
@@ -227,37 +374,305 @@ export default function PipelineRunDetail() {
)}
>
{/* ═══════════ 阶段进度条 (交互式) ═══════════ */}
<StageProgressBar
selectedRun={selectedRun}
selectedStage={selectedStage}
onSelectStage={setSelectedStage}
/>
<div className="flex items-center gap-0 px-4 py-3 rounded-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] overflow-x-auto">
{STAGE_DEFS.map((def, i) => {
const status = stageStats.get(def.key) || '';
const isSuccess = status === 'success' || status === 'completed';
const isRunning = status === 'running' || status === 'in_progress';
const isFailed = status === 'failed' || status === 'error';
const isActive = selectedStage === def.key;
const _isSelected = selectedStage === def.key;
const hasData = stageStats.size > 0;
// 颜色: 失败红 / 运行中蓝 / 成功绿 / 未运行灰
const color = isFailed ? '#EF4444'
: isRunning ? '#3B82F6'
: isSuccess ? '#207060' // teal 绿 (OCR 设计稿色)
: '#6B7280';
const bgColor = isFailed ? '#EF444420'
: isRunning ? '#3B82F620'
: isSuccess ? '#20706020'
: '#6B728010';
const borderColor = isActive ? color : (color + '50');
const Icon = def.icon;
const icon = isFailed ? <XCircle className="h-3.5 w-3.5" />
: isRunning ? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: isSuccess ? <CheckCircle2 className="h-3.5 w-3.5" />
: hasData ? <span className="text-[10px] opacity-40">○</span>
: <Icon className="h-3.5 w-3.5 opacity-40" />;
const isLast = i === STAGE_DEFS.length - 1;
const borderRadius = i === 0 ? '8px 0 0 8px' : isLast ? '0 8px 8px 0' : '0';
return (
<div key={def.key} className="flex items-center">
<button
onClick={() => setSelectedStage(selectedStage === def.key ? null : def.key)}
title={`${stageLabel(def.key)}${status ? ` (${status})` : ''} — ${t('deploy.runDetail.clickToViewLog')}`}
className={`flex items-center gap-1.5 px-3 py-2 text-[12px] font-semibold border shadow-sm transition-all cursor-pointer
${isActive ? 'ring-1 ring-offset-0 scale-[1.02]' : 'hover:brightness-110'}`}
style={{
backgroundColor: bgColor,
borderColor,
color,
borderRadius,
}}
>
{icon}
<span>{stageLabel(def.key)}</span>
</button>
{!isLast && (
<div className="flex items-center justify-center w-5 h-5 -mx-px z-10">
<ChevronRightSmall className="h-3.5 w-3.5" style={{ color: color + '99' }} />
</div>
)}
</div>
);
})}
{selectedRun && (
<span className="ml-auto text-[10px] text-[var(--dg-text-secondary)] opacity-40 shrink-0 pl-3 whitespace-nowrap">
{selectedStage ? stageLabel(selectedStage) : t('deploy.pipeline.allStages')}
</span>
)}
</div>
{/* ═══════════ 主内容区: 执行记录表 + 日志 ═══════════ */}
<div className="flex-1 flex flex-col min-h-0">
<RunsTable
runs={runs}
loading={loading}
selectedRun={selectedRun}
selectedIds={selectedIds}
onSelectRun={setSelectedRun}
onToggleSelect={toggleSelect}
onToggleAll={toggleAll}
onStopRun={stopRun}
onBrowse={(run) => { setBrowserRun({ id: run.id, buildNumber: run.build_number }); setBrowserOpen(true); }}
/>
{/* ─── 执行记录表 ─── */}
<div className="rounded-md border border-[var(--dg-border)] overflow-hidden mb-4">
{loading && runs.length === 0 ? (
<div className="text-center py-12">
<Loader2 className="w-6 h-6 animate-spin mx-auto mb-2 text-[var(--dg-text-muted)]" />
{t('common.loading')}
</div>
) : runs.length === 0 ? (
<div className="text-center py-12 border border-dashed border-[var(--dg-border)] rounded-lg m-4">
<History className="w-10 h-10 mx-auto mb-3 text-[var(--dg-text-muted)]" />
<p className="text-[var(--dg-text-secondary)]">{t('deploy.run.noRecordsHint')}</p>
</div>
) : (
<table className="w-full text-sm">
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
<tr>
<th className="px-2 py-2.5 w-10">
<input type="checkbox" checked={selectedIds.size === runs.length && runs.length > 0}
onChange={toggleAll} className="rounded" />
</th>
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)] w-16">{t('deploy.pipeline.buildNumber')}</th>
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)] w-20">{t('deploy.table.status')}</th>
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)]">{t('deploy.table.trigger')}</th>
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)]">{t('deploy.table.user')}</th>
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)]">{t('deploy.table.duration')}</th>
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)] hidden md:table-cell">{t('common.createdAt')}</th>
<th className="px-3 py-2.5 text-right font-medium text-[var(--dg-text-secondary)] w-32">{t('common.actions')}</th>
</tr>
</thead>
<tbody>
{runs.map(run => {
const StatusIcon = STATUS_ICONS[run.status] || Clock;
return (
<tr key={run.id}
className={`border-b border-[var(--dg-border)] cursor-pointer transition-colors
${selectedRun?.id === run.id ? 'bg-[var(--dg-blue)]/10' : 'hover:bg-[var(--dg-bg-tertiary)]/50'}`}
onClick={() => { setSelectedRun(run); }}>
<td className="px-2 py-2.5" onClick={e => e.stopPropagation()}>
<input type="checkbox" checked={selectedIds.has(run.id)}
onChange={() => toggleSelect(run.id)} className="rounded" />
</td>
<td className="px-3 py-2.5 font-mono font-semibold text-[var(--dg-text-primary)]">
#{run.build_number}
</td>
<td className="px-3 py-2.5">
<Badge className={`text-xs ${STATUS_BADGES[run.status] || ''} flex items-center gap-1 w-fit`}>
<StatusIcon className={`w-3 h-3 ${run.status === 'running' ? 'animate-spin' : ''}`} />
{run.status === 'success' ? t('deploy.status.success')
: run.status === 'failed' ? t('deploy.status.failed')
: run.status === 'running' ? t('deploy.runDetail.executing')
: run.status === 'cancelled' ? t('deploy.status.cancelled')
: t('deploy.status.pending')}
</Badge>
</td>
<td className="px-3 py-2.5">
<Badge variant="outline" className="text-xs">{triggerLabel(run)}</Badge>
</td>
<td className="px-3 py-2.5 text-[var(--dg-text-secondary)] flex items-center gap-1">
<User className="w-3 h-3" />
{run.triggered_by || 'admin'}
</td>
<td className="px-3 py-2.5 text-[var(--dg-text-secondary)] font-mono text-xs">
{run.status === 'running' ? (
<span className="flex items-center gap-1 dg-text-blue">
<Loader2 className="w-3 h-3 animate-spin" />{t('deploy.pipeline.executing')}
</span>
) : formatDuration(run.duration)}
</td>
<td className="px-3 py-2.5 text-[var(--dg-text-muted)] text-xs hidden md:table-cell">
{formatTime(run.created_at)}
</td>
<td className="px-3 py-2.5 text-right" onClick={e => e.stopPropagation()}>
<div className="flex justify-end gap-1">
<Button variant="ghost" size="sm" className="h-7 text-xs"
onClick={() => setSelectedRun(run)}>
{t('deploy.label.log')}
</Button>
<Button variant="ghost" size="sm" className="h-7 text-xs"
onClick={() => { setBrowserRun({ id: run.id, buildNumber: run.build_number }); setBrowserOpen(true); }}>
<FolderOpen className="w-3.5 h-3.5 mr-1" />{t('deploy.label.browse')}
</Button>
{run.status === 'running' && (
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-500"
onClick={() => stopRun(run.id)}>
<Square className="w-3.5 h-3.5" />
</Button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
{/* ═══════════ 阶段日志 Console ═══════════ */}
{/* ─── 阶段日志 Console ─── */}
{selectedRun && (
<LogConsole
selectedRun={selectedRun}
pipeline={pipeline}
pipelineCode={pipelineCode}
selectedStage={selectedStage}
filteredLogs={filteredLogs}
stageSummary={stageSummary}
logEndRef={logEndRef}
/>
<Card className="flex-1 flex flex-col min-h-0 border-[var(--dg-border)] bg-[var(--dg-bg-secondary)]">
<CardHeader className="py-3 px-4 flex flex-row items-center justify-between border-b border-[var(--dg-border)]">
<div className="flex items-center gap-3">
<CardTitle className="text-sm font-mono">
{pipeline?.name || `#${pipelineCode}`} #{selectedRun.build_number}
</CardTitle>
{selectedRun.status === 'running' && (
<Badge className="bg-blue-500/20 dg-text-blue border-blue-500/30 text-xs">
<Loader2 className="w-3 h-3 animate-spin mr-1" />{t('deploy.runDetail.realtime')}
</Badge>
)}
{selectedStage && (
<Badge className="text-xs border-[var(--dg-border)]" variant="outline">
{stageLabel(selectedStage)}
</Badge>
)}
</div>
<div className="flex items-center gap-2 text-xs text-[var(--dg-text-muted)]">
<span>{t('deploy.runDetail.trigger')}: {triggerLabel(selectedRun)} ({selectedRun.triggered_by || 'admin'})</span>
<span className="opacity-40">|</span>
<span>{t('deploy.run.branch')}: {selectedRun.branch || '-'}</span>
{selectedRun.commit_id && (
<>
<span className="opacity-40">|</span>
<code className="bg-amber-400/10 dg-text-yellow px-1 py-0.5 rounded text-[10px]">
{selectedRun.commit_id.slice(0, 8)}
</code>
</>
)}
{selectedRun.image_tag && (
<>
<span className="opacity-40">|</span>
<code className="bg-cyan-400/10 dg-text-cyan px-1 py-0.5 rounded text-[10px]">
📦 {selectedRun.image_tag}
</code>
</>
)}
</div>
</CardHeader>
<CardContent className="flex-1 p-0 overflow-hidden min-h-0">
{/* ─── 阶段摘要 (OCR 设计稿特性: [代码检出] 命令 + 结果) ─── */}
{selectedStage && stageSummary && (
<div className="px-4 py-2 border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]/30">
<div className="flex items-center gap-2">
<span className="text-[11px] font-semibold text-[var(--dg-text-secondary)]">
[{stageLabel(selectedStage)}]
</span>
{(() => {
const st = stageStats.get(selectedStage);
if (st === 'success' || st === 'completed') return <CheckCircle2 className="h-3.5 w-3.5 dg-text-green" />;
if (st === 'failed' || st === 'error') return <XCircle className="h-3.5 w-3.5 dg-text-red" />;
if (st === 'running' || st === 'in_progress') return <Loader2 className="h-3.5 w-3.5 dg-text-blue animate-spin" />;
return null;
})()}
<span className="text-xs text-[var(--dg-text-muted)] truncate font-mono">
{stageSummary}
</span>
</div>
</div>
)}
<div className="h-full overflow-y-auto bg-[#0d1117] font-mono text-xs leading-5 p-4" style={{ minHeight: 280 }}>
{filteredLogs.length === 0 && selectedRun.status !== 'running' ? (
<div className="text-center py-12 dg-text-muted">
{selectedStage ? t('deploy.pipeline.noLogStage') : t('deploy.pipeline.logOutput')}
</div>
) : (
<>
{(() => {
let lastStage = '';
const stageStyles: Record<string, string> = {
checkout: 'border-indigo-500 bg-indigo-500/10',
build: 'border-amber-500 bg-amber-500/10',
test: 'border-emerald-500 bg-emerald-500/10',
image: 'border-cyan-500 bg-cyan-500/10',
deploy: 'border-red-500 bg-red-500/10',
verify: 'border-blue-500 bg-blue-500/10',
};
const stageIcons: Record<string, string> = {
checkout: '⬇', build: '⚙', test: '✓',
image: '📦', deploy: '🚀', verify: '🛡',
};
return filteredLogs.map((entry, i) => {
const showStage = !selectedStage && entry.stage && entry.stage !== lastStage;
if (showStage) lastStage = entry.stage || '';
const isError = entry.level === 'error';
const isWarn = entry.level === 'warn';
const isSuccess = entry.level === 'success';
const accent = isError ? 'border-l-2 border-red-500 bg-red-500/10'
: isWarn ? 'border-l-2 border-amber-500 bg-amber-500/10'
: isSuccess ? 'border-l-2 border-emerald-500 bg-emerald-500/5' : '';
const msgColor = isError ? 'text-red-200'
: isWarn ? 'text-amber-200'
: isSuccess ? 'text-emerald-200' : 'text-[var(--dg-text-secondary)]';
const levelBadge = isError
? <span className="shrink-0 px-1.5 py-px rounded text-[10px] font-bold bg-red-600/40 text-red-200">ERR</span>
: isWarn
? <span className="shrink-0 px-1.5 py-px rounded text-[10px] font-bold bg-amber-600/40 text-amber-200">WARN</span>
: isSuccess
? <span className="shrink-0 px-1.5 py-px rounded text-[10px] font-bold bg-emerald-600/30 text-emerald-200"> OK </span>
: <span className="shrink-0 px-1.5 py-px rounded text-[10px] bg-[var(--dg-bg-active)]/30 dg-text-secondary">INFO</span>;
return (
<React.Fragment key={i}>
{showStage && (
<div className={`flex items-center gap-2 my-2 py-1 px-2 border-l-4 rounded-r ${stageStyles[entry.stage || ''] || 'border-[var(--dg-border)] bg-[var(--dg-bg-active)]/10'}`}>
<span className="text-xs">{stageIcons[entry.stage || ''] || '▸'}</span>
<span className="text-[11px] font-semibold tracking-wider text-[var(--dg-text-primary)]">{entry.stage}</span>
<span className="flex-1 border-t border-[var(--dg-border)]/50"></span>
</div>
)}
<div className={`flex items-start gap-2 px-2 py-0.5 -mx-1 rounded-sm ${accent} ${!isError && !isWarn && !isSuccess ? 'hover:bg-white/[0.03]' : ''}`}>
<span className="dg-text-muted shrink-0 w-14 text-right text-[10px] pt-px">{entry.time}</span>
{levelBadge}
<span className={`whitespace-pre-wrap break-words min-w-0 ${msgColor}`}>{entry.message}</span>
</div>
</React.Fragment>
);
});
})()}
{selectedRun.status === 'running' && (
<div className="flex items-center gap-2 mt-2 py-1.5 px-3 bg-blue-500/10 border-l-4 border-blue-500 rounded-r">
<Loader2 className="w-3 h-3 animate-spin dg-text-blue shrink-0" />
<span className="text-[11px] text-blue-300 font-medium">{t('deploy.pipeline.realtimeOutput')}</span>
<span className="flex gap-1 ml-1">
<span className="w-1 h-1 rounded-full bg-blue-400 animate-bounce" style={{ animationDelay: '0ms' }}></span>
<span className="w-1 h-1 rounded-full bg-blue-400 animate-bounce" style={{ animationDelay: '150ms' }}></span>
<span className="w-1 h-1 rounded-full bg-blue-400 animate-bounce" style={{ animationDelay: '300ms' }}></span>
</span>
</div>
)}
<div ref={logEndRef} />
</>
)}
</div>
</CardContent>
</Card>
)}
</div>
@@ -66,7 +66,6 @@ export function ReleaseManager() {
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
+271 -12
View File
@@ -1,13 +1,17 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { RefreshCw, Loader2, Download, CheckCircle, XCircle, Clock, Settings2, ChevronDown, Trash2 } from 'lucide-react';
import { RefreshCw, Loader2, Download, CheckCircle, XCircle, Clock, Settings2, ChevronDown, Trash2, Wrench } from 'lucide-react';
import { TechnologyBrandIcon } from '@/components/icons/TechnologyBrandIcon';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { toast } from 'sonner';
import { deployApi } from '@/api';
import { deployApi, pipelineApi } from '@/api';
import type { JsonData } from '@/types/common';
interface ToolConfig {
@@ -22,6 +26,17 @@ interface VersionOption {
version: string; label: string;
}
interface JenkinsToolForm {
base_url: string;
username: string;
token: string;
auth_type: string;
enabled: boolean;
overwrite: boolean;
ref_mode: 'branch' | 'tag' | 'both';
ref_param: string;
}
const categoryLabelKey: Record<string, string> = {
build: 'pipeline.build', deploy: 'pipeline.deploy', automation: 'pipeline.automation', other: 'pipeline.other',
};
@@ -34,13 +49,30 @@ const categoryColor: Record<string, string> = {
// 支持版本选择的工具 // Logan.Li
const VERSIONED_TOOLS = ['jdk', 'golang', 'nodejs', 'maven', 'nvm'];
const defaultJenkinsForm: JenkinsToolForm = {
base_url: '',
username: '',
token: '',
auth_type: 'token',
enabled: true,
overwrite: true,
ref_mode: 'both',
ref_param: 'SELECT',
};
export default function ToolConfigTab() {
const { t } = useTranslation();
const confirm = useConfirm();
const [mode, setMode] = useState<'local' | 'jenkins'>('local');
const [tools, setTools] = useState<ToolConfig[]>([]);
const [loading, setLoading] = useState(false);
const [installing, setInstalling] = useState<number | null>(null);
const [uninstalling, setUninstalling] = useState<number | null>(null);
const [jenkinsForm, setJenkinsForm] = useState<JenkinsToolForm>(defaultJenkinsForm);
const [jenkinsTokenSet, setJenkinsTokenSet] = useState(false);
const [jenkinsSaving, setJenkinsSaving] = useState(false);
const [jenkinsTesting, setJenkinsTesting] = useState(false);
const [jenkinsSyncing, setJenkinsSyncing] = useState(false);
// Version dialog state
const [versionDialog, setVersionDialog] = useState<{ tool: ToolConfig; versions: VersionOption[] } | null>(null);
@@ -58,16 +90,38 @@ export default function ToolConfigTab() {
finally { setLoading(false); }
};
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => { fetchTools(); }, []);
// Jenkins 模式配置按需加载,Token 只展示是否已保存。 // Logan.Li
useEffect(() => {
if (mode !== 'jenkins') return;
pipelineApi.getJenkinsConfig()
.then((data) => {
const cfg = data as Record<string, unknown>;
setJenkinsForm((prev) => ({
...prev,
base_url: String(cfg.base_url || ''),
username: String(cfg.username || ''),
auth_type: String(cfg.auth_type || 'token'),
enabled: cfg.enabled !== false,
token: '',
ref_mode: ['branch', 'tag', 'both'].includes(String(cfg.ref_mode)) ? String(cfg.ref_mode) as JenkinsToolForm['ref_mode'] : 'both',
ref_param: String(cfg.ref_param || 'SELECT'),
}));
setJenkinsTokenSet(Boolean(cfg.token_set));
})
.catch(() => {
setJenkinsForm(defaultJenkinsForm);
setJenkinsTokenSet(false);
});
}, [mode]);
// 安装中/失败状态轮询,与磁盘实际状态同步 // Logan.Li
useEffect(() => {
const pending = tools.filter((t) => t.status === 'installing' || t.status === 'failed');
if (pending.length === 0) return undefined;
const timer = setInterval(() => { void fetchTools(); }, 4000);
return () => clearInterval(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tools]);
const openVersionDialog = async (tool: ToolConfig) => {
@@ -156,18 +210,223 @@ export default function ToolConfigTab() {
setTesting(null);
};
const updateJenkinsForm = <K extends keyof JenkinsToolForm>(key: K, value: JenkinsToolForm[K]) => {
setJenkinsForm((prev) => ({ ...prev, [key]: value }));
};
const saveJenkinsConfig = async () => {
if (!jenkinsForm.base_url.trim()) {
toast.error('请填写 Jenkins 地址');
return false;
}
setJenkinsSaving(true);
try {
await pipelineApi.saveJenkinsConfig({
base_url: jenkinsForm.base_url.trim(),
username: jenkinsForm.username.trim(),
token: jenkinsForm.token,
auth_type: jenkinsForm.auth_type,
enabled: jenkinsForm.enabled,
ref_mode: jenkinsForm.ref_mode,
ref_param: jenkinsForm.ref_param.trim() || 'SELECT',
});
if (jenkinsForm.token) setJenkinsTokenSet(true);
setJenkinsForm((prev) => ({ ...prev, token: '' }));
toast.success('Jenkins 配置已保存');
return true;
} catch {
toast.error('保存 Jenkins 配置失败');
return false;
} finally {
setJenkinsSaving(false);
}
};
const testJenkinsConfig = async () => {
const ok = await saveJenkinsConfig();
if (!ok) return;
setJenkinsTesting(true);
try {
await pipelineApi.testJenkinsConfig();
toast.success('Jenkins 连接成功');
} catch {
toast.error('Jenkins 连接失败');
} finally {
setJenkinsTesting(false);
}
};
const syncJenkinsJobs = async () => {
const ok = await saveJenkinsConfig();
if (!ok) return;
setJenkinsSyncing(true);
try {
const res = await pipelineApi.syncJenkinsJobs({ overwrite: jenkinsForm.overwrite }) as Record<string, unknown>;
toast.success(`同步完成:视图 ${String(res.views || 0)},发现 ${String(res.total || 0)},新增 ${String(res.created || 0)},更新 ${String(res.updated || 0)},失败 ${String(res.failed || 0)}`);
} catch {
toast.error('同步 Jenkins Jobs 失败');
} finally {
setJenkinsSyncing(false);
}
};
const busyJenkins = jenkinsSaving || jenkinsTesting || jenkinsSyncing;
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-xs text-[var(--dg-text-secondary)]">
{t('deploy.tool.subtitle')}
</p>
<Button variant="outline" size="sm" onClick={fetchTools}>
<RefreshCw className="w-3.5 h-3.5 mr-1" />{t('common.refresh')}
</Button>
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<p className="text-xs text-[var(--dg-text-secondary)]">
{mode === 'local' ? t('deploy.tool.subtitle') : 'Jenkins 模式下平台只负责接入、同步、触发和审计,构建工具由 Jenkins 节点自身维护。'}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] p-1">
{[
{ key: 'local', label: '本地模式', icon: Wrench },
{ key: 'jenkins', label: 'Jenkins 模式', icon: Wrench },
].map((item) => {
const Icon = item.icon;
const active = mode === item.key;
return (
<button
key={item.key}
type="button"
onClick={() => setMode(item.key as 'local' | 'jenkins')}
className={`inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
active
? 'bg-[var(--dg-bg-primary)] text-[var(--dg-text-primary)] shadow-sm'
: 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]'
}`}
>
{item.key === 'jenkins'
? <TechnologyBrandIcon brand="jenkins" className="h-3.5 w-3.5" />
: <Icon className="h-3.5 w-3.5" />}
{item.label}
</button>
);
})}
</div>
{mode === 'local' && (
<Button variant="outline" size="sm" onClick={fetchTools}>
<RefreshCw className="w-3.5 h-3.5 mr-1" />{t('common.refresh')}
</Button>
)}
</div>
</div>
{loading ? (
{mode === 'jenkins' ? (
<div className="rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] p-4">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<div className="flex items-center gap-2 text-sm font-semibold text-[var(--dg-text-primary)]">
<TechnologyBrandIcon brand="jenkins" className="h-4 w-4" />Jenkins 接入配置
</div>
<p className="mt-1 text-xs text-[var(--dg-text-secondary)]">保存 Jenkins API 凭据后,可扫描 Jobs 并同步为 AIOps Jenkins 流水线。</p>
</div>
<Badge variant="outline" className={jenkinsForm.enabled ? 'border-emerald-500/30 dg-text-green' : 'border-[var(--dg-border)] text-[var(--dg-text-muted)]'}>
{jenkinsForm.enabled ? '已启用' : '已停用'}
</Badge>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<div className="space-y-1.5">
<Label>Jenkins 地址</Label>
<Input
value={jenkinsForm.base_url}
onChange={(e) => updateJenkinsForm('base_url', e.target.value)}
placeholder="https://jenkins.example.com"
/>
</div>
<div className="space-y-1.5">
<Label>用户</Label>
<Input
value={jenkinsForm.username}
onChange={(e) => updateJenkinsForm('username', e.target.value)}
placeholder="jenkins"
/>
</div>
<div className="space-y-1.5">
<Label>{jenkinsTokenSet ? 'Token / 密码(已保存)' : 'Token / 密码'}</Label>
<Input
value={jenkinsForm.token}
onChange={(e) => updateJenkinsForm('token', e.target.value)}
type="password"
placeholder={jenkinsTokenSet ? '留空则保持原密钥' : 'API Token 或密码'}
/>
</div>
</div>
<div className="mt-4 grid gap-3 lg:grid-cols-2">
<div className="flex items-center justify-between rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] px-3 py-2">
<div>
<div className="text-sm font-medium text-[var(--dg-text-primary)]">启用 Jenkins 模式</div>
<div className="text-xs text-[var(--dg-text-muted)]">关闭后 Jenkins 类型流水线不可触发</div>
</div>
<Switch checked={jenkinsForm.enabled} onCheckedChange={(v) => updateJenkinsForm('enabled', v)} />
</div>
<div className="flex items-center justify-between rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] px-3 py-2">
<div>
<div className="text-sm font-medium text-[var(--dg-text-primary)]">覆盖同步</div>
<div className="text-xs text-[var(--dg-text-muted)]">保持 Jenkins 原 Job 名称,用 Job 信息更新描述和映射 URL</div>
</div>
<Switch checked={jenkinsForm.overwrite} onCheckedChange={(v) => updateJenkinsForm('overwrite', v)} />
</div>
</div>
<div className="mt-4 grid gap-3 lg:grid-cols-[1.4fr_1fr]">
<div className="rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] px-3 py-2">
<div className="mb-2">
<div className="text-sm font-medium text-[var(--dg-text-primary)]">构建引用</div>
<div className="text-xs text-[var(--dg-text-muted)]">用于 Jenkins Build with Parameters 的分支和 Tag 选择。</div>
</div>
<div className="flex flex-wrap gap-1 rounded-md bg-[var(--dg-bg-secondary)] p-1">
{[
{ key: 'branch', label: '仅分支' },
{ key: 'tag', label: '仅 Tag' },
{ key: 'both', label: '分支 + Tag' },
].map((item) => (
<button
key={item.key}
type="button"
onClick={() => updateJenkinsForm('ref_mode', item.key as JenkinsToolForm['ref_mode'])}
className={`rounded px-3 py-1.5 text-xs font-medium transition-colors ${
jenkinsForm.ref_mode === item.key
? 'bg-[var(--dg-bg-primary)] text-[var(--dg-text-primary)] shadow-sm'
: 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]'
}`}
>
{item.label}
</button>
))}
</div>
</div>
<div className="space-y-1.5 rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] px-3 py-2">
<Label>Jenkins 参数名</Label>
<Input
value={jenkinsForm.ref_param}
onChange={(e) => updateJenkinsForm('ref_param', e.target.value)}
placeholder="SELECT"
/>
</div>
</div>
<div className="mt-4 flex flex-wrap justify-end gap-2 border-t border-[var(--dg-border)] pt-4">
<Button variant="outline" size="sm" onClick={testJenkinsConfig} disabled={busyJenkins}>
{jenkinsTesting ? <Loader2 className="w-3.5 h-3.5 animate-spin mr-1" /> : <CheckCircle className="w-3.5 h-3.5 mr-1" />}
测试
</Button>
<Button variant="outline" size="sm" onClick={saveJenkinsConfig} disabled={busyJenkins}>
{jenkinsSaving ? <Loader2 className="w-3.5 h-3.5 animate-spin mr-1" /> : <Settings2 className="w-3.5 h-3.5 mr-1" />}
保存
</Button>
<Button size="sm" onClick={syncJenkinsJobs} disabled={busyJenkins}>
{jenkinsSyncing ? <Loader2 className="w-3.5 h-3.5 animate-spin mr-1" /> : <RefreshCw className="w-3.5 h-3.5 mr-1" />}
同步 Jobs
</Button>
</div>
</div>
) : loading ? (
<div className="text-center py-12 text-[var(--dg-text-secondary)]">
<Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" />{t('common.loading')}
</div>
@@ -61,7 +61,7 @@ export default function UserGroupsManager({ embedded = false }: { embedded?: boo
};
const handleDelete = async (g: Group) => {
const ok = await _confirm({ title: i18n.t('deploy.rbac.deleteGroup'), description: i18n.t('deploy.rbac.deleteGroupDesc', { name: g.name }), variant: 'destructive' });
const ok = await _confirm({ title: i18n.t('deploy.rbac.deleteGroup'), description: `确定删除「${g.name}」?所有成员和流水线授权将同时移除。`, variant: 'destructive' });
if (!ok) return;
try { await deployApi.deleteGroup(g.id); toast.success(i18n.t('monitor.silenceRules.deleted')); loadGroups(); }
catch { toast.error(i18n.t('kubernetes.msg.deleteFailed2')); }
@@ -75,7 +75,7 @@ export default function UserGroupsManager({ embedded = false }: { embedded?: boo
};
const handleRemoveMember = async (groupId: number, memberId: number, username: string) => {
const ok = await _confirm({ title: i18n.t('pipeline.removeMember'), description: i18n.t('pipeline.removeMemberDesc', { username }) });
const ok = await _confirm({ title: i18n.t('pipeline.removeMember'), description: `确定从组中移除「${username}」?` });
if (!ok) return;
try { await deployApi.removeGroupMember(groupId, memberId); toast.success(i18n.t('deploy.toast.removed')); loadGroups(); }
catch { toast.error(i18n.t('deploy.toast.removeFailed')); }
@@ -88,10 +88,10 @@ export default function UserGroupsManager({ embedded = false }: { embedded?: boo
const toolbar = embedded ? (
<div className="flex items-center justify-end gap-2 mb-3">
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={loadGroups}>
<RefreshCw className="w-4 h-4 mr-1" />{i18n.t("common.refresh")}
<RefreshCw className="w-4 h-4 mr-1" />刷新
</Button>
<Button size="sm" className="dg-btn-primary" onClick={() => { setEditId(null); setFormName(''); setFormDesc(''); setFormOpen(true); }}>
<Plus className="w-4 h-4 mr-1" />{i18n.t("common.newUserGroup")}
<Plus className="w-4 h-4 mr-1" />新建用户组
</Button>
</div>
) : null;
@@ -99,11 +99,11 @@ export default function UserGroupsManager({ embedded = false }: { embedded?: boo
const listBody = !loading && groups.length === 0 ? (
<PageEmpty
icon={<Users className="h-10 w-10" />}
title={i18n.t('deploy.userGroup.noGroups')}
description={i18n.t('deploy.userGroup.createFirstHint')}
title={i18n.t('pipeline.usergroupsmanag.sd1edd832')}
description={i18n.t('pipeline.usergroupsmanag.scf98655a')}
action={(
<Button size="sm" onClick={() => { setEditId(null); setFormName(''); setFormDesc(''); setFormOpen(true); }}>
<Plus className="w-4 h-4 mr-1" />{i18n.t("common.newUserGroup")}
<Plus className="w-4 h-4 mr-1" />新建用户组
</Button>
)}
/>
@@ -116,7 +116,7 @@ export default function UserGroupsManager({ embedded = false }: { embedded?: boo
<Shield className="w-5 h-5 dg-text-blue" />
<div>
<div className="font-medium text-[var(--dg-text-primary)]">{g.name}</div>
<div className="text-xs text-[var(--dg-text-secondary)]">{g.description || i18n.t('system.roleDescFallback')} · {i18n.t('common.memberCount', { count: (g.members || []).length })}</div>
<div className="text-xs text-[var(--dg-text-secondary)]">{g.description || i18n.t('system.roleDescFallback')} · {(g.members || []).length} 位成员</div>
</div>
</div>
<div className="flex items-center gap-2">
@@ -165,7 +165,7 @@ export default function UserGroupsManager({ embedded = false }: { embedded?: boo
<DialogContent>
<DialogHeader><DialogTitle>{editId ? i18n.t('deploy.rbac.groupEditTitle') : i18n.t('pipeline.newGroup')}</DialogTitle></DialogHeader>
<div className="space-y-3">
<div className="space-y-1"><Label> {i18n.t('monitor.groupName')} </Label><Input value={formName} onChange={e => setFormName(e.target.value)} placeholder={i18n.t('deploy.userGroup.namePlaceholder')} /></div>
<div className="space-y-1"><Label> {i18n.t('monitor.groupName')} </Label><Input value={formName} onChange={e => setFormName(e.target.value)} placeholder={i18n.t('pipeline.usergroupsmanag.s5a56328e')} /></div>
<div className="space-y-1"><Label> {i18n.t('aiops.llmModelDesc')} </Label><Input value={formDesc} onChange={e => setFormDesc(e.target.value)} placeholder={i18n.t('rbac.jobRole.placeholder.description')} /></div>
</div>
<div className="flex justify-end gap-2 mt-4 pt-4 border-t">
@@ -190,24 +190,24 @@ export default function UserGroupsManager({ embedded = false }: { embedded?: boo
<PageShell
variant="list"
title={i18n.t('pipeline.releaseTitle')}
subtitle={i18n.t('deploy.userGroup.subtitle')}
subtitle={i18n.t('pipeline.usergroupsmanag.sf80ecf33')}
icon={<Users className="h-5 w-5" />}
loading={loading}
metrics={(
<StatStrip
items={[
{ label: i18n.t('pipeline.groupLabel'), value: groups.length, color: 'blue' },
{ label: i18n.t('deploy.userGroup.memberCount'), value: memberCount, color: 'purple' },
{ label: i18n.t('pipeline.usergroupsmanag.sdf2b42fe'), value: memberCount, color: 'purple' },
]}
/>
)}
actions={(
<>
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={loadGroups}>
<RefreshCw className="w-4 h-4 mr-1" />{i18n.t("common.refresh")}
<RefreshCw className="w-4 h-4 mr-1" />刷新
</Button>
<Button size="sm" className="dg-btn-primary" onClick={() => { setEditId(null); setFormName(''); setFormDesc(''); setFormOpen(true); }}>
<Plus className="w-4 h-4 mr-1" />{i18n.t("common.newUserGroup")}
<Plus className="w-4 h-4 mr-1" />新建用户组
</Button>
</>
)}
@@ -13,9 +13,9 @@ import { cn } from '@/lib/utils';
import { attachTerminalResize, mountXterm } from '@/lib/xterm-utils';
import { useUserStore } from '@/store';
import { pipelineApi } from '@/api';
import { useTranslation } from 'react-i18next';
import { TerminalLoadingOverlay } from '@/components/common/TerminalLoadingOverlay';
import type { JsonData } from '@/types/common';
import { getWsBase } from '@/utils/request';
interface WorkspaceTermDialogProps {
open: boolean;
@@ -33,7 +33,6 @@ export function WorkspaceTermDialog({
runId,
buildNumber,
}: WorkspaceTermDialogProps) {
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<XTerm | null>(null);
const wsRef = useRef<WebSocket | null>(null);
@@ -73,9 +72,8 @@ export function WorkspaceTermDialog({
termRef.current = term;
fitRef.current = fit;
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const token = useUserStore.getState().token || '';
const wsUrl = `${proto}://${location.host}/ws/pipeline/${runId}/workspace-shell?token=${encodeURIComponent(token)}`;
const wsUrl = `${getWsBase()}/ws/pipeline/${runId}/workspace-shell?token=${encodeURIComponent(token)}`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
@@ -106,12 +104,12 @@ export function WorkspaceTermDialog({
ws.onclose = () => {
setConnected(false);
setHasOutput(true);
term.write(`\r\n\x1b[31m${t('deploy.terminal.connClosed')}\x1b[0m\r\n`);
term.write('\r\n\x1b[31m[连接已关闭]\x1b[0m\r\n');
};
ws.onerror = () => {
setHasOutput(true);
term.write(`\r\n\x1b[31m${t('deploy.terminal.connError')}\x1b[0m\r\n`);
term.write('\r\n\x1b[31m[连接发生错误]\x1b[0m\r\n');
};
return () => {
@@ -120,7 +118,6 @@ export function WorkspaceTermDialog({
wsRef.current = null;
setConnected(false);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, runId]);
// 容器尺寸变化时 fit 并同步远端 PTY,避免缩放后黑边或铺不满。 // Logan.Li
@@ -157,8 +154,8 @@ export function WorkspaceTermDialog({
)}
style={fullscreen ? { maxWidth: '100vw', width: '100vw', height: '100vh' } : { maxWidth: '900px', width: '90vw' }}
>
<DialogTitle className="sr-only">{t("deploy.workspaceTerminalTitle", { num: buildNumber })}</DialogTitle>
<DialogDescription className="sr-only">{t("common.buildTerminal")}</DialogDescription>
<DialogTitle className="sr-only">Workspace 终端 #{buildNumber}</DialogTitle>
<DialogDescription className="sr-only">流水线构建目录终端</DialogDescription>
<div className="flex items-center justify-between px-4 py-2 border-b border-[var(--dg-border)]">
<div className="flex items-center gap-2 text-sm min-w-0">
<div className={cn('w-2 h-2 rounded-full shrink-0', connected ? 'bg-emerald-400' : 'bg-red-400')} />
@@ -189,7 +186,7 @@ export function WorkspaceTermDialog({
</div>
<div className="relative bg-[#0a0e14]" style={{ height: fullscreen ? 'calc(100vh - 49px)' : '480px' }}>
<div ref={containerRef} className="xterm-terminal-host h-full" />
<TerminalLoadingOverlay show={!hasOutput} label={t('common.enteringWorkspace')} />
<TerminalLoadingOverlay show={!hasOutput} label="正在进入工作区..." />
</div>
</DialogContent>
</Dialog>
@@ -19,52 +19,7 @@ export interface CredDetailDialogProps {
credId: number | null;
}
// --- Module-level Field component (avoids remount on parent re-render) ---
interface CredFieldProps {
label: string;
value: unknown;
sensitive?: boolean;
fieldKey?: string;
showMap: Record<string, boolean>;
onToggle: (key: string) => void;
onCopy: (val: string) => void;
}
function CredField({ label, value, sensitive, fieldKey, showMap, onToggle, onCopy }: CredFieldProps) {
if (value === undefined || value === null || value === '') return null;
const isLong = typeof value === 'string' && value.length > 80;
const show = fieldKey ? showMap[fieldKey] : false;
return (
<div className="mb-3">
<div className="flex items-center gap-2 text-xs text-[var(--dg-text-muted)] mb-1">
<Lock className="h-3 w-3" />{label}
</div>
<div className="flex gap-1">
{isLong ? (
<textarea readOnly value={sensitive && !show ? '••••••••' : String(value)}
className="flex-1 rounded-md bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] text-[var(--dg-text-primary)] p-2 text-xs font-mono resize-none"
rows={4} />
) : (
<Input readOnly value={sensitive && !show ? '••••••••' : String(value)}
className="flex-1 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] text-[var(--dg-text-primary)] font-mono text-xs" />
)}
<div className="flex flex-col gap-0.5 shrink-0">
{sensitive && (
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onToggle(fieldKey!)}>
{show ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
</Button>
)}
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onCopy(String(value))}>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
</div>
);
}
// 凭据详情弹窗
// 凭据详情弹窗 // Logan.Li
export function CredDetailDialog({ open, onClose, credId }: CredDetailDialogProps) {
const [showMap, setShowMap] = useState<Record<string, boolean>>({});
const { data: detail, isLoading: loading } = useCredentialDetail(credId, open);
@@ -78,7 +33,38 @@ export function CredDetailDialog({ open, onClose, credId }: CredDetailDialogProp
const cfg = TYPE_CONFIG[st];
const content = (detail as Record<string, unknown>)?.content as Record<string, unknown> || {};
const fieldProps = { showMap, onToggle: toggleShow, onCopy: copyValue };
const Field = ({ label, value, sensitive, fieldKey }: { label: string; value: unknown; sensitive?: boolean; fieldKey?: string }) => {
if (value === undefined || value === null || value === '') return null;
const isLong = typeof value === 'string' && value.length > 80;
const show = fieldKey ? showMap[fieldKey] : false;
return (
<div className="mb-3">
<div className="flex items-center gap-2 text-xs text-[var(--dg-text-muted)] mb-1">
<Lock className="h-3 w-3" />{label}
</div>
<div className="flex gap-1">
{isLong ? (
<textarea readOnly value={sensitive && !show ? '••••••••' : String(value)}
className="flex-1 rounded-md bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] text-[var(--dg-text-primary)] p-2 text-xs font-mono resize-none"
rows={4} />
) : (
<Input readOnly value={sensitive && !show ? '••••••••' : String(value)}
className="flex-1 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] font-mono text-xs" />
)}
<div className="flex flex-col gap-0.5 shrink-0">
{sensitive && (
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => toggleShow(fieldKey!)}>
{show ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
</Button>
)}
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => copyValue(String(value))}>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
</div>
);
};
const detailRecord = detail as Record<string, unknown> | undefined;
@@ -88,20 +74,20 @@ export function CredDetailDialog({ open, onClose, credId }: CredDetailDialogProp
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
{cfg ? <cfg.icon className={`h-5 w-5 ${cfg.color}`} /> : <Key className="h-5 w-5 text-[var(--dg-text-muted)]" />}
{i18n.t('deploy.cred.credDetail')}
凭据详情
</DialogTitle>
</DialogHeader>
{loading ? (
<div className="text-center py-8 text-[var(--dg-text-muted)]"> {i18n.t('monitor.logPanel.logResults.loading')} </div>
) : detail ? (
<div className="space-y-4">
{/* 基本信息 */}
{/* 基本信息 // Logan.Li */}
<div className="grid grid-cols-2 gap-3 text-sm">
<div><span className="text-[var(--dg-text-muted)]">{i18n.t('common.name')}:</span><span className="text-[var(--dg-text-primary)] font-medium">{detailRecord?.name as string}</span></div>
<div><span className="text-[var(--dg-text-muted)]">{i18n.t('deploy.cred.credType')}:</span>
<div><span className="text-[var(--dg-text-muted)]"> {i18n.t('pipeline.creddetaildialo.s5b478615')} </span><span className="text-[var(--dg-text-primary)] font-medium">{detailRecord?.name as string}</span></div>
<div><span className="text-[var(--dg-text-muted)]"> {i18n.t('pipeline.creddetaildialo.se91f5a13')} </span>
<Badge variant="outline" className={`text-xs ml-1 ${TYPE_BADGE_CLASS[st] || ''}`}>{cfg?.label || st}</Badge>
</div>
<div><span className="text-[var(--dg-text-muted)]">{i18n.t('approval.detail.statusLabel')}:</span>
<div><span className="text-[var(--dg-text-muted)]"> {i18n.t('approval.detail.statusLabel')} </span>
{detailRecord?.is_active
? <Badge className="bg-green-500/20 dg-text-green text-xs ml-1"><CheckCircle2 className="h-3 w-3 mr-0.5" /> {i18n.t('system.userActive')} </Badge>
: <Badge className="bg-red-500/20 dg-text-red text-xs ml-1"><XCircle className="h-3 w-3 mr-0.5" /> {i18n.t('system.userInactive')} </Badge>}
@@ -110,72 +96,72 @@ export function CredDetailDialog({ open, onClose, credId }: CredDetailDialogProp
)}
</div>
<div className="col-span-2 cursor-pointer" onClick={() => copyValue((detailRecord?.env_prefix as string) || '')}>
<span className="text-[var(--dg-text-muted)]">{i18n.t('deploy.cred.refIdLabel')}</span>
<code className="text-[var(--dg-blue)] font-mono text-xs">{detailRecord?.env_prefix as string || i18n.t('common.none')}</code>
<span className="text-[var(--dg-text-muted)]"> {i18n.t('deploy.cred.refIdLabel')} </span>
<code className="text-[var(--dg-blue)] font-mono text-xs">{detailRecord?.env_prefix as string || '无'}</code>
<Copy className="h-3 w-3 inline ml-1 text-[var(--dg-text-muted)]" />
</div>
{(detailRecord?.tags as string[])?.length > 0 && (
<div className="col-span-2">
<span className="text-[var(--dg-text-muted)]">{i18n.t('common.tag')}:</span>
<span className="text-[var(--dg-text-muted)]"> {i18n.t('pipeline.creddetaildialo.s1655dd5f')} </span>
{(detailRecord?.tags as string[]).map((t: string) => (
<Badge key={t} variant="outline" className="text-xs ml-1 border-[var(--dg-border)]">{t}</Badge>
))}
</div>
)}
<div className="col-span-2"><span className="text-[var(--dg-text-muted)]">{i18n.t('common.description')}:</span><span className="text-[var(--dg-text-secondary)]">{(detailRecord?.description as string) || i18n.t('common.none')}</span></div>
<div><span className="text-[var(--dg-text-muted)]">{i18n.t('common.createdAt')}:</span><span className="text-[var(--dg-text-secondary)] text-xs">{new Date(detailRecord?.created_at as string).toLocaleString()}</span></div>
<div><span className="text-[var(--dg-text-muted)]">{i18n.t('common.updatedAt')}:</span><span className="text-[var(--dg-text-secondary)] text-xs">{new Date(detailRecord?.updated_at as string).toLocaleString()}</span></div>
<div className="col-span-2"><span className="text-[var(--dg-text-muted)]"> {i18n.t('pipeline.creddetaildialo.s4a4e3b5a')} </span><span className="text-[var(--dg-text-secondary)]">{(detailRecord?.description as string) || '无'}</span></div>
<div><span className="text-[var(--dg-text-muted)]"> {i18n.t('pipeline.creddetaildialo.s76d768fb')} </span><span className="text-[var(--dg-text-secondary)] text-xs">{new Date(detailRecord?.created_at as string).toLocaleString()}</span></div>
<div><span className="text-[var(--dg-text-muted)]"> {i18n.t('pipeline.creddetaildialo.s3820b7e1')} </span><span className="text-[var(--dg-text-secondary)] text-xs">{new Date(detailRecord?.updated_at as string).toLocaleString()}</span></div>
</div>
{/* 密钥数据 */}
{/* 密钥数据 // Logan.Li */}
<div className="border-t border-[var(--dg-border)] pt-3">
<div className="flex items-center gap-2 text-sm text-[var(--dg-text-primary)] font-medium mb-3">
<Key className="h-4 w-4 text-yellow-500" />{i18n.t('deploy.cred.credData')}
<Key className="h-4 w-4 text-yellow-500" />密钥数据
</div>
<div className="p-3 rounded-md bg-yellow-500/5 border border-yellow-500/20 mb-3">
<p className="text-xs dg-text-yellow/80">{i18n.t('deploy.cred.sensitiveWarn')}</p>
<p className="text-xs dg-text-yellow/80"> {i18n.t('pipeline.creddetaildialo.s03b218d4')} </p>
</div>
{st === 'ssh' && <>
<CredField label={i18n.t('deploy.cred.form.sshUser')} value={content.ssh_user} {...fieldProps} />
<CredField label={i18n.t('deploy.cred.form.sshPort')} value={content.ssh_port} {...fieldProps} />
<CredField label={i18n.t('system.sshPassword')} value={content.password} sensitive fieldKey="ssh_password" {...fieldProps} />
<CredField label={i18n.t('system.sshPrivateKey')} value={content.private_key} sensitive fieldKey="ssh_private_key" {...fieldProps} />
<CredField label={i18n.t('system.sshPublicKey')} value={content.public_key} fieldKey="ssh_public_key" {...fieldProps} />
<Field label={i18n.t('pipeline.creddetaildialo.s2272f5e9')} value={content.ssh_user} />
<Field label={i18n.t('pipeline.creddetaildialo.s3f284a59')} value={content.ssh_port} />
<Field label={i18n.t('system.sshPassword')} value={content.password} sensitive fieldKey="ssh_password" />
<Field label={i18n.t('system.sshPrivateKey')} value={content.private_key} sensitive fieldKey="ssh_private_key" />
<Field label={i18n.t('system.sshPublicKey')} value={content.public_key} fieldKey="ssh_public_key" />
</>}
{st === 'docker' && <>
<CredField label={i18n.t('deploy.cred.form.registryAddr')} value={content.server} {...fieldProps} />
<CredField label={i18n.t('system.userName')} value={content.username} {...fieldProps} />
<CredField label={i18n.t('system.passwordLabel')} value={content.password} sensitive fieldKey="docker_password" {...fieldProps} />
<Field label={i18n.t('deploy.form.registryAddr')} value={content.server} />
<Field label={i18n.t('system.userName')} value={content.username} />
<Field label={i18n.t('system.passwordLabel')} value={content.password} sensitive fieldKey="docker_password" />
</>}
{st === 'tls' && <>
<CredField label={i18n.t('deploy.cred.form.certPem')} value={content.cert} sensitive fieldKey="tls_cert" {...fieldProps} />
<CredField label={i18n.t('deploy.cred.form.keyPem')} value={content.key} sensitive fieldKey="tls_key" {...fieldProps} />
<CredField label={i18n.t('deploy.cred.form.caCert')} value={content.ca} sensitive fieldKey="tls_ca" {...fieldProps} />
<Field label={i18n.t('pipeline.creddetaildialo.sd40b5115')} value={content.cert} sensitive fieldKey="tls_cert" />
<Field label={i18n.t('system.sshPrivateKey')} value={content.key} sensitive fieldKey="tls_key" />
<Field label={i18n.t('pipeline.credformdialogt.sa24356be')} value={content.ca} sensitive fieldKey="tls_ca" />
</>}
{st === 'aksk' && <>
<CredField label="Access Key ID" value={content.access_key} {...fieldProps} />
<CredField label="Secret Access Key" value={content.secret_key} sensitive fieldKey="aksk_secret_key" {...fieldProps} />
<Field label="Access Key ID" value={content.access_key} />
<Field label="Secret Access Key" value={content.secret_key} sensitive fieldKey="aksk_secret_key" />
</>}
{st === 'database' && <>
<CredField label={i18n.t('deploy.cred.form.dbType')} value={String(content.db_type || '').toUpperCase()} {...fieldProps} />
<CredField label={i18n.t('deploy.cred.form.host')} value={content.host} {...fieldProps} />
<CredField label={i18n.t('system.ldapPort')} value={content.port} {...fieldProps} />
<CredField label={i18n.t('deploy.cred.form.dbName')} value={content.db_name} {...fieldProps} />
<CredField label={i18n.t('system.userName')} value={content.username} {...fieldProps} />
<CredField label={i18n.t('system.passwordLabel')} value={content.password} sensitive fieldKey="db_password" {...fieldProps} />
<Field label={i18n.t('pipeline.credformdialogt.s84b916da')} value={String(content.db_type || '').toUpperCase()} />
<Field label={i18n.t('agent.agentHost')} value={content.host} />
<Field label={i18n.t('system.ldapPort')} value={content.port} />
<Field label={i18n.t('pipeline.credformdialogt.s5ccbbd01')} value={content.db_name} />
<Field label={i18n.t('system.userName')} value={content.username} />
<Field label={i18n.t('system.passwordLabel')} value={content.password} sensitive fieldKey="db_password" />
</>}
{st === 'api' && <>
<CredField label={i18n.t('deploy.cred.form.apiKeyToken')} value={content.api_key} sensitive fieldKey="api_key" {...fieldProps} />
<CredField label={i18n.t('deploy.cred.form.apiEndpoint')} value={content.endpoint} {...fieldProps} />
<Field label="API Key" value={content.api_key} sensitive fieldKey="api_key" />
<Field label={i18n.t('pipeline.creddetaildialo.s48ec3494')} value={content.endpoint} />
</>}
{st === 'generic' && <>
<CredField label="Key" value={content.generic_key} {...fieldProps} />
<CredField label="Value" value={content.generic_value} sensitive fieldKey="generic_value" {...fieldProps} />
<Field label="Key" value={content.generic_key} />
<Field label="Value" value={content.generic_value} sensitive fieldKey="generic_value" />
</>}
</div>
{/* 引用提示 */}
{/* 引用提示 // Logan.Li */}
<div className="border-t border-[var(--dg-border)] pt-3">
<div className="text-xs text-[var(--dg-text-muted)] mb-2"> {i18n.t('deploy.cred.refHint')} </div>
<code className="text-xs bg-[var(--dg-bg-tertiary)] px-3 py-1.5 rounded block text-[var(--dg-blue)]">
@@ -187,9 +173,9 @@ export function CredDetailDialog({ open, onClose, credId }: CredDetailDialogProp
<div className="text-center py-8 text-[var(--dg-text-muted)]"> {i18n.t('deploy.cred.noCredData')} </div>
)}
<DialogFooter>
<Button variant="outline" onClick={onClose}> {i18n.t('common.close')} </Button>
<Button variant="outline" onClick={onClose}> {i18n.t('agent.portClosed')} </Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
}
@@ -123,8 +123,8 @@ export function CredFormDialog({ open, onClose, onSaved, editData }: CredFormDia
if (sshPrivateKey.trim()) payload.ssh_private_key = sshPrivateKey.trim();
if (sshPublicKey.trim()) payload.ssh_public_key = sshPublicKey.trim();
} else if (secretType === 'rdp') {
if (!rdpUsername.trim()) { toast.error(t('deploy.rdpUserRequired')); return; }
if (!isEdit && !rdpPassword.trim()) { toast.error(t('deploy.rdpPasswordRequired')); return; }
if (!rdpUsername.trim()) { toast.error('请填写 RDP 用户'); return; }
if (!isEdit && !rdpPassword.trim()) { toast.error('请填写 RDP 密码'); return; }
if (rdpUsername.trim()) payload.rdp_username = rdpUsername.trim();
if (rdpPassword.trim()) payload.rdp_password = rdpPassword.trim();
if (rdpDomain.trim()) payload.rdp_domain = rdpDomain.trim();
@@ -292,12 +292,12 @@ export function CredFormDialog({ open, onClose, onSaved, editData }: CredFormDia
{secretType === 'rdp' && (
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">{t("deploy.cred.windowsUser")} <span className="dg-text-red">*</span></Label>
<Label className="text-[var(--dg-text-secondary)] text-xs">Windows 用户 <span className="dg-text-red">*</span></Label>
<Input value={rdpUsername} onChange={e => setRdpUsername(e.target.value)} placeholder="Administrator"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
</div>
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">{t("common.securityMode")}</Label>
<Label className="text-[var(--dg-text-secondary)] text-xs">安全模式</Label>
<Select value={rdpSecurity} onValueChange={setRdpSecurity}>
<SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
<SelectValue />
@@ -305,16 +305,16 @@ export function CredFormDialog({ open, onClose, onSaved, editData }: CredFormDia
<SelectContent>
<SelectItem value="nla">NLA</SelectItem>
<SelectItem value="tls">TLS</SelectItem>
<SelectItem value="rdp">{t("common.standardRdp")}</SelectItem>
<SelectItem value="rdp">标准 RDP</SelectItem>
</SelectContent>
</Select>
</div>
{/* 用户名(左上)与密码(左下)同列上下行,安全模式/域归右列 // Logan.Li */}
<div><FieldWithToggle label={t('deploy.rdpPassword')} value={rdpPassword} onChange={setRdpPassword}
placeholder={t('deploy.rdpPasswordPlaceholder')} fieldKey="rdp_pwd" required /></div>
<div><FieldWithToggle label="Windows 密码" value={rdpPassword} onChange={setRdpPassword}
placeholder="Windows 登录密码" fieldKey="rdp_pwd" required /></div>
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">{t("common.domainLabel")}</Label>
<Input value={rdpDomain} onChange={e => setRdpDomain(e.target.value)} placeholder={t('common.optional')}
<Label className="text-[var(--dg-text-secondary)] text-xs">域</Label>
<Input value={rdpDomain} onChange={e => setRdpDomain(e.target.value)} placeholder="可选"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
</div>
</div>
@@ -3,18 +3,17 @@
import {
Key, Server, Container, Shield, Cloud, Database, FileText, Globe, Monitor,
} from 'lucide-react';
import i18n from '@/i18n';
import type { SecretType } from './types';
export const TYPE_CONFIG: Record<SecretType, { label: string; labelKey: string; icon: typeof Key; color: string; desc: string; descKey: string }> = {
ssh: { label: i18n.t('deploy.credType.ssh'), labelKey: 'deploy.credType.ssh', icon: Server, color: 'dg-text-yellow', desc: i18n.t('deploy.credTypeDesc.ssh'), descKey: 'deploy.credTypeDesc.ssh' },
rdp: { label: i18n.t('deploy.credType.rdp'), labelKey: 'deploy.credType.rdp', icon: Monitor, color: 'dg-text-blue', desc: i18n.t('deploy.credTypeDesc.rdp'), descKey: 'deploy.credTypeDesc.rdp' },
docker: { label: i18n.t('deploy.credType.docker'), labelKey: 'deploy.credType.docker', icon: Container, color: 'dg-text-cyan', desc: i18n.t('deploy.credTypeDesc.docker'), descKey: 'deploy.credTypeDesc.docker' },
tls: { label: i18n.t('deploy.credType.tls'), labelKey: 'deploy.credType.tls', icon: Shield, color: 'dg-text-green', desc: i18n.t('deploy.credTypeDesc.tls'), descKey: 'deploy.credTypeDesc.tls' },
aksk: { label: i18n.t('deploy.credType.aksk'), labelKey: 'deploy.credType.aksk', icon: Cloud, color: 'dg-text-purple', desc: i18n.t('deploy.credTypeDesc.aksk'), descKey: 'deploy.credTypeDesc.aksk' },
database: { label: i18n.t('deploy.credType.database'), labelKey: 'deploy.credType.database', icon: Database, color: 'dg-text-blue', desc: i18n.t('deploy.credTypeDesc.database'), descKey: 'deploy.credTypeDesc.database' },
api: { label: i18n.t('deploy.credType.api'), labelKey: 'deploy.credType.api', icon: Globe, color: 'dg-text-orange', desc: i18n.t('deploy.credTypeDesc.api'), descKey: 'deploy.credTypeDesc.api' },
generic: { label: i18n.t('deploy.credType.generic'), labelKey: 'deploy.credType.generic', icon: FileText, color: 'dg-text-secondary', desc: i18n.t('deploy.credTypeDesc.generic'), descKey: 'deploy.credTypeDesc.generic' },
export const TYPE_CONFIG: Record<SecretType, { label: string; icon: typeof Key; color: string; desc: string }> = {
ssh: { label: 'SSH 密钥', icon: Server, color: 'dg-text-yellow', desc: 'SSH 私钥/公钥/密码' },
rdp: { label: 'Windows RDP 密码', icon: Monitor, color: 'dg-text-blue', desc: 'Windows 远程桌面用户名/密码' },
docker: { label: 'Docker 凭证', icon: Container, color: 'dg-text-cyan', desc: 'Docker Registry 登录凭据' },
tls: { label: 'TLS 证书', icon: Shield, color: 'dg-text-green', desc: 'TLS/SSL 证书与私钥' },
aksk: { label: 'AKSK 密钥', icon: Cloud, color: 'dg-text-purple', desc: '云服务 AccessKey/SecretKey' },
database: { label: '数据库凭证', icon: Database, color: 'dg-text-blue', desc: '数据库连接信息' },
api: { label: 'API 密钥', icon: Globe, color: 'dg-text-orange', desc: 'API Token/Key + 端点' },
generic: { label: '通用凭证', icon: FileText, color: 'dg-text-secondary', desc: '自定义 Key/Value 键值对' },
};
export const TYPE_BADGE_CLASS: Record<SecretType, string> = {
@@ -89,7 +89,7 @@ export function JobRoleCreateDialog({
<Input
value={pattern}
onChange={(e) => onPatternChange(e.target.value)}
placeholder={t('deploy.jobNamePatternHint')}
placeholder="^cex-.* 或 ^api-server$"
className="h-8 text-sm font-mono dg-input"
/>
<p className="text-[10px] text-[var(--dg-text-muted)]">{patternHint}</p>
@@ -149,8 +149,8 @@ export function DeployTargetFields({ value, onChange, requireTarget = true }: De
<div className="space-y-3">
<div className="flex gap-1 p-1 rounded-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)]">
{([
{ key: 'ssh' as const, label: i18n.t('deploy.target.hostDeploy'), icon: Server },
{ key: 'k8s' as const, label: i18n.t('deploy.target.hostDeploy'), icon: Network },
{ key: 'ssh' as const, label: i18n.t('pipeline.deploytargetfie.secf81967'), icon: Server },
{ key: 'k8s' as const, label: i18n.t('pipeline.deploytargetfie.s7ff062d5'), icon: Network },
]).map((item) => {
const active = value.deployTarget === item.key;
const Icon = item.icon;
@@ -200,17 +200,17 @@ export function DeployTargetFields({ value, onChange, requireTarget = true }: De
<Input
value={serverSearch}
onChange={(e) => setServerSearch(e.target.value)}
placeholder={i18n.t('deploy.target.searchHost')}
placeholder={i18n.t('pipeline.deploytargetfie.sc7624940')}
className="pl-8 h-8 text-xs dg-input"
/>
</div>
<ScrollArea className="h-[140px] rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)]">
{loadingServers ? (
<div className="flex items-center justify-center py-8 text-xs text-[var(--dg-text-muted)]">
<Loader2 className="h-4 w-4 animate-spin mr-1.5" />{i18n.t("common.loadingEllipsis")}
<Loader2 className="h-4 w-4 animate-spin mr-1.5" />加载中
</div>
) : filteredServers.length === 0 ? (
<p className="text-xs text-[var(--dg-text-muted)] text-center py-8"> {i18n.t('pipeline.deployTargetField.noHostAvailable')} </p>
<p className="text-xs text-[var(--dg-text-muted)] text-center py-8"> {i18n.t('pipeline.deploytargetfie.sb4240171')} </p>
) : (
<div className="p-1.5 space-y-1">
{filteredServers.map((srv) => {
@@ -242,10 +242,10 @@ export function DeployTargetFields({ value, onChange, requireTarget = true }: De
</div>
)}
</ScrollArea>
<p className="text-[10px] text-[var(--dg-text-muted)]"> {i18n.t('pipeline.deployTargetField.scpDeployHint')} </p>
<p className="text-[10px] text-[var(--dg-text-muted)]"> {i18n.t('pipeline.deploytargetfie.sb2b6db98')} </p>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs"> {i18n.t('kubernetes.container.containerName')} </Label>
<Label className="text-xs"> {i18n.t('pipeline.deploytargetfie.s5ca05a9b')} </Label>
<Input
value={value.deployRemotePath}
onChange={(e) => onChange({ deployRemotePath: e.target.value })}
@@ -254,7 +254,7 @@ export function DeployTargetFields({ value, onChange, requireTarget = true }: De
/>
</div>
<div className="space-y-1">
<Label className="text-xs"> {i18n.t('kubernetes.container.containerName')} </Label>
<Label className="text-xs"> {i18n.t('pipeline.deploytargetfie.s65b20ba7')} </Label>
<Input
value={value.deployScript}
onChange={(e) => onChange({ deployScript: e.target.value })}
@@ -292,11 +292,11 @@ export function DeployTargetFields({ value, onChange, requireTarget = true }: De
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs"> {i18n.t('kubernetes.container.containerName')} </Label>
<Label className="text-xs"> {i18n.t('pipeline.deploytargetfie.s806e4e01')} </Label>
<Input
readOnly
value={value.k8sDeployment ? `${value.k8sNamespace}/${value.k8sDeployment}` : ''}
placeholder={i18n.t('deploy.target.selectFromList')}
placeholder={i18n.t('pipeline.deploytargetfie.sbb1671f3')}
className="h-8 text-xs dg-input font-mono"
/>
</div>
@@ -308,14 +308,14 @@ export function DeployTargetFields({ value, onChange, requireTarget = true }: De
<Input
value={depSearch}
onChange={(e) => setDepSearch(e.target.value)}
placeholder={i18n.t('deploy.target.searchDeployment')}
placeholder={i18n.t('pipeline.deploytargetfie.saa98b28a')}
className="pl-8 h-8 text-xs dg-input"
/>
</div>
<ScrollArea className="h-[140px] rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)]">
{loadingDeps ? (
<div className="flex items-center justify-center py-8 text-xs text-[var(--dg-text-muted)]">
<Loader2 className="h-4 w-4 animate-spin mr-1.5" />{i18n.t("common.loadDeployment")}
<Loader2 className="h-4 w-4 animate-spin mr-1.5" />加载 Deployment
</div>
) : filteredDeployments.length === 0 ? (
<p className="text-xs text-[var(--dg-text-muted)] text-center py-8"> {i18n.t('k8s.workloads.noDeployment')} </p>
@@ -349,11 +349,11 @@ export function DeployTargetFields({ value, onChange, requireTarget = true }: De
)}
</ScrollArea>
<div className="space-y-1">
<Label className="text-xs"> {i18n.t('kubernetes.container.containerName')} <span className="text-[var(--dg-text-muted)]">{i18n.t('common.fillIfMultiContainer')}</span></Label>
<Label className="text-xs"> {i18n.t('kubernetes.container.containerName')} <span className="text-[var(--dg-text-muted)]">(多容器时填写)</span></Label>
<Input
value={value.k8sContainerName}
onChange={(e) => onChange({ k8sContainerName: e.target.value })}
placeholder={i18n.t('deploy.target.searchDeployment')}
placeholder={i18n.t('pipeline.deploytargetfie.s389076f8')}
className="h-8 text-xs font-mono dg-input"
/>
</div>
@@ -0,0 +1,242 @@
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { pipelineApi } from '@/api';
import { CheckCircle2, RefreshCw } from 'lucide-react';
import { toast } from 'sonner';
import { TechnologyBrandIcon } from '@/components/icons/TechnologyBrandIcon';
interface JenkinsSettingsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSynced: () => void;
}
interface JenkinsConfigForm {
base_url: string;
username: string;
token: string;
auth_type: string;
enabled: boolean;
overwrite: boolean;
ref_mode: 'branch' | 'tag' | 'both';
ref_param: string;
}
const defaultForm: JenkinsConfigForm = {
base_url: '',
username: '',
token: '',
auth_type: 'token',
enabled: true,
overwrite: true,
ref_mode: 'both',
ref_param: 'SELECT',
};
// Jenkins 接入弹窗:保存连接、测试 API、同步 Jobs 到 AIOps 流水线。 // Logan.Li
export function JenkinsSettingsDialog({ open, onOpenChange, onSynced }: JenkinsSettingsDialogProps) {
const [form, setForm] = useState<JenkinsConfigForm>(defaultForm);
const [tokenSet, setTokenSet] = useState(false);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [syncing, setSyncing] = useState(false);
useEffect(() => {
if (!open) return;
pipelineApi.getJenkinsConfig()
.then((data) => {
const cfg = data as Record<string, unknown>;
setForm((prev) => ({
...prev,
base_url: String(cfg.base_url || ''),
username: String(cfg.username || ''),
auth_type: String(cfg.auth_type || 'token'),
enabled: cfg.enabled !== false,
token: '',
ref_mode: ['branch', 'tag', 'both'].includes(String(cfg.ref_mode)) ? String(cfg.ref_mode) as JenkinsConfigForm['ref_mode'] : 'both',
ref_param: String(cfg.ref_param || 'SELECT'),
}));
setTokenSet(Boolean(cfg.token_set));
})
.catch(() => {
setForm(defaultForm);
setTokenSet(false);
});
}, [open]);
const update = <K extends keyof JenkinsConfigForm>(key: K, value: JenkinsConfigForm[K]) => {
setForm((prev) => ({ ...prev, [key]: value }));
};
const save = async () => {
if (!form.base_url.trim()) {
toast.error('请填写 Jenkins 地址');
return false;
}
setSaving(true);
try {
await pipelineApi.saveJenkinsConfig({
base_url: form.base_url.trim(),
username: form.username.trim(),
token: form.token,
auth_type: form.auth_type,
enabled: form.enabled,
ref_mode: form.ref_mode,
ref_param: form.ref_param.trim() || 'SELECT',
});
if (form.token) setTokenSet(true);
setForm((prev) => ({ ...prev, token: '' }));
toast.success('Jenkins 配置已保存');
return true;
} catch {
toast.error('保存 Jenkins 配置失败');
return false;
} finally {
setSaving(false);
}
};
const test = async () => {
const ok = await save();
if (!ok) return;
setTesting(true);
try {
await pipelineApi.testJenkinsConfig();
toast.success('Jenkins 连接成功');
} catch {
toast.error('Jenkins 连接失败');
} finally {
setTesting(false);
}
};
const sync = async () => {
const ok = await save();
if (!ok) return;
setSyncing(true);
try {
const res = await pipelineApi.syncJenkinsJobs({ overwrite: form.overwrite }) as Record<string, unknown>;
toast.success(`同步完成:视图 ${String(res.views || 0)},发现 ${String(res.total || 0)},新增 ${String(res.created || 0)},更新 ${String(res.updated || 0)},失败 ${String(res.failed || 0)}`);
onSynced();
} catch {
toast.error('同步 Jenkins Jobs 失败');
} finally {
setSyncing(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<TechnologyBrandIcon brand="jenkins" className="h-5 w-5" />
Jenkins 接入
</DialogTitle>
<DialogDescription>
同步 Jenkins Jobs 到 AIOps 流水线,并通过 Jenkins API 触发构建。
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<Label>Jenkins 地址</Label>
<Input
value={form.base_url}
onChange={(e) => update('base_url', e.target.value)}
placeholder="https://jenkins.example.com"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>用户</Label>
<Input value={form.username} onChange={(e) => update('username', e.target.value)} placeholder="jenkins" />
</div>
<div className="space-y-1.5">
<Label>{tokenSet ? 'Token / 密码(已保存)' : 'Token / 密码'}</Label>
<Input
value={form.token}
onChange={(e) => update('token', e.target.value)}
type="password"
placeholder={tokenSet ? '留空则保持原密钥' : 'API Token 或密码'}
/>
</div>
</div>
<div className="flex items-center justify-between rounded border border-[var(--dg-border)] px-3 py-2">
<div>
<div className="text-sm font-medium text-[var(--dg-text-primary)]">覆盖同步</div>
<div className="text-xs text-[var(--dg-text-muted)]">保持 Jenkins 原 Job 名称,用 Job 信息更新描述和映射 URL</div>
</div>
<Switch checked={form.overwrite} onCheckedChange={(v) => update('overwrite', v)} />
</div>
<div className="flex items-center justify-between rounded border border-[var(--dg-border)] px-3 py-2">
<div>
<div className="text-sm font-medium text-[var(--dg-text-primary)]">启用 Jenkins 模式</div>
<div className="text-xs text-[var(--dg-text-muted)]">关闭后 Jenkins 类型流水线不可触发</div>
</div>
<Switch checked={form.enabled} onCheckedChange={(v) => update('enabled', v)} />
</div>
<div className="rounded border border-[var(--dg-border)] px-3 py-2 space-y-3">
<div>
<div className="text-sm font-medium text-[var(--dg-text-primary)]">构建引用</div>
<div className="text-xs text-[var(--dg-text-muted)]">控制 Jenkins 参数化构建时展示分支、Tag 或两者。</div>
</div>
<div className="flex flex-wrap gap-1 rounded-md bg-[var(--dg-bg-secondary)] p-1">
{[
{ key: 'branch', label: '仅分支' },
{ key: 'tag', label: '仅 Tag' },
{ key: 'both', label: '分支 + Tag' },
].map((item) => (
<button
key={item.key}
type="button"
onClick={() => update('ref_mode', item.key as JenkinsConfigForm['ref_mode'])}
className={`rounded px-3 py-1.5 text-xs font-medium transition-colors ${
form.ref_mode === item.key
? 'bg-[var(--dg-bg-primary)] text-[var(--dg-text-primary)] shadow-sm'
: 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]'
}`}
>
{item.label}
</button>
))}
</div>
<div className="space-y-1.5">
<Label>Jenkins 参数名</Label>
<Input
value={form.ref_param}
onChange={(e) => update('ref_param', e.target.value)}
placeholder="SELECT"
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={test} disabled={saving || testing || syncing}>
{testing ? <RefreshCw className="h-4 w-4 mr-2 animate-spin" /> : <CheckCircle2 className="h-4 w-4 mr-2" />}
测试
</Button>
<Button variant="outline" onClick={save} disabled={saving || testing || syncing}>
保存
</Button>
<Button onClick={sync} disabled={saving || testing || syncing}>
{syncing ? <RefreshCw className="h-4 w-4 mr-2 animate-spin" /> : <RefreshCw className="h-4 w-4 mr-2" />}
同步 Jobs
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -14,21 +14,25 @@ import {
Trash2,
ChevronDown,
ChevronRight,
Container,
Globe,
History,
Server,
Network,
ArrowRight,
Package,
ChevronRight as ChevronRightSmall,
ExternalLink,
Hash,
Timer,
PanelsTopLeft,
} from 'lucide-react';
import { getStatusBadge } from '../pipeline-helpers';
import { STAGE_DEFS, fmtDuration, parseStages } from './constants';
import { PipelineRunPanel } from './PipelineRunPanel';
import { TechnologyBrandIcon } from '@/components/icons/TechnologyBrandIcon';
interface PipelineCardProps {
pipeline: Record<string, unknown>;
viewMode?: 'default' | 'jenkins';
isExpanded: boolean;
isLoadingRuns: boolean;
pipelineRuns: Record<string, unknown>[];
@@ -39,9 +43,31 @@ interface PipelineCardProps {
onStopRun: (runId: number | string) => void;
}
// 读取 Jenkins View 名称,用于兼容视图卡片展示。 // Logan.Li
function getJenkinsViewNames(pipeline: Record<string, unknown>): string[] {
const rawViews = pipeline.jenkins_views;
if (typeof rawViews === 'string' && rawViews.trim()) {
try {
const parsed = JSON.parse(rawViews) as unknown;
if (Array.isArray(parsed)) {
const names = parsed
.map((item) => String((item as Record<string, unknown>).name || '').trim())
.filter(Boolean);
const businessNames = names.filter((name) => name.toLowerCase() !== 'all');
return businessNames.length > 0 ? businessNames : names;
}
} catch {
return [];
}
}
const view = String(pipeline.jenkins_view || '').trim();
return view ? [view] : [];
}
// 单条流水线卡片(含展开/折叠) // Logan.Li
export function PipelineCard({
pipeline,
viewMode = 'default',
isExpanded,
isLoadingRuns,
pipelineRuns,
@@ -54,10 +80,9 @@ export function PipelineCard({
const navigate = useNavigate();
const { t } = useTranslation();
const stages = parseStages(pipeline.stages);
const repo = pipeline.repo as Record<string, unknown> | undefined;
const registry = pipeline.registry as Record<string, unknown> | undefined;
const environment = pipeline.environment as Record<string, unknown> | undefined;
const code = (pipeline.code || pipeline.id) as string;
const ciProvider = String(pipeline.ci_provider || 'aiops');
const deployTarget = String(pipeline.deploy_target || 'ssh');
let serverCount = 0;
try {
@@ -66,6 +91,117 @@ export function PipelineCard({
} catch { /* ignore */ }
const k8sDep = pipeline.k8s_deployment ? String(pipeline.k8s_deployment) : '';
const k8sNs = pipeline.k8s_namespace ? String(pipeline.k8s_namespace) : '';
const isJenkinsView = viewMode === 'jenkins' && ciProvider === 'jenkins';
const jenkinsJobName = String(pipeline.jenkins_job_name || pipeline.job_name || pipeline.name || 'Jenkins Job');
const jenkinsJobURL = String(pipeline.jenkins_job_url || '');
const jenkinsViewNames = getJenkinsViewNames(pipeline);
const lastRunAt = pipeline.last_run_at ? new Date(pipeline.last_run_at as string).toLocaleString('zh-CN') : '-';
if (isJenkinsView) {
return (
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] overflow-hidden">
<CardContent className="p-0">
<div className="px-4 py-3">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline" className="border-sky-500/40 text-sky-500">
<TechnologyBrandIcon brand="jenkins" className="mr-1 h-3.5 w-3.5" />Jenkins
</Badge>
{getStatusBadge(String(pipeline.last_status), t)}
<h3 className="font-semibold text-[var(--dg-text-primary)] truncate">{jenkinsJobName}</h3>
{pipeline.run_count !== undefined && (
<Badge variant="secondary" className="text-[11px]">
<Hash className="h-3 w-3 mr-0.5" />{String(pipeline.run_count || 0)}
</Badge>
)}
{jenkinsViewNames.slice(0, 3).map((viewName) => (
<Badge key={viewName} variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] text-[11px]">
<PanelsTopLeft className="h-3 w-3 mr-0.5" />{viewName}
</Badge>
))}
{jenkinsViewNames.length > 3 && (
<Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-muted)] text-[11px]">
+{jenkinsViewNames.length - 3}
</Badge>
)}
</div>
<div className="mt-1.5 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-[var(--dg-text-secondary)]">
<span className="flex items-center gap-1 font-mono">
<TechnologyBrandIcon brand="jenkins" className="h-3.5 w-3.5" />{String(pipeline.code || '-')}
</span>
<span className="flex items-center gap-1">
<GitBranch className="h-3.5 w-3.5" />{String(pipeline.branch || pipeline.last_run_branch || 'main')}
</span>
<span className="flex items-center gap-1">
<Clock className="h-3.5 w-3.5" />最近构建:{lastRunAt}
</span>
{jenkinsJobURL && (
<a
href={jenkinsJobURL}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-sky-500 hover:text-sky-400"
>
<ExternalLink className="h-3.5 w-3.5" />打开 Jenkins
</a>
)}
</div>
</div>
<div className="grid grid-cols-3 gap-2 lg:w-[300px]">
<div className="rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] px-3 py-2">
<div className="text-[10px] text-[var(--dg-text-muted)]">成功</div>
<div className="mt-0.5 text-sm font-semibold dg-text-green">{String(pipeline.success_count || 0)}</div>
</div>
<div className="rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] px-3 py-2">
<div className="text-[10px] text-[var(--dg-text-muted)]">失败</div>
<div className="mt-0.5 text-sm font-semibold dg-text-red">{String(pipeline.fail_count || 0)}</div>
</div>
<div className="rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] px-3 py-2">
<div className="text-[10px] text-[var(--dg-text-muted)]">最近耗时</div>
<div className="mt-0.5 flex items-center gap-1 text-sm font-semibold text-[var(--dg-text-primary)]">
<Timer className="h-3.5 w-3.5" />{fmtDuration(pipeline.last_duration as number | undefined)}
</div>
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
{pipeline.last_status === 'running' ? (
<Button variant="destructive" size="sm" className="h-8" onClick={onStopRunning}>
<StopCircle className="h-3.5 w-3.5 mr-1" />停止
</Button>
) : (
<Button variant="default" size="sm" className="h-8" onClick={onTrigger}>
<Play className="h-3.5 w-3.5 mr-1" />Build
</Button>
)}
<Button variant="outline" size="sm" className="h-8" onClick={() => navigate(`/pipelines/${code}/runs`)}>
<History className="h-3.5 w-3.5 mr-1" />构建记录
</Button>
<Button variant="outline" size="sm" className="h-8" onClick={() => navigate(`/pipelines/${code}`)}>配置</Button>
<Button variant="ghost" size="sm" className="h-8" onClick={onToggleExpand}>
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-red-500" onClick={onDelete}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
{isExpanded && (
<PipelineRunPanel
pipeline={pipeline}
pipelineRuns={pipelineRuns}
isLoadingRuns={isLoadingRuns}
onStopRun={onStopRun}
/>
)}
</CardContent>
</Card>
);
}
return (
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] overflow-hidden">
@@ -81,6 +217,11 @@ export function PipelineCard({
</Badge>
)}
{getStatusBadge(String(pipeline.last_status), t)}
{ciProvider === 'jenkins' && (
<Badge variant="outline" className="text-xs border-sky-500/40 text-sky-500">
<TechnologyBrandIcon brand="jenkins" className="mr-1 h-3.5 w-3.5" />Jenkins
</Badge>
)}
{environment && (
<Badge
style={{ backgroundColor: `${environment.color}20`, color: environment.color as string }}
@@ -139,7 +280,12 @@ export function PipelineCard({
{pipeline.deploy_mode === 'tag' ? t('deploy.pipelineCard.tagRelease') : String(pipeline.branch || 'main')}
</span>
<span className="flex items-center gap-1">
{deployTarget === 'k8s' ? (
{ciProvider === 'jenkins' ? (
<>
<TechnologyBrandIcon brand="jenkins" className="h-3 w-3" />
{String(pipeline.jenkins_job_name || pipeline.job_name || 'Jenkins Job')}
</>
) : deployTarget === 'k8s' ? (
<>
<Network className="h-3 w-3" />
{k8sDep ? `${k8sNs}/${k8sDep}` : t('deploy.pipelineCard.k8sDeployment')}
@@ -151,8 +297,6 @@ export function PipelineCard({
</>
)}
</span>
{repo && <span className="flex items-center gap-1"><Globe className="h-3 w-3" />{String(repo.name)}</span>}
{registry && <span className="flex items-center gap-1"><Container className="h-3 w-3" />{String(registry.name)}</span>}
{pipeline.last_run_at && (
<span><Clock className="h-3 w-3 inline mr-0.5" />{new Date(pipeline.last_run_at as string).toLocaleString('zh-CN')}</span>
)}
@@ -260,4 +404,4 @@ export function PipelineCard({
</CardContent>
</Card>
);
}
}
@@ -13,17 +13,60 @@ import {
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Rocket, GitBranch, Globe, Container, Loader2 } from 'lucide-react';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import {
Rocket,
Plus,
GitBranch,
Globe,
Container,
Loader2,
Trash2,
ChevronDown,
Settings2,
} from 'lucide-react';
import { buildNodeApi, credentialApi, deployApi, pipelineApi, type BuildNode } from '@/api';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { nameToCode } from '../pipeline-helpers';
import { STAGE_DEFS } from './constants';
import { DeployTargetFields, type DeployTargetValue } from './DeployTargetFields';
import { PRESETS, DEFAULT_TARGET, type PipelineCreateDialogProps, type EnvVar } from './pipeline-create/types';
import { AdvancedSettingsPanel } from './pipeline-create/AdvancedSettingsPanel';
// 新建流水线对话框 — 精简分步 + 双发布目标
interface PipelineCreateDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreated: () => void;
}
const PRESETS = [
{
key: 'ssh',
labelKey: 'deploy.presetHost',
descKey: 'deploy.presetHostDesc',
stages: ['checkout', 'build', 'deploy'],
deployTarget: 'ssh' as const,
},
{
key: 'k8s',
labelKey: 'deploy.presetK8s',
descKey: 'deploy.presetK8sDesc',
stages: ['checkout', 'build', 'image', 'deploy'],
deployTarget: 'k8s' as const,
},
];
const DEFAULT_TARGET: DeployTargetValue = {
deployTarget: 'ssh',
serverIds: [],
deployRemotePath: '',
deployScript: '',
k8sClusterId: '',
k8sNamespace: '',
k8sDeployment: '',
k8sContainerName: '',
};
// 新建流水线对话框 — 精简分步 + 双发布目标 // Logan.Li
export function PipelineCreateDialog({ open, onOpenChange, onCreated }: PipelineCreateDialogProps) {
const { t } = useTranslation();
const [step, setStep] = useState<1 | 2>(1);
@@ -48,7 +91,7 @@ export function PipelineCreateDialog({ open, onOpenChange, onCreated }: Pipeline
const [newTriggerMode, setNewTriggerMode] = useState('manual');
const [newWatchBranches, setNewWatchBranches] = useState('');
const [newCronExpr, setNewCronExpr] = useState('');
const [newEnvVars, setNewEnvVars] = useState<EnvVar[]>([]);
const [newEnvVars, setNewEnvVars] = useState<{ key: string; value: string }[]>([]);
const [environments, setEnvironments] = useState<Record<string, unknown>[]>([]);
const [buildNodes, setBuildNodes] = useState<BuildNode[]>([]);
const [buildNodeId, setBuildNodeId] = useState('');
@@ -60,20 +103,15 @@ export function PipelineCreateDialog({ open, onOpenChange, onCreated }: Pipeline
credentialApi.list({ page_size: 100 }).catch(() => ({ list: [] })),
buildNodeApi.list().catch(() => ({ list: [] })),
] as const);
setEnvironments(
Array.isArray((rEnv as Record<string, unknown>)?.list)
? ((rEnv as Record<string, unknown>).list as Record<string, unknown>[])
: [],
);
const allCreds: Record<string, unknown>[] =
((rCred as Record<string, unknown>)?.list as Record<string, unknown>[]) || [];
setEnvironments(Array.isArray((rEnv as Record<string, unknown>)?.list)
? (rEnv as Record<string, unknown>).list as Record<string, unknown>[]
: []);
const allCreds: Record<string, unknown>[] = (rCred as Record<string, unknown>)?.list as Record<string, unknown>[] || [];
setGitCredentials(allCreds.filter((c) => c.secret_type === 'api'));
setDockerCredentials(allCreds.filter((c) => c.secret_type === 'docker'));
const nodeData = rNodes as { list?: BuildNode[] };
setBuildNodes(Array.isArray(nodeData?.list) ? nodeData.list.filter((n) => n.enabled) : []);
} catch {
/* silent */
}
} catch { /* silent */ }
}, []);
useEffect(() => {
@@ -148,14 +186,12 @@ export function PipelineCreateDialog({ open, onOpenChange, onCreated }: Pipeline
setSaving(true);
try {
const stageNames = selectedStages.map((k) => STAGE_DEFS.find((s) => s.key === k)?.label || k);
const stages = JSON.stringify(
stageNames.map((name, i) => ({
id: `stage-${i + 1}`,
name,
type: selectedStages[i] || 'custom',
tasks: [],
})),
);
const stages = JSON.stringify(stageNames.map((name, i) => ({
id: `stage-${i + 1}`,
name,
type: selectedStages[i] || 'custom',
tasks: [],
})));
await pipelineApi.createPipeline({
name: newName,
code: nameToCode(newName),
@@ -168,8 +204,7 @@ export function PipelineCreateDialog({ open, onOpenChange, onCreated }: Pipeline
stages,
deploy_mode: 'branch',
deploy_target: deployTarget.deployTarget,
server_ids:
deployTarget.serverIds.length > 0 ? JSON.stringify(deployTarget.serverIds) : undefined,
server_ids: deployTarget.serverIds.length > 0 ? JSON.stringify(deployTarget.serverIds) : undefined,
deploy_remote_path: deployTarget.deployRemotePath || undefined,
deploy_script: deployTarget.deployScript || undefined,
k8s_cluster_id: deployTarget.k8sClusterId ? Number(deployTarget.k8sClusterId) : null,
@@ -197,22 +232,16 @@ export function PipelineCreateDialog({ open, onOpenChange, onCreated }: Pipeline
};
const toggleStage = (key: string) => {
setSelectedStages((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]));
setSelectedStages((prev) => (
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]
));
};
return (
<Dialog
open={open}
onOpenChange={(v) => {
if (!v) resetForm();
onOpenChange(v);
}}
>
<Dialog open={open} onOpenChange={(v) => { if (!v) resetForm(); onOpenChange(v); }}>
<DialogContent className="max-w-lg max-h-[88vh] overflow-y-auto bg-[var(--dg-bg-primary)] border-[var(--dg-border)]">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)]">
{t('deploy.newPipelineTitle')}
</DialogTitle>
<DialogTitle className="text-[var(--dg-text-primary)]">{t('deploy.newPipelineTitle')}</DialogTitle>
<DialogDescription className="text-[var(--dg-text-muted)]">
{t('deploy.newPipelineDesc')}
</DialogDescription>
@@ -225,9 +254,7 @@ export function PipelineCreateDialog({ open, onOpenChange, onCreated }: Pipeline
onClick={() => setStep(1)}
className={cn(
'px-2.5 py-1 rounded-md transition-colors',
step === 1
? 'bg-[var(--dg-bg-hover)] text-[var(--dg-text-primary)]'
: 'text-[var(--dg-text-muted)]',
step === 1 ? 'bg-[var(--dg-bg-hover)] text-[var(--dg-text-primary)]' : 'text-[var(--dg-text-muted)]',
)}
>
{t('deploy.step1')}
@@ -235,14 +262,10 @@ export function PipelineCreateDialog({ open, onOpenChange, onCreated }: Pipeline
<span className="text-[var(--dg-border)]">/</span>
<button
type="button"
onClick={() => {
if (validateStep1()) setStep(2);
}}
onClick={() => { if (validateStep1()) setStep(2); }}
className={cn(
'px-2.5 py-1 rounded-md transition-colors',
step === 2
? 'bg-[var(--dg-bg-hover)] text-[var(--dg-text-primary)]'
: 'text-[var(--dg-text-muted)]',
step === 2 ? 'bg-[var(--dg-bg-hover)] text-[var(--dg-text-primary)]' : 'text-[var(--dg-text-muted)]',
)}
>
{t('deploy.step2')}
@@ -264,20 +287,14 @@ export function PipelineCreateDialog({ open, onOpenChange, onCreated }: Pipeline
: 'border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]',
)}
>
<div className="text-sm font-medium text-[var(--dg-text-primary)]">
{t(p.labelKey)}
</div>
<div className="text-[11px] text-[var(--dg-text-muted)] mt-0.5">
{t(p.descKey)}
</div>
<div className="text-sm font-medium text-[var(--dg-text-primary)]">{t(p.labelKey)}</div>
<div className="text-[11px] text-[var(--dg-text-muted)] mt-0.5">{t(p.descKey)}</div>
</button>
))}
</div>
<div className="space-y-1.5">
<Label className="text-xs">
{t('deploy.pipeName')} <span className="text-[var(--dg-red)]">*</span>
</Label>
<Label className="text-xs">{t('deploy.pipeName')} <span className="text-[var(--dg-red)]">*</span></Label>
<Input
value={newName}
onChange={(e) => setNewName(e.target.value)}
@@ -303,9 +320,7 @@ export function PipelineCreateDialog({ open, onOpenChange, onCreated }: Pipeline
value={String(newEnvironmentId)}
onValueChange={(v) => setNewEnvironmentId(v === '__none' ? '' : v)}
>
<SelectTrigger className="h-9 dg-input">
<SelectValue placeholder={t('deploy.selectEnv')} />
</SelectTrigger>
<SelectTrigger className="h-9 dg-input"><SelectValue placeholder={t('deploy.selectEnv')} /></SelectTrigger>
<SelectContent>
<SelectItem value="__none">{t('deploy.notSpecified')}</SelectItem>
{environments.map((env) => (
@@ -318,16 +333,8 @@ export function PipelineCreateDialog({ open, onOpenChange, onCreated }: Pipeline
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button
onClick={() => {
if (validateStep1()) setStep(2);
}}
>
下一步
</Button>
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
<Button onClick={() => { if (validateStep1()) setStep(2); }}>下一步</Button>
</div>
</div>
) : (
@@ -341,10 +348,7 @@ export function PipelineCreateDialog({ open, onOpenChange, onCreated }: Pipeline
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1.5">
<Label className="text-xs">
{t('deploy.gitRepo')}{' '}
{selectedStages.includes('checkout') && (
<span className="text-[var(--dg-red)]">*</span>
)}
{t('deploy.gitRepo')} {selectedStages.includes('checkout') && <span className="text-[var(--dg-red)]">*</span>}
</Label>
<Input
value={newSource}
@@ -355,32 +359,20 @@ export function PipelineCreateDialog({ open, onOpenChange, onCreated }: Pipeline
</div>
<div className="space-y-1.5">
<Label className="text-xs">{t('deploy.pipeBranch')}</Label>
<Input
value={newBranch}
onChange={(e) => setNewBranch(e.target.value)}
className="h-8 text-xs dg-input"
/>
<Input value={newBranch} onChange={(e) => setNewBranch(e.target.value)} className="h-8 text-xs dg-input" />
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1.5">
<Label className="text-xs">{t('deploy.gitCredential')}</Label>
<Select
value={String(newGitCredentialId)}
onValueChange={(v) => setNewGitCredentialId(v === '__none' ? '' : v)}
>
<SelectTrigger className="h-8 text-xs dg-input">
<SelectValue placeholder={t('deploy.selectCredentialPlaceholder')} />
</SelectTrigger>
<Select value={String(newGitCredentialId)} onValueChange={(v) => setNewGitCredentialId(v === '__none' ? '' : v)}>
<SelectTrigger className="h-8 text-xs dg-input"><SelectValue placeholder="选择凭证" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none">{t('deploy.notLinked')}</SelectItem>
{gitCredentials.map((c) => (
<SelectItem key={c.id as number} value={String(c.id)}>
<span className="flex items-center gap-1">
<Globe className="h-3 w-3" />
{String(c.name)}
</span>
<span className="flex items-center gap-1"><Globe className="h-3 w-3" />{String(c.name)}</span>
</SelectItem>
))}
</SelectContent>
@@ -388,24 +380,14 @@ export function PipelineCreateDialog({ open, onOpenChange, onCreated }: Pipeline
</div>
{selectedStages.includes('image') && (
<div className="space-y-1.5">
<Label className="text-xs">
{t('deploy.imageCredential')} <span className="text-[var(--dg-red)]">*</span>
</Label>
<Select
value={String(newRegistryCredentialId)}
onValueChange={(v) => setNewRegistryCredentialId(v === '__none' ? '' : v)}
>
<SelectTrigger className="h-8 text-xs dg-input">
<SelectValue placeholder={t('deploy.dockerCredPlaceholder')} />
</SelectTrigger>
<Label className="text-xs">{t('deploy.imageCredential')} <span className="text-[var(--dg-red)]">*</span></Label>
<Select value={String(newRegistryCredentialId)} onValueChange={(v) => setNewRegistryCredentialId(v === '__none' ? '' : v)}>
<SelectTrigger className="h-8 text-xs dg-input"><SelectValue placeholder="Docker 凭证" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none">{t("common.notLinked")}</SelectItem>
<SelectItem value="__none">不关联</SelectItem>
{dockerCredentials.map((c) => (
<SelectItem key={c.id as number} value={String(c.id)}>
<span className="flex items-center gap-1">
<Container className="h-3 w-3" />
{String(c.name)}
</span>
<span className="flex items-center gap-1"><Container className="h-3 w-3" />{String(c.name)}</span>
</SelectItem>
))}
</SelectContent>
@@ -447,45 +429,125 @@ export function PipelineCreateDialog({ open, onOpenChange, onCreated }: Pipeline
{selectedStages.length > 0 && (
<p className="text-[10px] text-[var(--dg-text-muted)] flex items-center gap-1 flex-wrap">
<GitBranch className="h-3 w-3" />
{selectedStages
.map((k) => STAGE_DEFS.find((d) => d.key === k)?.label || k)
.join(' → ')}
{selectedStages.map((k) => STAGE_DEFS.find((d) => d.key === k)?.label || k).join(' → ')}
</p>
)}
</div>
<AdvancedSettingsPanel
open={advancedOpen}
onOpenChange={setAdvancedOpen}
buildNodeId={buildNodeId}
onBuildNodeIdChange={setBuildNodeId}
buildNodes={buildNodes}
jobName={newJobName}
onJobNameChange={setNewJobName}
triggerMode={newTriggerMode}
onTriggerModeChange={setNewTriggerMode}
watchBranches={newWatchBranches}
onWatchBranchesChange={setNewWatchBranches}
cronExpr={newCronExpr}
onCronExprChange={setNewCronExpr}
envVars={newEnvVars}
onEnvVarsChange={setNewEnvVars}
/>
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex items-center gap-1.5 text-xs text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"
>
<Settings2 className="h-3.5 w-3.5" />
{t('deploy.advancedSettings')}
<ChevronDown className={cn('h-3.5 w-3.5 transition-transform', advancedOpen && 'rotate-180')} />
</button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-3 space-y-3 pt-3 border-t border-[var(--dg-border)]">
<div className="space-y-1.5">
<Label className="text-xs">{t('deploy.buildNode')}</Label>
<Select value={buildNodeId || '__auto'} onValueChange={(v) => setBuildNodeId(v === '__auto' ? '' : v)}>
<SelectTrigger className="h-8 text-xs dg-input"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="__auto">{t('deploy.autoSchedule')}</SelectItem>
{buildNodes.map((n) => (
<SelectItem key={n.id} value={String(n.id)}>
{n.name} ({n.running_count || 0}/{n.max_executors})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Job 名称 <span className="text-[var(--dg-text-muted)]">(Webhook)</span></Label>
<Input value={newJobName} onChange={(e) => setNewJobName(e.target.value)} className="h-8 text-xs font-mono dg-input" />
</div>
<div className="space-y-1.5">
<Label className="text-xs">{t('deploy.triggerMethod')}</Label>
<Select value={newTriggerMode} onValueChange={setNewTriggerMode}>
<SelectTrigger className="h-8 text-xs dg-input"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="manual">{t('deploy.triggerManual')}</SelectItem>
<SelectItem value="git_push">{t('deploy.codePush')}</SelectItem>
<SelectItem value="cron">{t('deploy.triggerCron')}</SelectItem>
<SelectItem value="webhook">Webhook</SelectItem>
</SelectContent>
</Select>
</div>
{newTriggerMode === 'git_push' && (
<Input
value={newWatchBranches}
onChange={(e) => setNewWatchBranches(e.target.value)}
placeholder="监听分支,如 main, release/*"
className="h-8 text-xs font-mono dg-input"
/>
)}
{newTriggerMode === 'cron' && (
<Input
value={newCronExpr}
onChange={(e) => setNewCronExpr(e.target.value)}
placeholder="0 2 * * *"
className="h-8 text-xs font-mono dg-input"
/>
)}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label className="text-xs">{t('deploy.envVars')}</Label>
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 text-[10px]"
onClick={() => setNewEnvVars([...newEnvVars, { key: '', value: '' }])}
>
<Plus className="h-3 w-3 mr-0.5" />{t('deploy.addVar')}
</Button>
</div>
{newEnvVars.map((v, i) => (
<div key={i} className="flex gap-1.5">
<Input
value={v.key}
onChange={(e) => {
const nv = [...newEnvVars];
nv[i] = { ...nv[i], key: e.target.value };
setNewEnvVars(nv);
}}
placeholder="KEY"
className="h-7 text-xs font-mono dg-input flex-1"
/>
<Input
value={v.value}
onChange={(e) => {
const nv = [...newEnvVars];
nv[i] = { ...nv[i], value: e.target.value };
setNewEnvVars(nv);
}}
placeholder="value"
className="h-7 text-xs font-mono dg-input flex-[2]"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={() => setNewEnvVars(newEnvVars.filter((_, idx) => idx !== i))}
>
<Trash2 className="h-3.5 w-3.5 text-[var(--dg-red)]" />
</Button>
</div>
))}
</div>
</CollapsibleContent>
</Collapsible>
<div className="flex justify-between gap-2 pt-2 border-t border-[var(--dg-border)]">
<Button variant="ghost" onClick={() => setStep(1)}>
{t('deploy.prevStep')}
</Button>
<Button variant="ghost" onClick={() => setStep(1)}>{t('deploy.prevStep')}</Button>
<div className="flex gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
<Button onClick={handleCreate} disabled={saving}>
{saving ? (
<Loader2 className="h-4 w-4 animate-spin mr-1" />
) : (
<Rocket className="h-4 w-4 mr-1" />
)}
{saving ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Rocket className="h-4 w-4 mr-1" />}
{saving ? t('deploy.creating') : t('deploy.pipeCreate')}
</Button>
</div>
@@ -495,4 +557,4 @@ export function PipelineCreateDialog({ open, onOpenChange, onCreated }: Pipeline
</DialogContent>
</Dialog>
);
}
}
@@ -45,7 +45,6 @@ function PipelineQueuePanel({ pipeline }: { pipeline: Record<string, unknown> })
loadQueue();
const t = setInterval(loadQueue, 10000);
return () => clearInterval(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [code]);
const handleCancel = async (runId: number) => {
@@ -74,7 +73,7 @@ function PipelineQueuePanel({ pipeline }: { pipeline: Record<string, unknown> })
<span className="font-bold text-[var(--dg-text-primary)]">{running}/{max}</span>
{queued > 0 && (
<span className="text-[var(--dg-text-secondary)] ml-2">
{i18n.t('deploy.runStatus.buildQueue')} <span className="font-bold dg-text-yellow">({queued})</span>
构建队列 <span className="font-bold dg-text-yellow">({queued})</span>
</span>
)}
</div>
@@ -90,7 +89,7 @@ function PipelineQueuePanel({ pipeline }: { pipeline: Record<string, unknown> })
<span className="text-[var(--dg-text-secondary)] opacity-50">{String(run.triggered_by || '-')}</span>
</div>
<Button variant="ghost" size="sm" className="h-6 text-xs dg-text-red hover:dg-text-red" onClick={() => handleCancel(run.id as number)}>
<XCircle className="h-3 w-3 mr-1" />{i18n.t('deploy.runStatus.cancel')}
<XCircle className="h-3 w-3 mr-1" />取消
</Button>
</div>
))}
@@ -106,7 +105,7 @@ function PipelineQueuePanel({ pipeline }: { pipeline: Record<string, unknown> })
<span className="text-[var(--dg-text-muted)] font-mono">Run #{id}</span>
</div>
<Button variant="ghost" size="sm" className="h-6 text-xs dg-text-red hover:dg-text-red" onClick={() => handleCancel(id)}>
<XCircle className="h-3 w-3 mr-1" />{i18n.t('deploy.runStatus.cancel')}
<XCircle className="h-3 w-3 mr-1" />取消
</Button>
</div>
))}
@@ -163,19 +162,19 @@ export function PipelineRunPanel({ pipeline, pipelineRuns, isLoadingRuns, onStop
<td className="px-4 py-2">
{r.status === 'success' ? (
<Badge className="bg-emerald-500/20 dg-text-green border-emerald-500/30 text-[11px]">
<CheckCircle className="h-3 w-3 mr-0.5" />{i18n.t('deploy.runStatus.success')}
<CheckCircle className="h-3 w-3 mr-0.5" />成功
</Badge>
) : r.status === 'failed' ? (
<Badge className="bg-red-500/20 dg-text-red border-red-500/30 text-[11px]">
<XCircle className="h-3 w-3 mr-0.5" />{i18n.t('deploy.runStatus.failed')}
<XCircle className="h-3 w-3 mr-0.5" />失败
</Badge>
) : r.status === 'running' ? (
<Badge className="bg-blue-500/20 dg-text-blue border-blue-500/30 text-[11px]">
<Loader2 className="h-3 w-3 animate-spin mr-0.5" />{i18n.t('deploy.runStatus.running')}
<Loader2 className="h-3 w-3 animate-spin mr-0.5" />执行中
</Badge>
) : r.status === 'cancelled' ? (
<Badge className="bg-yellow-500/20 dg-text-yellow border-yellow-500/30 text-[11px]">
<StopCircle className="h-3 w-3 mr-0.5" />{i18n.t('deploy.runStatus.cancelled')}
<StopCircle className="h-3 w-3 mr-0.5" />已停止
</Badge>
) : (
<Badge variant="secondary" className="text-[11px]">{String(r.status)}</Badge>
@@ -189,11 +188,11 @@ export function PipelineRunPanel({ pipeline, pipelineRuns, isLoadingRuns, onStop
<td className="px-4 py-2 text-right">
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="sm" className="h-7 text-xs dg-text-blue hover:text-blue-300" onClick={() => handleViewLog(r)}>
<FileText className="h-3.5 w-3.5 mr-1" />{i18n.t('deploy.runStatus.log')}
<FileText className="h-3.5 w-3.5 mr-1" />日志
</Button>
{(r.status === 'running' || r.status === 'pending') && (
<Button variant="ghost" size="sm" className="h-7 text-xs dg-text-red hover:dg-text-red" onClick={() => onStopRun(r.id as number | string)}>
<StopCircle className="h-3.5 w-3.5 mr-1" />{i18n.t('deploy.runStatus.stop')}
<StopCircle className="h-3.5 w-3.5 mr-1" />停止
</Button>
)}
</div>
@@ -208,14 +207,14 @@ export function PipelineRunPanel({ pipeline, pipelineRuns, isLoadingRuns, onStop
<DialogContent className="max-w-3xl max-h-[85vh] overflow-y-auto bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />{i18n.t('deploy.runStatus.execLog', { number: String(logRun?.build_number ?? logRun?.id ?? '') })}
<FileText className="h-5 w-5" />执行日志 #{String(logRun?.build_number ?? logRun?.id ?? '')}
</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="flex flex-wrap gap-3 text-sm text-[var(--dg-text-secondary)]">
<span>{i18n.t('deploy.runStatus.triggerMethod')} {String(logRun?.triggered_by || 'manual')}</span>
<span>{i18n.t('deploy.runStatus.branch')} {String(logRun?.branch || '-')}</span>
<span>{i18n.t('deploy.runStatus.duration')} {fmtDuration(logRun?.duration as number | undefined)}</span>
<span>触发方式: {String(logRun?.triggered_by || 'manual')}</span>
<span>分支: {String(logRun?.branch || '-')}</span>
<span>耗时: {fmtDuration(logRun?.duration as number | undefined)}</span>
</div>
<div className="bg-[var(--dg-bg-primary)] rounded-md p-3 max-h-[50vh] overflow-y-auto font-mono text-xs">
{logs.length === 0 ? (
@@ -224,7 +223,7 @@ export function PipelineRunPanel({ pipeline, pipelineRuns, isLoadingRuns, onStop
logs.map((l, i) => (
<div key={i} className="flex gap-2 py-0.5">
<span className="dg-text-muted shrink-0">[{l.time}]</span>
<span className={l.level === 'error' ? 'dg-text-red' : l.level === 'warn' ? 'dg-text-yellow' : l.level === 'success' ? 'dg-text-green' : 'text-[var(--dg-text-secondary)]'}>
<span className={`whitespace-pre-wrap break-words min-w-0 ${l.level === 'error' ? 'dg-text-red' : l.level === 'warn' ? 'dg-text-yellow' : l.level === 'success' ? 'dg-text-green' : 'text-[var(--dg-text-secondary)]'}`}>
{l.level === 'success' ? '✓' : l.level === 'error' ? '✗' : l.level === 'warn' ? '!' : '>'} {l.message}
</span>
</div>
@@ -236,4 +235,4 @@ export function PipelineRunPanel({ pipeline, pipelineRuns, isLoadingRuns, onStop
</Dialog>
</div>
);
}
}
@@ -10,12 +10,12 @@ import {
// 流水线阶段定义(固定顺序) // Logan.Li
export const STAGE_DEFS = [
{ key: 'checkout', label: i18n.t('pipeline.checkout'), icon: GitBranch, color: '#6366F1', order: 1, needs: ['repo'], desc: i18n.t('deploy.stage.checkout') },
{ key: 'build', label: i18n.t('pipeline.build'), icon: Box, color: '#F59E0B', order: 2, needs: [], desc: i18n.t('deploy.stage.build') },
{ key: 'test', label: i18n.t('pipeline.testTool'), icon: CheckCircle, color: '#10B981', order: 3, needs: [], desc: i18n.t('deploy.stage.test') },
{ key: 'image', label: i18n.t('pipeline.imageBuild'), icon: Container, color: '#06B6D4', order: 4, needs: ['registry'], desc: i18n.t('deploy.stage.imageBuild') },
{ key: 'deploy', label: i18n.t('pipeline.deploy2'), icon: Rocket, color: '#EF4444', order: 5, needs: [], desc: i18n.t('deploy.stage.deploy') },
{ key: 'verify', label: i18n.t('system.credentialVerify'), icon: ShieldCheck, color: '#3B82F6', order: 6, needs: [], desc: i18n.t('deploy.stage.verify') },
{ key: 'checkout', label: i18n.t('pipeline.checkout'), icon: GitBranch, color: '#6366F1', order: 1, needs: ['repo'], desc: i18n.t('pipeline.constantsts.s4769cecb') },
{ key: 'build', label: i18n.t('pipeline.build'), icon: Box, color: '#F59E0B', order: 2, needs: [], desc: i18n.t('pipeline.constantsts.s3318af6f') },
{ key: 'test', label: i18n.t('pipeline.testTool'), icon: CheckCircle, color: '#10B981', order: 3, needs: [], desc: i18n.t('pipeline.constantsts.s350a98e1') },
{ key: 'image', label: i18n.t('pipeline.imageBuild'), icon: Container, color: '#06B6D4', order: 4, needs: ['registry'], desc: i18n.t('pipeline.constantsts.s98ea1149') },
{ key: 'deploy', label: i18n.t('pipeline.deploy2'), icon: Rocket, color: '#EF4444', order: 5, needs: [], desc: i18n.t('pipeline.constantsts.s45f0eba0') },
{ key: 'verify', label: i18n.t('system.credentialVerify'), icon: ShieldCheck, color: '#3B82F6', order: 6, needs: [], desc: i18n.t('pipeline.constantsts.seb164cd5') },
].sort((a, b) => a.order - b.order);
// 运行日志条目 // Logan.Li
@@ -32,7 +32,7 @@ export function fmtDuration(s: number | undefined): string {
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
const sec = s % 60;
return sec > 0 ? i18n.t('common.duration.minSec', { m, s: sec }) : i18n.t('common.duration.min', { m });
return sec > 0 ? `${m}分${sec}秒` : `${m}分`;
}
// 解析 stages 字段 // Logan.Li
@@ -14,10 +14,10 @@ import {
// 环境配置(值与后端 oneof=dev test staging prod 一致) // Logan.Li
export const ENVIRONMENTS: Record<string, { label: string; color: string }> = {
dev: { label: 'dev', color: 'border-blue-500/30 dg-text-blue bg-blue-500/10' },
test: { label: 'test', color: 'border-cyan-500/30 dg-text-cyan bg-cyan-500/10' },
staging: { label: 'staging', color: 'border-amber-500/30 dg-text-yellow bg-amber-500/10' },
prod: { label: 'prod', color: 'border-red-500/30 dg-text-red bg-red-500/10' },
dev: { label: '开发环境', color: 'border-blue-500/30 dg-text-blue bg-blue-500/10' },
test: { label: '测试环境', color: 'border-cyan-500/30 dg-text-cyan bg-cyan-500/10' },
staging: { label: '预发环境', color: 'border-amber-500/30 dg-text-yellow bg-amber-500/10' },
prod: { label: '生产环境', color: 'border-red-500/30 dg-text-red bg-red-500/10' },
};
const ENV_LABEL_KEY: Record<string, string> = {
@@ -0,0 +1,49 @@
// TechnologyBrandIcon 统一渲染本地打包的技术品牌图标,避免运行时依赖第三方 CDN。 // Logan.Li
import type { SimpleIcon } from 'simple-icons';
import {
siDocker,
siElasticsearch,
siGrafana,
siJenkins,
siKubernetes,
siPrometheus,
} from 'simple-icons';
const ICONS = {
docker: siDocker,
elasticsearch: siElasticsearch,
grafana: siGrafana,
jenkins: siJenkins,
kubernetes: siKubernetes,
prometheus: siPrometheus,
} satisfies Record<string, SimpleIcon>;
export type TechnologyBrand = keyof typeof ICONS;
interface TechnologyBrandIconProps {
brand: TechnologyBrand;
className?: string;
title?: string;
}
export function TechnologyBrandIcon({
brand,
className = 'h-5 w-5',
title,
}: TechnologyBrandIconProps) {
const icon = ICONS[brand];
const label = title || icon.title;
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="currentColor"
style={{ color: `#${icon.hex}` }}
role="img"
aria-label={label}
>
<path d={icon.path} />
</svg>
);
}
@@ -35,7 +35,6 @@ export default function CapacityPage() {
navigate(`/kubernetes/${code}/capacity`, { replace: true });
}
}).catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
@@ -79,7 +78,7 @@ export default function CapacityPage() {
<PageShell
variant="list"
title={i18n.t('k8s.capacity.resources')}
subtitle={clusterName ? `${clusterName} · ${i18n.t('k8s.capacity.resources1')}` : i18n.t('k8s.capacity.resources1')}
subtitle={clusterName ? `${clusterName} · ${i18n.t('k8s.capacity.deploymentResources')}` : i18n.t('k8s.capacity.deploymentResources')}
icon={<BarChart3 className="h-5 w-5" />}
actions={(
<>
@@ -92,12 +91,14 @@ export default function CapacityPage() {
<Select value={selectedNs} onValueChange={setSelectedNs}>
<SelectTrigger className="w-32 h-9 dg-input"><SelectValue placeholder={i18n.t('k8s.capacity.namespace')} /></SelectTrigger>
<SelectContent>
<SelectItem value="all">{i18n.t("common.allNamespaces")}</SelectItem>
<SelectItem value="all">{i18n.t('k8s.capacity.allNamespaces')}</SelectItem>
{namespaces.map(n => <SelectItem key={n} value={n}>{n}</SelectItem>)}
</SelectContent>
</Select>
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={loadCapacity} disabled={loading}>
<RefreshCw className={`h-4 w-4 mr-1 ${loading ? 'animate-spin' : ''}`} />{i18n.t('common.refresh')}</Button>
<RefreshCw className={`h-4 w-4 mr-1 ${loading ? 'animate-spin' : ''}`} />
{i18n.t('common.refresh')}
</Button>
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={exportCSV}>
<Download className="h-4 w-4 mr-1" />
CSV
@@ -109,9 +110,9 @@ export default function CapacityPage() {
<Table>
<TableHeader><TableRow className="border-[var(--dg-border)] hover:bg-transparent">
<TableHead className="dg-text-secondary">Deployment</TableHead>
<TableHead className="dg-text-secondary">{i18n.t("common.namespaceLabel")}</TableHead>
<TableHead className="dg-text-secondary">{i18n.t("common.replicasLabel")}</TableHead>
<TableHead className="dg-text-secondary">{i18n.t("common.containerLabel")}</TableHead>
<TableHead className="dg-text-secondary">{i18n.t('k8s.capacity.namespaceColumn')}</TableHead>
<TableHead className="dg-text-secondary">{i18n.t('k8s.capacity.replicas')}</TableHead>
<TableHead className="dg-text-secondary">{i18n.t('k8s.capacity.container')}</TableHead>
<TableHead className="dg-text-secondary"><Cpu className="h-3 w-3 inline mr-1" />CPU Req</TableHead>
<TableHead className="dg-text-secondary"><Cpu className="h-3 w-3 inline mr-1" />CPU Lim</TableHead>
<TableHead className="dg-text-secondary"><HardDrive className="h-3 w-3 inline mr-1" />Mem Req</TableHead>
@@ -119,7 +120,7 @@ export default function CapacityPage() {
</TableRow></TableHeader>
<TableBody>
{rows.length === 0 ? (
<TableRow><TableCell colSpan={8} className="text-center dg-text-muted py-8">{i18n.t('common.noData')}</TableCell></TableRow>
<TableRow><TableCell colSpan={8} className="text-center dg-text-muted py-8">{i18n.t('k8s.capacity.s934752c9')}</TableCell></TableRow>
) : rows.map((r, i) => (
<TableRow key={`${r.deployment}-${r.container}-${i}`} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]">
<TableCell className="font-medium text-[var(--dg-text-primary)] text-sm">{r.deployment}</TableCell>
@@ -54,10 +54,9 @@ export default function ClusterOverviewPage() {
const current = list.find((c: JsonData) => (c.code || String(c.id)) === clusterCode);
setClusterInfo(current || null);
if (!current && list.length > 0 && clusterCode) {
toast({ title: i18n.t('k8s.clusterOverview.notFound'), description: i18n.t('k8s.toast.clusterNotFound', { code: clusterCode }), variant: 'destructive' });
toast({ title: i18n.t('kubernetes.clusteroverview.s9ff765c1'), description: `找不到集群 ${clusterCode}`, variant: 'destructive' });
}
} catch { setClusters([]); }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [clusterCode]);
const loadData = useCallback(async () => {
@@ -85,8 +84,8 @@ export default function ClusterOverviewPage() {
return (
<PageShell
variant="default"
title={clusterCode || i18n.t('k8s.clusterOverview.title')}
subtitle={clusterInfo?.version ? `${i18n.t('k8s.clusterOverview.title')} · Kubernetes ${clusterInfo.version}` : i18n.t('k8s.clusterOverview.title')}
title={clusterCode || i18n.t('kubernetes.clusteroverview.s066c0aa3')}
subtitle={clusterInfo?.version ? `集群概览 · Kubernetes ${clusterInfo.version}` : i18n.t('kubernetes.clusteroverview.s066c0aa3')}
icon={<Server className="h-5 w-5" />}
metrics={!loading ? (
<StatStrip
@@ -99,13 +98,15 @@ export default function ClusterOverviewPage() {
) : undefined}
actions={(
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={loadData} disabled={loading}>
<RefreshCw className={`h-4 w-4 mr-1 ${loading ? 'animate-spin' : ''}`} />{i18n.t('common.refresh')}</Button>
<RefreshCw className={`h-4 w-4 mr-1 ${loading ? 'animate-spin' : ''}`} />
刷新
</Button>
)}
>
{/* 节点列表 // Logan.Li */}
<div>
<h2 className="text-lg font-semibold text-[var(--dg-text-primary)] mb-3 flex items-center gap-2">
<Cpu className="h-4 w-4 text-[var(--dg-blue)]" />{i18n.t("common.nodeList")}
<Cpu className="h-4 w-4 text-[var(--dg-blue)]" />节点列表
</h2>
<ContentPanel>
<Table>
@@ -155,7 +156,7 @@ export default function ClusterOverviewPage() {
<span className="text-[var(--dg-text-secondary)]">{cpuPct.toFixed(0)}%</span>
</div>
) : (
<span className="text-[var(--dg-text-muted)]">{cpuCores} {i18n.t("k8s.cluster.cpuCores", { count: cpuCores })}</span>
<span className="text-[var(--dg-text-muted)]">{cpuCores} 核</span>
)}
</TableCell>
<TableCell className="text-xs">
@@ -183,10 +184,10 @@ export default function ClusterOverviewPage() {
<div>
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-[var(--dg-text-primary)] flex items-center gap-2">
<Boxes className="h-4 w-4 text-[var(--dg-green)]" />{i18n.t("common.namespaceLabel")}
<Boxes className="h-4 w-4 text-[var(--dg-green)]" />命名空间
</h2>
<Link to={`/kubernetes/${clusterCode}/namespaces`} className="text-sm text-[var(--dg-blue)] hover:text-[var(--dg-blue)] transition-colors flex items-center gap-1">
{i18n.t('common.manageAll')} <ChevronRight className="h-3 w-3" />
管理全部 <ChevronRight className="h-3 w-3" />
</Link>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
@@ -61,7 +61,7 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac
setPods(allPods.filter((p: JsonData) => p.namespace === namespace && p.name.startsWith(deploymentName)));
setHpas(allHpas.filter((h: JsonData) => h.target_name === deploymentName && h.namespace === namespace));
} catch (e) {
console.error(i18n.t('k8s.deploymentWorkload.loadFailed'), e);
console.error(i18n.t('kubernetes.deploymentdetai.sb46ea9c4'), e);
} finally {
setLoading(false);
}
@@ -75,7 +75,7 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac
setActionLoading('scale');
try {
await kubernetesApi.scaleDeployment(clusterCode, namespace, deploymentName, newReplicas);
toast({ title: i18n.t('k8s.workloads.scaleSuccess'), description: i18n.t('k8s.toast.scaleSet', { count: newReplicas }) });
toast({ title: i18n.t('k8s.workloads.scaleSuccess'), description: `副本数已设置为 ${newReplicas}` });
loadData();
} catch (e) {
toast({ title: i18n.t('cloud.operationFailed'), description: e?.message || String(e), variant: 'destructive' });
@@ -86,7 +86,7 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac
setActionLoading('restart');
try {
await kubernetesApi.restartDeployment(clusterCode, namespace, deploymentName);
toast({ title: i18n.t('k8s.workloadView.restartSent'), description: i18n.t('k8s.deploymentWorkload.restarting') });
toast({ title: i18n.t('kubernetes.deploymentdetai.s68a70009'), description: i18n.t('kubernetes.deploymentdetai.sd187617a') });
} catch (e) {
toast({ title: i18n.t('kubernetes.deployment.restartFailed'), description: e?.message || String(e), variant: 'destructive' });
} finally { setActionLoading(''); }
@@ -97,7 +97,7 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac
setActionLoading('image');
try {
await kubernetesApi.updateDeploymentImage(clusterCode, namespace, deploymentName, newImage);
toast({ title: i18n.t('k8s.deploymentDetail.imageUpdated'), description: i18n.t('k8s.toast.imageUpdating', { image: newImage }) });
toast({ title: i18n.t('kubernetes.deploymentdetai.s6df3c303'), description: `正在滚动更新为 ${newImage}` });
setShowImageUpdate(false);
loadData();
} catch (e) {
@@ -108,14 +108,14 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac
const handleHpaToggle = async (hpa: JsonData) => {
const ok = await confirm({
title: i18n.t('kubernetes.hpa.deleteHpa'),
description: i18n.t('k8s.toast.confirmDeleteHpa', { name: hpa.name }),
description: `确定删除 HPA "${hpa.name}"?`,
confirmText: i18n.t('kubernetes.storage.delete2'),
variant: 'destructive',
});
if (!ok) return;
try {
await kubernetesApi.deleteHPA(clusterCode, namespace, hpa.name);
toast({ title: i18n.t('k8s.deploymentDetail.hpaDeleted') });
toast({ title: i18n.t('kubernetes.deploymentdetai.s0f958f8e') });
loadData();
} catch (e) {
toast({ title: i18n.t('kubernetes.msg.deleteFailed2'), description: e?.message || String(e), variant: 'destructive' });
@@ -138,7 +138,7 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac
<Card className="dg-card">
<CardContent className="p-12 text-center">
<XCircle className="h-8 w-8 mx-auto dg-text-red" />
<p className="dg-text-secondary mt-3">{i18n.t('common.notFoundDeploy')}: {deploymentName}</p>
<p className="dg-text-secondary mt-3">未找到 Deployment: {deploymentName}</p>
<Button variant="outline" className="mt-4" onClick={onBack}> {i18n.t('common.back')} </Button>
</CardContent>
</Card>
@@ -176,10 +176,10 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac
<Tabs defaultValue="pods">
<TabsList className="bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)]">
<TabsTrigger value="pods" className="data-[state=active]:bg-[var(--dg-bg-active)]">
{i18n.t('k8s.deploymentDetail.podList')} ({pods.length})
Pod 列表 ({pods.length})
</TabsTrigger>
<TabsTrigger value="logs" className="data-[state=active]:bg-[var(--dg-bg-active)]">
<FileText className="h-4 w-4 mr-1" />{i18n.t("common.podLogs")}
<FileText className="h-4 w-4 mr-1" />日志
</TabsTrigger>
<TabsTrigger value="hpa" className="data-[state=active]:bg-[var(--dg-bg-active)]">
<Gauge className="h-4 w-4 mr-1" />HPA ({hpas.length})
@@ -1,24 +1,19 @@
// DeploymentEditAdvanced.tsx — 编辑器「高级设置」Tab (M8) // Logan.Li
// 编辑内容:更新策略 / 历史版本 / 调度(节点选择/容忍) / DNS / 安全上下文 / 终止宽限期 / 调度器 / SA
// Q21 拆分: 提取 types(parser+builder) + UpdateStrategyCard + TolerationsCard + PodMiscCard + PodSecurityCard + HostSettingsCard
// 作者: Logan <admin@attacker.club>
import { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Loader2, Save } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Save, Loader2, Settings, RefreshCw, Shield, Clock, Network, Plus, Trash2,
} from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { kubernetesApi } from '@/api';
import { saveDeploymentManifest } from './deployment-manifest-save';
import { SchedulingSection } from './deployment-edit-advanced/SchedulingSection';
import { SchedulingSection, type ScheduleMode } from './deployment-edit-advanced/SchedulingSection';
import { DnsHostSection } from './deployment-edit-advanced/DnsHostSection';
import {
parseAdvancedManifest, buildAdvancedManifest,
type AdvancedState, type Toleration, type NodeInfo,
} from './deployment-edit-advanced/types';
import { UpdateStrategyCard } from './deployment-edit-advanced/UpdateStrategyCard';
import { TolerationsCard } from './deployment-edit-advanced/TolerationsCard';
import { PodMiscCard } from './deployment-edit-advanced/PodMiscCard';
import { PodSecurityCard } from './deployment-edit-advanced/PodSecurityCard';
import { HostSettingsCard } from './deployment-edit-advanced/HostSettingsCard';
import type { JsonData } from '@/types/common';
import i18n from '@/i18n';
@@ -31,6 +26,14 @@ interface Props {
onCancel: () => void;
}
interface Toleration {
key: string;
operator: string; // Equal | Exists
value: string;
effect: string; // NoSchedule | PreferNoSchedule | NoExecute
tolerationSeconds: string;
}
export function DeploymentEditAdvanced({ clusterCode, namespace, deploymentName, manifest, onSaved, onCancel }: Props) {
const { toast } = useToast();
const [saving, setSaving] = useState(false);
@@ -47,15 +50,13 @@ export function DeploymentEditAdvanced({ clusterCode, namespace, deploymentName,
const [progressDeadlineSeconds, setProgressDeadlineSeconds] = useState('600');
// ──── 调度策略 ────
const [scheduleMode, setScheduleMode] = useState<AdvancedState['scheduleMode']>('nodeSelector');
const [scheduleMode, setScheduleMode] = useState<ScheduleMode>('nodeSelector');
const [nodeName, setNodeName] = useState('');
const [availableNodes, setAvailableNodes] = useState<NodeInfo[]>([]);
const [availableNodes, setAvailableNodes] = useState<Array<{ name: string; ip: string; labels: Record<string, string> }>>([]);
const [nodesLoading, setNodesLoading] = useState(false);
const [labelSelectorKv, setLabelSelectorKv] = useState<Array<{ key: string; value: string }>>([]);
const [nodeLabels, setNodeLabels] = useState<Record<string, string[]>>({});
const [tolerations, setTolerations] = useState<Toleration[]>([]);
// Pod 级别杂项
const [dnsPolicy, setDnsPolicy] = useState('ClusterFirst');
const [restartPolicy, setRestartPolicy] = useState('Always');
const [schedulerName, setSchedulerName] = useState('default-scheduler');
@@ -68,57 +69,61 @@ export function DeploymentEditAdvanced({ clusterCode, namespace, deploymentName,
const [podRunAsGroup, setPodRunAsGroup] = useState('');
const [podRunAsNonRoot, setPodRunAsNonRoot] = useState(false);
// 主机设置
// 主机设置(M8 补全)
const [hostNetwork, setHostNetwork] = useState(false);
const [hostIPC, setHostIPC] = useState(false);
const [hostPID, setHostPID] = useState(false);
const [shareProcessNamespace, setShareProcessNamespace] = useState(false);
// hostAliases
// hostAliases(M8 补全)
const [hostAliases, setHostAliases] = useState<Array<{ ip: string; hostnames: string }>>([]);
// dnsConfig
// dnsConfig(M8 补全)
const [dnsNameservers, setDnsNameservers] = useState('');
const [dnsSearches, setDnsSearches] = useState('');
const [dnsOptions, setDnsOptions] = useState<Array<{ name: string; value: string }>>([]);
useEffect(() => {
if (!manifest) { setLoading(false); return; }
setRawManifest(manifest);
const s = parseAdvancedManifest(manifest);
setStrategyType(s.strategyType);
setMaxSurge(s.maxSurge);
setMaxUnavailable(s.maxUnavailable);
setRevisionHistoryLimit(s.revisionHistoryLimit);
setProgressDeadlineSeconds(s.progressDeadlineSeconds);
setScheduleMode(s.scheduleMode);
setNodeName(s.nodeName);
setLabelSelectorKv(s.labelSelectorKv);
setTolerations(s.tolerations);
setDnsPolicy(s.dnsPolicy);
setRestartPolicy(s.restartPolicy);
setSchedulerName(s.schedulerName);
setTerminationGracePeriodSeconds(s.terminationGracePeriodSeconds);
setServiceAccountName(s.serviceAccountName);
setPodFsGroup(s.podFsGroup);
setPodRunAsUser(s.podRunAsUser);
setPodRunAsGroup(s.podRunAsGroup);
setPodRunAsNonRoot(s.podRunAsNonRoot);
setHostNetwork(s.hostNetwork);
setHostIPC(s.hostIPC);
setHostPID(s.hostPID);
setShareProcessNamespace(s.shareProcessNamespace);
setHostAliases(s.hostAliases);
setDnsNameservers(s.dnsNameservers);
setDnsSearches(s.dnsSearches);
setDnsOptions(s.dnsOptions);
const m = manifest;
if (!m) { setLoading(false); return; }
setRawManifest(m);
const spec = m?.spec || {};
const podSpec = spec?.template?.spec || {};
// 加载节点列表(副作用,留在 useEffect 内)
// 更新策略
const strat = spec.strategy || {};
setStrategyType(strat.type || 'RollingUpdate');
const ru = strat.rollingUpdate || {};
setMaxSurge(String(ru.maxSurge ?? '25%'));
setMaxUnavailable(String(ru.maxUnavailable ?? '25%'));
// 历史/进度
setRevisionHistoryLimit(spec.revisionHistoryLimit != null ? String(spec.revisionHistoryLimit) : '10');
setProgressDeadlineSeconds(spec.progressDeadlineSeconds != null ? String(spec.progressDeadlineSeconds) : '600');
// nodeSelector → labelSelectorKv
const ns = podSpec.nodeSelector || {};
const nsEntries = Object.entries(ns).map(([k, v]) => ({ key: k, value: String(v) }));
setLabelSelectorKv(nsEntries);
// nodeName
setNodeName(podSpec.nodeName || '');
// Determine scheduleMode
if (podSpec.nodeName) {
setScheduleMode('nodeName');
} else if (podSpec.affinity?.nodeAffinity) {
setScheduleMode('nodeAffinity');
} else if (nsEntries.length > 0) {
setScheduleMode('nodeSelector');
}
// 加载节点列表
if (clusterCode) {
setNodesLoading(true);
kubernetesApi.getClusterNodes(clusterCode).then((res: JsonData) => {
const nodes: JsonData[] = Array.isArray(res) ? res : (res?.items || res?.list || []);
const mapped: NodeInfo[] = nodes.map((n: JsonData) => {
const mapped = nodes.map((n: JsonData) => {
const meta = n.metadata || {};
const status = n.status || {};
const addresses: JsonData[] = Array.isArray(status.addresses) ? status.addresses : [];
@@ -138,8 +143,51 @@ export function DeploymentEditAdvanced({ clusterCode, namespace, deploymentName,
setNodeLabels(lblMap);
}).catch(() => {}).finally(() => setNodesLoading(false));
}
// tolerations
const tols: JsonData[] = Array.isArray(podSpec.tolerations) ? podSpec.tolerations : [];
setTolerations(tols.map(t => ({
key: t.key || '',
operator: t.operator || 'Equal',
value: t.value || '',
effect: t.effect || '',
tolerationSeconds: t.tolerationSeconds != null ? String(t.tolerationSeconds) : '',
})));
setDnsPolicy(podSpec.dnsPolicy || 'ClusterFirst');
setRestartPolicy(podSpec.restartPolicy || 'Always');
setSchedulerName(podSpec.schedulerName || 'default-scheduler');
setTerminationGracePeriodSeconds(podSpec.terminationGracePeriodSeconds != null ? String(podSpec.terminationGracePeriodSeconds) : '30');
setServiceAccountName(podSpec.serviceAccountName || '');
// Pod securityContext
const sc = podSpec.securityContext || {};
setPodFsGroup(sc.fsGroup != null ? String(sc.fsGroup) : '');
setPodRunAsUser(sc.runAsUser != null ? String(sc.runAsUser) : '');
setPodRunAsGroup(sc.runAsGroup != null ? String(sc.runAsGroup) : '');
setPodRunAsNonRoot(!!sc.runAsNonRoot);
// 主机设置(M8 补全)
setHostNetwork(!!podSpec.hostNetwork);
setHostIPC(!!podSpec.hostIPC);
setHostPID(!!podSpec.hostPID);
setShareProcessNamespace(!!podSpec.shareProcessNamespace);
// hostAliases(M8 补全)
const ha: JsonData[] = Array.isArray(podSpec.hostAliases) ? podSpec.hostAliases : [];
setHostAliases(ha.map(a => ({
ip: a.ip || '',
hostnames: Array.isArray(a.hostnames) ? a.hostnames.join(', ') : '',
})));
// dnsConfig(M8 补全)
const dc = podSpec.dnsConfig || {};
setDnsNameservers(Array.isArray(dc.nameservers) ? dc.nameservers.join('\n') : '');
setDnsSearches(Array.isArray(dc.searches) ? dc.searches.join('\n') : '');
const opts: JsonData[] = Array.isArray(dc.options) ? dc.options : [];
setDnsOptions(opts.map(o => ({ name: o.name || '', value: o.value || '' })));
setLoading(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [manifest]);
// labelSelector 操作
@@ -148,73 +196,252 @@ export function DeploymentEditAdvanced({ clusterCode, namespace, deploymentName,
const updateNs = (i: number, patch: Partial<{ key: string; value: string }>) =>
setLabelSelectorKv(prev => prev.map((n, idx) => (idx === i ? { ...n, ...patch } : n)));
// tolerations 操作
const addToleration = () =>
setTolerations(prev => [...prev, { key: '', operator: 'Equal', value: '', effect: 'NoSchedule', tolerationSeconds: '' }]);
const removeToleration = (i: number) => setTolerations(prev => prev.filter((_, idx) => idx !== i));
const updateToleration = (i: number, patch: Partial<Toleration>) =>
setTolerations(prev => prev.map((t, idx) => (idx === i ? { ...t, ...patch } : t)));
const save = async () => {
setSaving(true);
try {
const state: AdvancedState = {
strategyType, maxSurge, maxUnavailable,
revisionHistoryLimit, progressDeadlineSeconds,
scheduleMode, nodeName, labelSelectorKv, tolerations,
dnsPolicy, restartPolicy, schedulerName,
terminationGracePeriodSeconds, serviceAccountName,
podFsGroup, podRunAsUser, podRunAsGroup, podRunAsNonRoot,
hostNetwork, hostIPC, hostPID, shareProcessNamespace,
hostAliases, dnsNameservers, dnsSearches, dnsOptions,
};
const base = buildAdvancedManifest(rawManifest, state);
const base = JSON.parse(JSON.stringify(rawManifest));
base.spec = base.spec || {};
base.spec.template = base.spec.template || {};
base.spec.template.spec = base.spec.template.spec || {};
const podSpec = base.spec.template.spec;
// 更新策略
base.spec.strategy = { type: strategyType };
if (strategyType === 'RollingUpdate') {
base.spec.strategy.rollingUpdate = { maxSurge, maxUnavailable };
}
// 历史/进度
if (revisionHistoryLimit) base.spec.revisionHistoryLimit = Number(revisionHistoryLimit);
if (progressDeadlineSeconds) base.spec.progressDeadlineSeconds = Number(progressDeadlineSeconds);
// nodeSelector / nodeName / nodeAffinity
if (scheduleMode === 'nodeName' && nodeName.trim()) {
podSpec.nodeName = nodeName.trim();
delete podSpec.nodeSelector;
if (podSpec.affinity) delete podSpec.affinity.nodeAffinity;
} else if (scheduleMode === 'nodeSelector') {
const nsObj: Record<string, string> = {};
for (const { key, value } of labelSelectorKv) {
const k = key.trim();
if (k) nsObj[k] = value;
}
if (Object.keys(nsObj).length > 0) podSpec.nodeSelector = nsObj;
else delete podSpec.nodeSelector;
if (podSpec.nodeName) delete podSpec.nodeName;
if (podSpec.affinity) delete podSpec.affinity.nodeAffinity;
} else if (scheduleMode === 'nodeAffinity') {
const matchExpressions: JsonData[] = labelSelectorKv
.filter(s => s.key.trim())
.map(s => ({ key: s.key.trim(), operator: 'In', values: [s.value] }));
if (matchExpressions.length > 0) {
podSpec.affinity = podSpec.affinity || {};
podSpec.affinity.nodeAffinity = {
requiredDuringSchedulingIgnoredDuringExecution: {
nodeSelectorTerms: [{ matchExpressions }],
},
};
} else if (podSpec.affinity) {
delete podSpec.affinity.nodeAffinity;
}
delete podSpec.nodeSelector;
if (podSpec.nodeName) delete podSpec.nodeName;
}
// tolerations
const tolsK8s = tolerations
.filter(t => t.key || t.operator === 'Exists')
.map(t => {
const out: JsonData = {};
if (t.key) out.key = t.key;
if (t.operator) out.operator = t.operator;
if (t.value && t.operator !== 'Exists') out.value = t.value;
if (t.effect) out.effect = t.effect;
if (t.tolerationSeconds && t.effect === 'NoExecute') out.tolerationSeconds = Number(t.tolerationSeconds);
return out;
});
if (tolsK8s.length > 0) podSpec.tolerations = tolsK8s;
else delete podSpec.tolerations;
// 其他 Pod 级别
if (dnsPolicy) podSpec.dnsPolicy = dnsPolicy;
if (restartPolicy) podSpec.restartPolicy = restartPolicy;
if (schedulerName) podSpec.schedulerName = schedulerName;
if (terminationGracePeriodSeconds) podSpec.terminationGracePeriodSeconds = Number(terminationGracePeriodSeconds);
if (serviceAccountName) podSpec.serviceAccountName = serviceAccountName;
// Pod 安全上下文
const sc: JsonData = {};
if (podFsGroup) sc.fsGroup = Number(podFsGroup);
if (podRunAsUser) sc.runAsUser = Number(podRunAsUser);
if (podRunAsGroup) sc.runAsGroup = Number(podRunAsGroup);
if (podRunAsNonRoot) sc.runAsNonRoot = true;
if (Object.keys(sc).length > 0) podSpec.securityContext = sc;
else delete podSpec.securityContext;
// 主机设置(M8 补全)— 只在为 true 时写入,false 删除字段
if (hostNetwork) podSpec.hostNetwork = true; else delete podSpec.hostNetwork;
if (hostIPC) podSpec.hostIPC = true; else delete podSpec.hostIPC;
if (hostPID) podSpec.hostPID = true; else delete podSpec.hostPID;
if (shareProcessNamespace) podSpec.shareProcessNamespace = true; else delete podSpec.shareProcessNamespace;
// hostAliases(M8 补全)
const haK8s = hostAliases
.filter(a => a.ip.trim())
.map(a => ({
ip: a.ip.trim(),
hostnames: a.hostnames.split(/[,\s]+/).map((s: string) => s.trim()).filter(Boolean),
}))
.filter(a => a.hostnames.length > 0);
if (haK8s.length > 0) podSpec.hostAliases = haK8s;
else delete podSpec.hostAliases;
// dnsConfig(M8 补全)— 仅 dnsPolicy=None 时 dnsConfig 才有意义,但仍允许编辑
const dcObj: JsonData = {};
const ns = dnsNameservers.split('\n').map(s => s.trim()).filter(Boolean);
if (ns.length > 0) dcObj.nameservers = ns;
const sr = dnsSearches.split('\n').map(s => s.trim()).filter(Boolean);
if (sr.length > 0) dcObj.searches = sr;
const dkOpts = dnsOptions.filter(o => o.name.trim()).map(o => {
const out: JsonData = { name: o.name.trim() };
if (o.value.trim()) out.value = o.value.trim();
return out;
});
if (dkOpts.length > 0) dcObj.options = dkOpts;
if (Object.keys(dcObj).length > 0) podSpec.dnsConfig = dcObj;
else delete podSpec.dnsConfig;
await saveDeploymentManifest(clusterCode, namespace, deploymentName, base);
toast({ title: i18n.t('kubernetes.deploymentEditAdvanced.saved'), description: i18n.t('k8s.toast.updatedSubmitted', { name: deploymentName }) });
toast({ title: i18n.t('kubernetes.deploymentedita.s9762d0a4'), description: `${deploymentName} 更新已提交` });
onSaved(base);
} catch (e) {
toast({ title: i18n.t('kubernetes.msg.saveFailed'), description: e instanceof Error ? e.message : String(e), variant: 'destructive' });
toast({ title: i18n.t('kubernetes.msg.saveFailed'), description: e?.message || String(e), variant: 'destructive' });
} finally { setSaving(false); }
};
if (loading) {
return (
<div 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>
</div>
<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>
</CardContent>
</Card>
);
}
if (!manifest) {
return (
<div className="p-8 text-center dg-text-muted">
{i18n.t('k8s.editAdvanced.noManifest')}
</div>
<Card className="dg-card">
<CardContent className="p-8 text-center dg-text-muted">
{i18n.t('k8s.editAdvanced.noManifest')}
</CardContent>
</Card>
);
}
return (
<div className="space-y-4">
<UpdateStrategyCard
strategyType={strategyType}
onStrategyTypeChange={setStrategyType}
maxSurge={maxSurge}
onMaxSurgeChange={setMaxSurge}
maxUnavailable={maxUnavailable}
onMaxUnavailableChange={setMaxUnavailable}
revisionHistoryLimit={revisionHistoryLimit}
onRevisionHistoryLimitChange={setRevisionHistoryLimit}
progressDeadlineSeconds={progressDeadlineSeconds}
onProgressDeadlineSecondsChange={setProgressDeadlineSeconds}
/>
{/* 更新策略 */}
<Card className="dg-card">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-[var(--dg-text-primary)] flex items-center gap-2">
<RefreshCw className="h-4 w-4 dg-text-blue" />{i18n.t('k8s.editAdvanced.updateStrategy')}
</CardTitle>
</CardHeader>
<CardContent className="pt-0 space-y-3">
<div className="flex items-center gap-4">
<label className="flex items-center gap-1.5 text-xs text-[var(--dg-text-secondary)] cursor-pointer">
<input type="radio" checked={strategyType === 'RollingUpdate'} onChange={() => setStrategyType('RollingUpdate')}
className="accent-blue-500" />
RollingUpdate(滚动更新)
</label>
<label className="flex items-center gap-1.5 text-xs text-[var(--dg-text-secondary)] cursor-pointer">
<input type="radio" checked={strategyType === 'Recreate'} onChange={() => setStrategyType('Recreate')}
className="accent-blue-500" />
Recreate(重建)
</label>
</div>
{strategyType === 'RollingUpdate' && (
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="dg-text-muted text-xs">maxSurge(整数或百分比,超出期望副本数)</Label>
<Input value={maxSurge} onChange={e => setMaxSurge(e.target.value)}
placeholder="25%" className="h-8 mt-1 dg-input text-sm font-mono" />
</div>
<div>
<Label className="dg-text-muted text-xs">maxUnavailable(整数或百分比,不可用上限)</Label>
<Input value={maxUnavailable} onChange={e => setMaxUnavailable(e.target.value)}
placeholder="25%" className="h-8 mt-1 dg-input text-sm font-mono" />
</div>
</div>
)}
</CardContent>
</Card>
<TolerationsCard
tolerations={tolerations}
onAdd={addToleration}
onRemove={removeToleration}
onUpdate={updateToleration}
/>
{/* 历史版本 / 进度 */}
<Card className="dg-card">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-[var(--dg-text-primary)] flex items-center gap-2">
<Clock className="h-4 w-4 dg-text-yellow" />历史版本 & 进度
</CardTitle>
</CardHeader>
<CardContent className="pt-0 grid grid-cols-2 gap-3">
<div>
<Label className="dg-text-muted text-xs">revisionHistoryLimit(保留 RS 历史)</Label>
<Input type="number" value={revisionHistoryLimit} onChange={e => setRevisionHistoryLimit(e.target.value)}
className="h-8 mt-1 dg-input text-sm font-mono" />
</div>
<div>
<Label className="dg-text-muted text-xs">progressDeadlineSeconds(升级超时秒数)</Label>
<Input type="number" value={progressDeadlineSeconds} onChange={e => setProgressDeadlineSeconds(e.target.value)}
className="h-8 mt-1 dg-input text-sm font-mono" />
</div>
</CardContent>
</Card>
{/* 容忍 (Tolerations) — 此前仅有状态无 UI // Logan.Li */}
<Card className="dg-card">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm text-[var(--dg-text-primary)]"> {i18n.t('kubernetes.deploymentedita.s13a8310b')} </CardTitle>
<Button size="sm" variant="outline" className="h-7 border-[var(--dg-border)] text-[var(--dg-text-secondary)] text-xs"
onClick={() => setTolerations(prev => [...prev, { key: '', operator: 'Equal', value: '', effect: 'NoSchedule', tolerationSeconds: '' }])}>
<Plus className="h-3 w-3 mr-1" />添加
</Button>
</div>
</CardHeader>
<CardContent className="pt-0 space-y-2">
{tolerations.length === 0 && <p className="text-xs dg-text-muted"> {i18n.t('kubernetes.deploymentedita.s8bc06bde')} </p>}
{tolerations.map((t, i) => (
<div key={i} className="grid grid-cols-[1fr_80px_1fr_100px_80px_auto] gap-2 items-center">
<Input value={t.key} placeholder="key" onChange={e => setTolerations(prev => prev.map((x, idx) => idx === i ? { ...x, key: e.target.value } : x))}
className="h-7 dg-input text-xs font-mono" />
<select value={t.operator} onChange={e => setTolerations(prev => prev.map((x, idx) => idx === i ? { ...x, operator: e.target.value } : x))}
className="h-7 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded text-xs text-[var(--dg-text-secondary)] px-1">
<option value="Equal">Equal</option>
<option value="Exists">Exists</option>
</select>
<Input value={t.value} placeholder="value" disabled={t.operator === 'Exists'}
onChange={e => setTolerations(prev => prev.map((x, idx) => idx === i ? { ...x, value: e.target.value } : x))}
className="h-7 dg-input text-xs font-mono" />
<select value={t.effect} onChange={e => setTolerations(prev => prev.map((x, idx) => idx === i ? { ...x, effect: e.target.value } : x))}
className="h-7 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded text-xs text-[var(--dg-text-secondary)] px-1">
<option value=""> {i18n.t('kubernetes.deploymentedita.s2a6eb2dd')} </option>
<option value="NoSchedule">NoSchedule</option>
<option value="PreferNoSchedule">PreferNoSchedule</option>
<option value="NoExecute">NoExecute</option>
</select>
<Input value={t.tolerationSeconds} placeholder={i18n.t('k8s.editAdvanced.s0c1fec65')} disabled={t.effect !== 'NoExecute'}
onChange={e => setTolerations(prev => prev.map((x, idx) => idx === i ? { ...x, tolerationSeconds: e.target.value } : x))}
className="h-7 dg-input text-xs font-mono" />
<Button variant="ghost" size="icon" className="h-7 w-7 dg-text-red" onClick={() => setTolerations(prev => prev.filter((_, idx) => idx !== i))}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</CardContent>
</Card>
<SchedulingSection
scheduleMode={scheduleMode}
@@ -230,40 +457,114 @@ export function DeploymentEditAdvanced({ clusterCode, namespace, deploymentName,
nodeLabels={nodeLabels}
/>
<PodMiscCard
dnsPolicy={dnsPolicy}
onDnsPolicyChange={setDnsPolicy}
restartPolicy={restartPolicy}
onRestartPolicyChange={setRestartPolicy}
schedulerName={schedulerName}
onSchedulerNameChange={setSchedulerName}
terminationGracePeriodSeconds={terminationGracePeriodSeconds}
onTerminationGracePeriodSecondsChange={setTerminationGracePeriodSeconds}
serviceAccountName={serviceAccountName}
onServiceAccountNameChange={setServiceAccountName}
/>
{/* Pod 级别杂项 */}
<Card className="dg-card">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-[var(--dg-text-primary)] flex items-center gap-2">
<Settings className="h-4 w-4 dg-text-secondary" />Pod 级别
</CardTitle>
</CardHeader>
<CardContent className="pt-0 grid grid-cols-2 gap-3">
<div>
<Label className="dg-text-muted text-xs">dnsPolicy</Label>
<select value={dnsPolicy} onChange={e => setDnsPolicy(e.target.value)}
className="h-8 mt-1 w-full bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded text-xs text-[var(--dg-text-secondary)] px-2">
<option value="ClusterFirst">ClusterFirst</option>
<option value="ClusterFirstWithHostNet">ClusterFirstWithHostNet</option>
<option value="Default">Default</option>
<option value="None">None</option>
</select>
</div>
<div>
<Label className="dg-text-muted text-xs">restartPolicy</Label>
<select value={restartPolicy} onChange={e => setRestartPolicy(e.target.value)}
className="h-8 mt-1 w-full bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded text-xs text-[var(--dg-text-secondary)] px-2">
<option value="Always">Always</option>
<option value="OnFailure">OnFailure</option>
<option value="Never">Never</option>
</select>
</div>
<div>
<Label className="dg-text-muted text-xs">schedulerName</Label>
<Input value={schedulerName} onChange={e => setSchedulerName(e.target.value)}
className="h-8 mt-1 dg-input text-sm font-mono" />
</div>
<div>
<Label className="dg-text-muted text-xs">terminationGracePeriodSeconds</Label>
<Input type="number" value={terminationGracePeriodSeconds} onChange={e => setTerminationGracePeriodSeconds(e.target.value)}
className="h-8 mt-1 dg-input text-sm font-mono" />
</div>
<div className="col-span-2">
<Label className="dg-text-muted text-xs">serviceAccountName</Label>
<Input value={serviceAccountName} onChange={e => setServiceAccountName(e.target.value)}
placeholder="default" className="h-8 mt-1 dg-input text-sm font-mono" />
</div>
</CardContent>
</Card>
<PodSecurityCard
podFsGroup={podFsGroup}
onPodFsGroupChange={setPodFsGroup}
podRunAsUser={podRunAsUser}
onPodRunAsUserChange={setPodRunAsUser}
podRunAsGroup={podRunAsGroup}
onPodRunAsGroupChange={setPodRunAsGroup}
podRunAsNonRoot={podRunAsNonRoot}
onPodRunAsNonRootChange={setPodRunAsNonRoot}
/>
{/* Pod 安全上下文 */}
<Card className="dg-card">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-[var(--dg-text-primary)] flex items-center gap-2">
<Shield className="h-4 w-4 text-rose-400" />Pod 安全上下文 (securityContext)
</CardTitle>
</CardHeader>
<CardContent className="pt-0 grid grid-cols-2 gap-3">
<div>
<Label className="dg-text-muted text-xs">fsGroup</Label>
<Input type="number" value={podFsGroup} onChange={e => setPodFsGroup(e.target.value)}
placeholder="1000" className="h-8 mt-1 dg-input text-sm font-mono" />
</div>
<div>
<Label className="dg-text-muted text-xs">runAsUser</Label>
<Input type="number" value={podRunAsUser} onChange={e => setPodRunAsUser(e.target.value)}
placeholder="1000" className="h-8 mt-1 dg-input text-sm font-mono" />
</div>
<div>
<Label className="dg-text-muted text-xs">runAsGroup</Label>
<Input type="number" value={podRunAsGroup} onChange={e => setPodRunAsGroup(e.target.value)}
placeholder="1000" className="h-8 mt-1 dg-input text-sm font-mono" />
</div>
<div className="flex items-end pb-1">
<label className="flex items-center gap-2 text-xs dg-text-secondary cursor-pointer">
<input type="checkbox" checked={podRunAsNonRoot} onChange={e => setPodRunAsNonRoot(e.target.checked)}
className="accent-blue-500" />
runAsNonRoot
</label>
</div>
</CardContent>
</Card>
<HostSettingsCard
hostNetwork={hostNetwork}
onHostNetworkChange={setHostNetwork}
hostIPC={hostIPC}
onHostIPCChange={setHostIPC}
hostPID={hostPID}
onHostPIDChange={setHostPID}
shareProcessNamespace={shareProcessNamespace}
onShareProcessNamespaceChange={setShareProcessNamespace}
/>
{/* 主机设置(M8 补全) */}
<Card className="dg-card">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-[var(--dg-text-primary)] flex items-center gap-2">
<Network className="h-4 w-4 dg-text-cyan" />主机设置 (Host Namespace)
</CardTitle>
</CardHeader>
<CardContent className="pt-0 grid grid-cols-2 gap-3">
<label className="flex items-center gap-2 text-xs text-[var(--dg-text-secondary)] cursor-pointer">
<input type="checkbox" checked={hostNetwork} onChange={e => setHostNetwork(e.target.checked)}
className="accent-blue-500" />
hostNetwork(共享主机网络命名空间)
</label>
<label className="flex items-center gap-2 text-xs text-[var(--dg-text-secondary)] cursor-pointer">
<input type="checkbox" checked={hostIPC} onChange={e => setHostIPC(e.target.checked)}
className="accent-blue-500" />
hostIPC(共享主机 IPC 命名空间)
</label>
<label className="flex items-center gap-2 text-xs text-[var(--dg-text-secondary)] cursor-pointer">
<input type="checkbox" checked={hostPID} onChange={e => setHostPID(e.target.checked)}
className="accent-blue-500" />
hostPID(共享主机 PID 命名空间)
</label>
<label className="flex items-center gap-2 text-xs text-[var(--dg-text-secondary)] cursor-pointer">
<input type="checkbox" checked={shareProcessNamespace} onChange={e => setShareProcessNamespace(e.target.checked)}
className="accent-blue-500" />
shareProcessNamespace(共享进程命名空间)
</label>
</CardContent>
</Card>
<DnsHostSection
dnsNameservers={dnsNameservers}
@@ -281,9 +582,9 @@ export function DeploymentEditAdvanced({ clusterCode, namespace, deploymentName,
<Button variant="outline" className="border-[var(--dg-border)] dg-text-secondary" onClick={onCancel}> {i18n.t('rbac.jobRole.cancel')} </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" />}
{i18n.t('common.save')}
保存
</Button>
</div>
</div>
);
}
}
@@ -82,11 +82,11 @@ export function DeploymentEditContainers({ clusterCode, namespace, deploymentNam
// validate
for (const c of [...initContainers, ...containers]) {
if (!c.name.trim()) {
toast({ title: i18n.t('kubernetes.msg.saveFailed'), description: i18n.t('k8s.toast.containerNameEmpty'), variant: 'destructive' });
toast({ title: i18n.t('kubernetes.msg.saveFailed'), description: i18n.t('kubernetes.deploymenteditc.s14b12ce3'), variant: 'destructive' });
return;
}
if (!c.image.trim()) {
toast({ title: i18n.t('kubernetes.msg.saveFailed'), description: i18n.t('k8s.toast.containerImageEmpty', { name: c.name }), variant: 'destructive' });
toast({ title: i18n.t('kubernetes.msg.saveFailed'), description: `容器 ${c.name} 的镜像不能为空`, variant: 'destructive' });
return;
}
}
@@ -102,7 +102,7 @@ export function DeploymentEditContainers({ clusterCode, namespace, deploymentNam
? imagePullSecrets.filter(s => s.trim()).map((s: string) => ({ name: s }))
: undefined;
await saveDeploymentManifest(clusterCode, namespace, deploymentName, base);
toast({ title: i18n.t('k8s.editContainers.saved'), description: i18n.t('k8s.toast.updatedSubmitted', { name: deploymentName }) });
toast({ title: i18n.t('kubernetes.deploymenteditc.sdf22b91b'), description: `${deploymentName} 更新已提交` });
onSaved(base);
} catch (e) {
toast({ title: i18n.t('kubernetes.msg.saveFailed'), description: e?.message || String(e), variant: 'destructive' });
@@ -123,7 +123,7 @@ export function DeploymentEditContainers({ clusterCode, namespace, deploymentNam
return (
<Card className="dg-card">
<CardContent className="p-8 text-center dg-text-muted">
{i18n.t('common.noManifestHint')}
未获取到 manifest 数据,请使用 YAML 编辑模式直接修改。
</CardContent>
</Card>
);
@@ -136,8 +136,8 @@ export function DeploymentEditContainers({ clusterCode, namespace, deploymentNam
<CardHeader className="pb-3">
<CardTitle className="text-sm text-[var(--dg-text-primary)] flex items-center gap-2">
<KeyRound className="h-4 w-4 dg-text-yellow" />
{i18n.t('common.imagePullSecrets')}
<Badge variant="outline" className="border-[var(--dg-border)] dg-text-muted text-[10px]">{i18n.t('k8s.podLevel')}</Badge>
镜像拉取凭证 (imagePullSecrets)
<Badge variant="outline" className="border-[var(--dg-border)] dg-text-muted text-[10px]">Pod 级别</Badge>
</CardTitle>
</CardHeader>
<CardContent className="pt-0 space-y-2">
@@ -149,10 +149,10 @@ export function DeploymentEditContainers({ clusterCode, namespace, deploymentNam
className="dg-text-red hover:dg-text-red ml-1"><Trash2 className="h-3 w-3" /></button>
</div>
))}
{imagePullSecrets.length === 0 && <span className="text-[11px] dg-text-muted">{i18n.t("common.none")}</span>}
{imagePullSecrets.length === 0 && <span className="text-[11px] dg-text-muted">无</span>}
</div>
<div className="flex gap-2">
<Input placeholder={i18n.t('k8s.editContainers.secretPlaceholder')} className="dg-input text-xs h-8"
<Input placeholder={i18n.t('kubernetes.deploymenteditc.s960f6ac5')} className="dg-input text-xs h-8"
onKeyDown={(e) => {
if (e.key === 'Enter') {
const val = (e.target as HTMLInputElement).value.trim();
@@ -161,7 +161,7 @@ export function DeploymentEditContainers({ clusterCode, namespace, deploymentNam
}} />
<Button size="sm" variant="outline" className="h-8 border-[var(--dg-border)] text-[var(--dg-text-secondary)] text-xs"
onClick={() => setImagePullSecrets(prev => [...prev, ''])}>
<Plus className="h-3 w-3 mr-1" />{i18n.t("common.add")}
<Plus className="h-3 w-3 mr-1" />新增
</Button>
</div>
</CardContent>
@@ -170,7 +170,7 @@ export function DeploymentEditContainers({ clusterCode, namespace, deploymentNam
{/* 初始化容器 */}
<ContainerGroupSection
title={i18n.t('kubernetes.container.initContainers')}
description={i18n.t('k8s.editContainers.initDesc')}
description={i18n.t('kubernetes.deploymenteditc.s883e09ce')}
icon={<Play className="h-4 w-4 dg-text-purple" />}
containers={initContainers}
accentColor="purple"
@@ -181,8 +181,8 @@ export function DeploymentEditContainers({ clusterCode, namespace, deploymentNam
{/* 工作容器 */}
<ContainerGroupSection
title={i18n.t('k8s.editContainers.initContainers')}
description={i18n.t('k8s.editContainers.initDesc')}
title={i18n.t('kubernetes.deploymenteditc.sc61b83bd')}
description={i18n.t('kubernetes.deploymenteditc.s43eebd4d')}
icon={<Container className="h-4 w-4 dg-text-blue" />}
containers={containers}
accentColor="blue"
@@ -195,14 +195,14 @@ export function DeploymentEditContainers({ clusterCode, namespace, deploymentNam
{/* 保存栏 */}
<div className="sticky bottom-0 bg-[var(--dg-bg-primary)]/80 backdrop-blur border-t border-[var(--dg-border)] p-3 flex items-center justify-between">
<div className="text-[11px] dg-text-muted">
{i18n.t("common.total")}: <span className="text-[var(--dg-text-secondary)] font-semibold">{initContainers.length}</span> {i18n.t("common.totalContainers")} +
<span className="text-[var(--dg-text-secondary)] font-semibold ml-1">{containers.length}</span> {i18n.t("common.workContainers")}
共 <span className="text-[var(--dg-text-secondary)] font-semibold">{initContainers.length}</span> 个初始化容器 +
<span className="text-[var(--dg-text-secondary)] font-semibold ml-1">{containers.length}</span> 个工作容器
</div>
<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 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" />}
{i18n.t('common.save')}
保存
</Button>
</div>
</div>
@@ -1,16 +1,26 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// DeploymentEditService.tsx — 编辑器「服务/应用路由」Tab (M9)
// 展示与当前 Deployment 关联的 Service / Ingress,并提供创建入口
// 作者: Logan <admin@attacker.club>
import { useState, useEffect, useCallback } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import {
Network,
GitBranch,
Loader2,
RefreshCw,
Pencil,
Plus,
CheckCircle,
Server,
Trash2,
Minus,
Zap,
} from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
@@ -19,24 +29,52 @@ import { kubernetesApi } from '@/api';
import { dumpManifest } from './k8s-manifest-utils';
import { ResourceVisualEditor } from './ResourceVisualEditor';
import i18n from '@/i18n';
import type { SvcSummary, IngSummary, SvcFormState, IngFormState } from './deployment-edit-service/types';
import { toArray } from './deployment-edit-service/types';
import { ServiceCreateForm } from './deployment-edit-service/ServiceCreateForm';
import { IngressCreateForm } from './deployment-edit-service/IngressCreateForm';
import { ServiceListItem } from './deployment-edit-service/ServiceListItem';
import { IngressListItem } from './deployment-edit-service/IngressListItem';
interface Props {
clusterCode: string;
namespace: string;
deploymentName: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
manifest: any | null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onSaved: (updated?: any) => void;
onCancel: () => void;
}
interface SvcSummary {
name: string;
type: string;
clusterIP: string;
externalIP: string;
ports: Array<{ port: number; targetPort: any; protocol: string; nodePort?: number; name?: string }>;
selector: Record<string, string>;
age: string;
raw: any;
}
interface IngSummary {
name: string;
className: string;
hosts: string[];
rules: Array<{ host: string; paths: Array<{ path: string; backend: { service: string; port: unknown} }> }>;
raw: any;
}
// 3 种常用端口预设
const PORT_PRESETS = [
{ label: 'HTTP', protocol: 'TCP' as const, port: 8080, targetPort: 8080, name: 'http' },
{ label: 'WebSocket', protocol: 'TCP' as const, port: 8001, targetPort: 8002, name: 'ws' },
{ label: 'gRPC', protocol: 'TCP' as const, port: 8002, targetPort: 8002, name: 'grpc' },
];
interface _SvcPortForm {
name: string;
port: string;
targetPort: string;
protocol: 'TCP' | 'UDP';
nodePort: string;
}
const toArray = ( v: any) => (Array.isArray(v) ? v : (v?.list || v?.items || v?.data || []));
export function DeploymentEditService({ clusterCode, namespace, deploymentName, manifest, onCancel }: Props) {
const { toast } = useToast();
const confirm = useConfirm();
@@ -48,16 +86,42 @@ export function DeploymentEditService({ clusterCode, namespace, deploymentName,
const [showCreate, setShowCreate] = useState<'service' | 'ingress' | null>(null);
// 创建 Service 表单 — 多端口
const [svcForm, setSvcForm] = useState<SvcFormState>({
const [svcForm, setSvcForm] = useState<{
name: string; type: string;
ports: Array<{ name: string; port: string; targetPort: string; protocol: 'TCP' | 'UDP'; nodePort: string }>;
}>({
name: '', type: 'ClusterIP',
ports: [{ name: 'http', port: '8080', targetPort: '8080', protocol: 'TCP', nodePort: '' }],
});
// 创建 Ingress 表单
const [ingForm, setIngForm] = useState<IngFormState>({
const [ingForm, setIngForm] = useState({
name: '', className: 'nginx', host: '', path: '/', pathType: 'Prefix', svcPort: '80',
});
// ──── 端口操作 ────
const addPort = (preset?: typeof PORT_PRESETS[number]) => {
setSvcForm(s => ({
...s,
ports: [...s.ports, preset
? { name: preset.name, port: String(preset.port), targetPort: String(preset.targetPort), protocol: preset.protocol as 'TCP' | 'UDP', nodePort: '' }
: { name: '', port: '', targetPort: '', protocol: 'TCP' as const, nodePort: '' }
],
}));
};
const updatePort = (i: number, field: string, value: string) => {
setSvcForm(s => ({
...s,
ports: s.ports.map((p, idx) => idx === i ? { ...p, [field]: value } : p),
}));
};
const removePort = (i: number) => {
if (svcForm.ports.length <= 1) return;
setSvcForm(s => ({ ...s, ports: s.ports.filter((_, idx) => idx !== i) }));
};
const load = useCallback(async () => {
setLoading(true);
try {
@@ -72,15 +136,12 @@ export function DeploymentEditService({ clusterCode, namespace, deploymentName,
const allSvcs = toArray(svcRes);
const allIngs = toArray(ingRes);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const svcSummaries: SvcSummary[] = allSvcs.map((s: any) => {
const svcSummaries: SvcSummary[] = allSvcs.map(( s: any) => {
const spec = s.spec || {};
const ports = Array.isArray(spec.ports) ? spec.ports.map(( // eslint-disable-next-line @typescript-eslint/no-explicit-any
p: any) => ({
const ports = Array.isArray(spec.ports) ? spec.ports.map(( p: any) => ({
port: p.port, targetPort: p.targetPort, protocol: p.protocol || 'TCP',
nodePort: p.nodePort, name: p.name,
})) : Array.isArray(s.portList) ? s.portList.map(( // eslint-disable-next-line @typescript-eslint/no-explicit-any
p: any) => ({
})) : Array.isArray(s.portList) ? s.portList.map(( p: any) => ({
port: p.port, targetPort: p.targetPort, protocol: p.protocol || 'TCP',
nodePort: p.nodePort, name: p.name,
})) : [];
@@ -96,16 +157,12 @@ export function DeploymentEditService({ clusterCode, namespace, deploymentName,
};
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ingSummaries: IngSummary[] = allIngs.map((i: any) => {
const ingSummaries: IngSummary[] = allIngs.map(( i: any) => {
const spec = i.spec || {};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rawRules: Record<string, any>[] = Array.isArray(spec.rules) ? spec.rules : (Array.isArray(i.rules) ? i.rules : []);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rules = rawRules.map((r: any) => ({
const rules = rawRules.map(( r: any) => ({
host: r.host || '',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
paths: (r.http?.paths || r.paths || []).map((p: any) => ({
paths: (r.http?.paths || r.paths || []).map(( p: any) => ({
path: p.path || '/',
backend: {
service: p.backend?.service?.name || p.backendService || '',
@@ -116,8 +173,7 @@ export function DeploymentEditService({ clusterCode, namespace, deploymentName,
return {
name: i.name || i.metadata?.name || '',
className: spec.ingressClassName || i.ingressClass || '<none>',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
hosts: rules.map((r: any) => r.host).filter(Boolean),
hosts: rules.map(( r: any) => r.host).filter(Boolean),
rules,
raw: i,
};
@@ -140,14 +196,13 @@ export function DeploymentEditService({ clusterCode, namespace, deploymentName,
const matchedSvcs = services.filter(s => selectorMatches(s.selector));
const matchedSvcNames = new Set(matchedSvcs.map(s => s.name));
const matchedIngs = ingresses.filter(i =>
i.rules.some(r => r.paths.some(p => matchedSvcNames.has(p.backend.service as string)))
i.rules.some(r => r.paths.some(p => matchedSvcNames.has(p.backend.service)))
);
// 创建 Service(多端口)
const createService = async () => {
const name = svcForm.name.trim() || `${deploymentName}-svc`;
const k8sPorts = svcForm.ports.map(p => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sp: Record<string, any> = {
name: p.name || 'http',
port: Number(p.port) || 80,
@@ -158,7 +213,6 @@ export function DeploymentEditService({ clusterCode, namespace, deploymentName,
return sp;
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const svcManifest: Record<string, any> = {
apiVersion: 'v1',
kind: 'Service',
@@ -172,12 +226,11 @@ export function DeploymentEditService({ clusterCode, namespace, deploymentName,
try {
const yaml = await dumpManifest(svcManifest);
await kubernetesApi.applyYAML(clusterCode, yaml);
toast({ title: i18n.t('k8s.editService.serviceCreated'), description: name });
toast({ title: i18n.t('kubernetes.deploymentedits.sabd7e004'), description: name });
setShowCreate(null);
load();
} catch (e) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
toast({ title: i18n.t('monitor.dashboard.custom.toastCreateFailed'), description: (e as any)?.message || String(e), variant: 'destructive' });
toast({ title: i18n.t('monitor.dashboard.custom.toastCreateFailed'), description: e?.message || String(e), variant: 'destructive' });
}
};
@@ -186,8 +239,7 @@ export function DeploymentEditService({ clusterCode, namespace, deploymentName,
const name = ingForm.name.trim() || `${deploymentName}-ingress`;
const targetSvc = matchedSvcs[0]?.name || `${deploymentName}-svc`;
const port = Number(ingForm.svcPort) || 80;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ingManifest: Record<string, any> = {
const manifest: Record<string, any> = {
apiVersion: 'networking.k8s.io/v1',
kind: 'Ingress',
metadata: { name, namespace, labels: { app: deploymentName } },
@@ -205,24 +257,23 @@ export function DeploymentEditService({ clusterCode, namespace, deploymentName,
}],
},
};
if (!ingForm.className) delete ingManifest.spec.ingressClassName;
if (!ingForm.className) delete manifest.spec.ingressClassName;
try {
const yaml = await dumpManifest(ingManifest);
const yaml = await dumpManifest(manifest);
await kubernetesApi.applyYAML(clusterCode, yaml);
toast({ title: i18n.t('k8s.editService.serviceCreated'), description: name });
toast({ title: i18n.t('kubernetes.deploymentedits.se356fbe5'), description: name });
setShowCreate(null);
load();
} catch (e) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
toast({ title: i18n.t('monitor.dashboard.custom.toastCreateFailed'), description: (e as any)?.message || String(e), variant: 'destructive' });
toast({ title: i18n.t('monitor.dashboard.custom.toastCreateFailed'), description: e?.message || String(e), variant: 'destructive' });
}
};
// 删除资源
const deleteResource = async (kind: 'service' | 'ingress', name: string) => {
const ok = await confirm({
title: i18n.t('common.delete'),
description: i18n.t('k8s.toast.confirmDeleteResource', { kind: kind === 'service' ? 'Service' : 'Ingress', name }),
title: i18n.t('kubernetes.deploymentedits.s8a2a35a5'),
description: `确认删除 ${kind === 'service' ? 'Service' : 'Ingress'} 「${name}」?此操作不可撤销。`,
confirmText: i18n.t('kubernetes.storage.delete2'),
variant: 'destructive',
});
@@ -230,11 +281,10 @@ export function DeploymentEditService({ clusterCode, namespace, deploymentName,
try {
const resource = kind === 'service' ? 'services' : 'ingresses';
await kubernetesApi.deleteResourceYaml(clusterCode, namespace, resource, name);
toast({ title: i18n.t('monitor.silenceRules.deleted'), description: i18n.t('k8s.toast.resourceDeleted', { kind: kind === 'service' ? 'Service' : 'Ingress', name }) });
toast({ title: i18n.t('monitor.silenceRules.deleted'), description: `${kind === 'service' ? 'Service' : 'Ingress'} ${name} 已删除` });
load();
} catch (e) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
toast({ title: i18n.t('kubernetes.msg.deleteFailed2'), description: (e as any)?.message || String(e), variant: 'destructive' });
toast({ title: i18n.t('kubernetes.msg.deleteFailed2'), description: e?.message || String(e), variant: 'destructive' });
}
};
@@ -256,17 +306,17 @@ export function DeploymentEditService({ clusterCode, namespace, deploymentName,
<CardContent className="p-3">
<div className="flex items-center gap-2 text-xs dg-text-secondary">
<Network className="h-3.5 w-3.5 dg-text-blue" />
<span> {i18n.t('kubernetes.deploymentEditService.selectorLabel')} </span>
<span> {i18n.t('kubernetes.deploymentedits.sb8eef7f0')} </span>
<div className="flex flex-wrap gap-1">
{Object.entries(selectorLabels).map(([k, v]) => (
<code key={k} className="bg-[var(--dg-bg-tertiary)] px-1.5 py-0.5 rounded text-[var(--dg-text-secondary)] text-[11px]">{k}={v}</code>
))}
{Object.keys(selectorLabels).length === 0 && (
<span className="dg-text-muted italic">{i18n.t('k8s.editService.noSelector')}</span>
<span className="dg-text-muted italic">(无 selector)</span>
)}
</div>
<Button size="sm" variant="ghost" className="h-6 ml-auto text-xs dg-text-secondary" onClick={load}>
<RefreshCw className="h-3 w-3 mr-1" />{i18n.t("common.refresh")}
<RefreshCw className="h-3 w-3 mr-1" />刷新
</Button>
</div>
</CardContent>
@@ -279,47 +329,170 @@ export function DeploymentEditService({ clusterCode, namespace, deploymentName,
<CardTitle className="text-sm text-[var(--dg-text-primary)] flex items-center gap-2">
<Network className="h-4 w-4 dg-text-blue" />
Services
<Badge variant="outline" className="border-[var(--dg-border)] dg-text-secondary">{i18n.t('k8s.editService.svcLinked', { count: matchedSvcs.length })}</Badge>
<Badge variant="outline" className="border-[var(--dg-border)] dg-text-secondary">{matchedSvcs.length} 个关联</Badge>
</CardTitle>
<Button size="sm" variant="outline" className="h-7 border-[var(--dg-border)] text-[var(--dg-text-secondary)] text-xs"
onClick={() => {
setSvcForm({ name: `${deploymentName}-svc`, type: 'ClusterIP', ports: [{ name: 'http', port: '8080', targetPort: '8080', protocol: 'TCP', nodePort: '' }] });
setShowCreate('service');
}}>
<Plus className="h-3 w-3 mr-1" />{i18n.t("common.createService")}
<Plus className="h-3 w-3 mr-1" />创建 Service
</Button>
</div>
<p className="text-xs dg-text-muted mt-1"> {i18n.t('kubernetes.deploymentEditService.selectorHint')} </p>
<p className="text-xs dg-text-muted mt-1"> {i18n.t('kubernetes.deploymentedits.sd0ad85b8')} </p>
</CardHeader>
<CardContent className="pt-0">
{matchedSvcs.length === 0 && showCreate !== 'service' ? (
<div className="text-center py-6 dg-text-muted text-sm border border-dashed border-[var(--dg-border)] rounded-lg">
<Network className="h-8 w-8 mx-auto mb-2 dg-text-muted" />
{i18n.t('k8s.editService.noSvcInNs')}
当前命名空间暂无关联 Service
</div>
) : (
<div className="space-y-2">
{matchedSvcs.map(svc => (
<ServiceListItem
key={svc.name}
svc={svc}
matched={selectorMatches(svc.selector)}
onEdit={() => setVisualEditor({ resource: 'services', name: svc.name })}
onDelete={() => deleteResource('service', svc.name)}
/>
))}
{matchedSvcs.map(svc => {
const matched = selectorMatches(svc.selector);
return (
<div key={svc.name} className={`rounded-lg border p-3 ${matched ? 'border-blue-700/50 bg-blue-950/20' : 'border-[var(--dg-border)] bg-[var(--dg-bg-primary)]/30'}`}>
<div className="flex items-center gap-2 flex-wrap">
{matched ? <CheckCircle className="h-4 w-4 dg-text-blue" /> : <Network className="h-4 w-4 dg-text-muted" />}
<span className="text-sm font-semibold text-[var(--dg-text-primary)]">{svc.name}</span>
<Badge variant="outline" className="border-[var(--dg-border)] dg-text-secondary text-[10px]">{svc.type}</Badge>
{matched && <Badge className="bg-blue-500/20 text-blue-300 border-blue-500/30 text-[10px]"> {i18n.t('kubernetes.deploymentedits.s1c3cf7f5')} </Badge>}
<code className="text-[11px] dg-text-muted ml-auto">{svc.clusterIP}</code>
<Button size="sm" variant="ghost" className="h-6 text-xs dg-text-green"
onClick={() => setVisualEditor({ resource: 'services', name: svc.name })}>
<Pencil className="h-3 w-3 mr-1" />编辑
</Button>
<Button size="sm" variant="ghost" className="h-6 w-6 p-0 dg-text-red hover:dg-text-red"
title={i18n.t('kubernetes.service.deleteService')} onClick={() => deleteResource('service', svc.name)}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
{/* Ports */}
<div className="mt-2 flex flex-wrap gap-1.5 text-[11px]">
{svc.ports.map((p, i) => (
<code key={i} className="bg-[var(--dg-bg-tertiary)]/60 px-1.5 py-0.5 rounded text-[var(--dg-text-secondary)]">
{p.name && <span className="dg-text-muted">{p.name}: </span>}
{p.port}→{p.targetPort}{p.nodePort ? `/NodePort:${p.nodePort}` : ''}/{p.protocol}
</code>
))}
</div>
{/* Selector */}
<div className="mt-1 flex flex-wrap gap-1 text-[10px]">
<span className="dg-text-muted">selector:</span>
{Object.entries(svc.selector).map(([k, v]) => (
<code key={k} className="dg-text-muted">{k}={v}</code>
))}
</div>
</div>
);
})}
</div>
)}
{/* ══════════ 创建 Service 表单 — 多端口 ══════════ */}
{showCreate === 'service' && (
<ServiceCreateForm
svcForm={svcForm}
setSvcForm={setSvcForm}
selectorLabels={selectorLabels}
deploymentName={deploymentName}
onCancel={() => setShowCreate(null)}
onCreate={createService}
/>
<div className="mt-3 border border-blue-800/50 rounded-lg p-3 bg-blue-950/20 space-y-3">
<div className="text-xs font-semibold text-blue-300 flex items-center gap-1.5">
<Plus className="h-3 w-3" />创建新 Service
</div>
{/* 基本信息 */}
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="dg-text-muted text-[11px]"> {i18n.t('kubernetes.secret.secretName')} </Label>
<Input value={svcForm.name} onChange={e => setSvcForm(s => ({ ...s, name: e.target.value }))}
placeholder={`${deploymentName}-svc`}
className="h-7 mt-0.5 dg-input text-[var(--dg-text-secondary)] text-xs font-mono" />
</div>
<div>
<Label className="dg-text-muted text-[11px]"> {i18n.t('system.credentialType')} </Label>
<select value={svcForm.type} onChange={e => setSvcForm(s => ({ ...s, type: e.target.value }))}
className="h-7 mt-0.5 w-full bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded text-xs text-[var(--dg-text-secondary)] px-2">
<option value="ClusterIP">ClusterIP</option>
<option value="NodePort">NodePort</option>
</select>
</div>
</div>
{/* 快速预设 */}
<div>
<Label className="dg-text-muted text-[11px] mb-1 block"> {i18n.t('kubernetes.deploymentedits.s83907cde')} </Label>
<div className="flex gap-1.5">
{PORT_PRESETS.map(p => (
<button key={p.name} onClick={() => addPort(p)}
className="flex items-center gap-1 px-2 py-1 rounded text-[10px] font-medium
bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:border-blue-500/50 hover:text-blue-300 transition-colors">
<Zap className="h-2.5 w-2.5" />
{p.label}
<span className="dg-text-muted text-[9px]">{p.protocol} {p.port}:{p.targetPort}</span>
</button>
))}
</div>
</div>
{/* 端口列表 */}
<div className="space-y-2">
<Label className="dg-text-muted text-[11px]">
端口配置 <span className="dg-text-muted">({svcForm.ports.length} 个端口)</span>
</Label>
{svcForm.ports.map((p, i) => (
<div key={i} className="flex items-center gap-1.5 p-2 rounded bg-[var(--dg-bg-tertiary)]/50 border border-[var(--dg-border)]/50">
{/* 名称 */}
<Input value={p.name} onChange={e => updatePort(i, 'name', e.target.value)}
placeholder="http"
className="h-7 w-[72px] dg-input text-[var(--dg-text-secondary)] text-xs font-mono" />
{/* 协议 */}
<select value={p.protocol} onChange={e => updatePort(i, 'protocol', e.target.value)}
className="h-7 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded text-xs text-[var(--dg-text-secondary)] px-1.5 w-16">
<option value="TCP">TCP</option>
<option value="UDP">UDP</option>
</select>
{/* port → targetPort */}
<Input value={p.port} onChange={e => updatePort(i, 'port', e.target.value)}
type="number" placeholder="port"
className="h-7 w-[72px] dg-input text-[var(--dg-text-secondary)] text-xs font-mono" />
<span className="dg-text-muted text-[10px]">→</span>
<Input value={p.targetPort} onChange={e => updatePort(i, 'targetPort', e.target.value)}
type="number" placeholder="targetPort"
className="h-7 w-[72px] dg-input text-[var(--dg-text-secondary)] text-xs font-mono" />
{/* NodePort (仅 NodePort 类型) */}
{svcForm.type === 'NodePort' && (
<Input value={p.nodePort} onChange={e => updatePort(i, 'nodePort', e.target.value)}
type="number" placeholder="30000-32767"
className="h-7 w-[80px] dg-input text-[var(--dg-text-secondary)] text-xs font-mono" />
)}
{/* 删除 */}
<Button size="icon" variant="ghost" className="h-6 w-6 dg-text-muted hover:dg-text-red"
onClick={() => removePort(i)} disabled={svcForm.ports.length <= 1}>
<Minus className="h-3 w-3" />
</Button>
</div>
))}
{/* 添加自定义端口 */}
<Button size="sm" variant="outline" className="h-7 w-full border-dashed border-[var(--dg-border)] dg-text-secondary text-xs"
onClick={() => addPort()}>
<Plus className="h-3 w-3 mr-1" />添加自定义端口
</Button>
</div>
{/* Selector 预览 */}
<div className="text-[10px] dg-text-muted border-t border-[var(--dg-border)]/50 pt-2">
<span className="dg-text-muted">selector (自动检测): </span>
{Object.keys(selectorLabels).length > 0 ? (
Object.entries(selectorLabels).map(([k, v]) => (
<code key={k} className="bg-[var(--dg-bg-tertiary)] px-1 py-0.5 rounded dg-text-secondary ml-1">{k}={v}</code>
))
) : (
<span className="dg-text-muted italic"> {i18n.t('kubernetes.deploymentedits.s5847b0f9')} </span>
)}
</div>
<div className="flex justify-end gap-2 pt-1">
<Button size="sm" variant="ghost" className="h-7 text-xs dg-text-secondary" onClick={() => setShowCreate(null)}> {i18n.t('rbac.jobRole.cancel')} </Button>
<Button size="sm" className="h-7 bg-blue-600 hover:bg-blue-700 text-xs" onClick={createService}> {i18n.t('rbac.jobRole.create')} </Button>
</div>
</div>
)}
</CardContent>
</Card>
@@ -330,7 +503,7 @@ export function DeploymentEditService({ clusterCode, namespace, deploymentName,
<div className="flex items-center justify-between">
<CardTitle className="text-sm text-[var(--dg-text-primary)] flex items-center gap-2">
<GitBranch className="h-4 w-4 dg-text-purple" />
{i18n.t('k8s.editService.ingressesLabel')}
Ingresses (应用路由)
<Badge variant="outline" className="border-[var(--dg-border)] dg-text-secondary">{matchedIngs.length}/{ingresses.length}</Badge>
</CardTitle>
<Button size="sm" variant="outline" className="h-7 border-[var(--dg-border)] text-[var(--dg-text-secondary)] text-xs"
@@ -338,39 +511,99 @@ export function DeploymentEditService({ clusterCode, namespace, deploymentName,
setIngForm(s => ({ ...s, name: `${deploymentName}-ingress`, svcPort: String(matchedSvcs[0]?.ports[0]?.port || 80) }));
setShowCreate('ingress');
}}>
<Plus className="h-3 w-3 mr-1" />{i18n.t("common.createIngress")}
<Plus className="h-3 w-3 mr-1" />创建 Ingress
</Button>
</div>
<p className="text-xs dg-text-muted mt-1">{i18n.t('k8s.editService.ingressAutoHighlight')}</p>
<p className="text-xs dg-text-muted mt-1">backend 指向关联 Service 的 Ingress 自动高亮</p>
</CardHeader>
<CardContent className="pt-0">
{ingresses.length === 0 ? (
<div className="text-center py-6 dg-text-muted text-sm border border-dashed border-[var(--dg-border)] rounded-lg">
<GitBranch className="h-8 w-8 mx-auto mb-2 dg-text-muted" />
{i18n.t('k8s.editService.noIngInNs')}
当前命名空间暂无 Ingress
</div>
) : (
<div className="space-y-2">
{ingresses.map(ing => (
<IngressListItem
key={ing.name}
ing={ing}
matched={matchedIngs.includes(ing)}
onEdit={() => setVisualEditor({ resource: 'ingresses', name: ing.name })}
onDelete={() => deleteResource('ingress', ing.name)}
/>
))}
{ingresses.map(ing => {
const matched = matchedIngs.includes(ing);
return (
<div key={ing.name} className={`rounded-lg border p-3 ${matched ? 'border-purple-700/50 bg-purple-950/20' : 'border-[var(--dg-border)] bg-[var(--dg-bg-primary)]/30'}`}>
<div className="flex items-center gap-2 flex-wrap">
{matched ? <CheckCircle className="h-4 w-4 dg-text-purple" /> : <GitBranch className="h-4 w-4 dg-text-muted" />}
<span className="text-sm font-semibold text-[var(--dg-text-primary)]">{ing.name}</span>
<Badge variant="outline" className="border-[var(--dg-border)] dg-text-secondary text-[10px]">{ing.className}</Badge>
{matched && <Badge className="bg-purple-500/20 text-purple-300 border-purple-500/30 text-[10px]"> {i18n.t('kubernetes.deploymentedits.s1c3cf7f5')} </Badge>}
<Button size="sm" variant="ghost" className="h-6 text-xs dg-text-green ml-auto"
onClick={() => setVisualEditor({ resource: 'ingresses', name: ing.name })}>
<Pencil className="h-3 w-3 mr-1" />编辑
</Button>
<Button size="sm" variant="ghost" className="h-6 w-6 p-0 dg-text-red hover:dg-text-red"
title={i18n.t('kubernetes.ingress.deleteIngress')} onClick={() => deleteResource('ingress', ing.name)}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
{/* Rules */}
<div className="mt-2 space-y-1 text-[11px]">
{ing.rules.map((r, i) => (
<div key={i} className="flex items-start gap-1.5">
<Server className="h-3 w-3 dg-text-muted mt-0.5" />
<code className="text-[var(--dg-text-secondary)]">{r.host || '*'}</code>
{r.paths.map((p, j) => (
<code key={j} className="bg-[var(--dg-bg-tertiary)]/60 px-1.5 py-0.5 rounded dg-text-secondary">
{p.path} → {(p as any).backend?.service}:{(p as any).backend?.port}
</code>
))}
</div>
))}
</div>
</div>
);
})}
</div>
)}
{/* 创建 Ingress 表单 */}
{showCreate === 'ingress' && (
<IngressCreateForm
ingForm={ingForm}
setIngForm={setIngForm}
deploymentName={deploymentName}
onCancel={() => setShowCreate(null)}
onCreate={createIngress}
/>
<div className="mt-3 border border-purple-800/50 rounded-lg p-3 bg-purple-950/20 space-y-2">
<div className="text-xs font-semibold text-purple-300 flex items-center gap-1.5">
<Plus className="h-3 w-3" />创建新 Ingress
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="dg-text-muted text-[11px]"> {i18n.t('kubernetes.secret.secretName')} </Label>
<Input value={ingForm.name} onChange={e => setIngForm(s => ({ ...s, name: e.target.value }))}
placeholder={`${deploymentName}-ingress`}
className="h-7 mt-0.5 dg-input text-[var(--dg-text-secondary)] text-xs font-mono" />
</div>
<div>
<Label className="dg-text-muted text-[11px]">ingressClassName</Label>
<Input value={ingForm.className} onChange={e => setIngForm(s => ({ ...s, className: e.target.value }))}
placeholder="nginx"
className="h-7 mt-0.5 dg-input text-[var(--dg-text-secondary)] text-xs font-mono" />
</div>
<div>
<Label className="dg-text-muted text-[11px]">host (域名)</Label>
<Input value={ingForm.host} onChange={e => setIngForm(s => ({ ...s, host: e.target.value }))}
placeholder="app.example.com"
className="h-7 mt-0.5 dg-input text-[var(--dg-text-secondary)] text-xs font-mono" />
</div>
<div>
<Label className="dg-text-muted text-[11px]">path</Label>
<Input value={ingForm.path} onChange={e => setIngForm(s => ({ ...s, path: e.target.value }))}
placeholder="/"
className="h-7 mt-0.5 dg-input text-[var(--dg-text-secondary)] text-xs font-mono" />
</div>
<div>
<Label className="dg-text-muted text-[11px]">Service 端口</Label>
<Input type="number" value={ingForm.svcPort} onChange={e => setIngForm(s => ({ ...s, svcPort: e.target.value }))}
className="h-7 mt-0.5 dg-input text-[var(--dg-text-secondary)] text-xs font-mono" />
</div>
</div>
<div className="flex justify-end gap-2 pt-1">
<Button size="sm" variant="ghost" className="h-7 text-xs dg-text-secondary" onClick={() => setShowCreate(null)}> {i18n.t('rbac.jobRole.cancel')} </Button>
<Button size="sm" className="h-7 bg-purple-600 hover:bg-purple-700 text-xs" onClick={createIngress}> {i18n.t('rbac.jobRole.create')} </Button>
</div>
</div>
)}
</CardContent>
</Card>
@@ -387,7 +620,7 @@ export function DeploymentEditService({ clusterCode, namespace, deploymentName,
)}
<div className="flex justify-end gap-2 pt-2">
<Button size="sm" variant="outline" className="h-7 text-xs border-[var(--dg-border)] dg-text-secondary" onClick={onCancel}> {i18n.t('kubernetes.deploymentEditService.backToView')} </Button>
<Button size="sm" variant="outline" className="h-7 text-xs border-[var(--dg-border)] dg-text-secondary" onClick={onCancel}> {i18n.t('kubernetes.deploymentedits.s3a77522f')} </Button>
</div>
</div>
);
@@ -40,12 +40,12 @@ interface Props {
}
const VOLUME_TYPES: { key: VolumeForm['type']; label: string; icon: typeof HardDrive; color: string }[] = [
{ key: 'persistentVolumeClaim', label: i18n.t('k8s.nsResource.pvcDesc'), icon: Database, color: 'dg-text-green' },
{ key: 'emptyDir', label: i18n.t('k8s.editStorage.emptyDir'), icon: Disc, color: 'dg-text-secondary' },
{ key: 'persistentVolumeClaim', label: i18n.t('kubernetes.namespaceresour.s18c90062'), icon: Database, color: 'dg-text-green' },
{ key: 'emptyDir', label: i18n.t('kubernetes.deploymentedits.s5449b239'), icon: Disc, color: 'dg-text-secondary' },
{ key: 'hostPath', label: 'HostPath', icon: HardDrive, color: 'dg-text-yellow' },
{ key: 'nfs', label: 'NFS', icon: Server, color: 'dg-text-purple' },
{ key: 'configMap', label: i18n.t('k8s.nsResource.configMapDesc'), icon: File, color: 'dg-text-blue' },
{ key: 'secret', label: i18n.t('k8s.secret.data'), icon: Lock, color: 'text-rose-400' },
{ key: 'configMap', label: i18n.t('kubernetes.namespaceovervi.s0986a6e7'), icon: File, color: 'dg-text-blue' },
{ key: 'secret', label: i18n.t('kubernetes.deploymentedits.sc099362a'), icon: Lock, color: 'text-rose-400' },
];
const SELECTABLE_TYPES = new Set(VOLUME_TYPES.map((t) => t.key));
@@ -129,20 +129,20 @@ export function DeploymentEditStorage({ clusterCode, namespace, deploymentName,
const save = async () => {
for (const entry of entries) {
if (!entry.volume.name.trim()) {
toast({ title: i18n.t('k8s.editStorage.volumeNameRequired'), variant: 'destructive' });
toast({ title: i18n.t('kubernetes.deploymentedits.s5b59d298'), variant: 'destructive' });
return;
}
if (!formToVolume(entry.volume)) {
const hint = entry.volume.type === 'configMap' ? i18n.t('k8s.editStorage.selectConfigMap')
: entry.volume.type === 'secret' ? i18n.t('k8s.editStorage.selectSecret')
: entry.volume.type === 'persistentVolumeClaim' ? i18n.t('k8s.editStorage.selectPVC')
: i18n.t('k8s.editStorage.typeRequiredHint');
toast({ title: i18n.t('k8s.editStorage.incompleteConfig'), description: i18n.t('k8s.editStorage.incompleteConfigDesc', { name: entry.volume.name, hint }), variant: 'destructive' });
const hint = entry.volume.type === 'configMap' ? i18n.t('kubernetes.deploymentedits.s2a6856ee')
: entry.volume.type === 'secret' ? i18n.t('kubernetes.deploymentedits.s1f31e442')
: entry.volume.type === 'persistentVolumeClaim' ? i18n.t('kubernetes.deploymentedits.sba9fc667')
: i18n.t('kubernetes.deploymentedits.s03480abb');
toast({ title: i18n.t('kubernetes.deploymentedits.se542a63b'), description: `请为「${entry.volume.name}」${hint}`, variant: 'destructive' });
return;
}
const validMounts = entry.mounts.filter((m) => m.mountPath.trim());
if (validMounts.length === 0) {
toast({ title: i18n.t('k8s.editStorage.mountPathRequired'), description: i18n.t('k8s.editStorage.mountPathRequiredDesc', { name: entry.volume.name }), variant: 'destructive' });
toast({ title: i18n.t('kubernetes.deploymentedits.s24b5b9e3'), description: `数据卷「${entry.volume.name}」需至少挂载到一个容器`, variant: 'destructive' });
return;
}
}
@@ -151,7 +151,7 @@ export function DeploymentEditStorage({ clusterCode, namespace, deploymentName,
const { volumes, mounts } = volumeEntriesToStorage(entries);
const base = applyStorageToManifest(rawManifest, volumes, mounts, passthroughVolumes);
await saveDeploymentManifest(clusterCode, namespace, deploymentName, base);
toast({ title: i18n.t('k8s.editStorage.saved'), description: i18n.t('k8s.toast.updatedSubmitted', { name: deploymentName }) });
toast({ title: i18n.t('kubernetes.deploymentedits.sa8cd7d90'), description: `${deploymentName} 更新已提交` });
onSaved(base);
} catch (e) {
toast({ title: i18n.t('kubernetes.msg.saveFailed'), description: (e as Error)?.message || String(e), variant: 'destructive' });
@@ -172,7 +172,7 @@ export function DeploymentEditStorage({ clusterCode, namespace, deploymentName,
if (!manifest) {
return (
<Card className="dg-card">
<CardContent className="p-8 text-center dg-text-muted"> {i18n.t('kubernetes.storage.noManifest')} </CardContent>
<CardContent className="p-8 text-center dg-text-muted"> {i18n.t('kubernetes.deploymentedits.s800b9f0b')} </CardContent>
</Card>
);
}
@@ -196,13 +196,13 @@ export function DeploymentEditStorage({ clusterCode, namespace, deploymentName,
<div>
<CardTitle className="text-sm text-[var(--dg-text-primary)] flex items-center gap-2">
<Database className="h-4 w-4 dg-text-green" />
{i18n.t('common.volumeDefinition')}
数据卷
<Badge variant="outline" className="border-[var(--dg-border)] dg-text-secondary">{entries.length}</Badge>
</CardTitle>
<p className="text-[11px] dg-text-muted mt-1"> {i18n.t('kubernetes.storage.volumeDesc')} </p>
<p className="text-[11px] dg-text-muted mt-1"> {i18n.t('kubernetes.deploymentedits.s1e0b322c')} </p>
</div>
<Button size="sm" variant="outline" className="h-7 border-[var(--dg-border)] text-[var(--dg-text-secondary)] text-xs shrink-0" onClick={addEntry}>
<Plus className="h-3 w-3 mr-1" />{i18n.t("common.addVolume")}
<Plus className="h-3 w-3 mr-1" />添加数据卷
</Button>
</div>
</CardHeader>
@@ -210,7 +210,7 @@ export function DeploymentEditStorage({ clusterCode, namespace, deploymentName,
{entries.length === 0 ? (
<div className="text-center py-10 dg-text-muted text-sm border border-dashed border-[var(--dg-border)] rounded-lg">
<Database className="h-8 w-8 mx-auto mb-2 dg-text-muted" />
{i18n.t('common.noVolumeHint')}
未配置数据卷,点击「添加数据卷」开始
</div>
) : entries.map((entry, i) => {
const vol = entry.volume;
@@ -223,7 +223,7 @@ export function DeploymentEditStorage({ clusterCode, namespace, deploymentName,
<div className="p-3 space-y-2.5 border-b border-[var(--dg-border)]/80">
<div className="flex items-center gap-2 flex-wrap">
<VIcon className={`h-4 w-4 ${vtype?.color || 'dg-text-secondary'}`} />
<span className="text-xs dg-text-secondary shrink-0"> {i18n.t('kubernetes.deploymentEditService.volumeName')} </span>
<span className="text-xs dg-text-secondary shrink-0"> {i18n.t('kubernetes.deploymentedits.sa3119d33')} </span>
<Input
value={vol.name}
onChange={(e) => {
@@ -234,7 +234,7 @@ export function DeploymentEditStorage({ clusterCode, namespace, deploymentName,
if (vol.type === 'persistentVolumeClaim' && !vol.pvcName) patch.pvcName = name;
updateVolume(i, patch);
}}
placeholder={i18n.t('k8s.editStorage.namePlaceholder')}
placeholder={i18n.t('kubernetes.deploymentedits.s478bfaef')}
className="h-8 w-44 dg-input text-sm font-mono"
/>
{/* 原生 select,避免 Radix Select 在 overflow 容器内无法下拉 // Logan.Li */}
@@ -282,10 +282,10 @@ export function DeploymentEditStorage({ clusterCode, namespace, deploymentName,
<div className="flex items-center justify-between gap-2">
<p className="text-[11px] dg-text-muted flex items-center gap-1.5">
<Container className="h-3.5 w-3.5 dg-text-blue" />
{i18n.t('common.mountToContainers')}
将上述数据卷挂载到容器
</p>
<Button size="sm" variant="ghost" className="h-6 px-2 text-[11px] dg-text-secondary hover:text-[var(--dg-text-primary)]" onClick={() => addMount(i)}>
<Plus className="h-3 w-3 mr-0.5" />{i18n.t("common.addMount")}
<Plus className="h-3 w-3 mr-0.5" />添加挂载
</Button>
</div>
{entry.mounts.map((mount, mi) => (
@@ -296,23 +296,23 @@ export function DeploymentEditStorage({ clusterCode, namespace, deploymentName,
onChange={(e) => updateMount(i, mi, { containerName: e.target.value })}
className="h-8 min-w-[140px] max-w-[280px] bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-md text-sm text-[var(--dg-text-primary)] px-2 font-medium truncate"
>
{containers.length === 0 && <option value="">{i18n.t('k8s.cluster.noContainer')}</option>}
{containers.length === 0 && <option value="">(无容器)</option>}
{containers.map((cn) => <option key={cn} value={cn}>{cn}</option>)}
</select>
<div className="flex items-center gap-1 bg-[var(--dg-bg-tertiary)] rounded-lg p-0.5 border border-[var(--dg-border)]">
<button type="button" onClick={() => updateMount(i, mi, { readOnly: true })}
className={`px-2.5 py-1 rounded text-xs font-medium transition-all ${mount.readOnly ? 'bg-amber-500/20 text-amber-300' : 'dg-text-muted hover:text-[var(--dg-text-secondary)]'}`}>
{i18n.t('common.readOnly')}
只读
</button>
<button type="button" onClick={() => updateMount(i, mi, { readOnly: false })}
className={`px-2.5 py-1 rounded text-xs font-medium transition-all ${!mount.readOnly ? 'bg-blue-500/20 text-blue-300' : 'dg-text-muted hover:text-[var(--dg-text-secondary)]'}`}>
{i18n.t('common.readWrite')}
读写
</button>
</div>
<Input
value={mount.mountPath}
onChange={(e) => updateMount(i, mi, { mountPath: e.target.value })}
placeholder={i18n.t('k8s.editStorage.mountPathPlaceholder')}
placeholder={i18n.t('kubernetes.deploymentedits.seb88e484')}
className="h-8 flex-1 min-w-[200px] dg-input text-sm font-mono"
/>
{entry.mounts.length > 1 && (
@@ -322,11 +322,11 @@ export function DeploymentEditStorage({ clusterCode, namespace, deploymentName,
)}
</div>
<div className="flex items-center gap-2">
<span className="text-[11px] dg-text-muted shrink-0"> {i18n.t('kubernetes.deploymentEditService.subPath')} </span>
<span className="text-[11px] dg-text-muted shrink-0"> {i18n.t('kubernetes.deploymentedits.sf4b25a22')} </span>
<Input
value={mount.subPath}
onChange={(e) => updateMount(i, mi, { subPath: e.target.value })}
placeholder={i18n.t('k8s.editStorage.mountPathPlaceholder')}
placeholder={i18n.t('kubernetes.deploymentedits.s68ab2e24')}
className="h-7 flex-1 max-w-md dg-input text-[var(--dg-text-secondary)] text-xs font-mono"
/>
</div>
@@ -343,7 +343,7 @@ export function DeploymentEditStorage({ clusterCode, namespace, deploymentName,
<Button variant="outline" className="border-[var(--dg-border)] dg-text-secondary" onClick={onCancel}> {i18n.t('rbac.jobRole.cancel')} </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" />}
{i18n.t('common.save')}
保存
</Button>
</div>
</div>
@@ -139,8 +139,8 @@ export function DeploymentView(props: DeploymentViewProps) {
const handleRollback = async (rev: JsonData) => {
const revision = rev.revision ?? rev.revision_number ?? rev.number;
const ok = await confirm({
title: i18n.t('k8s.deploymentView.rollbackVersion'),
description: i18n.t('k8s.toast.confirmRollback', { revision }),
title: i18n.t('kubernetes.deploymentviewt.s6a374e98'),
description: `确认回滚到修订版本 #${revision}?`,
confirmText: i18n.t('pipeline.rollback'),
variant: 'destructive',
});
@@ -148,7 +148,7 @@ export function DeploymentView(props: DeploymentViewProps) {
setRollbackLoading(revision);
try {
await kubernetesApi.rollbackDeployment(clusterCode, namespace, deployment.name, Number(revision));
toast({ title: i18n.t('k8s.deploymentView.rollbackSent'), description: i18n.t('k8s.toast.rollbackToRevision', { revision }) });
toast({ title: i18n.t('kubernetes.deploymentviewt.s754d92e1'), description: `正在回滚到 #${revision}` });
onRefresh();
} catch (e) {
toast({ title: i18n.t('pipeline.rollbackFailed'), description: e?.message || String(e), variant: 'destructive' });
@@ -160,7 +160,7 @@ export function DeploymentView(props: DeploymentViewProps) {
setScaleLoading(true);
try {
await kubernetesApi.scaleDeployment(clusterCode, namespace, deployment.name, scaleValue);
toast({ title: i18n.t('k8s.deploymentView.scaleSent'), description: i18n.t('k8s.toast.scaleTo', { count: scaleValue }) });
toast({ title: i18n.t('kubernetes.deploymentviewt.sb9c9e03e'), description: `副本数 → ${scaleValue}` });
onRefresh();
} catch (e) {
toast({ title: i18n.t('kubernetes.msg.scaleFailed'), description: e?.message || String(e), variant: 'destructive' });
@@ -168,10 +168,10 @@ export function DeploymentView(props: DeploymentViewProps) {
};
const TABS = [
{ key: 'pods', label: i18n.t('k8s.deploymentView.pods'), icon: Container, count: pods.length },
{ key: 'services', label: i18n.t('k8s.deploymentView.access'), icon: Network, count: services.length },
{ key: 'pods', label: i18n.t('kubernetes.deploymentviewt.s479692cc'), icon: Container, count: pods.length },
{ key: 'services', label: i18n.t('kubernetes.deploymentviewt.s7068046e'), icon: Network, count: services.length },
{ key: 'events', label: i18n.t('kubernetes.event.title'), icon: Activity, count: events.length },
{ key: 'scale', label: i18n.t('k8s.deploymentView.scale'), icon: Plus },
{ key: 'scale', label: i18n.t('kubernetes.deploymentviewt.s3b291d31'), icon: Plus },
{ key: 'history', label: i18n.t('kubernetes.deployment.revisions'), icon: History, count: revisions.length },
];
@@ -186,8 +186,8 @@ export function DeploymentView(props: DeploymentViewProps) {
{isHealthy ? 'Running' : 'Updating'}
</Badge>
} />
<InfoItem label={i18n.t('common.status')} value={<span className="font-mono">{ready}/{total}</span>} />
<InfoItem label={i18n.t('common.status')} value={deployment?.strategy || 'RollingUpdate'} />
<InfoItem label={i18n.t('kubernetes.capacitypagetsx.s0428b36a')} value={<span className="font-mono">{ready}/{total}</span>} />
<InfoItem label={i18n.t('kubernetes.deploymentviewt.s66914536')} value={deployment?.strategy || 'RollingUpdate'} />
{pods[0] && (
<>
<InfoItem label={i18n.t('kubernetes.pod.nodeName')} value={
@@ -195,7 +195,7 @@ export function DeploymentView(props: DeploymentViewProps) {
{pods[0].node_name || pods[0].node || '-'}
</button>
} />
<InfoItem label={i18n.t('kubernetes.deploymentView.podIp')} value={<span className="font-mono">{pods[0].ip || pods[0].pod_ip || '-'}</span>} />
<InfoItem label={i18n.t('kubernetes.deploymentviewt.s6d2d954e')} value={<span className="font-mono">{pods[0].ip || pods[0].pod_ip || '-'}</span>} />
</>
)}
<InfoItem label={i18n.t('system.userCreatedAt')} value={formatK8sTime(deployment?.created_at || deployment?.creationTimestamp)} />
@@ -41,6 +41,7 @@ import { extractContainers } from './deployment-detail/helpers';
import { PageShell } from '@/components/layout/PageShell';
import type { ActionModal, DeploymentWorkloadProps } from './deployment-workload/types';
import i18n from '@/i18n';
import { buildApiUrl } from '@/utils/request';
export type { DeploymentWorkloadProps, WorkloadMode } from './deployment-workload/types';
@@ -118,7 +119,7 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
setRevisions(toArray(revRes));
} catch (e) {
console.error(i18n.t('k8s.deploymentWorkload.loadFailed'), e);
console.error(i18n.t('kubernetes.deploymentdetai.sb46ea9c4'), e);
} finally {
setLoading(false);
}
@@ -133,10 +134,9 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
const { manifest: parsed } = await parseEditableManifest(yamlText);
setManifest(parsed || deployment);
} catch (e) {
console.warn(i18n.t('k8s.deploymentWorkload.manifestLoadFailed'), e);
console.warn(i18n.t('kubernetes.deploymentworkl.sce123fbd'), e);
setManifest(deployment);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [clusterCode, namespace, deploymentName, deployment]);
const loadBundleYaml = useCallback(
@@ -162,7 +162,7 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
setActionLoading('scale');
try {
await kubernetesApi.scaleDeployment(clusterCode, namespace, deploymentName, newReplicas);
toast({ title: i18n.t('k8s.workloads.scaleSuccess'), description: i18n.t('k8s.deploymentDetail.replicasSetTo', { count: newReplicas }) });
toast({ title: i18n.t('k8s.workloads.scaleSuccess'), description: `副本数已设置为 ${newReplicas}` });
loadData();
} catch (e) {
toast({ title: i18n.t('cloud.operationFailed'), description: e?.message || String(e), variant: 'destructive' });
@@ -173,7 +173,7 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
setActionLoading('scale');
try {
await kubernetesApi.scaleDeployment(clusterCode, namespace, deploymentName, scaleValue);
toast({ title: i18n.t('kubernetes.msg.scaleSuccess'), description: i18n.t('k8s.deploymentDetail.replicasSetTo', { count: scaleValue }) });
toast({ title: i18n.t('kubernetes.msg.scaleSuccess'), description: `副本数已设置为 ${scaleValue}` });
setModal(null);
loadData();
} catch (e) {
@@ -184,14 +184,14 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
const handleRestart = async () => {
const ok = await confirm({
title: i18n.t('kubernetes.deployment.restart'),
description: i18n.t('k8s.deploymentDetail.confirmRestart', { name: deploymentName }),
description: `确认滚动重启 Deployment "${deploymentName}"?`,
confirmText: i18n.t('agent.agentServiceRestart'),
});
if (!ok) return;
setActionLoading('restart');
try {
await kubernetesApi.restartDeployment(clusterCode, namespace, deploymentName);
toast({ title: i18n.t('k8s.workloadView.restartSent'), description: i18n.t('k8s.deploymentWorkload.restarting') });
toast({ title: i18n.t('kubernetes.deploymentdetai.s68a70009'), description: i18n.t('kubernetes.deploymentdetai.sd187617a') });
setTimeout(loadData, 1500);
} catch (e) {
toast({ title: i18n.t('kubernetes.deployment.restartFailed'), description: e?.message || String(e), variant: 'destructive' });
@@ -202,8 +202,8 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
const isPaused = !!deployment?.paused;
const action = isPaused ? i18n.t('kubernetes.deployment.resume') : i18n.t('kubernetes.job.suspend');
const ok = await confirm({
title: i18n.t('k8s.deploymentDetail.rollingUpdate', { action }),
description: i18n.t('k8s.deploymentDetail.confirmRollingUpdate', { action, name: deploymentName }),
title: `${action}滚动更新`,
description: `确认${action} Deployment "${deploymentName}" 的滚动更新?`,
confirmText: action,
});
if (!ok) return;
@@ -214,10 +214,10 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
} else {
await kubernetesApi.pauseDeployment(clusterCode, namespace, deploymentName);
}
toast({ title: i18n.t('k8s.deploymentDetail.actionSuccess', { action }), description: i18n.t('k8s.deploymentDetail.rollingUpdated', { action }) });
toast({ title: `${action}成功`, description: `Deployment 滚动更新已${action}` });
setTimeout(loadData, 1000);
} catch (e) {
toast({ title: i18n.t('k8s.deploymentDetail.actionFailed', { action }), description: e?.message || String(e), variant: 'destructive' });
toast({ title: `${action}失败`, description: e?.message || String(e), variant: 'destructive' });
} finally { setActionLoading(''); }
};
@@ -234,7 +234,7 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
await kubernetesApi.updateDeploymentImage(
clusterCode, namespace, deploymentName, newImage, cicdContainerName || undefined,
);
toast({ title: i18n.t('k8s.deploymentWorkload.scaleSuccess'), description: i18n.t('k8s.deploymentDetail.rollingUpdateImage', { image: newImage }) });
toast({ title: i18n.t('kubernetes.deploymentdetai.s6df3c303'), description: `正在滚动更新为 ${newImage}` });
setModal(null);
loadData();
} catch (e) {
@@ -247,11 +247,10 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
try {
// 复用通用删除:通过 PUT 空文档或调用隐式端点。若无专用端点,则提示。
const tk = useUserStore.getState().token || '';
const baseUrl = import.meta.env.VITE_API_BASE_URL || '';
const url = `${baseUrl}/api/v1/kubernetes/${clusterCode}/namespace/${namespace}/deployments/${deploymentName}`;
const url = buildApiUrl(`/kubernetes/${clusterCode}/namespace/${namespace}/deployments/${deploymentName}`);
const resp = await fetch(url, { method: 'DELETE', headers: { Authorization: `Bearer ${tk}` } });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
toast({ title: i18n.t('kubernetes.msg.deleteSuccess'), description: i18n.t('k8s.deploymentDetail.deploymentDeleted', { name: deploymentName }) });
toast({ title: i18n.t('kubernetes.msg.deleteSuccess'), description: `${deploymentName} 已删除` });
setModal(null);
setTimeout(() => navigate(`/kubernetes/${clusterCode}/namespace/${namespace}/deployments`), 800);
} catch (e) {
@@ -272,7 +271,7 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
const handleDeletePod = async (pod: any) => {
const ok = await confirm({
title: i18n.t('kubernetes.pod.deletePod'),
description: i18n.t('k8s.deploymentDetail.confirmDeletePod', { name: pod.name }),
description: `确认删除 Pod "${pod.name}"?操作不可恢复。`,
confirmText: i18n.t('kubernetes.storage.delete2'),
});
if (!ok) return;
@@ -288,7 +287,7 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
// ============ 渲染 // Logan.Li ============
if (loading) {
return (
<PageShell variant="workspace" title={deploymentName} subtitle={`${clusterCode} / ${namespace}`} loading loadingLabel={i18n.t('k8s.deploymentWorkload.loading')} actions={<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={onBack}><ArrowLeft className="h-4 w-4 mr-1" /> {i18n.t('common.back')} </Button>}>
<PageShell variant="workspace" title={deploymentName} subtitle={`${clusterCode} / ${namespace}`} loading loadingLabel={i18n.t('kubernetes.deploymentworkl.seb300b29')} actions={<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={onBack}><ArrowLeft className="h-4 w-4 mr-1" /> {i18n.t('common.back')} </Button>}>
{null}
</PageShell>
);
@@ -297,7 +296,7 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
if (!deployment && !isCreate) {
return (
<PageShell variant="default" title={deploymentName} subtitle={`${clusterCode} / ${namespace}`} actions={<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={onBack}><ArrowLeft className="h-4 w-4 mr-1" /> {i18n.t('common.back')} </Button>}>
<Card className="dg-card"><CardContent className="p-12 text-center"><XCircle className="h-8 w-8 mx-auto dg-text-red" /><p className="dg-text-secondary mt-3">{i18n.t('k8s.deploymentDetail.deploymentNotFound', { name: deploymentName })}</p></CardContent></Card>
<Card className="dg-card"><CardContent className="p-12 text-center"><XCircle className="h-8 w-8 mx-auto dg-text-red" /><p className="dg-text-secondary mt-3">未找到 Deployment: {deploymentName}</p></CardContent></Card>
</PageShell>
);
}
@@ -308,7 +307,7 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
if (name) navigate(`/kubernetes/${clusterCode}/namespace/${namespace}/workload/view/Deployment/${name}`);
};
return (
<PageShell variant="workspace" title={i18n.t('k8s.deploymentWorkload.create')} subtitle={`${clusterCode} / ${namespace}`} actions={<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={onBack}><ArrowLeft className="h-4 w-4 mr-1" /> {i18n.t('common.back')} </Button>}>
<PageShell variant="workspace" title={i18n.t('kubernetes.deploymentworkl.se2c85a21')} subtitle={`${clusterCode} / ${namespace}`} actions={<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={onBack}><ArrowLeft className="h-4 w-4 mr-1" /> {i18n.t('common.back')} </Button>}>
<EditModeContainer clusterCode={clusterCode} namespace={namespace} deploymentName={CREATE_DEPLOYMENT_SLUG} manifest={manifest} isCreate onSaved={handleCreated} onCancel={onBack} />
</PageShell>
);
@@ -330,13 +329,13 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
<PageShell
variant="workspace"
title={deploymentName}
subtitle={`${clusterCode} / ${namespace} · Deployment · ${i18n.t('k8s.deploymentDetail.imageLabel')}: ${deployment?.image || '-'}`}
subtitle={`${clusterCode} / ${namespace} · Deployment · 镜像: ${deployment?.image || '-'}`}
actions={(
<>
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={onBack}><ArrowLeft className="h-4 w-4 mr-1" /> {i18n.t('common.back')} </Button>
{modeTabs}
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={loadData}><RefreshCw className="h-4 w-4" /></Button>
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={() => { setNewImage(deployment?.image || ''); setModal({ kind: 'image' }); }}><ImageIcon className="h-3.5 w-3.5 mr-1" /> {i18n.t('kubernetes.deploymentWorkload.adjustImage')} </Button>
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={() => { setNewImage(deployment?.image || ''); setModal({ kind: 'image' }); }}><ImageIcon className="h-3.5 w-3.5 mr-1" /> {i18n.t('kubernetes.deploymentworkl.sf2e0208b')} </Button>
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={handleRestart}><RotateCw className="h-3.5 w-3.5 mr-1" /> {i18n.t('agent.agentServiceRestart')} </Button>
<Button variant="outline" size="sm" className="dg-btn-secondary" disabled={!!actionLoading} onClick={handleTogglePause}>
{deployment?.paused ? <><Play className="h-3.5 w-3.5 mr-1 dg-text-green" /> {i18n.t('kubernetes.deployment.resume')} </> : <><Pause className="h-3.5 w-3.5 mr-1 dg-text-orange" /> {i18n.t('kubernetes.job.suspend')} </>}
@@ -352,7 +351,7 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
<Badge className={isHealthy ? 'bg-emerald-500/20 dg-text-green border-emerald-500/30' : 'bg-amber-500/20 dg-text-yellow border-amber-500/30'}>
{isHealthy ? <CheckCircle className="h-3 w-3 mr-1" /> : <AlertTriangle className="h-3 w-3 mr-1" />}{ready}/{total}
</Badge>
{deployment?.paused && <Badge className="bg-orange-500/20 dg-text-orange border-orange-500/30"><Pause className="h-3 w-3 mr-1" /> {i18n.t('kubernetes.deploymentWorkload.paused')} </Badge>}
{deployment?.paused && <Badge className="bg-orange-500/20 dg-text-orange border-orange-500/30"><Pause className="h-3 w-3 mr-1" /> {i18n.t('kubernetes.deploymentworkl.s8be2521d')} </Badge>}
</div>
{/* ============ 主体:view / edit / yaml ============ */}
@@ -379,7 +378,7 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
resource="deployments"
name={deploymentName}
inline
resourceHint={i18n.t('k8s.deploymentWorkload.withService')}
resourceHint={i18n.t('kubernetes.deploymentworkl.s239604b0')}
loadYamlFn={loadBundleYaml}
saveYamlFn={saveBundleYaml}
onSaved={loadData}
@@ -404,7 +403,7 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
namespace={namespace}
resource="deployments"
name={deploymentName}
resourceHint={i18n.t('k8s.deploymentWorkload.withService')}
resourceHint={i18n.t('kubernetes.deploymentworkl.s239604b0')}
loadYamlFn={loadBundleYaml}
saveYamlFn={saveBundleYaml}
onClose={() => setModal(null)}
@@ -484,4 +483,4 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
/>
</PageShell>
);
}
}
@@ -47,7 +47,6 @@ export function EventTable({
: [t('k8s.event.colLast'), t('k8s.event.colFirst'), t('k8s.event.colCount'), t('k8s.event.colRepeat'), t('k8s.event.colType'), t('k8s.event.colReason'), t('k8s.event.colObject'), t('k8s.event.colSource'), t('k8s.event.colMessage')];
const resolvedEmpty = emptyText || t('k8s.event.empty');
// eslint-disable-next-line react-hooks/incompatible-library
const virtualizer = useVirtualizer({
count: events.length,
getScrollElement: () => scrollRef.current,
@@ -93,7 +93,6 @@ export default function EventsPage() {
navigate(`/kubernetes/${code}/events`, { replace: true });
}
}).catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
@@ -162,15 +161,15 @@ export default function EventsPage() {
const filterToolbar = (selectedReason || selectedKind) ? (
<div className="flex items-center gap-2 text-xs">
<span className="text-[var(--dg-text-muted)]"> {i18n.t('kubernetes.eventsPage.filterLabel')} </span>
<span className="text-[var(--dg-text-muted)]"> {i18n.t('kubernetes.eventspagetsx.s841389c5')} </span>
{selectedReason && (
<button type="button" className="inline-flex items-center gap-1 rounded border border-[var(--dg-border)] px-2 py-0.5 text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-tertiary)]" onClick={() => setSelectedReason('')}>
{i18n.t('k8s.eventsPage.reasonLabel')} {selectedReason} <X className="h-3 w-3" />
原因 {selectedReason} <X className="h-3 w-3" />
</button>
)}
{selectedKind && (
<button type="button" className="inline-flex items-center gap-1 rounded border border-[var(--dg-border)] px-2 py-0.5 text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-tertiary)]" onClick={() => setSelectedKind('')}>
{i18n.t('k8s.eventsPage.kindLabel')} {selectedKind} <X className="h-3 w-3" />
类型 {selectedKind} <X className="h-3 w-3" />
</button>
)}
</div>
@@ -179,16 +178,16 @@ export default function EventsPage() {
return (
<PageShell
variant="list"
title={i18n.t('k8s.event.center')}
subtitle={clusterName ? `${clusterName} · ${i18n.t('k8s.event.clusterAlerts')}` : i18n.t('k8s.event.clusterAlerts')}
title={i18n.t('kubernetes.eventspagetsx.s08bf79b4')}
subtitle={clusterName ? `${clusterName} · 集群事件与告警` : i18n.t('kubernetes.eventspagetsx.s6bef2ba0')}
icon={<Bell className="h-5 w-5" />}
metrics={!loading ? (
<StatStrip
items={[
{ label: i18n.t('k8s.event.normal'), value: summary?.total ?? events.length, color: 'muted' },
{ label: i18n.t('k8s.event.warning'), value: summary?.warning_count ?? warningCount, color: 'amber' },
{ label: i18n.t('k8s.event.normal'), value: summary?.normal_count ?? normalCount, color: 'green' },
{ label: i18n.t('k8s.event.warningType'), value: reasonData.length, color: 'red' },
{ label: i18n.t('kubernetes.eventspagetsx.s0d3047da'), value: summary?.total ?? events.length, color: 'muted' },
{ label: i18n.t('kubernetes.eventspagetsx.s0e5b83cc'), value: summary?.warning_count ?? warningCount, color: 'amber' },
{ label: i18n.t('kubernetes.eventspagetsx.s349c629f'), value: summary?.normal_count ?? normalCount, color: 'green' },
{ label: i18n.t('kubernetes.eventspagetsx.sc62e34c5'), value: reasonData.length, color: 'red' },
]}
/>
) : undefined}
@@ -206,31 +205,33 @@ export default function EventsPage() {
<Select value={selectedNs} onValueChange={(v) => { setSelectedNs(v); setSelectedReason(''); setSelectedKind(''); }}>
<SelectTrigger className="w-36 h-9 dg-input"><SelectValue placeholder={i18n.t('kubernetes.namespace.title')} /></SelectTrigger>
<SelectContent>
<SelectItem value="all"> {i18n.t('kubernetes.podMetricsPage.allNamespaces')} </SelectItem>
<SelectItem value="all"> {i18n.t('kubernetes.podmetricspaget.s58173b22')} </SelectItem>
{namespaces.map((n) => <SelectItem key={n} value={n}>{n}</SelectItem>)}
</SelectContent>
</Select>
<Select value={timeWindow} onValueChange={setTimeWindow}>
<SelectTrigger className="w-32 h-9 dg-input"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="0"> {i18n.t('kubernetes.eventsPage.allTime')} </SelectItem>
<SelectItem value="1"> {i18n.t('monitor.helpers.last1h')} </SelectItem>
<SelectItem value="0"> {i18n.t('kubernetes.eventspagetsx.s98c99f62')} </SelectItem>
<SelectItem value="1"> {i18n.t('monitor.helpersts.sf972b7b1')} </SelectItem>
<SelectItem value="6"> {i18n.t('monitor.siteProtocol.rangeDefault')} </SelectItem>
<SelectItem value="24"> {i18n.t('monitor.helpers.last24h')} </SelectItem>
<SelectItem value="168"> {i18n.t('monitor.helpers.last7d')} </SelectItem>
<SelectItem value="24"> {i18n.t('monitor.helpersts.s6e99b786')} </SelectItem>
<SelectItem value="168"> {i18n.t('monitor.helpersts.sd765efc4')} </SelectItem>
</SelectContent>
</Select>
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={() => { loadEvents(); loadSummary(); }} disabled={loading}>
<RefreshCw className={`h-4 w-4 mr-1 ${loading ? 'animate-spin' : ''}`} />{i18n.t('common.refresh')}</Button>
<RefreshCw className={`h-4 w-4 mr-1 ${loading ? 'animate-spin' : ''}`} />
刷新
</Button>
</>
)}
>
{reasonData.length > 0 && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<RankPanel title={i18n.t('k8s.event.resourceType')} items={reasonData} selected={selectedReason} onSelect={setSelectedReason} />
<RankPanel title={i18n.t('kubernetes.eventspagetsx.s8c894faa')} items={reasonData} selected={selectedReason} onSelect={setSelectedReason} />
{nsData.length > 0 && <RankPanel title={i18n.t('kubernetes.namespace.title')} items={nsData} />}
{kindData.length > 0 && (
<RankPanel title={i18n.t('k8s.event.warningReason')} items={kindData} selected={selectedKind} onSelect={setSelectedKind} />
<RankPanel title={i18n.t('kubernetes.eventspagetsx.s14871aad')} items={kindData} selected={selectedKind} onSelect={setSelectedKind} />
)}
</div>
)}
@@ -240,7 +241,7 @@ export default function EventsPage() {
<CardContent className="p-4">
<h3 className="text-sm font-medium text-[var(--dg-text-primary)] mb-3 flex items-center gap-1.5">
<AlertTriangle className="h-4 w-4 text-[var(--dg-orange)]" />
{i18n.t('k8s.eventsPage.recentWarnings')} ({summary.recent_warnings.length})
最新告警 ({summary.recent_warnings.length})
</h3>
<div className="space-y-1">
{summary.recent_warnings.map((w: JsonData, i: number) => (
@@ -264,11 +265,11 @@ export default function EventsPage() {
<ContentPanel
toolbar={(
<div className="flex items-center justify-between w-full">
<span className="text-sm dg-text-secondary">{i18n.t('common.events')} ({displayEvents.length})</span>
<span className="text-sm dg-text-secondary">事件列表 ({displayEvents.length})</span>
<Select value={filterType} onValueChange={setFilterType}>
<SelectTrigger className="w-28 h-8 dg-input text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all"> {i18n.t('kubernetes.eventsPage.allTypes')} </SelectItem>
<SelectItem value="all"> {i18n.t('kubernetes.eventspagetsx.sfb995166')} </SelectItem>
<SelectItem value="Warning">Warning</SelectItem>
<SelectItem value="Normal">Normal</SelectItem>
</SelectContent>
@@ -14,6 +14,7 @@ import { attachTerminalResize, mountXterm } from '@/lib/xterm-utils';
import { useUserStore } from '@/store';
import { TerminalLoadingOverlay } from '@/components/common/TerminalLoadingOverlay';
import i18n from '@/i18n';
import { getWsBase } from '@/utils/request';
interface KubectlTerminalProps {
open: boolean;
@@ -66,10 +67,8 @@ export function KubectlTerminal({ open, onOpenChange, clusterId, clusterName }:
fitRef.current = fit;
// WebSocket
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const baseUrl = import.meta.env.VITE_API_BASE_URL || `${proto}//${window.location.host}`;
const token = useUserStore.getState().token || '';
const wsUrl = `${baseUrl.replace(/^http/, 'ws').replace('/api/v1', '')}/ws/kubectl?token=${encodeURIComponent(token)}&id=${clusterId}`;
const wsUrl = `${getWsBase()}/ws/kubectl?token=${encodeURIComponent(token)}&id=${clusterId}`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
@@ -109,7 +108,7 @@ export function KubectlTerminal({ open, onOpenChange, clusterId, clusterName }:
ws.onerror = () => {
setError(i18n.t('k8s.kubectl.failed'));
setHasOutput(true);
term.write(i18n.t('k8s.kubectl.connectError'));
term.write(i18n.t('k8s.kubectl.sc8107726'));
};
return () => {
@@ -161,7 +160,7 @@ export function KubectlTerminal({ open, onOpenChange, clusterId, clusterName }:
style={fullscreen ? { maxWidth: '100vw', width: '100vw', height: '100vh' } : { maxWidth: '900px', width: '90vw' }}
>
<DialogTitle className="sr-only">kubectl — {clusterName}</DialogTitle>
<DialogDescription className="sr-only">{i18n.t('k8s.kubectlTerminalDesc')}</DialogDescription>
<DialogDescription className="sr-only">Kubernetes 集群命令行终端</DialogDescription>
{/* 头部 */}
<div className="flex items-center justify-between px-4 py-2 border-b border-[var(--dg-border)]">
@@ -2,18 +2,24 @@ import { useState, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Cloud, XCircle } from 'lucide-react';
import { Cloud, XCircle, RefreshCw } from 'lucide-react';
import { kubernetesApi } from '@/api';
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
import type { JsonData } from '@/types/common';
import { computeClusterMetrics, buildTrendData } from './cluster-detail/types';
import { OverviewTab } from './cluster-detail/OverviewTab';
import { NodesTab } from './cluster-detail/NodesTab';
import { PodsTab } from './cluster-detail/PodsTab';
import { DeploymentsTab } from './cluster-detail/DeploymentsTab';
import { ServicesTab } from './cluster-detail/ServicesTab';
import { IngressesTab } from './cluster-detail/IngressesTab';
import { EventsTab } from './cluster-detail/EventsTab';
// 资源使用趋势数据
// 由于当前无持久化时序存储,这里基于当前节点 metrics 构造一个简短的模拟趋势(最近几分钟变化)
// 真正历史数据需要后端记录指标快照后才能实现 // Logan.Li
export function KubernetesClusterDetail({ cluster, onClose }: { cluster?: JsonData; onClose: () => void }) {
const { t } = useTranslation();
@@ -23,11 +29,45 @@ export function KubernetesClusterDetail({ cluster, onClose }: { cluster?: JsonDa
const [services, setServices] = useState<JsonData[]>([]);
const [ingresses, setIngresses] = useState<JsonData[]>([]);
// 从节点数据计算集群级 CPU/内存使用率(实时快照)
const clusterMetrics = useMemo(() => computeClusterMetrics(nodes), [nodes]);
// 从节点数据计算集群级 CPU/内存使用率(实时快照) // Logan.Li
const clusterMetrics = useMemo(() => {
if (!nodes || nodes.length === 0) return { cpuPct: 0, memPct: 0 };
let totalCpuPct = 0, totalMemPct = 0, validNodes = 0;
for (const n of nodes) {
// 后端 ListNodes 返回 cpu_usage_m (如 "122m"), mem_usage_mi (如 "2105Mi"), cpu (核数), memory_mb (MB)
const cpuCores = n.cpu || 0;
const cpuUsageM = n.cpu_usage_m ? parseFloat(String(n.cpu_usage_m).replace('m', '')) : 0;
const cpuPct = cpuCores > 0 ? (cpuUsageM / 1000 / cpuCores) * 100 : 0;
const memMb = n.memory_mb || 0;
const memUsageMi = n.mem_usage_mi ? parseFloat(String(n.mem_usage_mi).replace('Mi', '').replace('Ki', '')) : 0;
const memUsageNorm = n.mem_usage_mi?.includes?.('Ki') ? memUsageMi / 1024 : n.mem_usage_mi?.includes?.('Gi') ? memUsageMi * 1024 : memUsageMi;
const memPct = memMb > 0 ? (memUsageNorm / memMb) * 100 : 0;
if (cpuPct > 0 || memPct > 0) {
totalCpuPct += cpuPct;
totalMemPct += memPct;
validNodes++;
}
}
return {
cpuPct: validNodes > 0 ? totalCpuPct / validNodes : 0,
memPct: validNodes > 0 ? totalMemPct / validNodes : 0,
};
}, [nodes]);
// 构造趋势数据(多点),避免单点导致"光点"效果
const trendData = useMemo(() => buildTrendData(clusterMetrics, t), [clusterMetrics, t]);
// 构造趋势数据(多点),避免单点导致“光点”效果 // Logan.Li
const trendData = useMemo(() => {
const cpu = Math.max(0, Math.min(100, Number(clusterMetrics.cpuPct.toFixed(1))));
const mem = Math.max(0, Math.min(100, Number(clusterMetrics.memPct.toFixed(1))));
// 模拟最近 5 个时间点的趋势(基于当前值轻微回溯)
return [
{ time: t('common.duration.min', { count: 10 }), cpu: Math.max(0, cpu * 0.72), memory: Math.max(0, mem * 0.78) },
{ time: t('common.duration.min', { count: 7 }), cpu: Math.max(0, cpu * 0.82), memory: Math.max(0, mem * 0.85) },
{ time: t('common.duration.min', { count: 4 }), cpu: Math.max(0, cpu * 0.90), memory: Math.max(0, mem * 0.93) },
{ 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]);
const loadClusterDetail = async (clusterCode: string) => {
try {
@@ -53,7 +93,6 @@ export function KubernetesClusterDetail({ cluster, onClose }: { cluster?: JsonDa
useEffect(() => {
if (cluster?.code) {
// eslint-disable-next-line react-hooks/set-state-in-effect
loadClusterDetail(cluster.code);
}
}, [cluster?.code]);
@@ -109,20 +148,397 @@ export function KubernetesClusterDetail({ cluster, onClose }: { cluster?: JsonDa
</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="mt-4">
<OverviewTab
cluster={cluster}
trendData={trendData}
metrics={clusterMetrics}
onRefresh={() => cluster?.id && loadClusterDetail(cluster.id)}
/>
<TabsContent value="overview" className="mt-4 space-y-4">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card className="bg-[var(--dg-bg-tertiary)]/50 border-[var(--dg-border)]">
<CardHeader className="pb-2">
<div className="flex items-center justify-between w-full">
<CardTitle className="text-sm text-[var(--dg-text-secondary)]">{t('k8s.cluster.resourceTrend')}</CardTitle>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]"
onClick={() => cluster?.id && loadClusterDetail(cluster.id)}
title={t('k8s.cluster.refreshMetrics')}
>
<RefreshCw className="h-3.5 w-3.5" />
</Button>
</div>
</CardHeader>
<CardContent>
<div className="h-[200px]">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={trendData} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
<defs>
<linearGradient id="colorCpu" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--dg-blue)" stopOpacity={0.35} />
<stop offset="95%" stopColor="var(--dg-blue)" stopOpacity={0.05} />
</linearGradient>
<linearGradient id="colorMemory" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--dg-green)" stopOpacity={0.35} />
<stop offset="95%" stopColor="var(--dg-green)" stopOpacity={0.05} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="2 2" stroke="var(--chart-grid)" />
<XAxis dataKey="time" stroke="var(--chart-axis)" fontSize={11} tickLine={false} />
<YAxis stroke="var(--chart-axis)" fontSize={11} domain={[0, 100]} unit="%" tickLine={false} />
<Tooltip
contentStyle={{
backgroundColor: 'var(--chart-tooltip-bg)',
border: '1px solid var(--chart-tooltip-border)',
borderRadius: '6px',
color: 'var(--dg-text-primary)',
fontSize: '12px',
}}
/>
<Area
type="monotone"
dataKey="cpu"
stroke="var(--dg-blue)"
strokeWidth={2}
fill="url(#colorCpu)"
dot={false}
activeDot={{ r: 3, fill: 'var(--dg-blue)' }}
name={t('k8s.label.cpuPercent')}
/>
<Area
type="monotone"
dataKey="memory"
stroke="var(--dg-green)"
strokeWidth={2}
fill="url(#colorMemory)"
dot={false}
activeDot={{ r: 3, fill: 'var(--dg-green)' }}
name={t('k8s.label.memoryPercent')}
/>
</AreaChart>
</ResponsiveContainer>
</div>
<div className="flex justify-center gap-6 mt-2 text-xs text-[var(--dg-text-secondary)]">
<span>{t('k8s.label.cpu')}: <span className="text-[var(--dg-blue)] font-bold">{clusterMetrics.cpuPct.toFixed(1)}%</span></span>
<span>{t('k8s.label.memory')}: <span className="text-[var(--dg-green)] font-bold">{clusterMetrics.memPct.toFixed(1)}%</span></span>
</div>
</CardContent>
</Card>
<Card className="bg-[var(--dg-bg-tertiary)]/50 border-[var(--dg-border)]">
<CardHeader className="pb-2">
<CardTitle className="text-sm text-[var(--dg-text-secondary)]">{t('k8s.cluster.resourceDistribution')}</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
<div className="text-center p-4 rounded-lg bg-[var(--dg-bg-tertiary)]">
<div className="text-3xl font-bold text-[var(--dg-blue)]">{cluster.pod_count || 0}</div>
<div className="text-sm text-[var(--dg-text-muted)] mt-1">{t('k8s.tab.pods')}</div>
</div>
<div className="text-center p-4 rounded-lg bg-[var(--dg-bg-tertiary)]">
<div className="text-3xl font-bold text-[var(--dg-green)]">{cluster.service_count || 0}</div>
<div className="text-sm text-[var(--dg-text-muted)] mt-1">{t('k8s.tab.services')}</div>
</div>
<div className="text-center p-4 rounded-lg bg-[var(--dg-bg-tertiary)]">
<div className="text-3xl font-bold text-[var(--dg-purple)]">{cluster.deployment_count || 0}</div>
<div className="text-sm text-[var(--dg-text-muted)] mt-1">{t('k8s.tab.deployments')}</div>
</div>
<div className="text-center p-4 rounded-lg bg-[var(--dg-bg-tertiary)]">
<div className="text-3xl font-bold text-[var(--dg-orange)]">{cluster.node_count || 0}</div>
<div className="text-sm text-[var(--dg-text-muted)] mt-1">{t('k8s.tab.nodes')}</div>
</div>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="nodes" className="mt-4">
<div className="border rounded-lg border-[var(--dg-border)] overflow-hidden">
<table className="w-full">
<thead className="bg-[var(--dg-bg-tertiary)]">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('k8s.column.name')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('k8s.column.role')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('common.status')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('k8s.column.cpu')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('k8s.column.memory')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('k8s.tab.pods')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('k8s.column.os')}</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--dg-border)]">
{nodes.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-[var(--dg-text-muted)]">{t('k8s.empty.nodes')}</td>
</tr>
) : (
nodes.map((node) => (
<tr key={node.id || node.name} className="hover:bg-[var(--dg-bg-tertiary)]/50">
<td className="px-4 py-3 text-sm text-[var(--dg-text-primary)]">{node.name}</td>
<td className="px-4 py-3">
<Badge
variant="outline"
className={
node.role === 'master'
? 'border-[var(--dg-purple)]/30 text-[var(--dg-purple)] bg-[var(--dg-purple)]/10'
: 'border-[var(--dg-blue)]/30 text-[var(--dg-blue)] bg-[var(--dg-blue)]/10'
}
>
{node.role === 'master' ? 'Master' : 'Worker'}
</Badge>
</td>
<td className="px-4 py-3">
<Badge
variant="outline"
className={
(node.status || '').toLowerCase() === 'ready'
? 'border-[var(--dg-green)]/30 text-[var(--dg-green)] bg-[var(--dg-green)]/10'
: 'border-[var(--dg-red)]/30 text-[var(--dg-red)] bg-[var(--dg-red)]/10'
}
>
{(node.status || '').toLowerCase() === 'ready' ? 'Ready' : 'NotReady'}
</Badge>
</td>
<td className="px-4 py-3 text-sm text-[var(--dg-text-secondary)]">
{node.cpu_usage_m || '0m'} / {node.cpu}{t('k8s.label.cores')}
</td>
<td className="px-4 py-3 text-sm text-[var(--dg-text-secondary)]">
{node.mem_usage_mi || '0Mi'} / {node.memory_mb ? (node.memory_mb / 1024).toFixed(0) + 'GB' : '-'}
</td>
<td className="px-4 py-3 text-sm text-[var(--dg-text-secondary)]">{node.max_pods ?? 0}</td>
<td className="px-4 py-3 text-sm text-[var(--dg-text-secondary)]">{node.os}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</TabsContent>
<TabsContent value="pods" className="mt-4">
<div className="border rounded-lg border-[var(--dg-border)] overflow-hidden">
<table className="w-full">
<thead className="bg-[var(--dg-bg-tertiary)]">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('k8s.column.name')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('k8s.column.namespace')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('common.status')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('k8s.column.restartCount')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('k8s.column.cpu')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('k8s.column.memory')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('k8s.column.node')}</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--dg-border)]">
{pods.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-[var(--dg-text-muted)]">{t('k8s.empty.pods')}</td>
</tr>
) : (
pods.map((pod) => (
<tr key={pod.id || pod.name} className="hover:bg-[var(--dg-bg-tertiary)]/50">
<td className="px-4 py-3 text-sm text-[var(--dg-text-primary)]">{pod.name}</td>
<td className="px-4 py-3">
<Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">
{pod.namespace}
</Badge>
</td>
<td className="px-4 py-3">
<Badge
variant="outline"
className={
(pod.status || '').toLowerCase() === 'running'
? 'border-[var(--dg-green)]/30 text-[var(--dg-green)] bg-[var(--dg-green)]/10'
: (pod.status || '').toLowerCase() === 'pending'
? 'border-[var(--dg-orange)]/30 text-[var(--dg-orange)] bg-[var(--dg-orange)]/10'
: 'border-[var(--dg-red)]/30 text-[var(--dg-red)] bg-[var(--dg-red)]/10'
}
>
{pod.status}
</Badge>
</td>
<td className="px-4 py-3 text-sm text-[var(--dg-text-secondary)]">{pod.restart_count ?? 0}</td>
<td className="px-4 py-3 text-sm text-[var(--dg-text-secondary)]">{pod.cpu_usage_m || '0m'}</td>
<td className="px-4 py-3 text-sm text-[var(--dg-text-secondary)]">{pod.mem_usage_mi || '0Mi'}</td>
<td className="px-4 py-3 text-sm text-[var(--dg-text-secondary)]">{pod.node_name}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</TabsContent>
<TabsContent value="deployments" className="mt-4">
<div className="border rounded-lg border-[var(--dg-border)] overflow-hidden">
<table className="w-full">
<thead className="bg-[var(--dg-bg-tertiary)]">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('k8s.column.name')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('k8s.column.namespace')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('k8s.column.replicas')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('k8s.column.availableReplicas')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('k8s.column.image')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('k8s.column.createdAt')}</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--dg-border)]">
{deployments.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-[var(--dg-text-muted)]">{t('k8s.empty.deployments')}</td>
</tr>
) : (
deployments.map((deployment) => (
<tr key={deployment.id || deployment.name} className="hover:bg-[var(--dg-bg-tertiary)]/50">
<td className="px-4 py-3 text-sm text-[var(--dg-text-primary)]">{deployment.name}</td>
<td className="px-4 py-3">
<Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">
{deployment.namespace}
</Badge>
</td>
<td className="px-4 py-3 text-sm text-[var(--dg-text-secondary)]">{deployment.replicas}</td>
<td className="px-4 py-3">
<span className={`text-sm ${(deployment.available_replicas ?? deployment.availableReplicas) === deployment.replicas ? 'text-[var(--dg-green)]' : 'text-[var(--dg-orange)]'}`}>
{deployment.available_replicas ?? deployment.availableReplicas} / {deployment.replicas}
</span>
</td>
<td className="px-4 py-3 text-sm text-[var(--dg-text-secondary)] truncate max-w-[200px]">{deployment.image}</td>
<td className="px-4 py-3 text-sm text-[var(--dg-text-secondary)]">{deployment.created_at || deployment.createdAt}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</TabsContent>
<TabsContent value="services" className="mt-4">
<div className="border rounded-lg border-[var(--dg-border)] overflow-hidden">
<table className="w-full">
<thead className="bg-[var(--dg-bg-tertiary)]">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('k8s.column.name')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('k8s.column.namespace')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('common.type')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">ClusterIP</th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('k8s.column.ports')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('k8s.column.selector')}</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--dg-border)]">
{services.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-[var(--dg-text-muted)]">{t('k8s.empty.services')}</td>
</tr>
) : (
services.map((service) => (
<tr key={service.id || service.name} className="hover:bg-[var(--dg-bg-tertiary)]/50">
<td className="px-4 py-3 text-sm text-[var(--dg-text-primary)]">{service.name}</td>
<td className="px-4 py-3">
<Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">
{service.namespace}
</Badge>
</td>
<td className="px-4 py-3">
<Badge
variant="outline"
className={
service.type === 'ClusterIP'
? 'border-[var(--dg-blue)]/30 text-[var(--dg-blue)] bg-[var(--dg-blue)]/10'
: service.type === 'NodePort'
? 'border-[var(--dg-purple)]/30 text-[var(--dg-purple)] bg-[var(--dg-purple)]/10'
: 'border-[var(--dg-green)]/30 text-[var(--dg-green)] bg-[var(--dg-green)]/10'
}
>
{service.type}
</Badge>
</td>
<td className="px-4 py-3 text-sm text-[var(--dg-text-secondary)]">{service.cluster_ip ?? service.clusterIP}</td>
<td className="px-4 py-3 text-sm text-[var(--dg-text-secondary)]">{service.ports}</td>
<td className="px-4 py-3 text-sm text-[var(--dg-text-secondary)]">{service.selector}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</TabsContent>
<TabsContent value="ingresses" className="mt-4">
<div className="border rounded-lg border-[var(--dg-border)] overflow-hidden">
<table className="w-full">
<thead className="bg-[var(--dg-bg-tertiary)]">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('k8s.column.name')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('k8s.column.namespace')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('k8s.column.hosts')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('k8s.column.backend')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('k8s.column.tls')}</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--dg-border)]">
{ingresses.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-[var(--dg-text-muted)]">{t('k8s.empty.ingresses')}</td>
</tr>
) : (
ingresses.map((ingress) => (
<tr key={ingress.id || ingress.name} className="hover:bg-[var(--dg-bg-tertiary)]/50">
<td className="px-4 py-3 text-sm text-[var(--dg-text-primary)]">{ingress.name}</td>
<td className="px-4 py-3">
<Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">
{ingress.namespace}
</Badge>
</td>
<td className="px-4 py-3 text-sm text-[var(--dg-text-secondary)]">{ingress.hosts}</td>
<td className="px-4 py-3 text-sm text-[var(--dg-text-secondary)]">{ingress.backend}</td>
<td className="px-4 py-3">
<Badge
variant="outline"
className={
ingress.tls
? 'border-[var(--dg-green)]/30 text-[var(--dg-green)] bg-[var(--dg-green)]/10'
: 'border-[var(--dg-border)] text-[var(--dg-text-secondary)]'
}
>
{ingress.tls ? t('k8s.label.enabled') : t('k8s.label.disabled')}
</Badge>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</TabsContent>
<TabsContent value="events" className="mt-4">
<div className="space-y-2">
{[
{ type: 'Normal', reason: 'Started', message: 'Started container nginx', time: t('common.duration.minAgo', { count: 2 }) },
{ type: 'Normal', reason: 'Created', message: 'Created pod nginx-deployment-xxx', time: t('common.duration.minAgo', { count: 5 }) },
{ type: 'Warning', reason: 'FailedScheduling', message: '0/3 nodes are available', time: t('common.duration.minAgo', { count: 10 }) },
{ type: 'Normal', reason: 'Pulling', message: 'Pulling image "nginx:latest"', time: t('common.duration.minAgo', { count: 15 }) },
{ type: 'Normal', reason: 'Pulled', message: 'Successfully pulled image', time: t('common.duration.minAgo', { count: 12 }) },
].map((event, index) => (
<div
key={index}
className="flex items-center gap-3 p-3 rounded-lg bg-[var(--dg-bg-tertiary)]/50"
>
<Badge
variant="outline"
className={
event.type === 'Normal'
? 'border-[var(--dg-green)]/30 text-[var(--dg-green)] bg-[var(--dg-green)]/10'
: 'border-[var(--dg-orange)]/30 text-[var(--dg-orange)] bg-[var(--dg-orange)]/10'
}
>
{event.type}
</Badge>
<span className="text-sm font-medium text-[var(--dg-text-secondary)] min-w-[120px]">{event.reason}</span>
<span className="text-sm text-[var(--dg-text-secondary)] flex-1">{event.message}</span>
<span className="text-xs text-[var(--dg-text-muted)]">{event.time}</span>
</div>
))}
</div>
</TabsContent>
<TabsContent value="nodes" className="mt-4"><NodesTab nodes={nodes} /></TabsContent>
<TabsContent value="pods" className="mt-4"><PodsTab pods={pods} /></TabsContent>
<TabsContent value="deployments" className="mt-4"><DeploymentsTab deployments={deployments} /></TabsContent>
<TabsContent value="services" className="mt-4"><ServicesTab services={services} /></TabsContent>
<TabsContent value="ingresses" className="mt-4"><IngressesTab ingresses={ingresses} /></TabsContent>
<TabsContent value="events" className="mt-4"><EventsTab clusterCode={cluster?.code as string | undefined} /></TabsContent>
</Tabs>
</CardContent>
</Card>
@@ -89,7 +89,7 @@ export function KubernetesClusterDialog({
if (!value.trim()) {
setNameError('');
} else if (!NAME_RE.test(value)) {
setNameError(i18n.t('k8s.clusterDialog.nameRule'));
setNameError(i18n.t('kubernetes.kubernetesclust.sc7ec6c52'));
} else {
setNameError('');
}
@@ -111,7 +111,7 @@ export function KubernetesClusterDialog({
const handleSubmit = async () => {
if (!formData.name.trim() || !NAME_RE.test(formData.name.trim())) {
toast.error(i18n.t('k8s.clusterDialog.nameInvalid')); return;
toast.error(i18n.t('kubernetes.kubernetesclust.s38bbfe92')); return;
}
try {
setSubmitting(true);
@@ -133,7 +133,7 @@ export function KubernetesClusterDialog({
onOpenChange(false);
onSaved();
} catch (e) {
console.error(i18n.t('k8s.clusterDialog.saveFailed'), e);
console.error(i18n.t('kubernetes.kubernetesclust.s7aa44ba2'), e);
} finally {
setSubmitting(false);
}
@@ -149,7 +149,7 @@ export function KubernetesClusterDialog({
{editingCluster ? i18n.t('kubernetes.cluster.editCluster') : i18n.t('kubernetes.cluster.addCluster')}
</DialogTitle>
<DialogDescription className="text-[var(--dg-text-secondary)]">
{editingCluster ? i18n.t('k8s.clusterDialog.editTitle') : i18n.t('k8s.clusterDialog.addTitle')}
{editingCluster ? i18n.t('kubernetes.kubernetesclust.s2d42f245') : i18n.t('kubernetes.kubernetesclust.sfe6c791d')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2 max-h-[60vh] overflow-y-auto pr-1">
@@ -159,20 +159,20 @@ export function KubernetesClusterDialog({
<Input
value={formData.name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder={i18n.t('k8s.clusterDialog.namePlaceholder')}
placeholder={i18n.t('kubernetes.kubernetesclust.s298c8665')}
className={`bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)] ${nameError ? 'border-red-500' : ''}`}
/>
{nameError && <p className="text-xs dg-text-red">{nameError}</p>}
<p className="text-xs text-[var(--dg-text-muted)]">
{i18n.t('k8s.clusterDialog.codeRule')}
只允许英文、数字和连字符(-),首尾不能是连字符。名称将作为集群唯一标识。
</p>
</div>
<div className="space-y-2">
<Label className="text-[var(--dg-text-secondary)]"> {i18n.t('kubernetes.kubernetesCluster.versionOptional')} </Label>
<Label className="text-[var(--dg-text-secondary)]"> {i18n.t('kubernetes.kubernetesclust.sbf23eb8e')} </Label>
<Input
value={formData.version}
onChange={(e) => setFormData({ ...formData, version: e.target.value })}
placeholder={i18n.t('k8s.clusterDialog.namePlaceholder')}
placeholder={i18n.t('kubernetes.kubernetesclust.s90a9ef87')}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
/>
</div>
@@ -183,13 +183,13 @@ export function KubernetesClusterDialog({
<Input
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder={i18n.t('k8s.clusterDialog.descLabel')}
placeholder={i18n.t('kubernetes.kubernetesclust.s33a940a9')}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
/>
</div>
<div className="space-y-2">
<Label className="text-[var(--dg-text-secondary)]"> {i18n.t('kubernetes.kubernetesCluster.accessMethod')} </Label>
<Label className="text-[var(--dg-text-secondary)]"> {i18n.t('kubernetes.kubernetesclust.saaa159b7')} </Label>
<RadioGroup
value={String(formData.config_type)}
onValueChange={(v) => setFormData({ ...formData, config_type: Number(v) })}
@@ -219,12 +219,12 @@ export function KubernetesClusterDialog({
{formData.config_type === 0 ? (
<div className="space-y-2">
<Label className="text-[var(--dg-text-secondary)]">
Token {hasToken && <span className="dg-text-green text-xs ml-1">{i18n.t('k8s.clusterDialog.configured')}</span>}
Token {hasToken && <span className="dg-text-green text-xs ml-1">✓ 已配置(留空不修改)</span>}
</Label>
<Textarea
value={formData.token}
onChange={(e) => setFormData({ ...formData, token: e.target.value })}
placeholder={hasToken ? i18n.t('k8s.clusterDialog.tokenConfigured') : i18n.t('k8s.clusterDialog.tokenPlaceholder')}
placeholder={hasToken ? i18n.t('kubernetes.kubernetesclust.sf26ca548') : i18n.t('kubernetes.kubernetesclust.s1050083d')}
rows={4}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)] font-mono text-xs"
/>
@@ -232,18 +232,18 @@ export function KubernetesClusterDialog({
) : (
<div className="space-y-2">
<Label className="text-[var(--dg-text-secondary)]">
Kubeconfig {hasKubeConfig && <span className="dg-text-green text-xs ml-1">{i18n.t('k8s.clusterDialog.configured')}</span>}
Kubeconfig {hasKubeConfig && <span className="dg-text-green text-xs ml-1">✓ 已配置(留空不修改)</span>}
</Label>
<Textarea
value={formData.kube_config}
onChange={(e) => handleKubeConfigChange(e.target.value)}
placeholder={hasKubeConfig ? i18n.t('k8s.clusterDialog.tokenConfigured') : 'apiVersion: v1\nkind: Config\nclusters:\n- cluster:\n server: https://10.0.0.1:6443\n ...'}
placeholder={hasKubeConfig ? i18n.t('kubernetes.kubernetesclust.sf26ca548') : 'apiVersion: v1\nkind: Config\nclusters:\n- cluster:\n server: https://10.0.0.1:6443\n ...'}
rows={10}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)] font-mono text-xs"
/>
{!hasKubeConfig && (
<p className="text-xs text-[var(--dg-text-muted)]">
{i18n.t('k8s.clusterDialog.kubeconfigHint')}
粘贴 kubeconfig 内容后将自动解析 API Server 地址
</p>
)}
</div>
@@ -255,7 +255,7 @@ export function KubernetesClusterDialog({
onClick={() => { onOpenChange(false); }}
className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]"
>
{i18n.t('common.cancel')}
取消
</Button>
<Button
onClick={handleSubmit}
@@ -30,7 +30,6 @@ export function KubernetesNodes() {
setClusters(list);
if (list.length > 0 && !selectedCluster) setSelectedCluster(String(list[0].code || list[0].id));
} catch { setClusters([]); }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => { loadClusters(); }, [loadClusters]);
@@ -72,7 +71,7 @@ export function KubernetesNodes() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"> {i18n.t('k8s.nodes.selectCluster')} </SelectItem>
<SelectItem value="all"> {i18n.t('kubernetes.kubernetesnodes.s5bcc10f8')} </SelectItem>
<SelectItem value="master">Master</SelectItem>
<SelectItem value="worker">Worker</SelectItem>
</SelectContent>
@@ -86,7 +85,7 @@ export function KubernetesNodes() {
<PageShell
variant="list"
title={i18n.t('menu.nodeManagement')}
subtitle={clusterLabel ? `${clusterLabel} · ${i18n.t('k8s.nodes.subtitle')}` : i18n.t('k8s.nodes.subtitle')}
subtitle={clusterLabel ? `${clusterLabel} · ${i18n.t('k8s.capacity.nodeResources')}` : i18n.t('kubernetes.kubernetesnodes.s01b441ea')}
icon={<Server className="h-5 w-5" />}
actions={(
<>
@@ -101,7 +100,9 @@ export function KubernetesNodes() {
</SelectContent>
</Select>
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={loadNodes} disabled={loading}>
<RefreshCw className={`h-4 w-4 mr-1 ${loading ? 'animate-spin' : ''}`} />{i18n.t('common.refresh')}</Button>
<RefreshCw className={`h-4 w-4 mr-1 ${loading ? 'animate-spin' : ''}`} />
{i18n.t('common.refresh')}
</Button>
</>
)}
>
@@ -123,7 +124,7 @@ export function KubernetesNodes() {
{loading ? (
<TableRow><TableCell colSpan={8} className="text-center text-[var(--dg-text-muted)] py-12"><Loader2 className="h-5 w-5 animate-spin mx-auto mb-2" /> {i18n.t('monitor.logPanel.logResults.loading')} </TableCell></TableRow>
) : filtered.length === 0 ? (
<TableRow><TableCell colSpan={8} className="text-center text-[var(--dg-text-muted)] py-12"><Server className="h-8 w-8 mx-auto mb-2 opacity-50" />{selectedCluster ? i18n.t('k8s.empty.nodes') : i18n.t('k8s.nodes.noData')}</TableCell></TableRow>
<TableRow><TableCell colSpan={8} className="text-center text-[var(--dg-text-muted)] py-12"><Server className="h-8 w-8 mx-auto mb-2 opacity-50" />{selectedCluster ? i18n.t('k8s.empty.nodes') : i18n.t('kubernetes.kubernetesnodes.sf8c38834')}</TableCell></TableRow>
) : (
filtered.map((n) => (
<TableRow key={n.id} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/30">
@@ -132,7 +133,7 @@ export function KubernetesNodes() {
<TableCell>{getRoleBadge(n.role)}</TableCell>
<TableCell>{getStatusBadge(n.status)}</TableCell>
<TableCell className="text-sm text-[var(--dg-text-secondary)]">
{n.cpu ? `${n.cpu}${i18n.t('k8s.nodes.cores')}` : '-'} / {n.memory ? `${Math.round(n.memory / 1024)}GB` : '-'}
{n.cpu ? i18n.t('k8s.capacity.cpuCores', { count: n.cpu }) : '-'} / {n.memory ? `${Math.round(n.memory / 1024)}GB` : '-'}
</TableCell>
<TableCell>
{(() => {
@@ -52,7 +52,6 @@ export function KubernetesWorkloads() {
setClusters(list);
if (list.length > 0 && !selectedCluster) setSelectedCluster(list[0].code || String(list[0].id));
} catch { setClusters([]); }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => { loadClusters(); }, [loadClusters]);
@@ -112,7 +112,7 @@ export default function NamespaceManagementPage() {
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={i18n.t('k8s.nsMgmt.searchPlaceholder')}
placeholder={i18n.t('kubernetes.namespacemanage.se91247a0')}
className="max-w-xs dg-input"
/>
);
@@ -120,8 +120,8 @@ export default function NamespaceManagementPage() {
return (
<PageShell
variant="list"
title={i18n.t('k8s.nsMgmt.title')}
subtitle={i18n.t('k8s.nsMgmt.quotaSubtitle', { code: clusterCode })}
title={i18n.t('kubernetes.namespacemanage.se935a33d')}
subtitle={`${clusterCode} · 资源配额与生命周期管理`}
icon={<Boxes className="h-5 w-5" />}
metrics={!loading ? (
<StatStrip
@@ -130,23 +130,25 @@ export default function NamespaceManagementPage() {
{ label: 'Deployments', value: totalDeploys, color: 'blue' },
{ label: 'Pods', value: totalPods, color: 'purple' },
{ label: 'Services', value: totalSvcs, color: 'cyan' },
{ label: i18n.t('k8s.nsMgmt.quotaLimit'), value: quotaCount, color: 'amber' },
{ label: i18n.t('kubernetes.namespacemanage.s47c3aee9'), value: quotaCount, color: 'amber' },
]}
/>
) : undefined}
actions={(
<>
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={loadData} disabled={loading}>
<RefreshCw className={`h-4 w-4 mr-1 ${loading ? 'animate-spin' : ''}`} />{i18n.t('common.refresh')}</Button>
<RefreshCw className={`h-4 w-4 mr-1 ${loading ? 'animate-spin' : ''}`} />
刷新
</Button>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button size="sm" className="dg-btn-primary">
<Plus className="h-4 w-4 mr-1" />{i18n.t("common.createNamespace")}
<Plus className="h-4 w-4 mr-1" />创建命名空间
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle> {i18n.t('k8s.nsMgmt.nameExample')} </DialogTitle>
<DialogTitle> {i18n.t('kubernetes.namespacemanage.s737f41e9')} </DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
@@ -154,11 +156,11 @@ export default function NamespaceManagementPage() {
<Input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder={i18n.t('k8s.nsMgmt.nameExample')}
placeholder={i18n.t('kubernetes.namespacemanage.scba00862')}
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
/>
<p className="text-xs text-[var(--dg-text-muted)]">
{i18n.t('k8s.nsMgmt.dnsHint')}
名称需符合 DNS-1123 标准(小写字母/数字/连字符)
</p>
</div>
</div>
@@ -166,7 +168,7 @@ export default function NamespaceManagementPage() {
<Button variant="outline" onClick={() => setCreateOpen(false)}> {i18n.t('rbac.jobRole.cancel')} </Button>
<Button onClick={handleCreate} disabled={creating || !newName.trim()}>
{creating ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : null}
{i18n.t('common.create')}
创建
</Button>
</DialogFooter>
</DialogContent>
@@ -180,7 +182,7 @@ export default function NamespaceManagementPage() {
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
<TableHead className="text-[var(--dg-text-secondary)]"> {i18n.t('kubernetes.secret.secretName')} </TableHead>
<TableHead className="text-[var(--dg-text-secondary)]"> {i18n.t('system.terminalStatus')} </TableHead>
<TableHead className="text-[var(--dg-text-secondary)]"> {i18n.t('kubernetes.namespaceManage.age')} </TableHead>
<TableHead className="text-[var(--dg-text-secondary)]"> {i18n.t('kubernetes.namespacemanage.sa01a9963')} </TableHead>
<TableHead className="text-[var(--dg-text-secondary)] text-center">Deploy</TableHead>
<TableHead className="text-[var(--dg-text-secondary)] text-center">Pods</TableHead>
<TableHead className="text-[var(--dg-text-secondary)] text-center">Svc</TableHead>
@@ -192,11 +194,11 @@ export default function NamespaceManagementPage() {
<TableBody>
{loading ? (
<TableRow><TableCell colSpan={9} className="text-center text-[var(--dg-text-muted)] py-12">
<Loader2 className="h-5 w-5 animate-spin mx-auto mb-2" />{i18n.t("common.loading")}
<Loader2 className="h-5 w-5 animate-spin mx-auto mb-2" />加载中...
</TableCell></TableRow>
) : filtered.length === 0 ? (
<TableRow><TableCell colSpan={9} className="text-center text-[var(--dg-text-muted)] py-12">
<Boxes className="h-8 w-8 mx-auto mb-2 opacity-50" />{i18n.t("common.noNamespace")}
<Boxes className="h-8 w-8 mx-auto mb-2 opacity-50" />暂无命名空间
</TableCell></TableRow>
) : (
filtered.map((ns) => {
@@ -224,7 +226,7 @@ export default function NamespaceManagementPage() {
<TableCell className="text-center">
{ns.has_resource_quota ? (
<Badge className="bg-[var(--dg-orange)]/20 text-[var(--dg-orange)]" title={JSON.stringify(ns.resource_quotas?.[0]?.status)}>
<Shield className="h-3 w-3 mr-1" />{i18n.t("common.hasLabel")}
<Shield className="h-3 w-3 mr-1" />有
</Badge>
) : (
<span className="text-xs text-[var(--dg-text-muted)]">-</span>
@@ -271,16 +273,16 @@ export default function NamespaceManagementPage() {
<DialogContent>
<DialogHeader>
<DialogTitle className="text-[var(--dg-red)] flex items-center gap-2">
<AlertTriangle className="h-5 w-5" />{i18n.t("common.deleteNamespace")}
<AlertTriangle className="h-5 w-5" />删除命名空间
</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-4">
<div className="bg-[var(--dg-red)]/10 border border-[var(--dg-red)]/30 rounded-lg p-3">
<p className="text-sm text-[var(--dg-red)]">
{i18n.t('common.deleteNamespaceWarn')} <span className="font-bold">{deleteTarget}</span> {i18n.t('common.allResources')}
⚠️ 即将删除命名空间 <span className="font-bold">{deleteTarget}</span> 及其中所有资源。
</p>
<p className="text-xs text-[var(--dg-red)]/70 mt-1">
{i18n.t('k8s.nsMgmt.confirmDeleteDesc')}
此操作不可逆,所有 Deployment/Pod/Service/ConfigMap 等资源将被永久删除。
</p>
</div>
</div>
@@ -288,7 +290,7 @@ export default function NamespaceManagementPage() {
<Button variant="outline" onClick={() => setDeleteTarget(null)}> {i18n.t('rbac.jobRole.cancel')} </Button>
<Button variant="destructive" onClick={handleDelete} disabled={deleting}>
{deleting ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : null}
{i18n.t('k8s.nsMgmt.confirmDelete')}
确认删除
</Button>
</DialogFooter>
</DialogContent>
@@ -19,14 +19,14 @@ import type { JsonData } from '@/types/common';
import i18n from '@/i18n';
const RESOURCE_LINKS = [
{ type: 'deployments', label: 'Deployments', icon: Boxes, desc: i18n.t('k8s.nsOverview.workloadReplicas') },
{ type: 'pods', label: 'Pods', icon: Container, desc: i18n.t('k8s.nsOverview.podStatus') },
{ type: 'cronjobs', label: 'CronJobs', icon: Clock, desc: i18n.t('k8s.nsOverview.cronjobManage') },
{ type: 'services', label: 'Services', icon: Network, desc: i18n.t('k8s.nsOverview.serviceDiscovery') },
{ type: 'ingresses', label: 'Ingresses', icon: Network, desc: i18n.t('k8s.nsOverview.externalRoute') },
{ type: 'configmaps', label: 'ConfigMaps', icon: FileText, desc: i18n.t('k8s.nsResource.configMapDesc') },
{ type: 'secrets', label: 'Secrets', icon: Shield, desc: i18n.t('k8s.nsOverview.secrets') },
{ type: 'hpas', label: 'HPAs', icon: Activity, desc: i18n.t('k8s.nsOverview.hpaStrategy') },
{ type: 'deployments', label: 'Deployments', icon: Boxes, desc: i18n.t('kubernetes.namespaceovervi.sb68c634e') },
{ type: 'pods', label: 'Pods', icon: Container, desc: i18n.t('kubernetes.namespaceovervi.sfdac258b') },
{ type: 'cronjobs', label: 'CronJobs', icon: Clock, desc: i18n.t('kubernetes.namespaceovervi.s9c9fc523') },
{ type: 'services', label: 'Services', icon: Network, desc: i18n.t('kubernetes.namespaceovervi.s3a3f6d6c') },
{ type: 'ingresses', label: 'Ingresses', icon: Network, desc: i18n.t('kubernetes.namespaceovervi.scc6971c7') },
{ type: 'configmaps', label: 'ConfigMaps', icon: FileText, desc: i18n.t('kubernetes.namespaceovervi.s0986a6e7') },
{ type: 'secrets', label: 'Secrets', icon: Shield, desc: i18n.t('kubernetes.namespaceovervi.sb3fb72cd') },
{ type: 'hpas', label: 'HPAs', icon: Activity, desc: i18n.t('kubernetes.namespaceovervi.sa84da2c5') },
];
export default function NamespaceOverviewPage() {
@@ -68,10 +68,10 @@ export default function NamespaceOverviewPage() {
<PageShell
variant="default"
title={namespace || i18n.t('kubernetes.namespace.title')}
subtitle={i18n.t('k8s.nsOverview.subtitle', { code: clusterCode })}
subtitle={`${clusterCode} · 命名空间概览`}
icon={<Cloud className="h-5 w-5" />}
loading={loading}
loadingLabel={i18n.t('k8s.nsOverview.loadingStats')}
loadingLabel={i18n.t('kubernetes.namespaceovervi.sfa525ae0')}
>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{RESOURCE_LINKS.map((r) => (
@@ -3,24 +3,23 @@ import { useState, useEffect, useCallback, useMemo } from 'react';
import { useParams, useNavigate, useLocation } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table';
import { RefreshCw, Search, Loader2, Download, Upload, Plus } from 'lucide-react';
import {
RefreshCw, Search, Loader2, Boxes, Container,
Network, FileText, Shield, Activity, Clock,
Download, FileCode, Pencil, Terminal, HardDrive, Plus, Upload, Trash2, Eye,
} from 'lucide-react';
import { YamlViewer } from './YamlViewer';
import { ResourceVisualEditor } from './ResourceVisualEditor';
import { YamlImportDialog } from './YamlImportDialog';
import { PodExecTerminal } from './PodExecTerminal';
import { PodFileBrowser } from './PodFileBrowser';
import { PodDetailDialog } from './PodDetailDialog';
import {
RESOURCE_CONFIG, VISUAL_RESOURCE_TYPES, DEPLOYMENT_LIST_TYPES,
EXPORT_YAML_TYPES, BULK_EXPORT_TYPES,
type ResourceType,
} from './namespace-resource-config';
import { kubernetesApi } from '@/api';
import { useToast } from '@/hooks/use-toast';
import { useConfirm } from '@/components/ui/confirm-dialog';
@@ -28,6 +27,304 @@ import { PageShell } from '@/components/layout/PageShell';
import { ContentPanel } from '@/components/layout/ContentPanel';
import i18n from '@/i18n';
type ResourceType =
| 'deployments' | 'pods' | 'services' | 'ingresses'
| 'configmaps' | 'secrets' | 'hpas' | 'cronjobs'
| 'pvcs' | 'statefulsets' | 'daemonsets'
| 'serviceaccounts' | 'roles' | 'rolebindings';
// 支持可视化编辑/新建的资源类型 // Logan.Li
const VISUAL_RESOURCE_TYPES: ResourceType[] = ['services', 'ingresses', 'configmaps', 'secrets', 'pvcs'];
/** Deployment 列表:新建 + YAML 导入,不做批量导出 // Logan.Li */
const DEPLOYMENT_LIST_TYPES: ResourceType[] = ['deployments'];
/** 支持命名空间 YAML 批量导出(对齐集群导出)// Logan.Li */
const EXPORT_YAML_TYPES: ResourceType[] = ['services', 'ingresses', 'configmaps', 'secrets'];
/** 列表页保留 CSV/JSON 导出的配置类资源 // Logan.Li */
const BULK_EXPORT_TYPES: ResourceType[] = ['services', 'ingresses', 'configmaps', 'secrets', 'pvcs'];
const VisualActions = ({ name, opts }: { name: string; opts: { onYaml?: (n: string) => void; onEdit?: (n: string) => void } }) => (
<div className="flex items-center gap-0.5">
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-[var(--dg-green)] hover:text-[var(--dg-green)]" title={i18n.t('k8s.resource.visualEditor')}
onClick={() => opts.onEdit?.(name)}><Pencil className="h-3.5 w-3.5 mr-1" /> {i18n.t('common.edit')} </Button>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-secondary)]" title="YAML"
onClick={() => opts.onYaml?.(name)}><FileCode className="h-3.5 w-3.5 mr-1" />YAML</Button>
</div>
);
const RESOURCE_CONFIG: Record<ResourceType, {
title: string;
subtitle: string;
icon: React.ComponentType<any>;
columns: string[];
renderRow: ( item: any, opts: { clusterCode: string; namespace: string; onDetail?: ( item: any) => void; onYaml?: (name: string) => void; onEdit?: (name: string) => void; onTerminal?: ( item: any) => void; onFileBrowser?: ( item: any) => void; onDeletePod?: ( item: any) => void; onPodDetail?: ( item: any) => void }) => React.ReactNode;
}> = {
deployments: {
title: 'Deployments', subtitle: i18n.t('kubernetes.namespaceresour.s35c744fb'), icon: Boxes,
columns: [i18n.t('kubernetes.secret.secretName'), i18n.t('kubernetes.namespace.title'), i18n.t('kubernetes.capacitypagetsx.s0428b36a'), i18n.t('kubernetes.container.image')],
renderRow: (d, opts) => (
<TableRow key={d.name} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/30 cursor-pointer" onClick={() => opts.onDetail?.(d)}>
<TableCell className="font-medium text-[var(--dg-text-primary)]">{d.name}</TableCell>
<TableCell><Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">{d.namespace}</Badge></TableCell>
<TableCell>
{(() => {
const ready = d.ready_replicas || 0; const total = d.replicas || 0;
const pct = total > 0 ? (ready / total) * 100 : 0; const done = ready === total;
return (
<div className="flex items-center gap-2 min-w-[100px]">
<div className="flex-1 h-1.5 rounded-full bg-[var(--dg-bg-tertiary)]/50 overflow-hidden">
<div className={`h-full rounded-full ${done ? 'bg-[var(--dg-green)]' : 'bg-[var(--dg-orange)]'}`} style={{ width: `${pct}%` }} />
</div>
<span className={`text-xs ${done ? 'text-[var(--dg-green)]' : 'text-[var(--dg-orange)]'}`}>{ready}/{total}</span>
</div>
);
})()}
</TableCell>
<TableCell className="text-xs text-[var(--dg-text-muted)] max-w-[200px] truncate">{d.image || '-'}</TableCell>
</TableRow>
),
},
pods: {
title: 'Pods', subtitle: i18n.t('kubernetes.namespaceresour.s780b7dd3'), icon: Container,
columns: [i18n.t('kubernetes.secret.secretName'), i18n.t('kubernetes.namespace.title'), i18n.t('system.terminalStatus'), i18n.t('kubernetes.node.title'), 'CPU', i18n.t('kubernetes.pod.memory'), i18n.t('agent.agentServiceRestart'), i18n.t('kubernetes.container.image'), i18n.t('system.auditAction')],
renderRow: (p, opts) => {
const statusMap: Record<string, string> = { Running: 'bg-[var(--dg-green)]/20 text-[var(--dg-green)] border-[var(--dg-green)]/30', Pending: 'bg-[var(--dg-orange)]/20 text-[var(--dg-orange)] border-[var(--dg-orange)]/30', Failed: 'bg-[var(--dg-red)]/20 text-[var(--dg-red)] border-[var(--dg-red)]/30' };
return (
<TableRow key={p.name} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/30">
<TableCell className="font-medium text-[var(--dg-text-primary)] cursor-pointer hover:text-[var(--dg-blue)]" onClick={() => opts.onPodDetail?.(p)}>
<div className="flex items-center gap-1.5">
<Eye className="h-3.5 w-3.5 text-[var(--dg-text-muted)] shrink-0" />
<span className="truncate">{p.name}</span>
</div>
</TableCell>
<TableCell><Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">{p.namespace}</Badge></TableCell>
<TableCell><Badge className={statusMap[p.status] || 'bg-[var(--dg-text-muted)]/20 text-[var(--dg-text-secondary)]'}>{p.status || 'Unknown'}</Badge></TableCell>
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{p.node_name || '-'}</TableCell>
<TableCell className="text-sm text-[var(--dg-cyan)]">{p.cpu_usage_m || '-'}</TableCell>
<TableCell className="text-sm text-[var(--dg-orange)]">{p.mem_usage_mi || '-'}</TableCell>
<TableCell className={(p.restart_count > 0 ? 'text-[var(--dg-orange)]' : 'text-[var(--dg-text-muted)]')}>{p.restart_count || 0}</TableCell>
<TableCell className="text-xs text-[var(--dg-text-muted)] max-w-[200px] truncate">{p.image || '-'}</TableCell>
<TableCell>
<div className="flex items-center gap-0.5">
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0 text-[var(--dg-green)]" title={i18n.t('kubernetes.pod.terminal')}
onClick={() => (opts as Record<string, any>).onTerminal?.(p)}><Terminal className="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0 text-[var(--dg-orange)]" title={i18n.t('k8s.pods.action.file')}
onClick={() => (opts as Record<string, any>).onFileBrowser?.(p)}><HardDrive className="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0 text-[var(--dg-blue)]" title="YAML"
onClick={() => opts.onYaml?.(p.name)}><FileCode className="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0 text-[var(--dg-red)] hover:text-[var(--dg-red)]" title={i18n.t('kubernetes.storage.delete2')}
onClick={() => opts.onDeletePod?.(p)}><Trash2 className="h-3.5 w-3.5" /></Button>
</div>
</TableCell>
</TableRow>
);
},
},
services: {
title: 'Services', subtitle: i18n.t('kubernetes.namespaceresour.s5e76b605'), icon: Network,
columns: [i18n.t('kubernetes.secret.secretName'), i18n.t('kubernetes.namespace.title'), i18n.t('system.credentialType'), 'Cluster IP', i18n.t('system.ldapPort'), i18n.t('system.auditAction')],
renderRow: (s, opts) => (
<TableRow key={s.name} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/30">
<TableCell className="font-medium text-[var(--dg-text-primary)]">{s.name}</TableCell>
<TableCell><Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">{s.namespace}</Badge></TableCell>
<TableCell><Badge className="bg-[var(--dg-blue)]/20 text-[var(--dg-blue)] border-[var(--dg-blue)]/30">{s.type || '-'}</Badge></TableCell>
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{s.cluster_ip || '-'}</TableCell>
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{s.ports || '-'}</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}><VisualActions name={s.name} opts={opts} /></TableCell>
</TableRow>
),
},
ingresses: {
title: 'Ingresses', subtitle: i18n.t('kubernetes.namespaceresour.s8d501473'), icon: Network,
columns: [i18n.t('kubernetes.secret.secretName'), i18n.t('kubernetes.namespace.title'), 'Hosts', 'TLS', i18n.t('system.auditAction')],
renderRow: (i, opts) => {
const hosts = i.hosts || [];
return (
<TableRow key={i.name} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/30">
<TableCell className="font-medium text-[var(--dg-text-primary)]">{i.name}</TableCell>
<TableCell><Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">{i.namespace}</Badge></TableCell>
<TableCell className="text-sm text-[var(--dg-text-secondary)] max-w-[250px] truncate">{hosts.length > 0 ? hosts.join(', ') : '-'}</TableCell>
<TableCell><Badge className={i.tls ? 'bg-[var(--dg-green)]/20 text-[var(--dg-green)]' : 'bg-[var(--dg-text-muted)]/20 text-[var(--dg-text-secondary)]'}>{i.tls ? 'HTTPS' : 'HTTP'}</Badge></TableCell>
<TableCell onClick={(e) => e.stopPropagation()}><VisualActions name={i.name} opts={opts} /></TableCell>
</TableRow>
);
},
},
configmaps: {
title: 'ConfigMaps', subtitle: i18n.t('kubernetes.namespaceovervi.s0986a6e7'), icon: FileText,
columns: [i18n.t('kubernetes.secret.secretName'), i18n.t('kubernetes.namespace.title'), i18n.t('kubernetes.namespaceresour.sbd1fa02a'), i18n.t('system.auditAction')],
renderRow: (c, opts) => (
<TableRow key={c.name} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/30">
<TableCell className="font-medium text-[var(--dg-text-primary)]">{c.name}</TableCell>
<TableCell><Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">{c.namespace}</Badge></TableCell>
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{c.key_count || '-'}</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}><VisualActions name={c.name} opts={opts} /></TableCell>
</TableRow>
),
},
secrets: {
title: 'Secrets', subtitle: i18n.t('kubernetes.namespaceresour.sd1424af7'), icon: Shield,
columns: [i18n.t('kubernetes.secret.secretName'), i18n.t('kubernetes.namespace.title'), i18n.t('system.credentialType'), i18n.t('system.auditAction')],
renderRow: (s, opts) => (
<TableRow key={s.name} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/30">
<TableCell className="font-medium text-[var(--dg-text-primary)]">{s.name}</TableCell>
<TableCell><Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">{s.namespace}</Badge></TableCell>
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{s.type || '-'}</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}><VisualActions name={s.name} opts={opts} /></TableCell>
</TableRow>
),
},
hpas: {
title: 'HPAs', subtitle: i18n.t('kubernetes.namespaceresour.s9abc8d19'), icon: Activity,
columns: [i18n.t('kubernetes.secret.secretName'), i18n.t('kubernetes.namespace.title'), i18n.t('monitor.alertEvents.col.target'), i18n.t('k8s.hpaCronHpa.col.minMax'), 'CPU%', 'Mem%'],
renderRow: (h, _opts) => (
<TableRow key={h.name} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/30">
<TableCell className="font-medium text-[var(--dg-text-primary)]">{h.name}</TableCell>
<TableCell><Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">{h.namespace}</Badge></TableCell>
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{h.target_kind}/{h.target_name}</TableCell>
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{h.min_replicas}/{h.max_replicas}</TableCell>
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{h.cpu_percent ?? '-'}%</TableCell>
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{h.mem_percent ?? '-'}%</TableCell>
</TableRow>
),
},
cronjobs: {
title: 'CronJobs', subtitle: i18n.t('automation.cronJobs'), icon: Clock,
columns: [i18n.t('kubernetes.secret.secretName'), i18n.t('kubernetes.namespace.title'), i18n.t('kubernetes.workload.scheduling'), i18n.t('system.terminalStatus'), i18n.t('automation.scriptLastExec')],
renderRow: (cj) => (
<TableRow key={cj.name} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/30">
<TableCell className="font-medium text-[var(--dg-text-primary)]">{cj.name}</TableCell>
<TableCell><Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">{cj.namespace}</Badge></TableCell>
<TableCell className="text-sm font-mono text-[var(--dg-text-secondary)]">{cj.schedule || '-'}</TableCell>
<TableCell><Badge className={cj.suspend ? 'bg-[var(--dg-orange)]/20 text-[var(--dg-orange)]' : 'bg-[var(--dg-green)]/20 text-[var(--dg-green)]'}>{cj.suspend ? i18n.t('kubernetes.deployment.paused') : i18n.t('agent.agentServiceRunning')}</Badge></TableCell>
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{cj.last_schedule || '-'}</TableCell>
</TableRow>
),
},
pvcs: {
title: i18n.t('kubernetes.namespaceresour.s18c90062'), subtitle: 'PersistentVolumeClaim', icon: Boxes,
columns: [i18n.t('kubernetes.secret.secretName'), i18n.t('kubernetes.namespace.title'), i18n.t('system.terminalStatus'), i18n.t('kubernetes.storage.pvcSize'), 'StorageClass', i18n.t('system.auditAction')],
renderRow: (p, opts) => (
<TableRow key={p.name} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/30">
<TableCell className="font-medium text-[var(--dg-text-primary)]">{p.name}</TableCell>
<TableCell><Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">{p.namespace}</Badge></TableCell>
<TableCell><Badge className={p.phase === 'Bound' ? 'bg-[var(--dg-green)]/20 text-[var(--dg-green)]' : 'bg-[var(--dg-orange)]/20 text-[var(--dg-orange)]'}>{p.phase || '-'}</Badge></TableCell>
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{p.size || '-'}</TableCell>
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{p.storage_class || '-'}</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}><VisualActions name={p.name} opts={opts} /></TableCell>
</TableRow>
),
},
statefulsets: {
title: 'StatefulSets', subtitle: i18n.t('kubernetes.namespaceresour.sbfeb44d8'), icon: Boxes,
columns: [i18n.t('kubernetes.secret.secretName'), i18n.t('kubernetes.namespace.title'), i18n.t('kubernetes.capacitypagetsx.s0428b36a'), i18n.t('kubernetes.pod.ready'), i18n.t('kubernetes.container.image')],
renderRow: (s, opts) => (
<TableRow key={s.name} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/30 cursor-pointer" onClick={() => opts.onDetail?.(s)}>
<TableCell className="font-medium text-[var(--dg-text-primary)]">{s.name}</TableCell>
<TableCell><Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">{s.namespace}</Badge></TableCell>
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{s.replicas || 0}</TableCell>
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{s.ready || 0}</TableCell>
<TableCell className="text-xs text-[var(--dg-text-muted)] max-w-[200px] truncate">{s.image || '-'}</TableCell>
</TableRow>
),
},
daemonsets: {
title: 'DaemonSets', subtitle: i18n.t('kubernetes.namespaceresour.se1c074e2'), icon: Boxes,
columns: [i18n.t('kubernetes.secret.secretName'), i18n.t('kubernetes.namespace.title'), i18n.t('monitor.siteProtocol.http.expectedPrefix'), i18n.t('cloud.current'), i18n.t('kubernetes.pod.ready'), i18n.t('kubernetes.container.image')],
renderRow: (d, opts) => (
<TableRow key={d.name} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/30 cursor-pointer" onClick={() => opts.onDetail?.(d)}>
<TableCell className="font-medium text-[var(--dg-text-primary)]">{d.name}</TableCell>
<TableCell><Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">{d.namespace}</Badge></TableCell>
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{d.desired || 0}</TableCell>
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{d.current || 0}</TableCell>
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{d.ready || 0}</TableCell>
<TableCell className="text-xs text-[var(--dg-text-muted)] max-w-[200px] truncate">{d.image || '-'}</TableCell>
</TableRow>
),
},
serviceaccounts: {
title: 'ServiceAccounts', subtitle: i18n.t('kubernetes.namespaceresour.s645c9a3d'), icon: Shield,
columns: [i18n.t('kubernetes.secret.secretName'), i18n.t('kubernetes.namespace.title'), i18n.t('kubernetes.namespaceresour.sbaf7ed70'), i18n.t('kubernetes.namespaceresour.s260c11ec'), i18n.t('system.userCreatedAt'), i18n.t('system.auditAction')],
renderRow: (sa, opts) => (
<TableRow key={sa.name} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/30">
<TableCell className="font-medium text-[var(--dg-text-primary)]">{sa.name}</TableCell>
<TableCell><Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">{sa.namespace}</Badge></TableCell>
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{sa.secret_count ?? 0}</TableCell>
<TableCell className="text-xs text-[var(--dg-text-muted)] max-w-[200px] truncate">
{sa.image_pull_secrets && sa.image_pull_secrets.length > 0 ? sa.image_pull_secrets.join(', ') : '-'}
</TableCell>
<TableCell className="text-xs text-[var(--dg-text-muted)]">{sa.age || '-'}</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="icon" className="h-7 w-7 text-[var(--dg-text-secondary)] hover:text-[var(--dg-blue)]" title={i18n.t('kubernetes.namespaceresour.s4bde086b')}
onClick={() => opts.onYaml?.(sa.name)}><FileCode className="h-3.5 w-3.5" /></Button>
</TableCell>
</TableRow>
),
},
roles: {
title: 'Roles', subtitle: i18n.t('kubernetes.namespaceresour.s8905a680'), icon: Shield,
columns: [i18n.t('kubernetes.secret.secretName'), i18n.t('kubernetes.namespace.title'), i18n.t('kubernetes.namespaceresour.secde032d'), i18n.t('system.apiKeyScope'), i18n.t('system.userCreatedAt'), i18n.t('system.auditAction')],
renderRow: (r, opts) => {
const verbs = r.verbs || [];
const resources = r.resources || [];
const verbsStr = verbs.length > 4 ? verbs.slice(0, 4).join(', ') + ` +${verbs.length - 4}` : verbs.join(', ');
const resStr = resources.length > 4 ? resources.slice(0, 4).join(', ') + ` +${resources.length - 4}` : resources.join(', ');
return (
<TableRow key={r.name} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/30">
<TableCell className="font-medium text-[var(--dg-text-primary)]">{r.name}</TableCell>
<TableCell><Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">{r.namespace}</Badge></TableCell>
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{r.rule_count ?? 0}</TableCell>
<TableCell className="text-xs text-[var(--dg-text-muted)] max-w-[280px]">
<div className="flex flex-col gap-0.5">
<span className="text-[var(--dg-cyan)] truncate" title={verbs.join(', ')}>verbs: {verbsStr || '-'}</span>
<span className="text-[var(--dg-orange)] truncate" title={resources.join(', ')}>resources: {resStr || '-'}</span>
</div>
</TableCell>
<TableCell className="text-xs text-[var(--dg-text-muted)]">{r.age || '-'}</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-0.5">
<Button variant="ghost" size="icon" className="h-7 w-7 text-[var(--dg-text-secondary)] hover:text-[var(--dg-blue)]" title={i18n.t('kubernetes.namespaceresour.s4bde086b')}
onClick={() => opts.onYaml?.(r.name)}><FileCode className="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-[var(--dg-green)] hover:text-[var(--dg-green)]" title={i18n.t('common.edit')}
onClick={() => opts.onEdit?.(r.name)}><Pencil className="h-3.5 w-3.5" /></Button>
</div>
</TableCell>
</TableRow>
);
},
},
rolebindings: {
title: 'RoleBindings', subtitle: i18n.t('kubernetes.namespaceresour.sca10fe5c'), icon: Shield,
columns: [i18n.t('kubernetes.secret.secretName'), i18n.t('kubernetes.namespace.title'), i18n.t('kubernetes.namespaceresour.s227e5e28'), i18n.t('kubernetes.namespaceresour.s2667c7d8'), i18n.t('system.userCreatedAt'), i18n.t('system.auditAction')],
renderRow: (rb, opts) => {
const subjects = rb.subjects || [];
const subjStr = subjects.length > 3 ? subjects.slice(0, 3).join(', ') + ` +${subjects.length - 3}` : subjects.join(', ');
return (
<TableRow key={rb.name} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/30">
<TableCell className="font-medium text-[var(--dg-text-primary)]">{rb.name}</TableCell>
<TableCell><Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">{rb.namespace}</Badge></TableCell>
<TableCell>
<Badge className="bg-[var(--dg-purple)]/20 text-[var(--dg-purple)] border-[var(--dg-purple)]/30">
{rb.role_ref_kind || '-'}/{rb.role_ref_name || '-'}
</Badge>
</TableCell>
<TableCell className="text-xs text-[var(--dg-text-muted)] max-w-[200px] truncate" title={subjects.join(', ')}>
{subjStr || '-'}
</TableCell>
<TableCell className="text-xs text-[var(--dg-text-muted)]">{rb.age || '-'}</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-0.5">
<Button variant="ghost" size="icon" className="h-7 w-7 text-[var(--dg-text-secondary)] hover:text-[var(--dg-blue)]" title={i18n.t('kubernetes.namespaceresour.s4bde086b')}
onClick={() => opts.onYaml?.(rb.name)}><FileCode className="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-[var(--dg-green)] hover:text-[var(--dg-green)]" title={i18n.t('common.edit')}
onClick={() => opts.onEdit?.(rb.name)}><Pencil className="h-3.5 w-3.5" /></Button>
</div>
</TableCell>
</TableRow>
);
},
},
};
export default function NamespaceResourcePage() {
const { clusterCode, namespace } = useParams<{ clusterCode: string; namespace: string }>();
const location = useLocation();
@@ -60,7 +357,7 @@ export default function NamespaceResourcePage() {
const [podDetail, setPodDetail] = useState<any>(null);
useEffect(() => {
kubernetesApi.getClusters().then((res: any) => {
kubernetesApi.getClusters().then(( res: any) => {
const list = res?.list || [];
setClusters(list);
if (!clusterCode && list.length > 0) {
@@ -68,19 +365,17 @@ export default function NamespaceResourcePage() {
navigate(`/kubernetes/${firstCode}/namespace/${namespace || 'default'}/${type}`, { replace: true });
}
}).catch(() => setClusters([]));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!clusterCode) return;
kubernetesApi.getNamespaces(clusterCode).then((res: any) => {
kubernetesApi.getNamespaces(clusterCode).then(( res: any) => {
const nsList: string[] = Array.isArray(res) ? res : (res?.list || res?.data || []);
setNamespaces(nsList);
if (!namespace && nsList.length > 0) {
navigate(`/kubernetes/${clusterCode}/namespace/${nsList[0]}/${type}`, { replace: true });
}
}).catch(() => setNamespaces([]));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [clusterCode]);
const loadData = useCallback(async () => {
@@ -110,10 +405,11 @@ export default function NamespaceResourcePage() {
useEffect(() => { loadData(); }, [loadData]);
const filtered = data.filter((d: any) => (d.name || '').toLowerCase().includes(search.toLowerCase()));
const filtered = data.filter(( d: any) => (d.name || '').toLowerCase().includes(search.toLowerCase()));
const handleDetail = (item: any) => {
const handleDetail = ( item: any) => {
if (type === 'deployments') {
// Kuboard 风格双模式路由(默认 view 模式)
navigate(`/kubernetes/${clusterCode}/namespace/${namespace}/workload/view/Deployment/${item.name}`);
} else if (type === 'statefulsets') {
navigate(`/kubernetes/${clusterCode}/namespace/${namespace}/workload/view/StatefulSet/${item.name}`);
@@ -144,7 +440,7 @@ export default function NamespaceResourcePage() {
if (!clusterCode || !namespace) return;
try {
await kubernetesApi.exportNamespaceResources(clusterCode, namespace);
toast({ title: i18n.t('common.exportComplete'), description: i18n.t('k8s.toast.yamlDownloadStarted', { namespace }) });
toast({ title: i18n.t('common.exportComplete'), description: i18n.t('k8s.capacity.yamlExportStarted', { namespace }) });
} catch (e) {
toast({ title: i18n.t('kubernetes.msg.exportFailed'), description: (e as Error)?.message || String(e), variant: 'destructive' });
}
@@ -167,12 +463,12 @@ export default function NamespaceResourcePage() {
}
};
if (!config) return <div className="text-[var(--dg-text-secondary)] p-8 text-center">{i18n.t('common.unknownResource', { type })}</div>;
if (!config) return <div className="text-[var(--dg-text-secondary)] p-8 text-center">{i18n.t('k8s.capacity.unknownResourceType', { type })}</div>;
const searchToolbar = (
<div className="relative w-56 flex-1 min-w-[200px] max-w-xs">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 dg-text-muted" />
<Input placeholder={i18n.t('kubernetes.namespaceResource.searchPlaceholder')} value={search} onChange={(e) => setSearch(e.target.value)} className="pl-9 h-9 dg-input" />
<Input placeholder={i18n.t('kubernetes.namespaceresour.s64e327ed')} value={search} onChange={(e) => setSearch(e.target.value)} className="pl-9 h-9 dg-input" />
</div>
);
@@ -199,29 +495,31 @@ export default function NamespaceResourcePage() {
<SelectContent className="max-h-[300px]">{namespaces.map((ns) => <SelectItem key={ns} value={ns}>{ns}</SelectItem>)}</SelectContent>
</Select>
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={loadData} disabled={loading}>
<RefreshCw className={`h-4 w-4 mr-1 ${loading ? 'animate-spin' : ''}`} />{i18n.t('common.refresh')}</Button>
<RefreshCw className={`h-4 w-4 mr-1 ${loading ? 'animate-spin' : ''}`} />
{i18n.t('common.refresh')}
</Button>
{BULK_EXPORT_TYPES.includes(type) && (
<>
<Button variant="outline" size="sm" className="dg-btn-secondary text-xs" onClick={handleExportCSV} title={i18n.t('k8s.nsResourcePage.exportCsv')}>
<Button variant="outline" size="sm" className="dg-btn-secondary text-xs" onClick={handleExportCSV} title={i18n.t('kubernetes.namespaceresour.sef28d57c')}>
<Download className="h-3 w-3 mr-1" />CSV
</Button>
<Button variant="outline" size="sm" className="dg-btn-secondary text-xs" onClick={handleExportJSON} title={i18n.t('k8s.nsResourcePage.exportJson')}>
<Button variant="outline" size="sm" className="dg-btn-secondary text-xs" onClick={handleExportJSON} title={i18n.t('kubernetes.namespaceresour.se11b48c7')}>
<Download className="h-3 w-3 mr-1" />JSON
</Button>
</>
)}
{EXPORT_YAML_TYPES.includes(type) && (
<Button variant="outline" size="sm" className="dg-btn-secondary text-xs" onClick={handleExportYaml} title={i18n.t('k8s.nsResourcePage.exportYaml')}>
<Download className="h-3 w-3 mr-1" />{i18n.t("common.exportYaml")}
<Button variant="outline" size="sm" className="dg-btn-secondary text-xs" onClick={handleExportYaml} title={i18n.t('kubernetes.namespaceresour.s2f89924c')}>
<Download className="h-3 w-3 mr-1" />{i18n.t('k8s.capacity.exportYaml')}
</Button>
)}
{(VISUAL_RESOURCE_TYPES.includes(type) || DEPLOYMENT_LIST_TYPES.includes(type)) && (
<>
<Button variant="outline" size="sm" className="dg-btn-secondary text-xs" onClick={() => setShowYamlImport(true)}>
<Upload className="h-3 w-3 mr-1" />{i18n.t("common.import")}
<Upload className="h-3 w-3 mr-1" />{i18n.t('k8s.capacity.importYaml')}
</Button>
<Button size="sm" className="dg-btn-primary text-xs" onClick={handleCreate}>
<Plus className="h-3 w-3 mr-1" />{i18n.t("common.buildNew")}
<Plus className="h-3 w-3 mr-1" />{i18n.t('common.create')}
</Button>
</>
)}
@@ -265,9 +563,9 @@ export default function NamespaceResourcePage() {
{loading ? (
<TableRow><TableCell colSpan={config.columns.length} className="text-center text-[var(--dg-text-muted)] py-12"><Loader2 className="h-5 w-5 animate-spin mx-auto mb-2" /> {i18n.t('monitor.logPanel.logResults.loading')} </TableCell></TableRow>
) : filtered.length === 0 ? (
<TableRow><TableCell colSpan={config.columns.length} className="text-center text-[var(--dg-text-muted)] py-12"><config.icon className="h-8 w-8 mx-auto mb-2 opacity-50" />{i18n.t('common.noData')} {config.title}</TableCell></TableRow>
<TableRow><TableCell colSpan={config.columns.length} className="text-center text-[var(--dg-text-muted)] py-12"><config.icon className="h-8 w-8 mx-auto mb-2 opacity-50" />{i18n.t('k8s.capacity.noResources', { resource: config.title })}</TableCell></TableRow>
) : (
filtered.map((item) => config.renderRow(item, { clusterCode: clusterCode || '', namespace: namespace || '', onDetail: handleDetail, onYaml: handleYaml, onEdit: handleEdit, onTerminal: (p: any) => setTerminalPod(p), onFileBrowser: (p: any) => setFileBrowserPod(p), onDeletePod: handleDeletePod, onPodDetail: (p: any) => setPodDetail(p) }))
filtered.map((item) => config.renderRow(item, { clusterCode: clusterCode || '', namespace: namespace || '', onDetail: handleDetail, onYaml: handleYaml, onEdit: handleEdit, onTerminal: ( p: any) => setTerminalPod(p), onFileBrowser: ( p: any) => setFileBrowserPod(p), onDeletePod: handleDeletePod, onPodDetail: ( p: any) => setPodDetail(p) }))
)}
</TableBody>
</Table>
@@ -280,7 +578,7 @@ export default function NamespaceResourcePage() {
clusterId={clusters[0]?.id || clusters[0]?.code || clusterCode || ''}
namespace={namespace || ''}
podName={terminalPod?.name || ''}
containers={(terminalPod?.containers || []).map((c: any) => c.name).filter(Boolean)}
containers={(terminalPod?.containers || []).map(( c: any) => c.name).filter(Boolean)}
/>
{/* ============ Pod 文件浏览器 ============ */}
<PodFileBrowser
@@ -289,10 +587,131 @@ export default function NamespaceResourcePage() {
clusterCode={clusterCode || ''}
namespace={namespace || ''}
podName={fileBrowserPod?.name || ''}
containers={(fileBrowserPod?.containers || []).map((c: any) => c.name).filter(Boolean)}
containers={(fileBrowserPod?.containers || []).map(( c: any) => c.name).filter(Boolean)}
/>
{/* ============ Pod 详情弹窗 ============ */}
<PodDetailDialog pod={podDetail} onClose={() => setPodDetail(null)} />
{podDetail && (
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center" onClick={() => setPodDetail(null)}>
<div className="bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg w-[680px] max-h-[85vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--dg-border)]">
<h3 className="text-base font-semibold text-[var(--dg-text-primary)] flex items-center gap-2">
<Container className="h-4 w-4 text-[var(--dg-blue)]" />
{i18n.t('k8s.capacity.podDetails')}
</h3>
<button className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]" onClick={() => setPodDetail(null)}>✕</button>
</div>
<div className="p-5 space-y-5">
{/* 基本信息 */}
<div>
<h4 className="text-sm font-medium text-[var(--dg-text-primary)] mb-3"> {i18n.t('k8s.editMode.basic')} </h4>
{(() => {
const p = podDetail;
const labels = p.labels || p.metadata?.labels || {};
const annotations = p.annotations || p.metadata?.annotations || {};
const created = p.creationTimestamp || p.created_at || p.create_time;
const statusMap: Record<string, string> = { Running: 'bg-[var(--dg-green)]/20 text-[var(--dg-green)]', Pending: 'bg-[var(--dg-orange)]/20 text-[var(--dg-orange)]', Failed: 'bg-[var(--dg-red)]/20 text-[var(--dg-red)]' };
const st = p.status || 'Running';
return (
<div className="grid grid-cols-2 gap-x-6 gap-y-2.5">
<div>
<span className="text-xs text-[var(--dg-text-muted)]"> {i18n.t('kubernetes.secret.secretName')} </span>
<p className="text-sm text-[var(--dg-text-primary)] font-mono">{p.name || '-'}</p>
</div>
<div>
<span className="text-xs text-[var(--dg-text-muted)]"> {i18n.t('kubernetes.namespace.title')} </span>
<p className="text-sm text-[var(--dg-text-primary)]">{p.namespace || '-'}</p>
</div>
<div>
<span className="text-xs text-[var(--dg-text-muted)]"> {i18n.t('system.terminalStatus')} </span>
<div><Badge className={statusMap[st] || 'bg-[var(--dg-text-muted)]/20 text-[var(--dg-text-secondary)]'}>{st}</Badge></div>
</div>
<div>
<span className="text-xs text-[var(--dg-text-muted)]"> {i18n.t('system.userCreatedAt')} </span>
<p className="text-sm text-[var(--dg-text-primary)]">{created ? new Date(created).toLocaleString('zh-CN') : '-'}</p>
</div>
<div>
<span className="text-xs text-[var(--dg-text-muted)]"> {i18n.t('kubernetes.node.title')} </span>
<p className="text-sm text-[var(--dg-text-primary)]">{p.node_name || p.node || '-'}</p>
</div>
<div>
<span className="text-xs text-[var(--dg-text-muted)]">Pod IP</span>
<p className="text-sm text-[var(--dg-text-primary)] font-mono">{p.ip || p.pod_ip || '-'}</p>
</div>
<div className="col-span-2">
<span className="text-xs text-[var(--dg-text-muted)]"> {i18n.t('kubernetes.namespace.labels')} </span>
<div className="flex flex-wrap gap-1 mt-0.5">
{Object.keys(labels).length === 0 ? <span className="text-xs text-[var(--dg-text-muted)]">-</span>
: Object.entries(labels).map(([k, v]) => (
<Badge key={k} variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-muted)] text-[10px] font-mono">{k}: {String(v)}</Badge>
))
}
</div>
</div>
<div className="col-span-2">
<span className="text-xs text-[var(--dg-text-muted)]"> {i18n.t('kubernetes.namespace.annotations')} </span>
<div className="flex flex-wrap gap-1 mt-0.5">
{Object.keys(annotations).length === 0 ? <span className="text-xs text-[var(--dg-text-muted)]">-</span>
: Object.entries(annotations).slice(0, 6).map(([k, v]) => (
<Badge key={k} variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-muted)] text-[10px] font-mono max-w-[300px] truncate" title={String(v)}>{k}</Badge>
))
}
</div>
</div>
</div>
);
})()}
</div>
{/* 现状详情(Conditions) */}
<div>
<h4 className="text-sm font-medium text-[var(--dg-text-primary)] mb-3"> {i18n.t('kubernetes.namespaceresour.s1d8d6e2e')} </h4>
{(() => {
const cond: any[] = podDetail.conditions || podDetail.pod_conditions || [];
if (cond.length === 0) return <p className="text-xs text-[var(--dg-text-muted)] py-2"> {i18n.t('aiops.assistantNoData')} </p>;
return (
<Table>
<TableHeader>
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
<TableHead className="text-[var(--dg-text-muted)] text-xs"> {i18n.t('system.credentialType')} </TableHead>
<TableHead className="text-[var(--dg-text-muted)] text-xs"> {i18n.t('system.terminalStatus')} </TableHead>
<TableHead className="text-[var(--dg-text-muted)] text-xs"> {i18n.t('automation.scriptUpdatedAt')} </TableHead>
<TableHead className="text-[var(--dg-text-muted)] text-xs"> {i18n.t('kubernetes.event.eventReason')} </TableHead>
<TableHead className="text-[var(--dg-text-muted)] text-xs"> {i18n.t('kubernetes.event.eventMessage')} </TableHead>
</TableRow>
</TableHeader>
<TableBody>
{cond.map((c: any, idx: number) => {
const type = c.type || c.Type || '-';
const st = c.status || c.Status || '-';
const ts = c.lastTransitionTime || c.LastTransitionTime || '';
const reason = c.reason || c.Reason || '-';
const msg = c.message || c.Message || '-';
const isTrue = st === 'True';
const isFalse = st === 'False';
return (
<TableRow key={idx} className="border-[var(--dg-border)]">
<TableCell className="text-xs text-[var(--dg-text-primary)] font-medium">{type}</TableCell>
<TableCell>
<Badge className={isTrue ? 'bg-[var(--dg-green)]/15 text-[var(--dg-green)] border-[var(--dg-green)]/25' : isFalse ? 'bg-[var(--dg-red)]/15 text-[var(--dg-red)] border-[var(--dg-red)]/25' : 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]'}>{st}</Badge>
</TableCell>
<TableCell className="text-xs text-[var(--dg-text-muted)] whitespace-nowrap">{ts ? new Date(ts).toLocaleString('zh-CN') : '-'}</TableCell>
<TableCell className="text-xs text-[var(--dg-text-primary)]">{reason}</TableCell>
<TableCell className="text-xs text-[var(--dg-text-muted)] max-w-[200px] truncate" title={msg}>{msg}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
);
})()}
</div>
</div>
<div className="flex justify-end px-5 py-3 border-t border-[var(--dg-border)]">
<Button variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]" onClick={() => setPodDetail(null)}> {i18n.t('agent.portClosed')} </Button>
</div>
</div>
</div>
)}
</PageShell>
);
}
@@ -1,7 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@@ -23,9 +22,9 @@ import {
import { kubernetesApi } from '@/api';
import { PageShell } from '@/components/layout/PageShell';
import i18n from '@/i18n';
export default function NodeDetailPage() {
const { t } = useTranslation();
const { clusterCode, nodeName } = useParams<{ clusterCode: string; nodeName: string }>();
const navigate = useNavigate();
const [data, setData] = useState<any>(null);
@@ -97,20 +96,20 @@ export default function NodeDetailPage() {
return (
<PageShell
variant="default"
title={nodeName || t('k8s.nodeDetail.nodeTitle')}
title={nodeName || i18n.t('kubernetes.nodedetailpaget.sb88b9847')}
subtitle={nodeSubtitle || clusterCode}
icon={<Server className="h-5 w-5" />}
loading={loading && !data}
loadingLabel={t('k8s.nodeDetail.loadingNode')}
loadingLabel={i18n.t('kubernetes.nodedetailpaget.sfdff6194')}
actions={(
<>
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4 mr-1" />
{t('common.back')}
返回
</Button>
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={loadData} disabled={loading}>
<RefreshCw className={`h-4 w-4 mr-1 ${loading ? 'animate-spin' : ''}`} />
{t('common.refresh')}
刷新
</Button>
</>
)}
@@ -130,7 +129,7 @@ export default function NodeDetailPage() {
<Card className="dg-card">
<CardContent className="py-16 text-center dg-text-muted">
<AlertTriangle className="h-8 w-8 mx-auto mb-2 opacity-50" />
{t('k8s.nodeDetail.loadFailed')}
无法加载节点详情
</CardContent>
</Card>
) : data ? (
@@ -141,11 +140,11 @@ export default function NodeDetailPage() {
<Card className="dg-card">
<CardContent className="p-4">
<h2 className="text-sm font-semibold text-[var(--dg-text-primary)] mb-3 flex items-center gap-2">
<Info className="h-4 w-4 dg-text-cyan" />{t('k8s.nodeDetail.systemInfo')}
<Info className="h-4 w-4 dg-text-cyan" />系统信息
</h2>
<div className="space-y-2 text-xs">
<div className="flex items-center justify-between py-1.5 border-b border-[var(--dg-border)]/50">
<span className="dg-text-muted"> {t('kubernetes.node.containerRuntime')} </span>
<span className="dg-text-muted"> {i18n.t('kubernetes.node.containerRuntime')} </span>
<span className="text-[var(--dg-text-secondary)] font-mono">{data.container_runtime || '-'}</span>
</div>
<div className="flex items-center justify-between py-1.5 border-b border-[var(--dg-border)]/50">
@@ -153,19 +152,19 @@ export default function NodeDetailPage() {
<span className="text-[var(--dg-text-secondary)] font-mono">{data.kubelet_version || '-'}</span>
</div>
<div className="flex items-center justify-between py-1.5 border-b border-[var(--dg-border)]/50">
<span className="dg-text-muted"> {t('agent.agentOs')} </span>
<span className="dg-text-muted"> {i18n.t('agent.agentOs')} </span>
<span className="text-[var(--dg-text-secondary)]">{data.os_image || '-'}</span>
</div>
<div className="flex items-center justify-between py-1.5 border-b border-[var(--dg-border)]/50">
<span className="dg-text-muted"> {t('kubernetes.node.kernelVersion')} </span>
<span className="dg-text-muted"> {i18n.t('kubernetes.node.kernelVersion')} </span>
<span className="text-[var(--dg-text-secondary)] font-mono">{data.kernel_version || '-'}</span>
</div>
<div className="flex items-center justify-between py-1.5 border-b border-[var(--dg-border)]/50">
<span className="dg-text-muted"> {t('agent.agentArch')} </span>
<span className="dg-text-muted"> {i18n.t('agent.agentArch')} </span>
<span className="text-[var(--dg-text-secondary)] font-mono">{data.architecture || '-'}</span>
</div>
<div className="flex items-center justify-between py-1.5 border-b border-[var(--dg-border)]/50">
<span className="dg-text-muted"> {t('system.userCreatedAt')} </span>
<span className="dg-text-muted"> {i18n.t('system.userCreatedAt')} </span>
<span className="text-[var(--dg-text-secondary)]">{data.creation_time ? new Date(data.creation_time).toLocaleString('zh-CN') : '-'}</span>
</div>
<div className="flex items-center justify-between py-1.5">
@@ -180,14 +179,14 @@ export default function NodeDetailPage() {
<Card className="dg-card">
<CardContent className="p-4">
<h2 className="text-sm font-semibold text-[var(--dg-text-primary)] mb-3 flex items-center gap-2">
<Activity className="h-4 w-4 dg-text-green" />{t('k8s.nodeDetail.resourceAllocation')}
<Activity className="h-4 w-4 dg-text-green" />资源分配率
</h2>
<div className="space-y-4 mt-4">
{/* CPU allocation (requested vs allocatable) */}
<ResourceBar
label={t('k8s.nodeDetail.cpuAllocated')}
used={`${(data.cpu_request_m / 1000).toFixed(2)} ${t('k8s.nodeDetail.coreUnit')}`}
total={`${data.cpu_allocatable} ${t('k8s.nodeDetail.coreUnit')}`}
label={i18n.t('kubernetes.nodedetailpaget.s7c390016')}
used={`${(data.cpu_request_m / 1000).toFixed(2)} 核`}
total={`${data.cpu_allocatable} 核`}
unit=""
pct={data.cpu_alloc_pct}
color="dg-text-green"
@@ -195,9 +194,9 @@ export default function NodeDetailPage() {
{/* CPU real usage (from metrics) */}
{data.cpu_usage_m && (
<ResourceBar
label={t('k8s.nodeDetail.cpuRealtimeUsage')}
label={i18n.t('kubernetes.nodedetailpaget.s1ce99635')}
used={data.cpu_usage_m}
total={`${data.cpu_capacity} ${t('k8s.nodeDetail.coreUnit')}`}
total={`${data.cpu_capacity} 核`}
unit=""
pct={(parseFloat(data.cpu_usage_m.replace('m', '')) / (data.cpu_capacity * 1000)) * 100}
color="dg-text-blue"
@@ -205,7 +204,7 @@ export default function NodeDetailPage() {
)}
{/* Memory allocation */}
<ResourceBar
label={t('k8s.nodeDetail.memAllocated')}
label={i18n.t('kubernetes.nodedetailpaget.sccf1cf7a')}
used={`${data.mem_request_mi} Mi`}
total={`${data.mem_allocatable_mb} Mi`}
unit=""
@@ -215,7 +214,7 @@ export default function NodeDetailPage() {
{/* Memory real usage */}
{data.mem_usage_mi && (
<ResourceBar
label={t('k8s.nodeDetail.memRealtimeUsage')}
label={i18n.t('kubernetes.nodedetailpaget.s841b8a96')}
used={data.mem_usage_mi}
total={`${data.mem_capacity_mb} Mi`}
unit=""
@@ -225,7 +224,7 @@ export default function NodeDetailPage() {
)}
{/* Pod allocation */}
<ResourceBar
label={t('k8s.nodeDetail.podCount')}
label={i18n.t('kubernetes.nodedetailpaget.s55edf286')}
used={`${data.pod_count}`}
total={`${data.pod_allocatable}`}
unit=""
@@ -236,15 +235,15 @@ export default function NodeDetailPage() {
{/* Capacity vs Allocatable raw */}
<div className="mt-4 pt-3 border-t border-[var(--dg-border)]/50 space-y-1 text-[11px] dg-text-muted">
<div className="flex justify-between">
<span>{t('k8s.nodeDetail.cpuCapacityAlloc')}</span>
<span className="font-mono">{data.cpu_capacity} / {data.cpu_allocatable} {t('k8s.nodeDetail.coreUnit')}</span>
<span>CPU 容量 / 可分配</span>
<span className="font-mono">{data.cpu_capacity} / {data.cpu_allocatable} 核</span>
</div>
<div className="flex justify-between">
<span> {t('k8s.nodeDetail.memCapacityAlloc')} </span>
<span> {i18n.t('kubernetes.nodedetailpaget.s5038523b')} </span>
<span className="font-mono">{data.mem_capacity_mb} / {data.mem_allocatable_mb} Mi</span>
</div>
<div className="flex justify-between">
<span>{t('k8s.nodeDetail.podCapacityAlloc')}</span>
<span>Pod 容量 / 可分配</span>
<span className="font-mono">{data.pod_capacity} / {data.pod_allocatable}</span>
</div>
</div>
@@ -258,10 +257,10 @@ export default function NodeDetailPage() {
<Card className="dg-card">
<CardContent className="p-4">
<h2 className="text-sm font-semibold text-[var(--dg-text-primary)] mb-3 flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 dg-text-blue" />{t('k8s.nodeDetail.conditions')}
<CheckCircle2 className="h-4 w-4 dg-text-blue" />节点条件
</h2>
{(!data.conditions || data.conditions.length === 0) ? (
<p className="text-xs dg-text-muted py-4 text-center"> {t('k8s.nodeDetail.noConditions')} </p>
<p className="text-xs dg-text-muted py-4 text-center"> {i18n.t('kubernetes.nodedetailpaget.s0f03f9da')} </p>
) : (
<div className="space-y-2">
{data.conditions.map(( cond: any, i: number) => (
@@ -285,17 +284,17 @@ export default function NodeDetailPage() {
<Card className="dg-card">
<CardContent className="p-4">
<h2 className="text-sm font-semibold text-[var(--dg-text-primary)] mb-3 flex items-center gap-2">
<AlertTriangle className="h-4 w-4 dg-text-yellow" />{t('k8s.nodeDetail.taints')}
<AlertTriangle className="h-4 w-4 dg-text-yellow" />污点 (Taints)
</h2>
{(!data.taints || data.taints.length === 0) ? (
<p className="text-xs dg-text-muted py-4 text-center"> {t('kubernetes.node.noTaint')} </p>
<p className="text-xs dg-text-muted py-4 text-center"> {i18n.t('kubernetes.node.noTaint')} </p>
) : (
<div className="space-y-2">
{data.taints.map(( tnt: any, i: number) => (
{data.taints.map(( t: any, i: number) => (
<div key={i} className="flex items-center gap-2 text-xs">
<Badge className={taintColor(tnt.effect)}>{tnt.effect}</Badge>
<span className="text-[var(--dg-text-secondary)] font-mono">{tnt.key}</span>
{tnt.value && <span className="dg-text-muted">= {tnt.value}</span>}
<Badge className={taintColor(t.effect)}>{t.effect}</Badge>
<span className="text-[var(--dg-text-secondary)] font-mono">{t.key}</span>
{t.value && <span className="dg-text-muted">= {t.value}</span>}
</div>
))}
</div>
@@ -309,7 +308,7 @@ export default function NodeDetailPage() {
<Card className="dg-card">
<CardContent className="p-4">
<h2 className="text-sm font-semibold text-[var(--dg-text-primary)] mb-3 flex items-center gap-2">
<Network className="h-4 w-4 dg-text-secondary" />{t('k8s.nodeDetail.labels', { count: data.labels.length })}
<Network className="h-4 w-4 dg-text-secondary" />标签 ({data.labels.length})
</h2>
<div className="flex flex-wrap gap-1.5">
{data.labels.map(( l: any, i: number) => (
@@ -330,23 +329,23 @@ export default function NodeDetailPage() {
<div className="px-4 py-3 border-b border-[var(--dg-border)]">
<h2 className="text-sm font-semibold text-[var(--dg-text-primary)] flex items-center gap-2">
<Container className="h-4 w-4 dg-text-cyan" />
{t('k8s.nodeDetail.podsOnNode')}
节点上的 Pod
<Badge className="bg-[var(--dg-bg-active)]/50 text-[var(--dg-text-secondary)]">{data.pods?.length || 0}</Badge>
</h2>
</div>
{(!data.pods || data.pods.length === 0) ? (
<p className="text-xs dg-text-muted py-8 text-center"> {t('k8s.nodeDetail.noPodsOnNode')} </p>
<p className="text-xs dg-text-muted py-8 text-center"> {i18n.t('kubernetes.nodedetailpaget.s86d276f5')} </p>
) : (
<Table>
<TableHeader>
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
<TableHead className="dg-text-secondary"> {t('kubernetes.namespace.title')} </TableHead>
<TableHead className="dg-text-secondary"> {t('kubernetes.secret.secretName')} </TableHead>
<TableHead className="dg-text-secondary"> {t('system.terminalStatus')} </TableHead>
<TableHead className="dg-text-secondary"> {i18n.t('kubernetes.namespace.title')} </TableHead>
<TableHead className="dg-text-secondary"> {i18n.t('kubernetes.secret.secretName')} </TableHead>
<TableHead className="dg-text-secondary"> {i18n.t('system.terminalStatus')} </TableHead>
<TableHead className="dg-text-secondary">IP</TableHead>
<TableHead className="dg-text-secondary">{t('k8s.nodeDetail.cpuRequest')}</TableHead>
<TableHead className="dg-text-secondary"> {t('kubernetes.container.memoryRequest')} </TableHead>
<TableHead className="dg-text-secondary"> {t('kubernetes.pod.restartCount')} </TableHead>
<TableHead className="dg-text-secondary">CPU 请求</TableHead>
<TableHead className="dg-text-secondary"> {i18n.t('kubernetes.container.memoryRequest')} </TableHead>
<TableHead className="dg-text-secondary"> {i18n.t('kubernetes.pod.restartCount')} </TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -151,7 +151,7 @@ export function PodExecTerminal({
console.error('[PodExec:connect] WS error event fired, readyState=', ws.readyState, 'type=', ev.type);
setConnState('error');
setHasOutput(true);
term.write(`\r\n\x1b[31m${i18n.t('k8s.podLogs.connError')}\x1b[0m\r\n`);
term.write(i18n.t('k8s.podExec.sc8107726'));
};
}, [clusterId, namespace, podName, container, shell]);
@@ -68,7 +68,7 @@ export function PodFileBrowser({
setEntries(list);
setCurrentPath(path);
} catch (e) {
setError((e as JsonData).message || i18n.t('k8s.podFile.loadFailed'));
setError((e as JsonData).message || i18n.t('kubernetes.podfilebrowsert.s78d4a7c6'));
setEntries([]);
} finally {
setLoading(false);
@@ -101,7 +101,6 @@ export function PodFileBrowser({
} else {
workdirDetected.current = false;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, container]);
const handleClick = (entry: FileEntry) => {
@@ -119,7 +118,7 @@ export function PodFileBrowser({
const text = await kubernetesApi.readPodFileText(clusterCode, namespace, podName, filePath, container || undefined);
setPreview({ file: fileName, content: text });
} catch (e) {
setPreview({ file: fileName, content: `${i18n.t('k8s.podFiles.readError')}: ${(e as JsonData).message}` });
setPreview({ file: fileName, content: `读取失败: ${(e as JsonData).message}` });
}
};
@@ -137,7 +136,7 @@ export function PodFileBrowser({
await kubernetesApi.uploadPodFile(clusterCode, namespace, podName, currentPath, file, container || undefined);
loadFiles(currentPath);
} catch (err) {
setError(`${i18n.t('k8s.podFiles.uploadError')}: ${(err as JsonData).message}`);
setError(`上传失败: ${(err as JsonData).message}`);
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = '';
@@ -172,7 +171,7 @@ export function PodFileBrowser({
<div className="flex items-center justify-between p-3 border-b border-[var(--dg-border)] shrink-0 gap-3">
<div className="flex items-center gap-2 min-w-0 flex-1">
<HardDrive className="h-4 w-4 shrink-0 dg-text-yellow" />
<span className="text-sm font-semibold text-[var(--dg-text-primary)] truncate" title={podName}>{i18n.t("common.fileLabel")}: {podName}</span>
<span className="text-sm font-semibold text-[var(--dg-text-primary)] truncate" title={podName}>文件: {podName}</span>
<Badge variant="outline" className="border-[var(--dg-border)] dg-text-secondary text-[10px] shrink-0">{namespace}</Badge>
</div>
<div className="flex items-center gap-2">
@@ -211,7 +210,7 @@ export function PodFileBrowser({
<div className="flex items-center gap-2">
<input type="file" ref={fileInputRef} className="hidden" onChange={handleUpload} />
<Button size="sm" variant="outline" className="h-7 border-[var(--dg-border)] text-[var(--dg-text-secondary)] text-xs" onClick={() => fileInputRef.current?.click()} disabled={uploading}>
{uploading ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : <Upload className="h-3 w-3 mr-1" />}{i18n.t('common.upload')}
{uploading ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : <Upload className="h-3 w-3 mr-1" />}上传
</Button>
<Button size="sm" variant="outline" className="h-7 border-[var(--dg-border)] text-[var(--dg-text-secondary)] text-xs" onClick={() => loadFiles(currentPath)} disabled={loading}>
{loading ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
@@ -273,7 +272,7 @@ export function PodFileBrowser({
</div>
<div className="flex-1 overflow-auto p-3">
<pre className="text-xs text-[var(--dg-text-secondary)] font-mono whitespace-pre-wrap break-all bg-[var(--dg-bg-primary)] rounded p-3">
{preview.content || i18n.t('kubernetes.podFileBrowser.emptyFile')}
{preview.content || i18n.t('kubernetes.podfilebrowsert.sf42649da')}
</pre>
</div>
</div>
@@ -5,27 +5,67 @@ import {
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
ScrollText, Maximize2, Minimize2, X, Play, Pause, Download, Search,
RefreshCw, Loader2, Box, History, Clock, Filter, AlertCircle,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { kubernetesApi } from '@/api';
import { useToast } from '@/hooks/use-toast';
import type { JsonData } from '@/types/common';
import i18n from '@/i18n';
import {
type PodLogsViewerProps,
type LogLine,
RING_BUFFER_MAX,
} from './pod-logs-viewer/types'; // types.tsx — 含 highlightKeyword JSX 纯函数
import { LogsHeader } from './pod-logs-viewer/LogsHeader';
import { ControlBar } from './pod-logs-viewer/ControlBar';
import { LogContentArea } from './pod-logs-viewer/LogContentArea';
import { BottomStatusBar } from './pod-logs-viewer/BottomStatusBar';
/**
* 增强版 Pod 日志查看器 — 增强 DeploymentDetail 的日志体验
*
* 功能:容器选择 / 实时跟踪(fetch stream) / 行数限制 / previous 日志 /
* 时间范围 / 关键字过滤(高亮)/ 下载 / 暂停-继续 / 10000 行环形缓冲
* 功能:
* - 容器选择下拉(多容器 Pod)
* - 实时跟踪开关(tail -f 效果,用 fetch stream)
* - 行数限制选择(100 / 500 / 1000 / 全部)
* - previous 容器日志(上一次崩溃的日志)
* - 时间范围筛选(sinceTime)
* - 关键字过滤(前端过滤,高亮)
* - 下载按钮(导出当前日志为文件)
* - 暂停 / 继续(流式时)
* - 10000 行环形缓冲(借鉴 opsx-agent)
*
* @author Logan <admin@attacker.club>
*/
interface PodLogsViewerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
clusterCode: string;
namespace: string;
podName: string;
/** 可选:Pod 容器名列表 */
containers?: string[];
initialContainer?: string;
}
const TAIL_OPTIONS = [
{ value: '100', label: i18n.t('kubernetes.podlogsviewerts.s58b02a7a') },
{ value: '500', label: i18n.t('kubernetes.podlogsviewerts.s9a036801') },
{ value: '1000', label: i18n.t('kubernetes.podlogsviewerts.sf89a330c') },
{ value: '0', label: i18n.t('system.auditAll') },
];
// 环形缓冲大小(借鉴 opsx-agent)
const RING_BUFFER_MAX = 10000;
interface LogLine {
id: number;
text: string;
}
export function PodLogsViewer({
open,
onOpenChange,
@@ -50,7 +90,7 @@ export function PodLogsViewer({
const [paused, setPaused] = useState(false);
const [loading, setLoading] = useState(false);
// 环形缓冲(state 触发渲染,ref 持久存储)
// 环形缓冲(使用 state 触发渲染,ref 持久存储)
const [lines, setLines] = useState<LogLine[]>([]);
const linesRef = useRef<LogLine[]>([]);
const lineIdRef = useRef(0);
@@ -81,6 +121,7 @@ export function PodLogsViewer({
for (const t of newTexts) {
next.push({ id: ++lineIdRef.current, text: t });
}
// 环形缓冲裁剪
if (next.length > RING_BUFFER_MAX) {
linesRef.current = next.slice(next.length - RING_BUFFER_MAX);
} else {
@@ -124,9 +165,8 @@ export function PodLogsViewer({
const arr = typeof raw === 'string' ? raw.split('\n') : (Array.isArray(raw) ? raw : String(raw).split('\n'));
appendLines(arr);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
appendLines([i18n.t('k8s.podLogs.fetchFailed', { msg })]);
toast({ title: i18n.t('k8s.podLogs.loadFailed'), description: msg, variant: 'destructive' });
appendLines([`\x1b[31m[获取日志失败: ${e?.message || String(e)}]\x1b[0m`]);
toast({ title: i18n.t('k8s.podLogs.loadFailed'), description: e?.message || String(e), variant: 'destructive' });
} finally {
setLoading(false);
}
@@ -167,6 +207,7 @@ export function PodLogsViewer({
const flush = () => {
if (!buffer) return;
// 按行切分,保留最后未结束的行
const parts = buffer.split('\n');
buffer = parts.pop() ?? '';
if (parts.length > 0) appendLines(parts);
@@ -175,22 +216,22 @@ export function PodLogsViewer({
while (true) {
const { done, value } = await reader.read();
if (done) break;
// 暂停时累积但不写入缓冲(丢弃,保持实时性);这里改为暂停时不 append
buffer += decoder.decode(value, { stream: true });
if (!pausedRef.current) {
flush();
} else {
// 暂停期间丢弃缓冲内容,避免堆积
buffer = '';
}
}
flush();
appendLines([`\x1b[33m${i18n.t('k8s.podLogs.streamEnded')}\x1b[0m`]);
flush(); // 收尾
appendLines([i18n.t('kubernetes.podlogsviewerts.s4f0e17cd')]);
} catch (e) {
const errName = (e as { name?: string })?.name;
if (errName === 'AbortError') {
appendLines([`\x1b[33m${i18n.t('k8s.podLogs.streamStopped')}\x1b[0m`]);
if (e?.name === 'AbortError') {
appendLines([i18n.t('kubernetes.podlogsviewerts.s1ca9010d')]);
} else {
const msg = e instanceof Error ? e.message : String(e);
appendLines([`\x1b[31m${i18n.t('k8s.podLogs.streamInterrupted', { msg })}\x1b[0m`]);
appendLines([`\x1b[31m[流式连接中断: ${e?.message || String(e)}]\x1b[0m`]);
}
} finally {
setStreaming(false);
@@ -209,15 +250,17 @@ export function PodLogsViewer({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, podName]);
// 参数变化时若正在流则重启
// 参数变化时若正在流则重启;否则只重新拉取(用户手动控制更友好,这里仅在非流时自动刷新)
useEffect(() => {
if (!open) return;
if (streaming) {
// 重启流
startStream();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [container, tailLines, previous, sinceTime]);
// 关闭弹窗清理
const handleClose = useCallback(() => {
stopStream();
onOpenChange(false);
@@ -234,7 +277,6 @@ export function PodLogsViewer({
a.click();
URL.revokeObjectURL(a.href);
toast({ title: i18n.t('monitor.logPanel.exported'), description: filename });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [podName, container, previous, toast]);
// 关键字过滤
@@ -244,6 +286,33 @@ export function PodLogsViewer({
return lines.filter((l) => l.text.toLowerCase().includes(kw));
}, [lines, keyword]);
// 高亮关键字渲染
const renderLine = (line: LogLine) => {
const text = line.text;
if (!keyword.trim()) return <span>{text}</span>;
const kw = keyword.trim();
const lower = text.toLowerCase();
const kwLower = kw.toLowerCase();
const parts: React.ReactNode[] = [];
let idx = 0;
let key = 0;
while (idx < text.length) {
const found = lower.indexOf(kwLower, idx);
if (found === -1) {
parts.push(<span key={key++}>{text.slice(idx)}</span>);
break;
}
if (found > idx) parts.push(<span key={key++}>{text.slice(idx, found)}</span>);
parts.push(
<mark key={key++} className="bg-amber-400/40 text-amber-200 rounded px-0.5">
{text.slice(found, found + kw.length)}
</mark>
);
idx = found + kw.length;
}
return <>{parts}</>;
};
const lineCount = lines.length;
const matchedCount = filteredLines.length;
@@ -261,70 +330,222 @@ export function PodLogsViewer({
)}
style={fullscreen ? { maxWidth: '100vw', width: '100vw', height: '100vh' } : { maxWidth: '1100px', width: '94vw' }}
>
<DialogTitle className="sr-only">{i18n.t('k8s.podLogs.logTitle', { name: podName })}</DialogTitle>
<DialogDescription className="sr-only">{i18n.t('k8s.podLogs.logDesc')}</DialogDescription>
<DialogTitle className="sr-only">日志 — {podName}</DialogTitle>
<DialogDescription className="sr-only">Pod 容器日志查看</DialogDescription>
<LogsHeader
podName={podName}
streaming={streaming}
paused={paused}
loading={loading}
fullscreen={fullscreen}
lineCount={lineCount}
matchedCount={matchedCount}
keyword={keyword}
onToggleFullscreen={() => setFullscreen(!fullscreen)}
onClose={handleClose}
/>
{/* 头部工具栏 */}
<div className="flex items-center justify-between gap-2 px-4 py-2 border-b border-[var(--dg-border)] flex-wrap">
<div className="flex items-center gap-2 text-sm min-w-0">
<div className={cn(
'w-2 h-2 rounded-full shrink-0',
streaming ? (paused ? 'bg-amber-400' : 'bg-emerald-400 animate-pulse') : 'bg-[var(--dg-bg-active)]'
)} />
<ScrollText className="h-4 w-4 dg-text-blue shrink-0" />
<span className="text-[var(--dg-text-primary)] font-medium truncate">日志 — {podName}</span>
{loading && <Loader2 className="h-3 w-3 animate-spin text-[var(--dg-text-muted)]" />}
<span className="text-xs text-[var(--dg-text-muted)]">
{streaming ? (paused ? i18n.t('kubernetes.deployment.paused') : i18n.t('kubernetes.podlogsviewerts.sd9c71f71')) : i18n.t('agent.agentServiceStopped')} · {matchedCount}{keyword ? `/${lineCount}` : ''} 行
{lineCount >= RING_BUFFER_MAX && i18n.t('kubernetes.podlogsviewerts.sf3280b73')}
</span>
</div>
<ControlBar
containers={containers}
container={container}
onContainerChange={setContainer}
tailLines={tailLines}
onTailLinesChange={setTailLines}
previous={previous}
onPreviousToggle={() => setPrevious(!previous)}
sinceTime={sinceTime}
onSinceTimeChange={setSinceTime}
keyword={keyword}
onKeywordChange={setKeyword}
streaming={streaming}
paused={paused}
loading={loading}
filteredLineCount={filteredLines.length}
onDownload={handleDownload}
onRefresh={fetchLogs}
onStartStream={startStream}
onStopStream={stopStream}
onPauseToggle={() => setPaused(!paused)}
/>
<div className="flex items-center gap-1">
<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')}
>
{fullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</button>
<button
onClick={handleClose}
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
title={i18n.t('agent.portClosed')}
>
<X className="h-4 w-4" />
</button>
</div>
</div>
<LogContentArea
filteredLines={filteredLines}
lineCount={lineCount}
loading={loading}
keyword={keyword}
fullscreen={fullscreen}
scrollRef={scrollRef}
autoScrollRef={autoScrollRef}
onScrollAt={(atBottom) => setAutoScroll(atBottom)}
/>
{/* 控制栏 */}
<div className="flex items-center gap-2 px-4 py-2 border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] flex-wrap">
{/* 容器 */}
{containers.length > 0 && (
<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')} />
</SelectTrigger>
<SelectContent>
{containers.map((c) => (
<SelectItem key={c} value={c}>{c}</SelectItem>
))}
</SelectContent>
</Select>
)}
<BottomStatusBar
lineCount={lineCount}
matchedCount={matchedCount}
keyword={keyword}
container={container}
previous={previous}
autoScroll={autoScroll}
onToggleAutoScroll={() => {
setAutoScroll(!autoScroll);
if (!autoScroll && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
{/* 行数 */}
<Select value={String(tailLines)} onValueChange={(v) => setTailLines(Number(v))}>
<SelectTrigger className="h-8 w-[130px] text-xs bg-[var(--dg-bg-hover)] border-[var(--dg-border)]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TAIL_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
{/* previous */}
<Button
variant={previous ? 'default' : 'outline'}
size="sm"
className={cn('h-8 text-xs', previous ? 'bg-amber-600 hover:bg-amber-700' : 'border-[var(--dg-border)] text-[var(--dg-text-secondary)]')}
onClick={() => setPrevious(!previous)}
title={i18n.t('kubernetes.podlogsviewerts.s76a2e637')}
>
<History className="h-3.5 w-3.5 mr-1" />
previous
</Button>
{/* sinceTime */}
<div className="flex items-center gap-1">
<Clock className="h-3.5 w-3.5 text-[var(--dg-text-muted)]" />
<Input
type="datetime-local"
value={sinceTime ? sinceTime.slice(0, 16) : ''}
onChange={(e) => {
const v = e.target.value;
setSinceTime(v ? new Date(v).toISOString() : '');
}}
className="h-8 w-[170px] text-xs bg-[var(--dg-bg-hover)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
title={i18n.t('kubernetes.podlogsviewerts.s127892fd')}
/>
</div>
<div className="flex-1" />
{/* 关键字过滤 */}
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-[var(--dg-text-muted)]" />
<Input
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder={i18n.t('kubernetes.podlogsviewerts.s7558f175')}
className="h-8 w-[160px] pl-7 text-xs bg-[var(--dg-bg-hover)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
/>
</div>
{/* 下载 */}
<Button
variant="outline" size="sm"
className="h-8 text-xs border-[var(--dg-border)] text-[var(--dg-text-secondary)]"
onClick={handleDownload}
title={i18n.t('kubernetes.podlogsviewerts.s1a023dfb')}
disabled={filteredLines.length === 0}
>
<Download className="h-3.5 w-3.5 mr-1" />导出
</Button>
{/* 刷新(一次性) */}
<Button
variant="outline" size="sm"
className="h-8 text-xs border-[var(--dg-border)] text-[var(--dg-text-secondary)]"
onClick={fetchLogs}
disabled={loading}
title={i18n.t('kubernetes.podlogsviewerts.s930b4e01')}
>
<RefreshCw className={cn('h-3.5 w-3.5 mr-1', loading && 'animate-spin')} />刷新
</Button>
{/* 实时跟踪 / 暂停 / 继续 */}
{!streaming ? (
<Button
size="sm"
className="h-8 text-xs bg-emerald-600 hover:bg-emerald-700"
onClick={startStream}
disabled={loading}
title={i18n.t('kubernetes.podlogsviewerts.s0d519cc0')}
>
<Play className="h-3.5 w-3.5 mr-1" />实时
</Button>
) : (
<>
<Button
variant="outline" size="sm"
className={cn('h-8 text-xs', paused ? 'border-emerald-500 dg-text-green' : 'border-amber-500 dg-text-yellow')}
onClick={() => setPaused(!paused)}
title={paused ? i18n.t('common.continue') : i18n.t('kubernetes.job.suspend')}
>
{paused ? <Play className="h-3.5 w-3.5 mr-1" /> : <Pause className="h-3.5 w-3.5 mr-1" />}
{paused ? i18n.t('common.continue') : i18n.t('kubernetes.job.suspend')}
</Button>
<Button
variant="outline" size="sm"
className="h-8 text-xs border-red-500 dg-text-red"
onClick={stopStream}
title={i18n.t('agent.agentServiceStop')}
>
<Pause className="h-3.5 w-3.5 mr-1" />停止
</Button>
</>
)}
</div>
{/* 日志内容 */}
<div
ref={scrollRef}
onScroll={(e) => {
const el = e.currentTarget;
// 用户主动向上滚时关闭自动跟随
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
if (!atBottom && autoScrollRef.current) setAutoScroll(false);
if (atBottom && !autoScrollRef.current) setAutoScroll(true);
}}
/>
className={cn(
'overflow-auto bg-[#0f172a] font-mono text-xs leading-relaxed p-3',
'text-[var(--dg-text-secondary)]'
)}
style={{ height: fullscreen ? 'calc(100vh - 98px)' : '520px' }}
>
{lineCount === 0 && !loading && (
<div className="flex flex-col items-center justify-center h-full text-[var(--dg-text-muted)]">
<AlertCircle className="h-8 w-8 mb-2 opacity-50" />
<span> {i18n.t('pipeline.noLog')} </span>
</div>
)}
{loading && lineCount === 0 && (
<div className="flex items-center justify-center h-full text-[var(--dg-text-muted)]">
<Loader2 className="h-4 w-4 animate-spin mr-2" />加载中…
</div>
)}
{filteredLines.map((line) => (
<div key={line.id} className="whitespace-pre-wrap break-all hover:bg-white/5 px-1">
{renderLine(line)}
</div>
))}
</div>
{/* 底部状态条 */}
<div className="flex items-center justify-between px-4 py-1.5 border-t border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] text-xs text-[var(--dg-text-muted)]">
<div className="flex items-center gap-3">
<span className="flex items-center gap-1"><Filter className="h-3 w-3" />缓冲 {lineCount}/{RING_BUFFER_MAX}</span>
{keyword && <span>匹配 {matchedCount}</span>}
{container && <span className="flex items-center gap-1"><Box className="h-3 w-3" />{container}</span>}
{previous && <span className="dg-text-yellow flex items-center gap-1"><History className="h-3 w-3" />previous</span>}
</div>
<button
className={cn('flex items-center gap-1', autoScroll ? 'dg-text-green' : 'text-[var(--dg-text-muted)]')}
onClick={() => {
setAutoScroll(!autoScroll);
if (!autoScroll && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}}
>
<Maximize2 className="h-3 w-3" />{autoScroll ? i18n.t('kubernetes.podlogsviewerts.sd7eb1e98') : i18n.t('kubernetes.podlogsviewerts.s130db316')}
</button>
</div>
</DialogContent>
</Dialog>
);
@@ -36,7 +36,6 @@ export default function PodMetricsPage() {
navigate(`/kubernetes/${code}/metrics/pods`, { replace: true });
}
}).catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
@@ -88,12 +87,12 @@ export default function PodMetricsPage() {
<PageShell
variant="list"
title={i18n.t('k8s.podMetrics.resources2')}
subtitle={clusterName ? `${clusterName} · ${i18n.t('k8s.podMetrics.realtimeMetrics')}` : i18n.t('k8s.podMetrics.container2')}
subtitle={clusterName ? `${clusterName} · 容器 CPU/内存实时指标` : i18n.t('k8s.podMetrics.container2')}
icon={<Activity className="h-5 w-5" />}
metrics={!loading ? (
<StatStrip
items={[
{ label: i18n.t('k8s.clusterOverview.podCount'), value: metrics.length, color: 'muted' },
{ label: i18n.t('k8s.podMetrics.sa2e8040e'), value: metrics.length, color: 'muted' },
{ label: i18n.t('k8s.podMetrics.cpu'), value: `${totalCPU}m`, color: 'blue' },
{ label: i18n.t('k8s.podMetrics.memory'), value: `${totalMem.toFixed(0)}Mi`, color: 'green' },
]}
@@ -110,12 +109,14 @@ export default function PodMetricsPage() {
<Select value={selectedNs} onValueChange={setSelectedNs}>
<SelectTrigger className="w-36 h-9 dg-input"><SelectValue placeholder={i18n.t('k8s.podMetrics.namespace')} /></SelectTrigger>
<SelectContent>
<SelectItem value="all">{i18n.t('k8s.podMetrics.allNamespaces')}</SelectItem>
<SelectItem value="all">所有命名空间</SelectItem>
{namespaces.map(n => <SelectItem key={n} value={n}>{n}</SelectItem>)}
</SelectContent>
</Select>
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={loadMetrics} disabled={loading}>
<RefreshCw className={`h-4 w-4 mr-1 ${loading ? 'animate-spin' : ''}`} />{i18n.t('common.refresh')}</Button>
<RefreshCw className={`h-4 w-4 mr-1 ${loading ? 'animate-spin' : ''}`} />
刷新
</Button>
</>
)}
>
@@ -123,14 +124,14 @@ export default function PodMetricsPage() {
<Table>
<TableHeader><TableRow className="border-[var(--dg-border)] hover:bg-transparent">
<TableHead className="dg-text-secondary">Pod</TableHead>
<TableHead className="dg-text-secondary">{i18n.t("common.namespaceLabel")}</TableHead>
<TableHead className="dg-text-secondary">{i18n.t("common.containerLabel")}</TableHead>
<TableHead className="dg-text-secondary">命名空间</TableHead>
<TableHead className="dg-text-secondary">容器</TableHead>
<TableHead className="dg-text-secondary"><Cpu className="h-3 w-3 inline mr-1" />CPU</TableHead>
<TableHead className="dg-text-secondary"><HardDrive className="h-3 w-3 inline mr-1" />Memory</TableHead>
</TableRow></TableHeader>
<TableBody>
{rows.length === 0 ? (
<TableRow><TableCell colSpan={5} className="text-center dg-text-muted py-8">{i18n.t('common.noData')}</TableCell></TableRow>
<TableRow><TableCell colSpan={5} className="text-center dg-text-muted py-8">{i18n.t('k8s.podMetrics.s934752c9')}</TableCell></TableRow>
) : rows.map((r, i) => (
<TableRow key={`${r.pod}-${r.container}-${i}`} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]">
<TableCell className="font-medium text-[var(--dg-text-primary)] text-sm">{r.pod}</TableCell>
@@ -2,9 +2,13 @@
// ResourceVisualEditor — ConfigMap/Secret/Service/Ingress/PVC 可视化编辑 // Logan.Li
import { useState, useEffect, useCallback } from 'react';
import type { ComponentType } from 'react';
import { X, Loader2, FileText, Network, Shield, Database, ChevronRight} from 'lucide-react';
import { X, Save, Loader2, Plus, Trash2, FileText, Network, Shield, Database, ChevronRight} from 'lucide-react';
import { Button } from '@/components/ui/button';
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 { kubernetesApi } from '@/api';
import { createEmptyManifest } from './resource-create-templates';
@@ -14,11 +18,6 @@ import { YamlCodeMirror } from './YamlCodeMirror';
import { getYamlResourceConfig, yamlDownloadFilename } from './k8s-yaml-resource-config';
import { downloadYamlFile } from '@/utils/export';
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 {
clusterCode: string;
@@ -68,12 +67,11 @@ export function ResourceVisualEditor({ clusterCode, namespace, resource, name, o
setManifest(parsed);
if (!parsed) {
setMode('yaml');
toast({ title: i18n.t('k8s.rve.visualUnavailable'), description: i18n.t('k8s.rve.fallbackToYaml') });
toast({ title: i18n.t('kubernetes.resourcevisuale.s80ca4f8e'), description: i18n.t('kubernetes.resourcevisuale.scc94aa41') });
}
} catch (e) {
toast({ title: i18n.t('kubernetes.msg.loadFailed'), description: e instanceof Error ? e.message : String(e), variant: 'destructive' });
} finally { setLoading(false); }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [clusterCode, namespace, resource, name, isCreate]);
useEffect(() => { loadData(); }, [loadData]);
@@ -92,7 +90,7 @@ export function ResourceVisualEditor({ clusterCode, namespace, resource, name, o
toast({ title: i18n.t('monitor.dashboard.custom.toastCreateSuccess'), description: `${resource}/${resName}` });
} else {
await kubernetesApi.updateResourceYaml(clusterCode, namespace, resource, name, yamlStr);
toast({ title: i18n.t('kubernetes.msg.saveSuccess'), description: i18n.t('k8s.toast.resourceUpdated', { resource, name }) });
toast({ title: i18n.t('kubernetes.msg.saveSuccess'), description: `${resource}/${name} 已更新` });
}
onSaved?.();
onClose();
@@ -107,10 +105,10 @@ export function ResourceVisualEditor({ clusterCode, namespace, resource, name, o
const yamlStr = await prepareYamlForSave(editYaml);
if (isCreate) {
await kubernetesApi.applyYAML(clusterCode, yamlStr);
toast({ title: i18n.t('monitor.dashboard.custom.toastCreateSuccess'), description: i18n.t('k8s.rve.saveFailed') });
toast({ title: i18n.t('monitor.dashboard.custom.toastCreateSuccess'), description: i18n.t('kubernetes.resourcevisuale.se5ff4ba7') });
} else {
await kubernetesApi.updateResourceYaml(clusterCode, namespace, resource, name, yamlStr);
toast({ title: i18n.t('kubernetes.msg.saveSuccess'), description: i18n.t('k8s.toast.resourceUpdated', { resource, name }) });
toast({ title: i18n.t('kubernetes.msg.saveSuccess'), description: `${resource}/${name} 已更新` });
}
setYamlEditing(false);
setDisplayYaml(yamlStr);
@@ -127,7 +125,7 @@ export function ResourceVisualEditor({ clusterCode, namespace, resource, name, o
const switchToVisual = async () => {
const parsed = await parseManifest(editYaml);
if (!parsed) {
toast({ title: i18n.t('k8s.rve.parseYamlFailed'), description: i18n.t('k8s.rve.checkSyntax'), variant: 'destructive' });
toast({ title: i18n.t('kubernetes.resourcevisuale.sf53bdf65'), description: i18n.t('kubernetes.resourcevisuale.sf2219a47'), variant: 'destructive' });
return;
}
setManifest(parsed);
@@ -156,7 +154,7 @@ export function ResourceVisualEditor({ clusterCode, namespace, resource, name, o
const downloadYamlContent = () => {
const fname = yamlDownloadFilename(resource, namespace, name || manifest?.metadata?.name || 'resource');
downloadYamlFile(fname, currentYamlText());
toast({ title: i18n.t('k8s.rve.downloaded'), description: fname });
toast({ title: i18n.t('kubernetes.yamlviewertsx.s9719fec8'), description: fname });
};
const resCfg = getYamlResourceConfig(resource);
@@ -182,7 +180,7 @@ export function ResourceVisualEditor({ clusterCode, namespace, resource, name, o
<MetaIcon className={`h-5 w-5 shrink-0 ${meta.color}`} />
<div className="min-w-0">
<h2 className="text-lg font-bold text-[var(--dg-text-primary)] truncate">
{isCreate ? i18n.t('common.createNew') + ' ' + meta.title : meta.title + ' ' + i18n.t('common.edit')}
{isCreate ? `新建 ${meta.title}` : `${meta.title} 编辑`}
</h2>
<p
className="text-xs text-[var(--dg-text-muted)] truncate"
@@ -203,7 +201,7 @@ export function ResourceVisualEditor({ clusterCode, namespace, resource, name, o
<button
className={`px-3 py-1.5 text-xs font-medium transition-colors ${mode === 'yaml' ? 'bg-blue-600 text-white' : 'bg-transparent text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-tertiary)]'}`}
onClick={() => { if (mode === 'visual') switchToYaml(); else setMode('yaml'); }}
>YAML {i18n.t('k8s.rve.yamlEditBtn')}</button>
>YAML 编辑</button>
</div>
<Button variant="ghost" size="icon" className="h-8 w-8 text-[var(--dg-text-secondary)]" onClick={onClose}><X className="h-4 w-4" /></Button>
</div>
@@ -262,16 +260,16 @@ export function ResourceVisualEditor({ clusterCode, namespace, resource, name, o
{!['configmaps', 'secrets', 'services', 'ingresses', 'pvcs'].includes(resource) && (
<div className="flex-1 flex items-center justify-center text-center text-[var(--dg-text-muted)]">
<div>
<p> {i18n.t('k8s.rve.hostPlaceholder')} </p>
<Button variant="outline" size="sm" className="mt-4" onClick={() => setMode('yaml')}> {i18n.t('kubernetes.resourceVisualEditor.switchToYaml')} </Button>
<p> {i18n.t('kubernetes.resourcevisuale.sfeb34f55')} </p>
<Button variant="outline" size="sm" className="mt-4" onClick={() => setMode('yaml')}> {i18n.t('kubernetes.resourcevisuale.s54c78d4f')} </Button>
</div>
</div>
)}
</div>
) : (
<div className="text-center py-12 text-[var(--dg-text-muted)]">
<p> {i18n.t('k8s.rve.keyPlaceholder')} </p>
<Button variant="outline" size="sm" className="mt-4" onClick={() => setMode('yaml')}> {i18n.t('kubernetes.resourceVisualEditor.switchToYaml')} </Button>
<p> {i18n.t('kubernetes.resourcevisuale.s8fe0aeb0')} </p>
<Button variant="outline" size="sm" className="mt-4" onClick={() => setMode('yaml')}> {i18n.t('kubernetes.resourcevisuale.s54c78d4f')} </Button>
</div>
)}
</div>
@@ -280,3 +278,603 @@ 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));
}, [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 &nbsp;&nbsp; <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>
);
}
@@ -1,34 +1,38 @@
import { useState, useEffect, useCallback, useRef } from 'react';
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useState, useEffect, useCallback } from 'react';
import { useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import {
Search, RefreshCw, Loader2, Cloud, Save, Plus, Link2, Pencil, RotateCcw,
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from '@/components/ui/dialog';
import { Textarea } from '@/components/ui/textarea';
import {
Search, RefreshCw, Loader2, Cloud, Save, Plus, Trash2, Link2, Pencil, RotateCcw,
} from 'lucide-react';
import { kubernetesApi } from '@/api';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { toast } from 'sonner';
import type { TopologyNode, TopologyEdge } from '@/types';
import {
buildTopology, getNodeIcon, getNodeColor, NODE_TYPE_LABELS, isCustomNode, type TopologyLayoutData,
buildTopology, getNodeIcon, getNodeColor, NODE_TYPE_LABELS, CUSTOM_NODE_TYPES,
isCustomNode, type TopologyLayoutData,
} from './topology-helpers';
import { PageShell } from '@/components/layout/PageShell';
import { StatStrip } from '@/components/ui/stat-strip';
import { TopologyCanvas } from './topology-view/TopologyCanvas';
import { NodeDetailDialog } from './topology-view/NodeDetailDialog';
import { EditNodeDialog } from './topology-view/EditNodeDialog';
import type { ClusterInfo, EditFormState, DragOffset, SvgPoint } from './topology-view/types';
export function TopologyView() {
const { t } = useTranslation();
const { clusterCode } = useParams<{ clusterCode?: string }>();
const _confirm = useConfirm();
const [clusters, setClusters] = useState<ClusterInfo[]>([]);
const [clusters, setClusters] = useState<any[]>([]);
const [selectedClusterId, setSelectedClusterId] = useState<string>('');
const [topology, setTopology] = useState<{ nodes: TopologyNode[]; edges: TopologyEdge[] }>({ nodes: [], edges: [] });
const [loading, setLoading] = useState(false);
@@ -36,9 +40,7 @@ export function TopologyView() {
const [searchQuery, setSearchQuery] = useState('');
const [viewType, setViewType] = useState<string>('all');
const [draggingId, setDraggingId] = useState<string | null>(null);
const [dragOffset, setDragOffset] = useState<DragOffset>({ x: 0, y: 0 });
const svgRef = useRef<SVGSVGElement>(null);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
// 编辑模式状态 // Logan.Li
const [editMode, setEditMode] = useState(false);
const [connectMode, setConnectMode] = useState(false);
@@ -49,17 +51,17 @@ export function TopologyView() {
// 编辑节点 Dialog
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [editingNode, setEditingNode] = useState<TopologyNode | null>(null);
const [editForm, setEditForm] = useState<EditFormState>({ name: '', type: 'database', note: '' });
const [editForm, setEditForm] = useState<{ name: string; type: TopologyNode['type']; note: string }>({ name: '', type: 'database', note: '' });
const loadClusters = useCallback(async () => {
try {
const res = await kubernetesApi.getClusters();
const list = ((res as { list?: ClusterInfo[] })?.list || []) as ClusterInfo[];
const res: Record<string, any> = await kubernetesApi.getClusters();
const list = res?.list || [];
setClusters(list);
// Auto-select from URL param (clusterCode) if provided, otherwise first cluster
if (!selectedClusterId) {
if (clusterCode) {
const matched = list.find((c) => (c.code || String(c.id)) === clusterCode);
const matched = list.find(( c: any) => (c.code || String(c.id)) === clusterCode);
if (matched) setSelectedClusterId(String(matched.id));
} else if (list.length > 0) {
setSelectedClusterId(String(list[0].id));
@@ -73,10 +75,10 @@ export function TopologyView() {
// 加载已保存的布局 // Logan.Li
const loadSavedLayout = useCallback(async (clusterId: string) => {
try {
const res = await kubernetesApi.getTopology(clusterId) as Record<string, unknown>;
const layout = res as Record<string, unknown>;
const res: Record<string, any> = await kubernetesApi.getTopology(clusterId);
const layout = res as Record<string, any>;
if (layout?.layout_data) {
const data = JSON.parse(layout.layout_data as string) as TopologyLayoutData;
const data = JSON.parse(layout.layout_data) as TopologyLayoutData;
setSavedLayout(data);
if (data.customNodes?.length > 0) {
const maxId = Math.max(...data.customNodes.map((n) => {
@@ -95,20 +97,16 @@ export function TopologyView() {
if (!selectedClusterId) return;
setLoading(true);
try {
const toArray = (v: unknown): unknown[] => {
if (Array.isArray(v)) return v;
const r = v as Record<string, unknown>;
return (r?.list || []) as unknown[];
};
const cluster = clusters.find((c) => String(c.id) === selectedClusterId) || {} as ClusterInfo;
const toArray = ( v: any) => (Array.isArray(v) ? v : (v?.list || []));
const cluster = clusters.find((c) => String(c.id) === selectedClusterId) || {};
// 统一使用 Kuboard code 路由获取资源 (ROADMAP A1)
const code = cluster.code || selectedClusterId;
const clusterCode = (cluster as any).code || selectedClusterId;
const [nodesRes, svcRes, depRes, ingRes, podRes] = await Promise.all([
kubernetesApi.getNodes(code).catch(() => []),
kubernetesApi.getServices(code).catch(() => []),
kubernetesApi.getDeployments(code).catch(() => []),
kubernetesApi.getIngresses(code).catch(() => []),
kubernetesApi.getPods(code).catch(() => []),
kubernetesApi.getNodes(clusterCode).catch(() => []),
kubernetesApi.getServices(clusterCode).catch(() => []),
kubernetesApi.getDeployments(clusterCode).catch(() => []),
kubernetesApi.getIngresses(clusterCode).catch(() => []),
kubernetesApi.getPods(clusterCode).catch(() => []),
]);
const built = buildTopology(cluster, toArray(ingRes), toArray(svcRes), toArray(depRes), toArray(nodesRes), toArray(podRes));
@@ -141,8 +139,10 @@ export function TopologyView() {
const visibleIds = new Set(filteredNodes.map((n) => n.id));
// --- 拖拽 ---
const getSvgPoint = (e: React.MouseEvent): SvgPoint => {
const svg = svgRef.current;
const getSvgPoint = (e: React.MouseEvent): { x: number; y: number } => {
// 从事件目标定位 SVG,避免渲染路径闭包读取 ref。 // Logan.Li
const currentTarget = e.currentTarget as SVGElement;
const svg = currentTarget instanceof SVGSVGElement ? currentTarget : currentTarget.ownerSVGElement;
if (!svg) return { x: 0, y: 0 };
const pt = svg.createSVGPoint();
pt.x = e.clientX; pt.y = e.clientY;
@@ -204,8 +204,7 @@ export function TopologyView() {
const handleEditNode = (node: TopologyNode) => {
setEditingNode(node);
const note = (node.data as Record<string, unknown>)?.note as string || '';
setEditForm({ name: node.name, type: node.type, note });
setEditForm({ name: node.name, type: node.type, note: (node.data as Record<string, any>)?.note || '' });
setEditDialogOpen(true);
};
@@ -291,6 +290,87 @@ export function TopologyView() {
const countByType = (type: string) => topology.nodes.filter((n) => n.type === type).length;
// --- 渲染 SVG 拓扑 ---
const renderTopology = () => {
if (topology.nodes.length === 0) return null;
const ys = [60, 180, 320, 480, 640];
const labels = [t('k8s.tab.overview'), 'Ingress', 'Service', 'Deployment', 'Node'];
return (
<svg width="100%" height="100%" viewBox={`0 0 ${canvasW} ${canvasH}`} className="bg-[var(--dg-bg-primary)] rounded-lg" preserveAspectRatio="xMidYMin meet"
onMouseMove={handleMouseMove} onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp}>
<defs>
<pattern id="grid" width="20" height="20" patternUnits="userSpaceOnUse">
<path d="M 20 0 L 0 0 0 20" fill="none" stroke="#1e293b" strokeWidth="0.5" />
</pattern>
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="28" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill={connectMode ? '#f59e0b' : '#475569'} />
</marker>
</defs>
<rect width="100%" height="100%" fill="url(#grid)" />
{labels.map((label, i) => (
<text key={label} x={14} y={ys[i] + 4} fill="#475569" fontSize="11" className="font-medium select-none">{label}</text>
))}
{topology.edges.map((edge) => {
const source = topology.nodes.find((n) => n.id === edge.source);
const target = topology.nodes.find((n) => n.id === edge.target);
if (!source || !target || !visibleIds.has(source.id) || !visibleIds.has(target.id)) return null;
const isCustom = edge.id.startsWith('custom-edge');
return (
<g key={edge.id} className={editMode && isCustom ? 'cursor-pointer' : ''} onClick={() => { if (editMode && isCustom) handleDeleteEdge(edge); }}>
<line x1={source.x} y1={source.y} x2={target.x} y2={target.y}
stroke={isCustom ? '#f59e0b' : '#475569'} strokeWidth={isCustom ? 2 : 1.5}
strokeDasharray={isCustom ? '6 3' : undefined}
markerEnd="url(#arrowhead)" opacity={isCustom ? 0.8 : 0.6} />
{editMode && isCustom && (
<text x={(source.x + target.x) / 2} y={(source.y + target.y) / 2}
fill="#f59e0b" fontSize="10" textAnchor="middle" className="select-none pointer-events-none">✕ {t('k8s.topology.deleteText')}</text>
)}
</g>
);
})}
{filteredNodes.map((node) => {
const isCustom = isCustomNode(node.id);
const isConnectSource = connectSource === node.id;
return (
<g key={node.id} transform={`translate(${node.x}, ${node.y})`}
className={connectMode ? 'cursor-crosshair' : draggingId === node.id ? 'cursor-grabbing' : 'cursor-grab'}
onMouseDown={(e) => handleNodeMouseDown(e, node)}
onClick={() => handleNodeClick(node)}
onDoubleClick={() => { if (editMode) handleEditNode(node); }}>
<circle r="28" fill="#0f172a"
stroke={isConnectSource ? '#f59e0b' : selectedNode?.id === node.id ? '#3b82f6' : isCustom ? '#f59e0b' : '#334155'}
strokeWidth={isConnectSource || selectedNode?.id === node.id ? 3 : 2}
strokeDasharray={isCustom ? '4 2' : undefined}
className="transition-all hover:stroke-blue-500" />
<foreignObject x="-12" y="-12" width="24" height="24">
<div className={`flex items-center justify-center w-full h-full ${getNodeColor(node.type).split(' ')[1]}`}>{getNodeIcon(node.type)}</div>
</foreignObject>
<text y="45" fill={isCustom ? '#f59e0b' : '#94a3b8'} fontSize="12" textAnchor="middle" className="font-medium select-none">
{node.name.length > 20 ? node.name.slice(0, 18) + '…' : node.name}
</text>
</g>
);
})}
</svg>
);
};
const renderNodeDetailFields = (node: TopologyNode) => {
const data = node.data || {};
const entries = Object.entries(data).filter(([k]) => k !== 'kind');
if (entries.length === 0) return null;
return (
<div className="grid grid-cols-2 gap-3">
{entries.map(([key, val]) => (
<div key={key} className="p-2 rounded-lg bg-[var(--dg-bg-tertiary)]/50">
<p className="text-xs dg-text-muted mb-0.5">{key}</p>
<p className="text-sm text-[var(--dg-text-primary)] truncate" title={String(val)}>{val === null || val === undefined ? '—' : String(val)}</p>
</div>
))}
</div>
);
};
const selectedClusterName = clusters.find((c) => String(c.id) === selectedClusterId)?.name;
const filterToolbar = (
<div className="flex items-center gap-3">
@@ -394,28 +474,7 @@ export function TopologyView() {
<Cloud className="h-12 w-12 mb-3 opacity-50" />
<p className="text-sm">{selectedClusterId ? t('k8s.topology.noDataWithCluster') : t('k8s.topology.noDataSelectCluster')}</p>
</div>
) : (
<TopologyCanvas
svgRef={svgRef}
canvasW={canvasW}
canvasH={canvasH}
nodes={topology.nodes}
edges={topology.edges}
filteredNodes={filteredNodes}
visibleIds={visibleIds}
editMode={editMode}
connectMode={connectMode}
connectSource={connectSource}
selectedNode={selectedNode}
draggingId={draggingId}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onNodeMouseDown={handleNodeMouseDown}
onNodeClick={handleNodeClick}
onNodeDoubleClick={(node) => { if (editMode) handleEditNode(node); }}
onEdgeClick={handleDeleteEdge}
/>
)}
) : renderTopology()}
</div>
</CardContent>
</Card>
@@ -448,25 +507,90 @@ export function TopologyView() {
</div>
{/* Node Detail Dialog */}
<NodeDetailDialog
node={selectedNode}
edges={topology.edges}
nodes={topology.nodes}
editMode={editMode}
onOpenChange={() => setSelectedNode(null)}
onEditNode={handleEditNode}
onDeleteNode={handleDeleteNode}
/>
<Dialog open={!!selectedNode} onOpenChange={() => setSelectedNode(null)}>
<DialogContent className="dg-card max-w-lg">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
<div className={`p-2 rounded-lg ${getNodeColor(selectedNode?.type || 'server')}`}>{selectedNode && getNodeIcon(selectedNode.type)}</div>
{selectedNode?.name}
{selectedNode && isCustomNode(selectedNode.id) && <Badge variant="outline" className="border-amber-500/30 dg-text-yellow text-xs">{t('k8s.topology.custom2')}</Badge>}
</DialogTitle>
<DialogDescription className="dg-text-muted">{t('common.type')}: {NODE_TYPE_LABELS[selectedNode?.type || ''] ? t(NODE_TYPE_LABELS[selectedNode?.type || '']) : selectedNode?.type}</DialogDescription>
</DialogHeader>
{selectedNode && (
<div className="space-y-4">
{renderNodeDetailFields(selectedNode)}
<div>
<h4 className="text-sm font-medium text-[var(--dg-text-secondary)] mb-2">{t('k8s.topology.relatedConnections')}</h4>
<div className="space-y-2">
{topology.edges.filter((e) => e.source === selectedNode.id || e.target === selectedNode.id).map((edge) => {
const isSource = edge.source === selectedNode.id;
const related = topology.nodes.find((n) => n.id === (isSource ? edge.target : edge.source));
if (!related) return null;
return (
<div key={edge.id} className="flex items-center gap-2 p-2 rounded-lg bg-[var(--dg-bg-tertiary)]/50">
<span className="text-sm dg-text-secondary">{isSource ? '→' : '←'}</span>
<div className={`p-1 rounded ${getNodeColor(related.type)}`}>{getNodeIcon(related.type, 'h-4 w-4')}</div>
<span className="text-sm text-[var(--dg-text-secondary)]">{related.name}</span>
<Badge variant="outline" className="ml-auto border-[var(--dg-border)] dg-text-secondary text-xs">{edge.label}</Badge>
</div>
);
})}
{topology.edges.filter((e) => e.source === selectedNode.id || e.target === selectedNode.id).length === 0 && <p className="text-xs dg-text-muted">{t('k8s.topology.noConnections')}</p>}
</div>
</div>
{/* 编辑模式操作按钮 */}
{editMode && selectedNode && (
<div className="flex gap-2 pt-2 border-t border-[var(--dg-border)]">
<Button size="sm" variant="outline" className="border-[var(--dg-border-hover)] text-[var(--dg-text-primary)]" onClick={() => handleEditNode(selectedNode)}>
<Pencil className="h-3.5 w-3.5 mr-1" />{t('common.edit')}
</Button>
{isCustomNode(selectedNode.id) && (
<Button size="sm" variant="outline" className="border-red-500/30 dg-text-red hover:bg-red-500/10" onClick={() => handleDeleteNode(selectedNode)}>
<Trash2 className="h-3.5 w-3.5 mr-1" />{t('common.delete')}
</Button>
)}
</div>
)}
</div>
)}
</DialogContent>
</Dialog>
{/* Edit/Add Node Dialog */}
<EditNodeDialog
open={editDialogOpen}
onOpenChange={setEditDialogOpen}
editingNode={editingNode}
editForm={editForm}
setEditForm={setEditForm}
onSave={handleSaveNode}
/>
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
<DialogContent className="dg-card max-w-md">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)]">{editingNode ? t('k8s.topology.editNodeTitle') : t('k8s.topology.addNodeTitle')}</DialogTitle>
<DialogDescription className="dg-text-muted">{editingNode ? t('k8s.topology.editNodeDesc') : t('k8s.topology.addNodeDesc')}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label className="text-[var(--dg-text-secondary)]">{t('k8s.topology.nodeName')}</Label>
<Input value={editForm.name} onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
placeholder={t('k8s.topology.namePlaceholder')} className="dg-input" />
</div>
<div className="space-y-2">
<Label className="text-[var(--dg-text-secondary)]">{t('k8s.topology.nodeType')}</Label>
<Select value={editForm.type} onValueChange={(v) => setEditForm({ ...editForm, type: v as TopologyNode['type'] })}>
<SelectTrigger className="dg-input"><SelectValue /></SelectTrigger>
<SelectContent className="dg-input">
{CUSTOM_NODE_TYPES.map((opt) => <SelectItem key={opt.value} value={opt.value}>{t(opt.label)} — {t(opt.desc)}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-[var(--dg-text-secondary)]">{t('k8s.topology.nodeNote')}</Label>
<Textarea value={editForm.note} onChange={(e) => setEditForm({ ...editForm, note: e.target.value })}
placeholder={t('k8s.topology.notePlaceholder')} rows={2} className="dg-input resize-none" />
</div>
</div>
<DialogFooter>
<Button variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]" onClick={() => setEditDialogOpen(false)}>{t('common.cancel')}</Button>
<Button onClick={handleSaveNode}>{editingNode ? t('common.save') : t('k8s.topology.add2')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</PageShell>
);
}
@@ -10,7 +10,8 @@ import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table';
import {
ArrowLeft, RotateCcw, FileCode, Container, Boxes, Database,
ArrowLeft, RotateCcw, FileCode, Activity, Container, Terminal,
HardDrive, Minus, Plus, Boxes, Tag, Settings, Database,
} from 'lucide-react';
import { kubernetesApi } from '@/api';
import { useToast } from '@/hooks/use-toast';
@@ -18,16 +19,40 @@ import { useConfirm } from '@/components/ui/confirm-dialog';
import { YamlViewer } from './YamlViewer';
import { PodExecTerminal } from './PodExecTerminal';
import { PodFileBrowser } from './PodFileBrowser';
import { formatK8sTime, STATUS_COLORS } from './k8s-yaml-utils';
import { formatK8sTime, statusColor, STATUS_COLORS } from './k8s-yaml-utils';
import { PageShell } from '@/components/layout/PageShell';
import type { JsonData } from '@/types/common';
import i18n from '@/i18n';
import { type WorkloadKind, type ViewMode, parseWorkloadStats } from './workload-view/types';
import { StatCard, ContainerInfo } from './workload-view/shared-components';
import { UpdateStrategySelectorCards } from './workload-view/UpdateStrategySelectorCards';
import { PodInstancesTable } from './workload-view/PodInstancesTable';
import { ScaleDialog } from './workload-view/ScaleDialog';
type WorkloadKind = 'StatefulSet' | 'DaemonSet';
type ViewMode = 'view' | 'yaml';
function StatCard({ label, value, sub, color }: { label: string; value: React.ReactNode; sub?: string; color?: string }) {
return (
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-3">
<p className="text-xs text-[var(--dg-text-muted)]">{label}</p>
<p className="text-base font-bold mt-0.5" style={{ color: color || 'var(--dg-text-primary)' }}>{value}</p>
{sub && <p className="text-[10px] text-[var(--dg-text-muted)] mt-0.5">{sub}</p>}
</CardContent>
</Card>
);
}
// Helper: render key-value pairs as badges
function LabelBadges({ labels }: { labels: Record<string, string> | undefined }) {
const entries = Object.entries(labels || {});
if (entries.length === 0) return <span className="text-xs text-[var(--dg-text-muted)]">无</span>;
return (
<div className="flex flex-wrap gap-1.5">
{entries.map(([k, v]) => (
<Badge key={k} variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] text-[10px] font-mono">
{k}={v}
</Badge>
))}
</div>
);
}
export default function WorkloadViewPage() {
const params = useParams<{ clusterCode: string; namespace: string; name: string; mode?: string }>();
@@ -96,7 +121,7 @@ export default function WorkloadViewPage() {
const handleRestart = async () => {
const ok = await confirm({
title: i18n.t('kubernetes.deployment.restart'),
description: i18n.t('k8s.toast.confirmRestart', { kind, name }),
description: `确认重启 ${kind} ${name}?将触发滚动重启。`,
confirmText: i18n.t('agent.agentServiceRestart'),
});
if (!ok) return;
@@ -107,10 +132,10 @@ export default function WorkloadViewPage() {
} else {
await kubernetesApi.restartDaemonSet(clusterCode, namespace, name);
}
toast({ title: i18n.t('k8s.workloadView.restartSent'), description: i18n.t('k8s.workloadView.restartSentDesc', { kind }) });
toast({ title: i18n.t('kubernetes.deploymentdetai.s68a70009'), description: `${kind} 滚动重启已触发` });
setTimeout(loadData, 1000);
} catch (e) {
toast({ title: i18n.t('kubernetes.deployment.restartFailed'), description: e instanceof Error ? e.message : String(e), variant: 'destructive' });
toast({ title: i18n.t('kubernetes.deployment.restartFailed'), description: e?.message || String(e), variant: 'destructive' });
} finally { setActionLoading(false); }
};
@@ -118,22 +143,51 @@ export default function WorkloadViewPage() {
setActionLoading(true);
try {
await kubernetesApi.scaleStatefulSet(clusterCode, namespace, name, scaleValue);
toast({ title: i18n.t('k8s.workloads.scaleSuccess'), description: i18n.t('k8s.workloads.scaleSuccessDesc', { count: scaleValue }) });
toast({ title: i18n.t('k8s.workloads.scaleSuccess'), description: `StatefulSet 副本数已更新为 ${scaleValue}` });
setScaleModal(false);
setTimeout(loadData, 1000);
} catch (e) {
toast({ title: i18n.t('k8s.workloadView.restartFailed'), description: e instanceof Error ? e.message : String(e), variant: 'destructive' });
toast({ title: i18n.t('kubernetes.workloadviewpag.s0b329fa8'), description: e?.message || String(e), variant: 'destructive' });
} finally { setActionLoading(false); }
};
// Parse status from manifest
const stats = parseWorkloadStats(manifest, kind);
const {
isSts, isHealthy,
replicas, readyReplicas, currentReplicas, updatedReplicas,
desiredScheduled, currentScheduled, numberReady, numberAvailable, numberUpdated,
vcts, containers, initContainers,
} = stats;
const spec = manifest?.spec || {};
const status = manifest?.status || {};
const containers: Record<string, unknown>[] = spec?.template?.spec?.containers || [];
const initContainers: Record<string, unknown>[] = spec?.template?.spec?.initContainers || [];
// For StatefulSet
const replicas = spec.replicas ?? 1;
const readyReplicas = status.readyReplicas ?? 0;
const currentReplicas = status.currentReplicas ?? 0;
const updatedReplicas = status.updatedReplicas ?? 0;
// For DaemonSet
const desiredScheduled = status.desiredNumberScheduled ?? 0;
const currentScheduled = status.currentNumberScheduled ?? 0;
const numberReady = status.numberReady ?? 0;
const numberAvailable = status.numberAvailable ?? 0;
const numberUpdated = status.updatedNumberScheduled ?? 0;
const isSts = kind === 'StatefulSet';
const isHealthy = isSts
? (readyReplicas === replicas && replicas > 0)
: (numberReady === desiredScheduled && desiredScheduled > 0);
// Update strategy
const updateStrategy = spec.updateStrategy || {};
const strategyType = updateStrategy.type || (isSts ? 'RollingUpdate' : 'RollingUpdate');
// VolumeClaimTemplates (StatefulSet only)
const vcts: Record<string, unknown>[] = spec.volumeClaimTemplates || [];
// Selector
const selectorLabels = spec.selector?.matchLabels || {};
// Labels & Annotations
const labels = manifest?.metadata?.labels || {};
const annotations = manifest?.metadata?.annotations || {};
const modeTabs = (
<div className="flex items-center gap-1 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg p-0.5">
@@ -149,20 +203,20 @@ export default function WorkloadViewPage() {
subtitle={`${clusterCode} / ${namespace} · ${kind}`}
icon={<Boxes className="h-5 w-5" />}
loading={loading}
loadingLabel={i18n.t('k8s.workloadView.loading')}
loadingLabel={i18n.t('kubernetes.workloadviewpag.s4eb429cb')}
actions={(
<>
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4 mr-1" />{i18n.t("common.back")}
<ArrowLeft className="h-4 w-4 mr-1" />返回
</Button>
{modeTabs}
{isSts && (
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={() => { setScaleValue(replicas); setScaleModal(true); }}>
<Boxes className="h-3.5 w-3.5 mr-1" />{i18n.t("common.scale")}
<Boxes className="h-3.5 w-3.5 mr-1" />伸缩
</Button>
)}
<Button variant="outline" size="sm" className="dg-btn-secondary" disabled={actionLoading} onClick={handleRestart}>
<RotateCcw className="h-3.5 w-3.5 mr-1" />{actionLoading ? i18n.t('k8s.workloadView.restarting') : i18n.t('agent.agentServiceRestart')}
<RotateCcw className="h-3.5 w-3.5 mr-1" />{actionLoading ? i18n.t('kubernetes.workloadviewpag.s9ac1042c') : i18n.t('agent.agentServiceRestart')}
</Button>
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={() => setYamlModal(true)}>
<FileCode className="h-3.5 w-3.5 mr-1" />YAML
@@ -175,7 +229,7 @@ export default function WorkloadViewPage() {
<Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">{namespace}</Badge>
{isHealthy
? <Badge className="bg-[var(--dg-green)]/20 text-[var(--dg-green)] border-[var(--dg-green)]/30"> {i18n.t('k8s.clusters.statusHealthy')} </Badge>
: <Badge className="bg-[var(--dg-orange)]/20 text-[var(--dg-orange)] border-[var(--dg-orange)]/30"> {i18n.t('kubernetes.workloadViewPage.updating')} </Badge>}
: <Badge className="bg-[var(--dg-orange)]/20 text-[var(--dg-orange)] border-[var(--dg-orange)]/30"> {i18n.t('kubernetes.workloadviewpag.sf339d471')} </Badge>}
</div>
{!loading && (mode === 'yaml' ? (
@@ -198,17 +252,17 @@ export default function WorkloadViewPage() {
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
{isSts ? (
<>
<StatCard label={i18n.t('kubernetes.workloadViewPage.replicasReady')} value={`${readyReplicas}/${replicas}`} color={isHealthy ? STATUS_COLORS.green : STATUS_COLORS.orange} sub={isHealthy ? i18n.t('kubernetes.workloadViewPage.allReady') : i18n.t('kubernetes.workloadViewPage.partialReady')} />
<StatCard label={i18n.t('kubernetes.workloadviewpag.sea639697')} value={`${readyReplicas}/${replicas}`} color={isHealthy ? STATUS_COLORS.green : STATUS_COLORS.orange} sub={isHealthy ? i18n.t('kubernetes.workloadviewpag.s23c944c1') : i18n.t('kubernetes.workloadviewpag.sac100213')} />
<StatCard label={i18n.t('kubernetes.hpa.currentReplicas')} value={currentReplicas} color={STATUS_COLORS.blue} />
<StatCard label={i18n.t('kubernetes.deployment.updatedReplicas')} value={updatedReplicas} color={STATUS_COLORS.green} />
<StatCard label={i18n.t('k8s.workloadView.currentReplicas')} value={stats.spec.serviceName || '-'} color="var(--dg-text-secondary)" />
<StatCard label={i18n.t('k8s.workloadView.currentReplicas')} value={pods.length} color="var(--dg-text-secondary)" />
<StatCard label={i18n.t('kubernetes.workloadviewpag.sf592ef2f')} value={spec.serviceName || '-'} color="var(--dg-text-secondary)" />
<StatCard label={i18n.t('kubernetes.nodedetailpaget.s55edf286')} value={pods.length} color="var(--dg-text-secondary)" />
<StatCard label={i18n.t('system.userCreatedAt')} value={<span className="text-xs">{formatK8sTime(manifest?.metadata?.creationTimestamp)}</span>} color="var(--dg-text-secondary)" />
</>
) : (
<>
<StatCard label={i18n.t('k8s.workloadView.desiredSchedule')} value={desiredScheduled} color={STATUS_COLORS.blue} />
<StatCard label={i18n.t('k8s.workloadView.actualSchedule')} value={currentScheduled} color="var(--dg-text-secondary)" />
<StatCard label={i18n.t('kubernetes.workloadviewpag.sd78bc83e')} value={desiredScheduled} color={STATUS_COLORS.blue} />
<StatCard label={i18n.t('kubernetes.workloadviewpag.sb7616465')} value={currentScheduled} color="var(--dg-text-secondary)" />
<StatCard label={i18n.t('kubernetes.daemonset.numberReady')} value={numberReady} color={isHealthy ? STATUS_COLORS.green : STATUS_COLORS.orange} />
<StatCard label={i18n.t('kubernetes.daemonset.numberAvailable')} value={numberAvailable} color={STATUS_COLORS.green} />
<StatCard label={i18n.t('deploy.toast.updated')} value={numberUpdated} color="var(--dg-text-secondary)" />
@@ -217,15 +271,99 @@ export default function WorkloadViewPage() {
)}
</div>
{/* Update Strategy + Selector & Labels */}
<UpdateStrategySelectorCards stats={stats} />
{/* Update Strategy + Selector */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-[var(--dg-text-primary)] flex items-center gap-2">
<Settings className="h-4 w-4 text-[var(--dg-orange)]" />更新策略
</CardTitle>
</CardHeader>
<CardContent className="pt-0 space-y-2 text-xs">
<div className="flex justify-between">
<span className="text-[var(--dg-text-muted)]"> {i18n.t('kubernetes.workloadviewpag.s0511a271')} </span>
<Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">{strategyType}</Badge>
</div>
{strategyType === 'RollingUpdate' && isSts && updateStrategy.rollingUpdate && (
<>
<div className="flex justify-between">
<span className="text-[var(--dg-text-muted)]"> {i18n.t('kubernetes.workloadviewpag.s45cb9d97')} </span>
<span className="text-[var(--dg-text-secondary)] font-mono">{updateStrategy.rollingUpdate.partition ?? 0}</span>
</div>
{updateStrategy.rollingUpdate.maxUnavailable && (
<div className="flex justify-between">
<span className="text-[var(--dg-text-muted)]"> {i18n.t('kubernetes.daemonset.maxUnavailable')} </span>
<span className="text-[var(--dg-text-secondary)] font-mono">{typeof updateStrategy.rollingUpdate.maxUnavailable === 'object' ? JSON.stringify(updateStrategy.rollingUpdate.maxUnavailable) : updateStrategy.rollingUpdate.maxUnavailable}</span>
</div>
)}
</>
)}
{strategyType === 'RollingUpdate' && !isSts && updateStrategy.rollingUpdate && (
<>
{updateStrategy.rollingUpdate.maxUnavailable && (
<div className="flex justify-between">
<span className="text-[var(--dg-text-muted)]"> {i18n.t('kubernetes.daemonset.maxUnavailable')} </span>
<span className="text-[var(--dg-text-secondary)] font-mono">{typeof updateStrategy.rollingUpdate.maxUnavailable === 'object' ? JSON.stringify(updateStrategy.rollingUpdate.maxUnavailable) : updateStrategy.rollingUpdate.maxUnavailable}</span>
</div>
)}
{updateStrategy.rollingUpdate.maxSurge && (
<div className="flex justify-between">
<span className="text-[var(--dg-text-muted)]"> {i18n.t('kubernetes.workloadviewpag.sfed4ce42')} </span>
<span className="text-[var(--dg-text-secondary)] font-mono">{typeof updateStrategy.rollingUpdate.maxSurge === 'object' ? JSON.stringify(updateStrategy.rollingUpdate.maxSurge) : updateStrategy.rollingUpdate.maxSurge}</span>
</div>
)}
</>
)}
{isSts && (
<div className="flex justify-between">
<span className="text-[var(--dg-text-muted)]">Pod 管理策略</span>
<span className="text-[var(--dg-text-secondary)] font-mono">{spec.podManagementPolicy || 'OrderedReady'}</span>
</div>
)}
<div className="flex justify-between">
<span className="text-[var(--dg-text-muted)]"> {i18n.t('kubernetes.workloadviewpag.s450a4e05')} </span>
<span className="text-[var(--dg-text-secondary)] font-mono">{spec.revisionHistoryLimit ?? 10}</span>
</div>
</CardContent>
</Card>
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-[var(--dg-text-primary)] flex items-center gap-2">
<Tag className="h-4 w-4 text-[var(--dg-purple)]" />选择器与标签
</CardTitle>
</CardHeader>
<CardContent className="pt-0 space-y-3 text-xs">
<div>
<p className="text-[var(--dg-text-muted)] mb-1.5">Selector (matchLabels)</p>
<LabelBadges labels={selectorLabels} />
</div>
<div>
<p className="text-[var(--dg-text-muted)] mb-1.5">Labels</p>
<LabelBadges labels={labels} />
</div>
{Object.keys(annotations).length > 0 && (
<div>
<p className="text-[var(--dg-text-muted)] mb-1.5">Annotations</p>
<div className="flex flex-wrap gap-1.5">
{Object.entries(annotations).slice(0, 8).map(([k, _v]) => (
<Badge key={k} variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-muted)] text-[10px] font-mono max-w-[300px] truncate">
{k}
</Badge>
))}
</div>
</div>
)}
</CardContent>
</Card>
</div>
{/* VolumeClaimTemplates (StatefulSet only) */}
{isSts && vcts.length > 0 && (
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-[var(--dg-text-primary)] flex items-center gap-2">
<Database className="h-4 w-4 text-[var(--dg-cyan)]" />{i18n.t("common.volumeClaimTemplates")}
<Database className="h-4 w-4 text-[var(--dg-cyan)]" />存储卷声明模板 (VolumeClaimTemplates)
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
@@ -262,7 +400,7 @@ export default function WorkloadViewPage() {
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-[var(--dg-text-primary)] flex items-center gap-2">
<Container className="h-4 w-4 text-[var(--dg-blue)]" />{i18n.t("common.containerTemplate")}
<Container className="h-4 w-4 text-[var(--dg-blue)]" />容器模板(Pod Template)
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
@@ -277,7 +415,7 @@ export default function WorkloadViewPage() {
</div>
)}
{containers.length === 0 ? (
<p className="text-xs text-[var(--dg-text-muted)] py-3"> {i18n.t('kubernetes.workloadViewPage.noContainerData')} </p>
<p className="text-xs text-[var(--dg-text-muted)] py-3"> {i18n.t('kubernetes.workloadviewpag.se4b61048')} </p>
) : (
<div className="space-y-3">
<p className="text-[11px] text-[var(--dg-text-muted)] mb-1 uppercase tracking-wide">Containers</p>
@@ -289,24 +427,82 @@ export default function WorkloadViewPage() {
</CardContent>
</Card>
{/* Pod instances */}
<PodInstancesTable
pods={pods}
onTerminal={setTerminalPod}
onFileBrowser={setFileBrowserPod}
/>
{/* Pod list */}
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-[var(--dg-text-primary)] flex items-center gap-2">
<Activity className="h-4 w-4 text-[var(--dg-green)]" />Pod 实例 ({pods.length})
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
<TableHead className="text-[var(--dg-text-secondary)]">Pod 名称</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]"> {i18n.t('system.terminalStatus')} </TableHead>
<TableHead className="text-[var(--dg-text-secondary)]"> {i18n.t('kubernetes.pod.nodeName')} </TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">Pod IP</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]"> {i18n.t('agent.agentServiceRestart')} </TableHead>
<TableHead className="text-[var(--dg-text-secondary)]"> {i18n.t('system.auditAction')} </TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pods.length === 0 ? (
<TableRow><TableCell colSpan={6} className="text-center text-[var(--dg-text-muted)] py-8"> {i18n.t('k8s.workloads.noPod')} </TableCell></TableRow>
) : pods.map((pod: JsonData) => (
<TableRow key={pod.name} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/30">
<TableCell className="font-medium text-[var(--dg-text-primary)]">{pod.name}</TableCell>
<TableCell>
<Badge style={{ background: `${statusColor(pod.status)}22`, color: statusColor(pod.status), border: `1px solid ${statusColor(pod.status)}44` }}>
{pod.status || 'Unknown'}
</Badge>
</TableCell>
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{pod.node_name || pod.node || '-'}</TableCell>
<TableCell className="text-sm text-[var(--dg-text-secondary)] font-mono">{pod.ip || pod.pod_ip || '-'}</TableCell>
<TableCell className={(pod.restart_count || 0) > 0 ? 'text-[var(--dg-orange)]' : 'text-[var(--dg-text-muted)]'}>{pod.restart_count || 0}</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-[var(--dg-green)] hover:text-[var(--dg-green)] hover:bg-[var(--dg-green)]/10"
onClick={() => setTerminalPod(pod)}>
<Terminal className="h-3.5 w-3.5 mr-1" />终端
</Button>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-[var(--dg-orange)] hover:text-[var(--dg-orange)] hover:bg-[var(--dg-orange)]/10"
onClick={() => setFileBrowserPod(pod)}>
<HardDrive className="h-3.5 w-3.5 mr-1" />文件浏览器
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</>
))}
{/* Scale modal */}
{scaleModal && (
<ScaleDialog
scaleValue={scaleValue}
actionLoading={actionLoading}
onScaleValueChange={setScaleValue}
onClose={() => setScaleModal(false)}
onScale={handleScale}
/>
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center" onClick={() => setScaleModal(false)}>
<div className="bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg p-6 w-80" onClick={e => e.stopPropagation()}>
<h3 className="text-sm font-semibold text-[var(--dg-text-primary)] mb-4"> {i18n.t('kubernetes.workloadviewpag.s7f541ee2')} </h3>
<div className="flex items-center justify-center gap-4 mb-4">
<Button variant="outline" size="icon" className="h-9 w-9" onClick={() => setScaleValue(Math.max(0, scaleValue - 1))}>
<Minus className="h-4 w-4" />
</Button>
<span className="text-2xl font-bold text-[var(--dg-text-primary)] w-12 text-center">{scaleValue}</span>
<Button variant="outline" size="icon" className="h-9 w-9" onClick={() => setScaleValue(scaleValue + 1)}>
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" className="flex-1 border-[var(--dg-border)] text-[var(--dg-text-secondary)]" onClick={() => setScaleModal(false)}> {i18n.t('rbac.jobRole.cancel')} </Button>
<Button size="sm" className="flex-1 bg-[var(--dg-blue)] hover:opacity-90" disabled={actionLoading} onClick={handleScale}>
{actionLoading ? i18n.t('automation.systemTaskRunning') : i18n.t('monitor.alertEvents.acknowledge')}
</Button>
</div>
</div>
</div>
)}
{/* YAML modal */}
@@ -346,3 +542,31 @@ export default function WorkloadViewPage() {
</PageShell>
);
}
// Container info sub-component
function ContainerInfo({ c }: { c: JsonData}) {
return (
<div className="border border-[var(--dg-border)] rounded-lg p-3 bg-[var(--dg-bg-primary)]/30">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Container className="h-4 w-4 text-[var(--dg-blue)]" />
<span className="text-sm font-semibold text-[var(--dg-text-primary)]">{c.name}</span>
</div>
<code className="text-[11px] text-[var(--dg-text-secondary)] font-mono truncate max-w-[400px]">{c.image}</code>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 text-xs">
<div className="text-[var(--dg-text-secondary)]">
CPU: <span className="text-[var(--dg-text-primary)] font-mono">{c.resources?.requests?.cpu || '-'}/{c.resources?.limits?.cpu || '-'}</span>
</div>
<div className="text-[var(--dg-text-secondary)]">
内存: <span className="text-[var(--dg-text-primary)] font-mono">{c.resources?.requests?.memory || '-'}/{c.resources?.limits?.memory || '-'}</span>
</div>
<div className="text-[var(--dg-text-secondary)]">
端口: <span className="text-[var(--dg-text-primary)] font-mono">
{(c.ports || []).map((p: JsonData) => p.containerPort || p.port).join(', ') || '-'}
</span>
</div>
</div>
</div>
);
}
@@ -74,7 +74,7 @@ export function YamlViewer({
setRawYaml(text);
await applyStripped(text);
} catch (e) {
toast({ title: i18n.t('k8s.yamlViewer.fetchFailed'), description: (e as JsonData).message, variant: 'destructive' });
toast({ title: i18n.t('kubernetes.yamlviewertsx.s1a72e368'), description: (e as JsonData).message, variant: 'destructive' });
const err = `# Error: ${(e as Error).message}`;
setRawYaml(err);
setDisplayYaml(err);
@@ -104,7 +104,7 @@ export function YamlViewer({
} else {
await kubernetesApi.updateResourceYaml(clusterCode, namespace, resource, name, yamlStr);
}
toast({ title: i18n.t('kubernetes.msg.saveSuccess'), description: i18n.t('k8s.toast.resourceUpdated', { resource, name }),});
toast({ title: i18n.t('kubernetes.msg.saveSuccess'), description: saveYamlFn ? i18n.t('kubernetes.yamlviewertsx.s68f258c4') : `${resource}/${name} 已更新` });
setEditing(false);
setRawYaml(yamlStr);
setDisplayYaml(yamlStr);
@@ -125,7 +125,7 @@ export function YamlViewer({
const downloadYaml = () => {
downloadYamlFile(yamlDownloadFilename(resource, namespace, name), currentYaml());
toast({ title: i18n.t('k8s.rve.downloaded'), description: yamlDownloadFilename(resource, namespace, name) });
toast({ title: i18n.t('kubernetes.yamlviewertsx.s9719fec8'), description: yamlDownloadFilename(resource, namespace, name) });
};
const display = editing ? editYaml : (showRaw ? rawYaml : displayYaml);
@@ -81,12 +81,12 @@ export async function saveDeploymentBundleYaml(
yamlText: string,
): Promise<void> {
const { manifests } = await parseEditableManifests(yamlText);
if (manifests.length === 0) throw new Error(i18n.t('kubernetes.deploymentBundle.yamlEmpty'));
if (manifests.length === 0) throw new Error(i18n.t('kubernetes.deploymentbundl.s47161e05'));
for (const manifest of manifests) {
const kind = String(manifest.kind || '').toLowerCase();
const name = String(manifest.metadata?.name || '').trim();
if (!kind || !name) throw new Error(i18n.t('k8s.misc.yamlDocError'));
if (!kind || !name) throw new Error(i18n.t('kubernetes.deploymentbundl.s659c1bb5'));
const docYaml = await prepareManifestForSave(manifest);
if (kind === 'deployment') {
@@ -97,6 +97,6 @@ export async function saveDeploymentBundleYaml(
await kubernetesApi.applyYAML(clusterCode, docYaml);
continue;
}
throw new Error(i18n.t('k8s.bundleYaml.unsupportedType', { kind: manifest.kind }));
throw new Error(`不支持的资源类型: ${manifest.kind}(仅支持 Deployment / Service)`);
}
}
@@ -57,34 +57,34 @@ export function CreateHPADialog({ clusterCode, namespace, deploymentName, onCrea
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" />{i18n.t("common.createHpa")}
<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">{i18n.t("common.createHpaTitle")}</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">{i18n.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">{i18n.t("common.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={i18n.t('k8s.hpa.targetInfo', { deploy: deploymentName, ns: namespace })}>
{i18n.t('k8s.hpa.targetInfo', { deploy: deploymentName, ns: 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">{i18n.t("common.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">{i18n.t("common.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 +92,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">{i18n.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">{i18n.t('k8s.misc.memory1')} <span className="dg-text-muted">{i18n.t("common.optional")}</span></Label>
<Label className="dg-text-secondary text-sm">{i18n.t('k8s.misc.memory1')} <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 +106,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)}>{i18n.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" /> : i18n.t('common.create')}
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : i18n.t('k8s.misc.sd9ac9228')}
</Button>
</div>
</div>
@@ -31,7 +31,6 @@ export function PodsTab({
const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line react-hooks/incompatible-library
const virtualizer = useVirtualizer({
count: pods.length,
getScrollElement: () => scrollRef.current,
@@ -60,7 +60,6 @@ export function CICDWebhookContent({
const body: Record<string, string> = { image: image || t('k8s.cicd.newImagePlaceholder') };
if (containerName) body.container_name = containerName;
return JSON.stringify(body);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [image, containerName]);
const accessKeyPlaceholder = t('k8s.cicd.accessKeyPlaceholder');
@@ -27,7 +27,6 @@ export function EditModeContainer({ clusterCode, namespace, deploymentName, mani
const [editManifest, setEditManifest] = useState<any>(manifest);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
if (manifest) setEditManifest(manifest);
}, [manifest]);
@@ -10,6 +10,7 @@ import { useToast } from '@/hooks/use-toast';
import { useUserStore } from '@/store';
import { useTranslation } from 'react-i18next';
import type { JsonData } from '@/types/common';
import { buildApiUrl } from '@/utils/request';
export interface PodLogsModalProps {
clusterCode: string;
@@ -38,15 +39,14 @@ export function PodLogsModal({ clusterCode, namespace, podName, containers, onCl
} finally { setLoading(false); }
};
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => { loadLogs(); }, [container, tail]);
const startStream = async () => {
setStreaming(true);
const token = useUserStore.getState().token || '';
const baseUrl = import.meta.env.VITE_API_BASE_URL || '';
const cParam = container ? `&container=${encodeURIComponent(container)}` : '';
const url = `${baseUrl}/api/v1/kubernetes/${clusterCode}/namespace/${namespace}/pods/${podName}/logs?tail=${tail}&stream=true${cParam}`;
const params = new URLSearchParams({ tail: String(tail), stream: 'true' });
if (container) params.set('container', container);
const url = buildApiUrl(`/kubernetes/${clusterCode}/namespace/${namespace}/pods/${podName}/logs`, params);
try {
const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
const reader = resp.body?.getReader();
@@ -101,4 +101,4 @@ export function PodLogsModal({ clusterCode, namespace, podName, containers, onCl
</div>
</div>
);
}
}
@@ -35,7 +35,6 @@ export function UpdateImageModal({
useEffect(() => {
const parsed = parseImageRef(newImage || currentImage);
// eslint-disable-next-line react-hooks/set-state-in-effect
setNewRepo(parsed.repository);
setNewTag(parsed.tag);
}, [newImage, currentImage]);
@@ -61,10 +61,10 @@ export function formatEventRelative(ts: string): string {
if (diff < 0) return '';
const mins = Math.floor(diff / 60000);
if (mins < 1) return i18n.t('task.cronJob.time.justNow');
if (mins < 60) return i18n.t('common.duration.minAgo', { count: mins });
if (mins < 60) return `${mins}分钟前`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return i18n.t('common.duration.hourAgo', { count: hrs });
return i18n.t('common.duration.dayAgo', { count: Math.floor(hrs / 24) });
if (hrs < 24) return `${hrs}小时前`;
return `${Math.floor(hrs / 24)}天前`;
}
/** 重复跨度:首次 → 最后 // Logan.Li */
@@ -438,14 +438,67 @@ function isSystemMountPath(line: string): boolean {
);
}
// Barrel re-export: 通用工具函数已提取到 k8s-helpers.ts
// 保留 re-export 以兼容现有 import 路径(8 个消费者无需修改)
export {
parseScalar,
STATUS_COLORS,
statusColor,
aggregateByImage,
formatK8sTime,
elapsedSeconds,
formatDuration,
} from './k8s-helpers';
/** 解析简单 "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, '') };
}
/** 状态色 */
export const STATUS_COLORS = {
green: '#10b981',
orange: '#f59e0b',
red: '#ef4444',
blue: '#326CE5',
} as const;
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;
}
/** 按镜像聚合 */
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 existing = map.get(img);
if (existing) {
existing._count += 1;
} else {
map.set(img, { ...it, _count: 1 });
}
}
return Array.from(map.values());
}
/** 格式化 K8s 时间戳 */
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));
}
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`;
}
@@ -15,6 +15,15 @@ interface State {
errorInfo: ErrorInfo | null;
}
function isChunkLoadError(error: Error | null) {
// 发版后旧入口引用已删除 chunk 时,浏览器会抛动态 import 加载失败。 // Logan.Li
const message = error?.message || '';
return message.includes('Failed to fetch dynamically imported module') ||
message.includes('Importing a module script failed') ||
message.includes('ChunkLoadError') ||
message.includes('Loading chunk');
}
/**
* ErrorBoundary - 全局错误边界组件
*
@@ -38,6 +47,10 @@ export class ErrorBoundary extends Component<Props, State> {
}
handleRetry = () => {
if (isChunkLoadError(this.state.error)) {
window.location.reload();
return;
}
this.setState({ hasError: false, error: null, errorInfo: null });
};
@@ -60,6 +73,7 @@ export class ErrorBoundary extends Component<Props, State> {
if (this.props.fallback) {
return this.props.fallback;
}
const chunkLoadError = isChunkLoadError(this.state.error);
return (
<div className="min-h-[60vh] flex items-center justify-center p-6">
@@ -75,7 +89,7 @@ export class ErrorBoundary extends Component<Props, State> {
{i18n.t('common.ebPageError')}
</h2>
<p className="text-sm text-[var(--dg-text-muted)] mt-2">
{i18n.t('common.ebDescription')}
{chunkLoadError ? i18n.t('common.ebChunkDescription') : i18n.t('common.ebDescription')}
</p>
</div>
@@ -116,7 +130,7 @@ export class ErrorBoundary extends Component<Props, State> {
onClick={this.handleRetry}
>
<RotateCcw className="h-4 w-4 mr-1.5" />
{i18n.t('common.reload')}
{chunkLoadError ? i18n.t('common.reloadPage') : i18n.t('common.reload')}
</Button>
<Button
variant="outline"
+16 -5
View File
@@ -46,7 +46,11 @@ function useKeyboardShortcuts() {
}, []);
}
export function Header() {
interface HeaderProps {
onMobileMenuToggle: () => void;
}
export function Header({ onMobileMenuToggle }: HeaderProps) {
const { t } = useTranslation();
const { sidebarCollapsed, toggleSidebar } = useThemeStore();
const { notifications, unreadCount, markAsRead, markAllAsRead } = useNotificationStore();
@@ -79,14 +83,14 @@ export function Header() {
return (
<header
className={cn(
'fixed top-0 right-0 z-30 h-[var(--layout-header-height)]',
'dg-app-header fixed top-0 right-0 z-30 h-[var(--layout-header-height)]',
'bg-[var(--surface-raised)]/95 backdrop-blur-md',
'border-b border-[var(--border-default)] transition-[margin] duration-300 ease-in-out',
'supports-[backdrop-filter]:bg-[var(--surface-raised)]/75',
)}
style={{ marginLeft: 'var(--sidebar-width)', left: 0 }}
>
<div className="flex items-center justify-between h-full px-4">
<div className="flex items-center justify-between h-full px-2.5 sm:px-4">
{/* Left: Toggle & Search */}
<div className="flex items-center gap-3 flex-1 min-w-0">
{/* Sidebar Toggle */}
@@ -94,7 +98,14 @@ export function Header() {
variant="ghost"
size="icon"
className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] flex-shrink-0"
onClick={toggleSidebar}
onClick={() => {
// 桌面沿用折叠侧栏,手机改为打开抽屉导航。 // Logan.Li
if (window.matchMedia('(max-width: 767px)').matches) {
onMobileMenuToggle();
return;
}
toggleSidebar();
}}
title={sidebarCollapsed ? t('header.expandSidebar') : t('header.collapseSidebar')}
>
<Menu className="h-4 w-4" />
@@ -185,7 +196,7 @@ export function Header() {
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-96 bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] p-0"
className="w-[calc(100vw-1rem)] sm:w-96 bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] p-0"
style={{ boxShadow: 'var(--shadow-dropdown)' }}
>
{/* Header */}
+42 -7
View File
@@ -1,5 +1,5 @@
import { useEffect } from 'react';
import { Outlet } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { Outlet, useLocation } from 'react-router-dom';
import { cn } from '@/lib/utils';
import { useThemeStore, useUserStore } from '@/store';
import { Sidebar } from './Sidebar';
@@ -8,6 +8,8 @@ import { Breadcrumb } from './Breadcrumb';
import { authApi } from '@/api';
import type { JsonData } from '@/types/common';
import { useTranslation } from 'react-i18next';
import { SessionWatermark } from '@/components/common/SessionWatermark';
import { shouldProtectPage } from '@/components/common/sensitivePageWatermark';
/**
* MainLayout — 统一应用布局壳
@@ -19,6 +21,10 @@ export function MainLayout() {
const token = useUserStore((s) => s.token);
const permissions = useUserStore((s) => s.permissions);
const setPermissions = useUserStore((s) => s.setPermissions);
const location = useLocation();
const [mobileNavOpen, setMobileNavOpen] = useState(false);
// 按敏感路由统一覆盖水印,避免普通监控大屏与首页的可读性受影响。 // Logan.Li
const shouldShowWatermark = shouldProtectPage(location.pathname);
useEffect(() => {
if (!token || permissions !== null) return;
@@ -36,6 +42,27 @@ export function MainLayout() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
useEffect(() => {
// 路由切换后自动收起手机导航,确保内容立即可见。 // Logan.Li
setMobileNavOpen(false);
}, [location.pathname]);
useEffect(() => {
// 手机上的 Escape 键与遮罩关闭行为保持一致。 // Logan.Li
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setMobileNavOpen(false);
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
useEffect(() => {
// 抽屉打开时锁定手机端页面滚动,避免遮罩下方内容发生误触。 // Logan.Li
if (!mobileNavOpen || !window.matchMedia('(max-width: 767px)').matches) return;
document.body.classList.add('dg-mobile-nav-open');
return () => document.body.classList.remove('dg-mobile-nav-open');
}, [mobileNavOpen]);
return (
<div
className="dg-app-shell"
@@ -45,18 +72,26 @@ export function MainLayout() {
: 'var(--layout-sidebar-expanded)',
} as React.CSSProperties}
>
<Sidebar />
<Sidebar mobileOpen={mobileNavOpen} onMobileClose={() => setMobileNavOpen(false)} />
<button
type="button"
className="dg-mobile-nav-backdrop"
aria-label="关闭导航"
tabIndex={mobileNavOpen ? 0 : -1}
onClick={() => setMobileNavOpen(false)}
/>
<div
className={cn('flex flex-col min-h-screen transition-[margin] duration-300 ease-in-out')}
className={cn('dg-app-workspace flex flex-col min-h-screen transition-[margin] duration-300 ease-in-out')}
style={{ marginLeft: 'var(--sidebar-width)' }}
>
<Header />
<Header onMobileMenuToggle={() => setMobileNavOpen((open) => !open)} />
<main className="dg-app-main">
<div className="dg-app-breadcrumb">
<Breadcrumb />
</div>
<div className="dg-app-content">
<div className="dg-app-content relative">
<Outlet />
{shouldShowWatermark && <SessionWatermark className="opacity-50" />}
</div>
</main>
<footer className="dg-app-footer">
@@ -68,4 +103,4 @@ export function MainLayout() {
</div>
</div>
);
}
}
+2 -2
View File
@@ -30,7 +30,7 @@ export function PageTabs({ items, value, onChange, className }: PageTabsProps) {
aria-selected={active}
onClick={() => onChange(tab.id)}
className={cn(
'dg-page-tab px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px',
'dg-page-tab shrink-0 px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px',
active
? 'text-[var(--brand)] border-[var(--brand)]'
: 'dg-text-secondary border-transparent hover:text-[var(--text-primary)]',
@@ -45,4 +45,4 @@ export function PageTabs({ items, value, onChange, className }: PageTabsProps) {
})}
</nav>
);
}
}
@@ -7,19 +7,19 @@ import { useLocation } from 'react-router-dom';
* 使用 IntersectionObserver + CSS animation 实现平滑的页面进入动画
* 所有子页面自动获得 fade-in 动画效果
*/
export function PageTransition({ children, key }: { children: React.ReactNode; key?: string }) {
export function PageTransition({ children, transitionKey }: { children: React.ReactNode; transitionKey?: string }) {
const ref = useRef<HTMLDivElement>(null);
const [isVisible, setIsVisible] = useState(false);
const location = useLocation();
const transitionKey = key || location.pathname;
// React 的 key 不会作为组件 props 传递,使用命名 prop 保证路由切换动画可正确更新。 // Logan.Li
const pageKey = transitionKey || location.pathname;
useEffect(() => {
// Reset visibility on route change
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsVisible(false);
const timer = setTimeout(() => setIsVisible(true), 30);
return () => clearTimeout(timer);
}, [transitionKey]);
}, [pageKey]);
return (
<div
+45 -13
View File
@@ -5,6 +5,7 @@ import { cn } from '@/lib/utils';
import { useThemeStore, useMenuStore } from '@/store';
import { usePermission } from '@/hooks/usePermission';
import {
LayoutDashboard,
Server,
Cloud,
@@ -177,24 +178,35 @@ const menuItems: MenuItem[] = [
/**
* MenuItemComponent - 侧边栏菜单项(支持多级嵌套)
*
*
* 优化点:
* 1. 使用 Tooltip 在折叠状态下显示菜单名称
* 2. 动画过渡平滑
* 3. 活动状态判断更精确
* 4. 子菜单展开/收起动画
*/
function MenuItemComponent({ item, depth = 0 }: { item: MenuItem; depth?: number }) {
function MenuItemComponent({
item,
depth = 0,
onMobileNavigate,
forceExpanded = false,
}: {
item: MenuItem;
depth?: number;
onMobileNavigate?: () => void;
forceExpanded?: boolean;
}) {
const location = useLocation();
const { t } = useTranslation();
const { openMenus, toggleMenu, activeMenu, setActiveMenu } = useMenuStore();
const { sidebarCollapsed } = useThemeStore();
const isCollapsed = sidebarCollapsed && !forceExpanded;
const hasChildren = item.children && item.children.length > 0;
const isOpen = openMenus.includes(item.id);
// 优先使用 i18n 翻译 key;无 labelKey 则回退到 name
const displayName = item.labelKey ? t(item.labelKey, { defaultValue: item.name }) : item.name;
// Determine if this menu item is active
const isActive = item.path
? location.pathname === item.path || location.pathname.startsWith(item.path + '/')
@@ -217,11 +229,12 @@ function MenuItemComponent({ item, depth = 0 }: { item: MenuItem; depth?: number
toggleMenu(item.id);
} else if (item.path) {
setActiveMenu(item.id);
onMobileNavigate?.();
}
};
// Collapsed mode (only top-level)
if (sidebarCollapsed && depth === 0) {
if (isCollapsed && depth === 0) {
return (
<TooltipProvider delayDuration={100}>
<Tooltip>
@@ -302,7 +315,13 @@ function MenuItemComponent({ item, depth = 0 }: { item: MenuItem; depth?: number
>
<div className="space-y-0.5 border-l border-[var(--dg-border)] ml-4 pl-1.5">
{item.children?.map((child) => (
<MenuItemComponent key={child.id} item={child} depth={depth + 1} />
<MenuItemComponent
key={child.id}
item={child}
depth={depth + 1}
onMobileNavigate={onMobileNavigate}
forceExpanded={forceExpanded}
/>
))}
</div>
</div>
@@ -330,7 +349,7 @@ function MenuItemComponent({ item, depth = 0 }: { item: MenuItem; depth?: number
/**
* Sidebar - 优化后的侧边栏导航
*
*
* 优化点:
* 1. 使用 CSS 变量控制宽度,统一协调
* 2. 添加 Tooltip 支持(折叠状态)
@@ -338,10 +357,17 @@ function MenuItemComponent({ item, depth = 0 }: { item: MenuItem; depth?: number
* 4. Logo 区域更精致
* 5. 底部添加版本信息
*/
export function Sidebar() {
interface SidebarProps {
mobileOpen: boolean;
onMobileClose: () => void;
}
export function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
const { t } = useTranslation();
const { sidebarCollapsed, toggleSidebar } = useThemeStore();
const { hasPermission } = usePermission();
// 手机抽屉始终展示完整文字菜单,不受桌面端折叠状态影响。 // Logan.Li
const isCollapsed = sidebarCollapsed && !mobileOpen;
// Listen for keyboard shortcut
useEffect(() => {
@@ -367,18 +393,19 @@ export function Sidebar() {
return (
<aside
className={cn(
'fixed left-0 top-0 z-40 h-screen bg-[var(--dg-bg-secondary)] border-r border-[var(--dg-border)]',
'dg-sidebar fixed left-0 top-0 z-40 h-screen bg-[var(--dg-bg-secondary)] border-r border-[var(--dg-border)]',
'flex flex-col transition-[width] duration-300 ease-in-out',
)}
data-mobile-open={mobileOpen}
style={{ width: 'var(--sidebar-width)' }}
>
{/* Logo Area */}
<div className="flex items-center h-14 px-3 border-b border-[var(--dg-border)] flex-shrink-0">
<div className={cn('flex items-center gap-2.5 flex-1 min-w-0', sidebarCollapsed && 'justify-center')}>
<div className={cn('flex items-center gap-2.5 flex-1 min-w-0', isCollapsed && 'justify-center')}>
<div className="dg-icon-box w-8 h-8 rounded-lg flex-shrink-0">
<Cloud className="h-5 w-5" />
</div>
{!sidebarCollapsed && (
{!isCollapsed && (
<span className="font-bold text-[var(--dg-text-primary)] text-sm tracking-tight truncate">
{t('common.appName')}
</span>
@@ -388,16 +415,21 @@ export function Sidebar() {
</div>
{/* Menu Items */}
<ScrollArea className="flex-1 min-h-0 py-2 px-1.5 overscroll-contain">
<ScrollArea className="flex-1 py-2 px-1.5">
<nav className="space-y-0.5">
{visibleMenuItems.map((item) => (
<MenuItemComponent key={item.id} item={item} />
<MenuItemComponent
key={item.id}
item={item}
onMobileNavigate={onMobileClose}
forceExpanded={mobileOpen}
/>
))}
</nav>
</ScrollArea>
{/* Footer */}
{!sidebarCollapsed && (
{!isCollapsed && (
<div className="px-3 py-2 border-t border-[var(--dg-border)] flex-shrink-0">
<div className="text-[10px] text-[var(--dg-text-muted)] text-center">
AIOps v1.0.0
@@ -121,7 +121,7 @@ export function AlertChannelDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="dg-input max-w-2xl">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)]"> {i18n.t('monitor.alertChannelDialog.title')} </DialogTitle>
<DialogTitle className="text-[var(--dg-text-primary)]"> {i18n.t('monitor.alertchanneldia.s976ad48f')} </DialogTitle>
<DialogDescription className="dg-text-muted">
管理告警通知渠道(钉钉/企业微信/邮件/短信/Webhook),配置后可在告警规则中引用
</DialogDescription>
@@ -131,7 +131,7 @@ export function AlertChannelDialog({
{/* 渠道列表 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-[var(--dg-text-secondary)] text-sm"> {i18n.t('monitor.alertChannel.name')} </Label>
<Label className="text-[var(--dg-text-secondary)] text-sm"> {i18n.t('monitor.alertchanneldia.se6ad00af')} </Label>
<Button
size="sm"
variant="outline"
@@ -176,7 +176,7 @@ export function AlertChannelDialog({
{ch.test_status === 'success' ? (
<span className="text-[11px] dg-text-green"> {i18n.t('monitor.alertChannels.testPassed')} </span>
) : (
<span className="text-[11px] dg-text-muted"> {i18n.t('monitor.alertChannelDialog.notTested')} </span>
<span className="text-[11px] dg-text-muted"> {i18n.t('monitor.alertchanneldia.s8c852a4c')} </span>
)}
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<Button
@@ -207,10 +207,10 @@ export function AlertChannelDialog({
{/* 渠道表单 */}
<div className="space-y-4">
<Label className="text-[var(--dg-text-secondary)] text-sm">
{editingChannel ? i18n.t('monitor.alertChannelDialog2.editChannel') : i18n.t('monitor.alertChannelDialog2.newChannel')}
{editingChannel ? i18n.t('monitor.alertchanneldia.sc4714508') : i18n.t('monitor.alertchanneldia.s82bd472e')}
</Label>
<div className="space-y-1.5">
<Label className="dg-text-secondary text-xs"> {i18n.t('monitor.alertChannel.name')} <span className="dg-text-red">*</span></Label>
<Label className="dg-text-secondary text-xs"> {i18n.t('monitor.alertchanneldia.sb8cbd86d')} <span className="dg-text-red">*</span></Label>
<Input
value={channelForm.name}
onChange={(e) => setChannelForm({ ...channelForm, name: e.target.value })}
@@ -247,7 +247,7 @@ export function AlertChannelDialog({
onCheckedChange={(v) => setChannelForm({ ...channelForm, enabled: v })}
className="data-[state=checked]:bg-emerald-600"
/>
<Label className="dg-text-secondary text-xs"> {i18n.t('monitor.alertChannelDialog.enableChannel')} </Label>
<Label className="dg-text-secondary text-xs"> {i18n.t('monitor.alertchanneldia.sbdab1c59')} </Label>
</div>
)}
</div>
@@ -76,15 +76,15 @@ export function AlertManager() {
];
const pageTabs = [
{ id: 'events', label: i18n.t('monitor.alertMgr2.alertEvents') },
{ id: 'events', label: i18n.t('kubernetes.eventspagetsx.s0e5b83cc') },
{ id: 'rules', label: i18n.t('monitor.alertRules') },
];
return (
<PageShell
variant="list"
title={i18n.t('monitor.alertMgr2.monitorAlert')}
subtitle={i18n.t('monitor.alertMgr2.subtitle')}
title={i18n.t('monitor.alertmanagertsx.sa57c10a3')}
subtitle={i18n.t('monitor.alertmanagertsx.s1a70ca90')}
icon={<Bell className="h-5 w-5" />}
tabs={pageTabs}
activeTab={activeTab}
@@ -94,7 +94,7 @@ export function AlertManager() {
items={[
{ label: i18n.t('monitor.alertRules'), value: rules.length, color: 'blue' },
{ label: i18n.t('monitor.alertHelper.statusFiring'), value: alerts.filter((e) => e.status === 'firing').length, color: 'red' },
{ label: i18n.t('monitor.alertMgr2.todayAlerts'), value: alerts.length, color: 'amber' },
{ label: i18n.t('monitor.alertmanagertsx.s240597ec'), value: alerts.length, color: 'amber' },
{ label: i18n.t('kubernetes.msg.uncordonSuccess'), value: alerts.filter((e) => e.status === 'resolved').length, color: 'emerald' },
]}
/>
@@ -118,8 +118,8 @@ export function AlertManager() {
>
<Card className="dg-card">
<CardHeader className="pb-2">
<CardTitle className="text-[var(--dg-text-primary)]"> {i18n.t('monitor.alertManager.trendTitle')} </CardTitle>
<CardDescription className="dg-text-muted"> {i18n.t('monitor.alertManager.last24hStats')} </CardDescription>
<CardTitle className="text-[var(--dg-text-primary)]"> {i18n.t('monitor.alertmanagertsx.s358b05d0')} </CardTitle>
<CardDescription className="dg-text-muted"> {i18n.t('monitor.alertmanagertsx.s615fa6cb')} </CardDescription>
</CardHeader>
<CardContent>
<div className="h-[200px]">
@@ -128,10 +128,10 @@ export function AlertRuleDialog({
<DialogContent className="dg-input max-w-lg">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)]">
{editingRule ? i18n.t('monitor.alertRuleDialog2.editRule') : i18n.t('monitor.alertRuleDialog2.newRule')}
{editingRule ? i18n.t('monitor.alertruledialog.sb62c8bc1') : i18n.t('monitor.alertruledialog.s3564fa12')}
</DialogTitle>
<DialogDescription className="dg-text-muted">
{editingRule ? i18n.t('monitor.alertRuleDialog2.editRule') : i18n.t('monitor.alertRuleDialog2.newRule')}
{editingRule ? i18n.t('monitor.alertruledialog.s44a3bb2d') : i18n.t('monitor.alertruledialog.sb785eced')}
</DialogDescription>
</DialogHeader>
@@ -141,17 +141,17 @@ export function AlertRuleDialog({
<Input
value={ruleForm.name}
onChange={(e) => setRuleForm({ ...ruleForm, name: e.target.value })}
placeholder={i18n.t('monitor.alertRuleDialog2.namePlaceholder')}
placeholder={i18n.t('monitor.alertruledialog.sf111cd25')}
className="dg-input"
/>
</div>
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]"> {i18n.t('monitor.alertRuleDialog2.ruleDesc')} </Label>
<Label className="text-[var(--dg-text-secondary)]"> {i18n.t('aiops.llmModelDesc')} </Label>
<Textarea
value={ruleForm.description}
onChange={(e) => setRuleForm({ ...ruleForm, description: e.target.value })}
placeholder={i18n.t('monitor.alertRuleDialog2.namePlaceholder')}
placeholder={i18n.t('monitor.alertruledialog.scc807bdb')}
rows={2}
className="dg-input resize-none"
/>
@@ -159,7 +159,7 @@ export function AlertRuleDialog({
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]"> {i18n.t('monitor.alertRuleDialog2.alertType')} </Label>
<Label className="text-[var(--dg-text-secondary)]"> {i18n.t('monitor.alertruledialog.s36582565')} </Label>
<select
value={ruleForm.type}
onChange={(e) => {
@@ -169,9 +169,9 @@ export function AlertRuleDialog({
}}
className="w-full h-9 px-3 rounded-md bg-[var(--dg-bg-tertiary)]/50 border border-[var(--dg-border)] text-[var(--dg-text-primary)] text-sm"
>
<option value="metric"> {i18n.t('monitor.alertRuleDialog2.metricAlert')} </option>
<option value="metric"> {i18n.t('monitor.alertruledialog.s38546de8')} </option>
<option value="log"> {i18n.t('monitor.alertHelper.srcLog')} </option>
<option value="event"> {i18n.t('monitor.alertRuleDialog2.eventAlert')} </option>
<option value="event"> {i18n.t('monitor.alertruledialog.s80069bea')} </option>
</select>
</div>
<div className="space-y-1.5">
@@ -199,25 +199,25 @@ export function AlertRuleDialog({
{ruleForm.type === 'log' && (
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]"> {i18n.t('monitor.alertRuleDialog2.logDatasource')} <span className="dg-text-red">*</span></Label>
<Label className="text-[var(--dg-text-secondary)]"> {i18n.t('monitor.alertruledialog.sa2bc1edd')} <span className="dg-text-red">*</span></Label>
<select
value={ruleForm.datasource_id || ''}
onChange={(e) => setRuleForm({ ...ruleForm, datasource_id: Number(e.target.value) || undefined })}
className="w-full h-9 px-3 rounded-md bg-[var(--dg-bg-tertiary)]/50 border border-[var(--dg-border)] text-[var(--dg-text-primary)] text-sm"
>
<option value=""> {i18n.t('monitor.alertRuleDialog2.selectLogSource')} </option>
<option value=""> {i18n.t('monitor.alertruledialog.s9acb966d')} </option>
{logSources.map((s) => (
<option key={s.id} value={s.id}>{s.name}{s.is_default ? i18n.t('monitor.logPanel.defaultSuffix') : ''}</option>
))}
</select>
{logSources.length === 0 && (
<p className="text-xs dg-text-yellow/80">{i18n.t('monitor.alertRuleDialog2.addLogSourceFirst', { type: ruleForm.source === 'loki' ? 'Loki' : 'Elasticsearch' })}</p>
<p className="text-xs dg-text-yellow/80">请先前往「日志库」添加 {ruleForm.source === 'loki' ? 'Loki' : 'Elasticsearch'} 数据源</p>
)}
</div>
)}
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]"> {i18n.t('monitor.alertRuleDialog2.expression')} <span className="dg-text-red">*</span></Label>
<Label className="text-[var(--dg-text-secondary)]"> {i18n.t('monitor.alertruledialog.s57ee9aea')} <span className="dg-text-red">*</span></Label>
<Input
value={ruleForm.expression}
onChange={(e) => setRuleForm({ ...ruleForm, expression: e.target.value })}
@@ -226,14 +226,14 @@ export function AlertRuleDialog({
/>
<p className="text-xs dg-text-muted">
{ruleForm.type === 'log'
? (ruleForm.source === 'loki' ? i18n.t('monitor.alertRuleDialog2.logqlHint') : i18n.t('monitor.alertRuleDialog2.esHint'))
: i18n.t('monitor.alertRuleDialog2.promqlHint')}
? (ruleForm.source === 'loki' ? i18n.t('monitor.alertruledialog.s465c2bb1') : i18n.t('monitor.alertruledialog.s7072bc91'))
: i18n.t('monitor.alertruledialog.se2017efe')}
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]"> {i18n.t('monitor.alertRuleDialog2.severity')} </Label>
<Label className="text-[var(--dg-text-secondary)]"> {i18n.t('monitor.alertruledialog.sfce93b38')} </Label>
<select
value={ruleForm.severity}
onChange={(e) => setRuleForm({ ...ruleForm, severity: e.target.value })}
@@ -245,7 +245,7 @@ export function AlertRuleDialog({
</select>
</div>
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]"> {i18n.t('monitor.alertRuleDialog2.duration')} </Label>
<Label className="text-[var(--dg-text-secondary)]"> {i18n.t('monitor.alertruledialog.scce774b3')} </Label>
<Input
type="number"
value={ruleForm.duration}
@@ -259,7 +259,7 @@ export function AlertRuleDialog({
<Label className="text-[var(--dg-text-secondary)]"> {i18n.t('monitor.notificationChannel')} </Label>
{notifyChannels.length === 0 ? (
<div className="text-xs dg-text-yellow/80 bg-amber-500/5 border border-amber-500/20 rounded-md px-3 py-2">
{i18n.t('monitor.alertRuleDialog2.noChannels')}
暂无已配置的通知渠道,请先在「通知渠道配置」中添加
</div>
) : (
<div className="flex flex-wrap gap-3 pt-1">
@@ -295,7 +295,7 @@ export function AlertRuleDialog({
className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]"
onClick={() => onOpenChange(false)}
>
{i18n.t('common.cancel')}
取消
</Button>
<Button
className="bg-blue-600 hover:bg-blue-700 text-white"
@@ -303,7 +303,7 @@ export function AlertRuleDialog({
onClick={handleSaveRule}
>
{ruleSaving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{editingRule ? i18n.t('cloud.saveChanges') : i18n.t('monitor.alertRuleDialog2.editRule')}
{editingRule ? i18n.t('cloud.saveChanges') : i18n.t('monitor.alertruledialog.s8e1e3dea')}
</Button>
</DialogFooter>
</DialogContent>
Loaded 100 of 460 files, more files were not shown because too many files have changed in this diff. Show more