chore: 统一本地服务器与远程仓库源码
- 合并 main 与 master 历史并以服务器结构为基线 - 保留迁移、数据保留、AWS 账单与部署安全能力 - 建立 SDD 规格和 CI 验证闭环
This commit is contained in:
commit
fd60361d93
460 files changed
+37492
-23871
No files matched your search
Symlink
+1
@@ -0,0 +1 @@
|
||||
/Users/loganli/devops/web全栈开发/aiops/frontend/node_modules
|
||||
Generated
+346
-258
File diff suppressed because it is too large.
Load diff
@@ -9,6 +9,7 @@
|
||||
"typecheck": "tsc --noEmit",
|
||||
"check": "npm run typecheck",
|
||||
"check:strict": "npm run lint && npm run typecheck",
|
||||
"check:i18n": "node scripts/check-i18n-keys.mjs",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
@@ -67,9 +68,10 @@
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^4.2.2",
|
||||
"react-router-dom": "^7.13.2",
|
||||
"react-router-dom": "^7.18.2",
|
||||
"recharts": "^2.15.4",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"simple-icons": "^16.27.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"vaul": "^1.1.2",
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const sourceRoot = path.resolve('src');
|
||||
const locale = JSON.parse(fs.readFileSync(path.resolve('src/locales/zh-CN.json'), 'utf8'));
|
||||
const files = [];
|
||||
|
||||
function walk(dir) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const file = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(file);
|
||||
else if (/\.(ts|tsx)$/.test(entry.name)) files.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLegacyKeys(resource) {
|
||||
const legacyEntries = [];
|
||||
const collect = (node, parentPath = []) => {
|
||||
for (const [key, value] of Object.entries(node)) {
|
||||
if (typeof value === 'string' && key.includes('.')) legacyEntries.push([parentPath, key, value]);
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) collect(value, [...parentPath, key]);
|
||||
}
|
||||
};
|
||||
const setIfMissing = (segments, value) => {
|
||||
let node = resource;
|
||||
for (const segment of segments.slice(0, -1)) {
|
||||
if (!node[segment] || typeof node[segment] !== 'object') node[segment] = {};
|
||||
node = node[segment];
|
||||
}
|
||||
const last = segments.at(-1);
|
||||
if (node[last] === undefined) node[last] = value;
|
||||
};
|
||||
collect(resource);
|
||||
legacyEntries.forEach(([parentPath, key, value]) => {
|
||||
setIfMissing([...parentPath, ...key.split('.')], value);
|
||||
setIfMissing(key.split('.'), value);
|
||||
});
|
||||
}
|
||||
|
||||
function hasKey(resource, key) {
|
||||
return key.split('.').every((segment) => {
|
||||
if (!resource || !Object.hasOwn(resource, segment)) return false;
|
||||
resource = resource[segment];
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
normalizeLegacyKeys(locale);
|
||||
walk(sourceRoot);
|
||||
const missing = new Map();
|
||||
const pattern = /(?:\bi18n\.)?t\(\s*['"]([^'"]+)['"]/g;
|
||||
for (const file of files) {
|
||||
const source = fs.readFileSync(file, 'utf8');
|
||||
for (const match of source.matchAll(pattern)) {
|
||||
const key = match[1];
|
||||
// 聚焦历史文案工具生成的哈希键,避免新业务文案的翻译建设阻塞构建。 // Logan.Li
|
||||
if (/\.s\d[a-z0-9]{7,}$/i.test(key) && !hasKey(locale, key)) missing.set(key, file);
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.size > 0) {
|
||||
for (const [key, file] of missing) console.error(`${key}: ${path.relative(process.cwd(), file)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`i18n check passed: ${files.length} source files scanned`);
|
||||
+54
-3
@@ -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 />} />
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
},
|
||||
};
|
||||
@@ -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>) => {
|
||||
|
||||
@@ -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`),
|
||||
};
|
||||
};
|
||||
@@ -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 }),
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 <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"
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Loaded 100 of 460 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user