feat: 完善堡垒机与 Agent 智能运维能力

- 支持 Windows Agent 构建、安装脚本和统一上报协议

- 增强 RDP 剪贴板、虚拟盘和文件传输能力

- 新增 AIOps 上下文工具、告警 RCA trace 落库和回放接口

- 重构 docs 模块文档并补充终端、堡垒机、监控、AI Agent 设计
This commit is contained in:
Logan.Li committed 2026-07-05 23:49:36 +08:00
1 parent a9513c2056
commit e0d652d211
169 files changed
+12192 -9116

No files matched your search

+2 -25
View File
@@ -139,7 +139,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -1983,7 +1982,6 @@
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.1.tgz",
"integrity": "sha512-+BIjw/AG3tDQ4pJgTLPYdAW25eDE66YsvM4LKyVPgGzVgZ4a9Wj1SRX8kPVKgBDdPt8oHtZ15F0qx7p0oOHdHw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/state": "^6.6.0",
"crelt": "^1.0.6",
@@ -4835,7 +4833,6 @@
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.95.2.tgz",
"integrity": "sha512-/wGkvLj/st5Ud1Q76KF1uFxScV7WeqN1slQx5280ycwAyYkIPGaRZAEgHxe3bjirSd5Zpwkj6zNcR4cqYni/ZA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@tanstack/query-core": "5.95.2"
},
@@ -5066,7 +5063,6 @@
"integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -5076,7 +5072,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -5087,7 +5082,6 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -5164,7 +5158,6 @@
"integrity": "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.52.0",
"@typescript-eslint/types": "8.52.0",
@@ -5475,7 +5468,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5749,7 +5741,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -6326,8 +6317,7 @@
"version": "8.6.0",
"resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz",
"integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/embla-carousel-react": {
"version": "8.6.0",
@@ -6422,7 +6412,6 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -6977,7 +6966,6 @@
}
],
"license": "MIT",
"peer": true,
"peerDependencies": {
"typescript": "^5 || ^6"
},
@@ -7180,7 +7168,6 @@
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@@ -8524,7 +8511,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -8572,7 +8558,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -8789,7 +8774,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -8820,7 +8804,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -8833,7 +8816,6 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.70.0.tgz",
"integrity": "sha512-COOMajS4FI3Wuwrs3GPpi/Jeef/5W1DRR84Yl5/ShlT3dKVFUfoGiEZ/QE6Uw8P4T2/CLJdcTVYKvWBMQTEpvw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -9557,7 +9539,6 @@
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2",
@@ -9734,7 +9715,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -10074,7 +10054,6 @@
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -10190,8 +10169,7 @@
"resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz",
"integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==",
"deprecated": "This package is now deprecated. Move to @xterm/xterm instead.",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/xterm-addon-fit": {
"version": "0.8.0",
@@ -10238,7 +10216,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz",
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
+155
View File
@@ -43,6 +43,147 @@ export interface ChatHistoryItem {
created_at: string;
}
export interface AIOpsAssetSummary {
id: number;
hostname: string;
ip: string;
public_ip?: string;
protocol: string;
os_type: string;
os?: string;
status: string;
agent_status: string;
cpu_usage: number;
memory_usage: number;
disk_usage: number;
tags?: string;
description?: string;
updated_at: string;
}
export interface AIOpsAlertSummary {
id: number;
source: string;
rule_name: string;
severity: string;
status: string;
target: string;
metric: string;
value: number;
threshold: number;
message: string;
started_at: string;
resolved_at?: string;
}
export interface AIOpsMetricSummary {
target: string;
hostname: string;
metric: string;
time_range: string;
sample_size: number;
latest: number;
min: number;
max: number;
avg: number;
p95: number;
trend: string;
points: { time: string; value: number }[];
}
export interface AIOpsContextSummary {
server_total: number;
server_online: number;
cluster_total: number;
firing_alerts: number;
unresolved_alerts: number;
high_load_hosts: AIOpsAssetSummary[];
recent_alerts: AIOpsAlertSummary[];
generated_at: string;
}
export interface AIOpsContextPack {
scope: string;
time_range: string;
assets: AIOpsAssetSummary[];
alerts: AIOpsAlertSummary[];
metrics: AIOpsMetricSummary[];
evidence: { type: string; source: string; summary: string; confidence: number }[];
generated_at: string;
}
export interface AIOpsContextPackRequest {
target?: string;
alert_id?: number;
metrics?: string[];
time_range?: string;
limit?: number;
}
export interface AIOpsEvidenceItem {
type: string;
source: string;
summary: string;
confidence: number;
}
export interface AIOpsAlertAnalysisRequest {
alert_id: number;
name: string;
severity: string;
target: string;
metric: string;
current_value: string;
threshold: string;
duration: string;
description: string;
time_range?: string;
}
export interface AIOpsAlertAnalysisResponse {
trace_id?: string;
span_id?: string;
root_cause: string;
impact: string;
recommendations: string[];
auto_fix_script?: string;
similar_cases: number;
confidence: number;
evidence: AIOpsEvidenceItem[];
context_pack?: AIOpsContextPack;
model: string;
source: string;
generated_at: string;
}
export interface AIOpsAnalysisTrace {
id: number;
trace_id: string;
span_id: string;
parent_span_id?: string;
user_id: number;
feature: string;
target: string;
alert_id: number;
provider: string;
model: string;
source: string;
status: string;
evidence_count: number;
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
duration: number;
error?: string;
created_at: string;
}
export interface AIOpsAnalysisTraceDetail {
trace: AIOpsAnalysisTrace & { context_pack?: string; result?: string };
context_pack?: AIOpsContextPack;
result?: AIOpsAlertAnalysisResponse;
}
export const aiApi = {
// 获取 LLM 配置(API Key 已脱敏)
getConfig: () => http.get<LLMConfigData>('/aiops/llm-config'),
@@ -134,4 +275,18 @@ export const aiApi = {
},
// 清空当前用户对话历史
clearHistory: () => http.delete('/aiops/chat-history'),
// AI 平台上下文总览,供智能助手快捷操作和未来 MCP 复用 // Logan.Li
getContextSummary: () => http.get<AIOpsContextSummary>('/aiops/context/summary'),
// 构建目标上下文包,返回资产、告警、指标和证据链 // Logan.Li
buildContextPack: (data: AIOpsContextPackRequest) =>
http.post<AIOpsContextPack>('/aiops/context/pack', data),
// 告警 RCA:后端构建 context pack 后调用 LLM,失败时返回规则兜底。 // Logan.Li
analyzeAlert: (data: AIOpsAlertAnalysisRequest) =>
http.post<AIOpsAlertAnalysisResponse>('/aiops/alert-analysis', data),
// AI 分析审计列表,用于查看每次分析使用的数据和模型。 // Logan.Li
listAnalysisTraces: (params?: { feature?: string; target?: string; status?: string; page?: number; page_size?: number }) =>
http.get<{ list: AIOpsAnalysisTrace[]; total: number }>('/aiops/analysis-traces', params),
// AI 分析审计回放,按 trace_id 返回 context pack 和结构化结果。 // Logan.Li
getAnalysisTrace: (traceId: string) =>
http.get<AIOpsAnalysisTraceDetail>(`/aiops/analysis-traces/${encodeURIComponent(traceId)}`),
};
-38
View File
@@ -1,38 +0,0 @@
import http from '@/utils/request';
export interface RdpFileItem {
name: string;
size: number;
is_dir: boolean;
mod_time: string;
}
export interface RdpFileListData {
root: string;
list: RdpFileItem[];
total: number;
}
export interface RdpStageResult {
token: string;
download_url: string;
remote_path: string;
deploy_command: string;
mode: string;
}
export const rdpFilesApi = {
list: async (serverId: number) => http.get<RdpFileListData>(`/servers/${serverId}/rdp-files`),
upload: async (serverId: number, file: File) => {
const fd = new FormData();
fd.append('file', file);
return http.upload<{ name: string; path: string; size: number }>(`/servers/${serverId}/rdp-files/upload`, fd);
},
stage: async (serverId: number, file: File) => {
const fd = new FormData();
fd.append('file', file);
return http.upload<RdpStageResult>(`/servers/${serverId}/rdp-files/stage`, fd);
},
download: async (serverId: number, name: string) =>
http.download(`/servers/${serverId}/rdp-files/download?name=${encodeURIComponent(name)}`, name),
};
+28 -5
View File
@@ -1,6 +1,6 @@
// 审计策略配置 API // Logan.Li
import { http } from '@/utils/request';
import { http } from "@/utils/request";
export interface AuditCleanupStats {
retention_days: number;
@@ -15,13 +15,36 @@ export interface AuditCleanupStats {
export interface AuditSettings {
retention_days: number;
record_all: boolean;
session_watermark_enabled: boolean;
session_record_enabled: boolean;
clipboard_enabled: boolean;
file_upload_enabled: boolean;
file_download_enabled: boolean;
command_policy_enabled: boolean;
approval_required: boolean;
last_cleanup_at?: string | null;
last_cleanup_summary?: string;
allowed_retention_days?: number[];
}
export type AuditSettingsApplyRequest = Pick<
AuditSettings,
| "retention_days"
| "record_all"
| "session_watermark_enabled"
| "session_record_enabled"
| "clipboard_enabled"
| "file_upload_enabled"
| "file_download_enabled"
| "command_policy_enabled"
| "approval_required"
>;
export const auditSettingsApi = {
get: async () => http.get<AuditSettings>('/admin/audit-settings'),
apply: async (data: { retention_days: number; record_all: boolean }) =>
http.put<AuditSettings & { cleanup?: AuditCleanupStats }>('/admin/audit-settings', data),
};
get: async () => http.get<AuditSettings>("/admin/audit-settings"),
apply: async (data: AuditSettingsApplyRequest) =>
http.put<AuditSettings & { cleanup?: AuditCleanupStats }>(
"/admin/audit-settings",
data,
),
};
+49 -16
View File
@@ -43,6 +43,7 @@ function detectServerAddress() {
export function AgentWizard({ open, onOpenChange, server, onStart, onInstalled, onFailed }: AgentWizardProps) {
const token = useUserStore((s) => s.token) || '<YOUR_TOKEN>';
const origin = detectServerAddress();
const isWindowsServer = Boolean(server?.os && server.os.toLowerCase().includes('windows'));
const [copied, setCopied] = useState(false);
const [showManualInput, setShowManualInput] = useState(false);
const [sshInstalling, setSshInstalling] = useState(false);
@@ -50,9 +51,9 @@ export function AgentWizard({ open, onOpenChange, server, onStart, onInstalled,
// 有目标主机时默认展示 SSH 一键安装页,无需用户再点一次 tab // Logan.Li
useEffect(() => {
if (open) {
setActiveTab(server ? 'ssh' : 'auto');
setActiveTab(server && !isWindowsServer ? 'ssh' : 'auto');
}
}, [open, server]);
}, [open, server, isWindowsServer]);
// 凭据输入(当服务器无认证凭据时显示)
const [sshUsername, setSshUsername] = useState('root');
const [sshPassword, setSshPassword] = useState('');
@@ -62,7 +63,9 @@ export function AgentWizard({ open, onOpenChange, server, onStart, onInstalled,
const needsCreds = server && !server.has_auth;
// install.sh 与 Agent 上报均走页面 origin(Nginx/Vite proxy 统一转发到后端)// Logan.Li
const installCommand = `curl -fsSL ${origin}/agent/install.sh | bash -s -- --server ${origin} --token ${token}`;
const linuxInstallCommand = `curl -fsSL ${origin}/agent/install.sh | bash -s -- --server ${origin} --token ${token}`;
const windowsInstallCommand = `powershell -ExecutionPolicy Bypass -Command "iwr ${origin}/agent/install.ps1 -UseBasicParsing -OutFile $env:TEMP\\install-aiops-agent.ps1; & $env:TEMP\\install-aiops-agent.ps1 -Server '${origin}' -Token '${token}'"`;
const installCommand = isWindowsServer ? windowsInstallCommand : linuxInstallCommand;
const handleCopy = () => {
const textArea = document.createElement('textarea');
@@ -114,6 +117,7 @@ export function AgentWizard({ open, onOpenChange, server, onStart, onInstalled,
};
const binaryUrl = `${origin}/agent/aiops-agent-linux-amd64`;
const windowsBinaryUrl = `${origin}/agent/aiops-agent-windows-amd64.exe`;
const manualSteps = [
{
@@ -158,11 +162,40 @@ export function AgentWizard({ open, onOpenChange, server, onStart, onInstalled,
],
},
];
const windowsManualSteps = [
{
icon: Download,
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`,
],
},
{
icon: Cpu,
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: '启动并检查',
commands: [
'Start-Service AIOpsAgent',
'Get-Service AIOpsAgent',
],
},
];
// SSH 一键安装(当从服务器列表传入 server 上下文时可用)
// 改为调用批量异步接口,单台也走后台 goroutine 执行,避免前端长时间等待 // Logan.Li
const handleSshInstall = async () => {
if (!server || !token) return;
if (isWindowsServer) {
toast.error('Windows Agent 请使用 PowerShell 安装命令,SSH 一键安装仅支持 Linux/macOS');
return;
}
if (needsCreds && !sshPassword && !sshKey) {
toast.error(i18n.t('agent.agentwizardtsx.s6cfe961c'));
return;
@@ -214,12 +247,12 @@ export function AgentWizard({ open, onOpenChange, server, onStart, onInstalled,
<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" />
{t('agent.agentwizardtsx.s1a2b3c4d')}
{isWindowsServer ? 'PowerShell 安装' : i18n.t('agent.agentwizardtsx.s1a2b3c4d')}
</TabsTrigger>
{server && (
{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.agentwizardtsx.s2b3c4d5e')}
{i18n.t('agent.agentwizardtsx.s2b3c4d5e')}
</TabsTrigger>
)}
{!server && (
@@ -229,14 +262,14 @@ export function AgentWizard({ open, onOpenChange, server, onStart, onInstalled,
</TabsTrigger>
)}
<TabsTrigger value="manual" className="data-[state=active]:bg-[var(--dg-bg-active)]">
{t('agent.agentwizardtsx.s3c4d5e6f')}
{i18n.t('agent.agentwizardtsx.s3c4d5e6f')}
</TabsTrigger>
</TabsList>
{/* 自动安装 */}
<TabsContent value="auto" className="mt-4 space-y-3">
<p className="text-sm dg-text-secondary">
{t('agent.agentwizardtsx.s4d5e6f7g')}
{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">
@@ -257,7 +290,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.agentwizardtsx.s5e6f7g8h')}
{i18n.t('agent.agentwizardtsx.s5e6f7g8h')}
</p>
<textarea
readOnly
@@ -322,17 +355,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.agentwizardtsx.s1k2l3m4n")}</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.agentwizardtsx.s2l3m4n5o')}
{i18n.t('agent.agentwizardtsx.s2l3m4n5o')}
</Button>
</div>
</div>
{authType === 'key' && (
<div>
<Label className="text-xs dg-text-secondary">{t("agent.agentwizardtsx.s3m4n5o6p")}</Label>
<Label className="text-xs dg-text-secondary">{i18n.t("agent.agentwizardtsx.s3m4n5o6p")}</Label>
<textarea
value={sshKey}
onChange={e => setSshKey(e.target.value)}
@@ -346,7 +379,7 @@ export function AgentWizard({ open, onOpenChange, server, onStart, onInstalled,
)}
<p className="text-sm dg-text-secondary mb-4">
{t("agent.agentwizardtsx.s7g8h9i0j")}
{i18n.t("agent.agentwizardtsx.s7g8h9i0j")}
</p>
<Button
onClick={handleSshInstall}
@@ -356,7 +389,7 @@ export function AgentWizard({ open, onOpenChange, server, onStart, onInstalled,
{sshInstalling ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
{t('agent.agentwizardtsx.s8h9i0j1k')}
{i18n.t('agent.agentwizardtsx.s8h9i0j1k')}
</>
) : (
<>
@@ -379,7 +412,7 @@ export function AgentWizard({ open, onOpenChange, server, onStart, onInstalled,
{/* 手动安装 */}
<TabsContent value="manual" className="mt-4 space-y-4">
<div className="max-h-[420px] overflow-y-auto space-y-3 pr-1">
{manualSteps.map((step) => (
{(isWindowsServer ? windowsManualSteps : manualSteps).map((step) => (
<div key={step.title} className="rounded-lg bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] overflow-hidden">
<div className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)]/50">
<step.icon className="h-4 w-4 dg-text-blue" />
@@ -399,7 +432,7 @@ export function AgentWizard({ open, onOpenChange, server, onStart, onInstalled,
))}
</div>
<p className="text-xs dg-text-muted">
{t('agent.agentwizardtsx.s0j1k2l3m')}
{i18n.t('agent.agentwizardtsx.s0j1k2l3m')}
</p>
</TabsContent>
</Tabs>
@@ -30,26 +30,6 @@ import { StatStrip } from '@/components/ui/stat-strip';
import type { JsonData } from '@/types/common';
import { useTranslation } from 'react-i18next';
// Parse LLM JSON response (may have markdown fences) into AIAnalysis
function parseAIResponse(raw: string, fallback: AIAnalysis): AIAnalysis {
try {
// Strip markdown code fences if present
const json = raw.replace(/```(?:json)?\s*\n?/g, '').replace(/```$/, '').trim();
const parsed = JSON.parse(json);
return {
rootCause: parsed.root_cause || parsed.rootCause || fallback.rootCause,
impact: parsed.impact || fallback.impact,
recommendations: Array.isArray(parsed.recommendations) ? parsed.recommendations : fallback.recommendations,
autoFixScript: parsed.auto_fix_script || parsed.autoFixScript || fallback.autoFixScript,
similarCases: typeof parsed.similar_cases === 'number' ? parsed.similar_cases : (parsed.similarCases ?? fallback.similarCases),
confidence: typeof parsed.confidence === 'number' ? parsed.confidence : fallback.confidence,
};
} catch {
// Not valid JSON — return raw text as rootCause
return { ...fallback, rootCause: raw };
}
}
export function AIAlertAnalyzer() {
const { t } = useTranslation();
const [alerts, setAlerts] = useState<AlertItem[]>([]);
@@ -78,7 +58,7 @@ export function AIAlertAnalyzer() {
}
};
// 触发 AI 分析 — 优先调用 LLM,失败/未配置时回退到规则模板
// 触发 AI 分析:后端构建 AIOps context pack 后执行 RCA,失败时前端再兜底。 // Logan.Li
const handleAnalyze = async (alert: AlertItem) => {
setSelectedAlert(alert);
setIsAnalyzing(true);
@@ -87,34 +67,32 @@ export function AIAlertAnalyzer() {
const fallback = generateAIAnalysis(alert);
try {
const prompt = `你是运维专家。请分析以下告警并以 JSON 格式回复(不要 markdown 代码块)。
告警名称:${alert.name}
严重程度:${alert.severity}
目标:${alert.source}
指标:${alert.metric} = ${alert.currentValue}(阈值 ${alert.threshold})
持续:${alert.duration}
描述:${alert.description}
返回 JSON 格式(字段名用 snake_case):
{
"root_cause": t('aiops.rootCauseSent'),
"impact": t('aiops.impactSent'),
"recommendations": ["建议1", "建议2", "建议3"],
"auto_fix_script": "#!/bin/bash\\n...\\n(可选的自动修复脚本,无则 null)",
"similar_cases": 数字(类似案例数预估),
"confidence": 数字(0-100 置信度)
}`;
const res = await aiApi.chat([{ role: 'user', content: prompt }]);
const content = (res as JsonData)?.content || (res as JsonData)?.message?.content || '';
if (content) {
setAnalysis(parseAIResponse(content, fallback));
} else {
setAnalysis(fallback);
}
const res = await aiApi.analyzeAlert({
alert_id: Number(alert.id) || 0,
name: alert.name,
severity: alert.severity,
target: alert.source,
metric: alert.metric,
current_value: alert.currentValue,
threshold: alert.threshold,
duration: alert.duration,
description: alert.description,
time_range: '24h',
});
setAnalysis({
traceId: res.trace_id,
spanId: res.span_id,
rootCause: res.root_cause || fallback.rootCause,
impact: res.impact || fallback.impact,
recommendations: res.recommendations?.length ? res.recommendations : fallback.recommendations,
autoFixScript: res.auto_fix_script || fallback.autoFixScript,
similarCases: res.similar_cases ?? fallback.similarCases,
confidence: res.confidence ?? fallback.confidence,
evidence: res.evidence,
source: res.source,
model: res.model,
});
} catch {
// LLM 未配置或调用失败 → 回退到规则模板
setAnalysis(fallback);
} finally {
setIsAnalyzing(false);
@@ -55,6 +55,16 @@ export function AIAnalysisResult({ selectedAlert, isAnalyzing, analysis, onAutoF
</div>
</div>
{analysis.traceId && (
<div className="p-3 bg-[var(--dg-bg-secondary)] rounded-lg border border-[var(--dg-border)]">
<div className="text-xs dg-text-muted mb-1">审计 Trace</div>
<div className="space-y-1 text-xs font-mono text-[var(--dg-text-secondary)] break-all">
<div>trace_id: {analysis.traceId}</div>
{analysis.spanId && <div>span_id: {analysis.spanId}</div>}
</div>
</div>
)}
{/* 根因分析 */}
<div className="space-y-2">
<h4 className="text-sm font-medium text-[var(--dg-text-secondary)] flex items-center gap-2">
@@ -77,6 +87,26 @@ export function AIAnalysisResult({ selectedAlert, isAnalyzing, analysis, onAutoF
</div>
</div>
{analysis.evidence && analysis.evidence.length > 0 && (
<div className="space-y-2">
<h4 className="text-sm font-medium text-[var(--dg-text-secondary)] flex items-center gap-2">
<FileText className="h-4 w-4 dg-text-blue" />
证据链
</h4>
<div className="space-y-2">
{analysis.evidence.slice(0, 5).map((item, idx) => (
<div key={`${item.source}-${idx}`} className="p-2 bg-[var(--dg-bg-secondary)] rounded-lg border border-[var(--dg-border)]">
<div className="flex items-center justify-between gap-3 mb-1">
<span className="text-xs dg-text-muted">{item.type} · {item.source}</span>
<span className="text-xs dg-text-blue">{Math.round(item.confidence * 100)}%</span>
</div>
<p className="text-sm text-[var(--dg-text-secondary)] leading-relaxed">{item.summary}</p>
</div>
))}
</div>
</div>
)}
{/* 修复建议 */}
<div className="space-y-2">
<h4 className="text-sm font-medium text-[var(--dg-text-secondary)] flex items-center gap-2">
@@ -74,6 +74,25 @@ async function loadMetricsContext(): Promise<string> {
// 拉取仪表盘综合数据,生成运维报告上下文
async function loadReportContext(): Promise<string> {
const summary = await aiApi.getContextSummary();
if (summary) {
const alertLines = (summary.recent_alerts || []).slice(0, 10).map((a, i) =>
`${i + 1}.[${a.severity || '?'}|${a.status || '?'}] ${a.rule_name || '-'} → ${a.target || '-'}`,
);
const highLoadLines = (summary.high_load_hosts || []).slice(0, 8).map((h, i) =>
`${i + 1}.${h.hostname || h.ip} CPU:${h.cpu_usage}% MEM:${h.memory_usage}% DISK:${h.disk_usage}% Agent:${h.agent_status}`,
);
return [
`平台概览:服务器:${summary.server_total}(在线${summary.server_online}) | Kubernetes集群:${summary.cluster_total} | 平台告警:${summary.firing_alerts} | Agent未恢复告警:${summary.unresolved_alerts}`,
'',
'高负载资产:',
highLoadLines.join('\n') || i18n.t('aiops.noneFallback'),
'',
'最近告警:',
alertLines.join('\n') || i18n.t('aiops.noneFallback'),
].join('\n');
}
const [stats, alerts, releases] = await Promise.all([
dashboardStatsApi.getStats().catch(() => null),
dashboardStatsApi.getAlerts(10).catch(() => null),
@@ -482,4 +501,4 @@ export function AIOpsAssistant() {
</Card>
</PageShell>
);
}
}
@@ -274,7 +274,7 @@ export function PredictiveCostTab({
: 'border-blue-500/30 dg-text-blue bg-blue-500/10'
}
>
{risk.metric === 'daily' ? '{t('aiops.predictivecost.s3w4x5y6z')}' : '{t('aiops.predictivecost.s4x5y6z7a')}'}
{risk.metric === 'daily' ? t('aiops.predictivecost.s3w4x5y6z') : t('aiops.predictivecost.s4x5y6z7a')}
</Badge>
</div>
<p className="text-sm dg-text-secondary mt-2">
@@ -19,12 +19,17 @@ export interface AlertItem {
// AI 分析结果
export interface AIAnalysis {
traceId?: string;
spanId?: string;
rootCause: string;
impact: string;
recommendations: string[];
autoFixScript?: string;
similarCases: number;
confidence: number;
evidence?: { type: string; source: string; summary: string; confidence: number }[];
source?: string;
model?: string;
}
// ─── Dynamic AI analysis generator (rule-based fallback) ───
@@ -48,7 +53,7 @@ export function generateAIAnalysis(alert: AlertItem): AIAnalysis {
`考虑临时扩容或将部分流量调度到其他节点`,
i18n.t('aiops.analyzerhelpers.sc8ee5079'),
],
autoFixScript: `#!/bin/bash\n# CPU 高负载诊断脚本 - ${target}\necho i18n.t('aiops.analyzerhelpers.sa0b9d055')\nps aux --sort=-%cpu | head -11\necho ""\necho i18n.t('aiops.analyzerhelpers.sc4e238d7')\nuptime\necho ""\necho i18n.t('aiops.analyzerhelpers.sfaca56f5')\nPID=$(ps aux --sort=-%cpu | sed -n '2p' | awk '{print $2}')\nif [ -n "$PID" ]; then\n echo i18n.t('aiops.analyzerhelpers.s303a9855')\n cat /proc/$PID/cmdline | tr '\\0' ' '\n echo ""\nfi\n`,
autoFixScript: `#!/bin/bash\n# CPU 高负载诊断脚本 - ${target}\necho "${i18n.t('aiops.analyzerhelpers.sa0b9d055')}"\nps aux --sort=-%cpu | head -11\necho ""\necho "${i18n.t('aiops.analyzerhelpers.sc4e238d7')}"\nuptime\necho ""\necho "${i18n.t('aiops.analyzerhelpers.sfaca56f5')}"\nPID=$(ps aux --sort=-%cpu | sed -n '2p' | awk '{print $2}')\nif [ -n "$PID" ]; then\n echo "${i18n.t('aiops.analyzerhelpers.s303a9855')}"\n cat /proc/$PID/cmdline | tr '\\0' ' '\n echo ""\nfi\n`,
similarCases: 0,
confidence: 0,
};
@@ -85,7 +90,7 @@ export function generateAIAnalysis(alert: AlertItem): AIAnalysis {
i18n.t('aiops.analyzerhelpers.sb9cbe0b9'),
i18n.t('aiops.analyzerhelpers.s4438245e'),
],
autoFixScript: `#!/bin/bash\n# 磁盘清理脚本 - ${target}\necho i18n.t('aiops.analyzerhelpers.s5b91c16b')\ndf -h /\necho ""\necho i18n.t('aiops.analyzerhelpers.s3b1993da')\nfind /var/log -name "*.log" -mtime +30 -delete 2>/dev/null\nfind /var/log -name "*.gz" -mtime +30 -delete 2>/dev/null\necho ""\necho i18n.t('aiops.analyzerhelpers.sb513e117')\njournalctl --vacuum-time=7d 2>/dev/null\necho ""\necho i18n.t('aiops.analyzerhelpers.s4c139c9f')\ndf -h /\n`,
autoFixScript: `#!/bin/bash\n# 磁盘清理脚本 - ${target}\necho "${i18n.t('aiops.analyzerhelpers.s5b91c16b')}"\ndf -h /\necho ""\necho "${i18n.t('aiops.analyzerhelpers.s3b1993da')}"\nfind /var/log -name "*.log" -mtime +30 -delete 2>/dev/null\nfind /var/log -name "*.gz" -mtime +30 -delete 2>/dev/null\necho ""\necho "${i18n.t('aiops.analyzerhelpers.sb513e117')}"\njournalctl --vacuum-time=7d 2>/dev/null\necho ""\necho "${i18n.t('aiops.analyzerhelpers.s4c139c9f')}"\ndf -h /\n`,
similarCases: 0,
confidence: 0,
};
@@ -122,7 +127,7 @@ export function generateAIAnalysis(alert: AlertItem): AIAnalysis {
i18n.t('aiops.analyzerhelpers.s902739be'),
i18n.t('aiops.analyzerhelpers.sdef735d5'),
],
autoFixScript: `#!/bin/bash\n# 服务恢复脚本 - ${target}\necho i18n.t('aiops.analyzerhelpers.se59337b9')\nsystemctl list-units --state=failed\necho ""\necho i18n.t('aiops.analyzerhelpers.scdd631f0')\nss -tlnp | grep -E ':(80|443|8080|3306|6379|9090)'\necho ""\necho i18n.t('aiops.analyzerhelpers.sad3e7bba')\nsystemctl reset-failed\n`,
autoFixScript: `#!/bin/bash\n# 服务恢复脚本 - ${target}\necho "${i18n.t('aiops.analyzerhelpers.se59337b9')}"\nsystemctl list-units --state=failed\necho ""\necho "${i18n.t('aiops.analyzerhelpers.scdd631f0')}"\nss -tlnp | grep -E ':(80|443|8080|3306|6379|9090)'\necho ""\necho "${i18n.t('aiops.analyzerhelpers.sad3e7bba')}"\nsystemctl reset-failed\n`,
similarCases: 0,
confidence: 0,
};
@@ -0,0 +1,42 @@
import { useMemo } from "react";
import { useSessionSecurityStore, useUserStore } from "@/store";
interface SessionWatermarkProps {
className?: string;
}
function escapeSvgText(text: string) {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
// SessionWatermark 在远程会话画面上叠加当前登录用户,便于截图和录屏追责。 // Logan.Li
export function SessionWatermark({ className = "" }: SessionWatermarkProps) {
const enabled = useSessionSecurityStore((state) => state.watermarkEnabled);
const user = useUserStore((state) => state.user);
const username = user?.username || "unknown";
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>`;
return `url("data:image/svg+xml;utf8,${encodeURIComponent(svg)}")`;
}, [username]);
if (!enabled || !username) return null;
return (
<div
aria-hidden="true"
className={`pointer-events-none absolute inset-0 z-10 mix-blend-screen ${className}`}
style={{
backgroundImage,
backgroundRepeat: "repeat",
backgroundSize: "260px 150px",
opacity: 0.78,
}}
/>
);
}
@@ -309,13 +309,14 @@ export function CredFormDialog({ open, onClose, onSaved, editData }: CredFormDia
</SelectContent>
</Select>
</div>
{/* 用户名(左上)与密码(左下)同列上下行,安全模式/域归右列 // Logan.Li */}
<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">域</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><FieldWithToggle label="Windows 密码" value={rdpPassword} onChange={setRdpPassword}
placeholder="Windows 登录密码" fieldKey="rdp_pwd" required /></div>
</div>
)}
@@ -41,15 +41,15 @@ interface PipelineCreateDialogProps {
const PRESETS = [
{
key: 'ssh',
label: t('deploy.presetHost'),
desc: t('deploy.presetHostDesc'),
labelKey: 'deploy.presetHost',
descKey: 'deploy.presetHostDesc',
stages: ['checkout', 'build', 'deploy'],
deployTarget: 'ssh' as const,
},
{
key: 'k8s',
label: t('deploy.presetK8s'),
desc: t('deploy.presetK8sDesc'),
labelKey: 'deploy.presetK8s',
descKey: 'deploy.presetK8sDesc',
stages: ['checkout', 'build', 'image', 'deploy'],
deployTarget: 'k8s' as const,
},
@@ -287,8 +287,8 @@ 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)]">{p.label}</div>
<div className="text-[11px] text-[var(--dg-text-muted)] mt-0.5">{p.desc}</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>
@@ -77,7 +77,7 @@ export default function CapacityPage() {
return (
<PageShell
variant="list"
title=i18n.t('k8s.capacity.resources')
title={i18n.t('k8s.capacity.resources')}
subtitle={clusterName ? `${clusterName} · Deployment 资源请求与限制` : i18n.t('k8s.capacity.resources1')}
icon={<BarChart3 className="h-5 w-5" />}
actions={(
@@ -89,7 +89,7 @@ export default function CapacityPage() {
</SelectContent>
</Select>
<Select value={selectedNs} onValueChange={setSelectedNs}>
<SelectTrigger className="w-32 h-9 dg-input"><SelectValue placeholder=i18n.t('k8s.capacity.namespace') /></SelectTrigger>
<SelectTrigger className="w-32 h-9 dg-input"><SelectValue placeholder={i18n.t('k8s.capacity.namespace')} /></SelectTrigger>
<SelectContent>
<SelectItem value="all">所有命名空间</SelectItem>
{namespaces.map(n => <SelectItem key={n} value={n}>{n}</SelectItem>)}
@@ -120,7 +120,7 @@ export default function CapacityPage() {
</TableRow></TableHeader>
<TableBody>
{rows.length === 0 ? (
<TableRow><TableCell colSpan={8} className="text-center dg-text-muted py-8i18n.t('k8s.capacity.s934752c9')animate-spin inline-block ml-2">⏳</span> : ''}</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>
@@ -432,7 +432,7 @@ export function DeploymentEditAdvanced({ clusterCode, namespace, deploymentName,
<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'}
<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))}>
@@ -190,7 +190,7 @@ export function KubectlTerminal({ open, onOpenChange, clusterId, clusterName }:
{/* 终端容器 */}
<div className="relative bg-[#0f172a]" style={{ height: fullscreen ? 'calc(100vh - 49px)' : '480px' }}>
<div ref={containerRef} className="xterm-terminal-host h-full" />
<TerminalLoadingOverlay show={!hasOutput && !error} label=i18n.t('k8s.kubectl.cluster') />
<TerminalLoadingOverlay show={!hasOutput && !error} label={i18n.t('k8s.kubectl.cluster')} />
</div>
</DialogContent>
</Dialog>
@@ -473,12 +473,16 @@ export default function NamespaceResourcePage() {
);
const ResourceIcon = config.icon;
const subtitleParts = [clusterCode, namespace].filter(Boolean);
const pageSubtitle = subtitleParts.length > 0
? `${subtitleParts.join(' / ')} · ${config.subtitle}`
: config.subtitle;
return (
<PageShell
variant="list"
title={config.title}
subtitle={`${clusterCode || '-'} / ${namespace || '-'} · ${config.subtitle}`}
subtitle={pageSubtitle}
icon={<ResourceIcon className="h-5 w-5" />}
actions={(
<>
@@ -308,7 +308,7 @@ export function PodExecTerminal({
<Select value={container} onValueChange={setContainer}>
<SelectTrigger className="h-8 w-[150px] text-xs bg-[var(--dg-bg-hover)] border-[var(--dg-border)]">
<Box className="h-3 w-3 mr-1 text-[var(--dg-text-muted)]" />
<SelectValue placeholder=i18n.t('k8s.podExec.container') />
<SelectValue placeholder={i18n.t('k8s.podExec.container')} />
</SelectTrigger>
<SelectContent>{containers.map((c) => <SelectItem key={c} value={c}>{c}</SelectItem>)}</SelectContent>
</Select>
@@ -320,13 +320,13 @@ export function PodExecTerminal({
<SelectContent>{SHELLS.map(s => <SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>)}</SelectContent>
</Select>
<Button variant="ghost" size="sm" className="h-8 px-2 text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]"
onClick={() => { connect(); toast({ title: i18n.t('k8s.podExec.reconnect1') }); }} title=i18n.t('k8s.podExec.reconnect2')>
onClick={() => { connect(); toast({ title: i18n.t('k8s.podExec.reconnect1') }); }} title={i18n.t('k8s.podExec.reconnect2')}>
<RotateCw className="h-3.5 w-3.5" />
</Button>
<button onClick={() => setFullscreen(!fullscreen)} className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]" title={fullscreen ? i18n.t('k8s.podExec.exitFullscreen') : i18n.t('k8s.podExec.fullscreen')}>
{fullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</button>
<button onClick={() => onOpenChange(false)} className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]" title=i18n.t('k8s.podExec.close1')>
<button onClick={() => onOpenChange(false)} className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]" title={i18n.t('k8s.podExec.close1')}>
<X className="h-4 w-4" />
</button>
</div>
@@ -339,7 +339,7 @@ export function PodExecTerminal({
className="xterm-terminal-host h-full cursor-text"
onClick={handleTerminalClick}
/>
<TerminalLoadingOverlay show={connState === 'connecting' && !hasOutput} label=i18n.t('k8s.podExec.container1') />
<TerminalLoadingOverlay show={connState === 'connecting' && !hasOutput} label={i18n.t('k8s.podExec.container1')} />
</div>
</DialogContent>
</Dialog>
@@ -86,7 +86,7 @@ export default function PodMetricsPage() {
return (
<PageShell
variant="list"
title=i18n.t('k8s.podMetrics.resources2')
title={i18n.t('k8s.podMetrics.resources2')}
subtitle={clusterName ? `${clusterName} · 容器 CPU/内存实时指标` : i18n.t('k8s.podMetrics.container2')}
icon={<Activity className="h-5 w-5" />}
metrics={!loading ? (
@@ -107,7 +107,7 @@ export default function PodMetricsPage() {
</SelectContent>
</Select>
<Select value={selectedNs} onValueChange={setSelectedNs}>
<SelectTrigger className="w-36 h-9 dg-input"><SelectValue placeholder=i18n.t('k8s.podMetrics.namespace') /></SelectTrigger>
<SelectTrigger className="w-36 h-9 dg-input"><SelectValue placeholder={i18n.t('k8s.podMetrics.namespace')} /></SelectTrigger>
<SelectContent>
<SelectItem value="all">所有命名空间</SelectItem>
{namespaces.map(n => <SelectItem key={n} value={n}>{n}</SelectItem>)}
@@ -131,7 +131,7 @@ export default function PodMetricsPage() {
</TableRow></TableHeader>
<TableBody>
{rows.length === 0 ? (
<TableRow><TableCell colSpan={5} className="text-center dg-text-muted py-8i18n.t('k8s.podMetrics.s934752c9')animate-spin inline-block ml-2">⏳</span> : ''}</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>
@@ -335,7 +335,7 @@ function ConfigMapEditor({ manifest, onSave, saving, isCreate }: { manifest: any
<Textarea
value={entry.value}
onChange={e => update(i, 'value', e.target.value)}
placeholder=i18n.t('k8s.resourceEditor.sfe7509e0')
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"
/>
@@ -98,7 +98,7 @@ export function CreateHPADialog({ clusterCode, namespace, deploymentName, onCrea
className="dg-input mt-1" />
</div>
<div>
<Label className="dg-text-secondary text-smi18n.t('k8s.misc.memory1')dg-text-muted">可选</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" />
@@ -126,18 +126,23 @@ export function Breadcrumb() {
}
}
// 当前页标题由 PageShell 负责展示,面包屑只保留父级导航,避免页面标题重复。// Logan.Li
if (isLast) {
return;
}
items.push({
name: displayName,
path: currentPath,
isLast,
isLast: false,
});
});
return items;
}, [location.pathname, t]);
// Don't show breadcrumb on dashboard (root path)
if (breadcrumbs.length <= 1 && location.pathname === '/') {
// 一级页面没有父级路径时不显示面包屑,避免与 PageShell 标题重复。// Logan.Li
if (breadcrumbs.length === 0) {
return null;
}
@@ -16,7 +16,7 @@ import {
} from '@/components/ui/dialog';
import { Plus, Trash2, Loader2 } from 'lucide-react';
import { alertApi } from '@/api';
import { CHANNEL_TYPES, getChannelTypeLabel } from './alertShared';
import { getChannelTypes, getChannelTypeLabel } from './alertShared';
import type { AlertChannel } from '@/types';
import i18n from '@/i18n';
@@ -225,7 +225,7 @@ export function AlertChannelDialog({
onChange={(e) => setChannelForm({ ...channelForm, type: e.target.value })}
className="w-full h-9 px-3 rounded-md bg-[var(--dg-bg-tertiary)]/50 border border-[var(--dg-border)] text-[var(--dg-text-primary)] text-sm"
>
{CHANNEL_TYPES.map(({ key, label }) => (
{getChannelTypes().map(({ key, label }) => (
<option key={key} value={key}>{label}</option>
))}
</select>
@@ -235,7 +235,7 @@ export function AlertChannelDialog({
<Textarea
value={channelForm.config}
onChange={(e) => setChannelForm({ ...channelForm, config: e.target.value })}
placeholder={CHANNEL_TYPES.find((t) => t.key === channelForm.type)?.placeholder || ''}
placeholder={getChannelTypes().find((t) => t.key === channelForm.type)?.placeholder || ''}
rows={5}
className="dg-input font-mono text-xs resize-none"
/>
+26 -18
View File
@@ -1,4 +1,5 @@
import { Mail, Phone, MessageSquare, Bell } from 'lucide-react';
import i18n from '@/i18n';
import type { AlertChannel, AlertRuleRecord } from '@/types';
// 将 notify_channels (可能是数组/JSON字符串/逗号分隔) 解析为数组
@@ -20,40 +21,47 @@ export function parseChannels( notifyChannels: unknown): string[] {
// 已知的渠道类型(用于旧数据兼容 — 旧规则存储类型字符串而非渠道 ID)
export const KNOWN_CHANNEL_TYPES = ['email', 'sms', 'dingtalk', 'wechat', 'webhook'];
// 根据渠道类型获取图标组件名(用字符串映射,组件内用 getChannelIcon 返回实际组件)
export const CHANNEL_TYPE_LABELS: Record<string, string> = {
dingtalk: '钉钉',
wechat: '企业微信',
email: '邮件',
sms: '短信',
webhook: 'Webhook',
// 渠道类型标签 key(运行时通过 i18n.t 解析,适配语言切换) // Logan.Li
export const CHANNEL_TYPE_LABEL_KEYS: Record<string, string> = {
dingtalk: 'monitor.channel.dingtalk',
wechat: 'monitor.channel.wechat',
email: 'monitor.channel.email',
sms: 'monitor.channel.sms',
webhook: 'monitor.channel.webhook',
};
// 兼容旧调用:返回当前语言的标签
export function getChannelTypeLabelKey(type: string): string {
return CHANNEL_TYPE_LABEL_KEYS[type] ? i18n.t(CHANNEL_TYPE_LABEL_KEYS[type]) : type;
}
// 解析单个 notify_channels 引用值 — 返回 {id?, type, name, isLegacy}
// 新格式存渠道 ID (string),旧格式存类型字符串 (email/dingtalk 等)
export function resolveChannelRef(value: string, channels: AlertChannel[]): { type: string; name: string; isLegacy: boolean } | null {
if (!value) return null;
// 尝试按 ID 匹配已配置渠道
const ch = channels.find((c) => String(c.id) === String(value));
if (ch) return { type: ch.type || 'unknown', name: ch.name || ch.type || '未命名', isLegacy: false };
if (ch) return { type: ch.type || 'unknown', name: ch.name || ch.type || i18n.t('common.unnamed'), isLegacy: false };
// 兼容旧格式:值本身是类型字符串
if (KNOWN_CHANNEL_TYPES.includes(value)) {
return { type: value, name: CHANNEL_TYPE_LABELS[value] || value, isLegacy: true };
return { type: value, name: getChannelTypeLabelKey(value), isLegacy: true };
}
return null;
}
// 渠道类型选项(供下拉选择)
export const CHANNEL_TYPES: { key: string; label: string; placeholder: string }[] = [
{ key: 'dingtalk', label: '钉钉', placeholder: '{\n "webhook": "https://oapi.dingtalk.com/robot/send?access_token=***",\n "secret": "SECxxx"\n}' },
{ key: 'wechat', label: '企业微信', placeholder: '{\n "webhook": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=***"\n}' },
{ key: 'email', label: '邮件', placeholder: '{\n "to": "ops@ezops.io",\n "subject": "告警通知"\n}' },
{ key: 'sms', label: '短信', placeholder: '{\n "phones": "13800138000",\n "sign": "AIOps"\n}' },
{ key: 'webhook', label: 'Webhook', placeholder: '{\n "url": "https://example.com/alert"\n}' },
];
// 渠道类型选项(供下拉选择,label 走 i18n) // Logan.Li
export function getChannelTypes(): { key: string; label: string; placeholder: string }[] {
return [
{ key: 'dingtalk', label: i18n.t('monitor.channel.dingtalk'), placeholder: '{\n "webhook": "https://oapi.dingtalk.com/robot/send?access_token=***",\n "secret": "SECxxx"\n}' },
{ key: 'wechat', label: i18n.t('monitor.channel.wechat'), placeholder: '{\n "webhook": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=***"\n}' },
{ key: 'email', label: i18n.t('monitor.channel.email'), placeholder: '{\n "to": "ops@ezops.io",\n "subject": "Alert Notification"\n}' },
{ key: 'sms', label: i18n.t('monitor.channel.sms'), placeholder: '{\n "phones": "13800138000",\n "sign": "AIOps"\n}' },
{ key: 'webhook', label: 'Webhook', placeholder: '{\n "url": "https://example.com/alert"\n}' },
];
}
export function getChannelTypeLabel(type: string) {
return CHANNEL_TYPES.find((t) => t.key === type)?.label || type;
return getChannelTypes().find((t) => t.key === type)?.label || type;
}
// 根据渠道类型返回对应图标组件
@@ -47,11 +47,12 @@ import { cn } from '@/lib/utils';
import { PageShell } from '@/components/layout/PageShell';
import { PageLoading } from '@/components/layout/PageLoading';
import { PageEmpty } from '@/components/layout/PageEmpty';
import i18n from '@/i18n';
// ═══════════ Tab 配置 ═══════════
type TabId = 'agent' | 'zabbix' | 'prometheus';
const getTabs = (t: (key: string) => string): { id: TabId; label: string; icon: React.ReactNode }[] => [
{ id: 'agent', label: t('monitor.dashboard.tab.agent'), icon: <Server className="w-3.5 h-3.5" /> },
{ id: 'agent', label: i18n.t('monitor.dashboard.tab.agent'), icon: <Server className="w-3.5 h-3.5" /> },
{ id: 'zabbix', label: 'Zabbix', icon: <Activity className="w-3.5 h-3.5" /> },
{ id: 'prometheus', label: 'Prometheus', icon: <Activity className="w-3.5 h-3.5" /> },
];
@@ -86,16 +87,16 @@ const AgentTab: React.FC = () => {
// 批量卸载
const [batchUninstalling, setBatchUninstalling] = useState(false);
const handleBatchUninstall = async () => {
const ok = await _confirm({ title: t('monitor.dashboard.confirm.uninstallTitle'), description: t('monitor.dashboard.confirm.uninstallDesc', { count: selIds.length }), confirmText: t('monitor.dashboard.confirm.uninstall'), variant: 'destructive' });
const ok = await _confirm({ title: i18n.t('monitor.dashboard.confirm.uninstallTitle'), description: i18n.t('monitor.dashboard.confirm.uninstallDesc', { count: selIds.length }), confirmText: i18n.t('monitor.dashboard.confirm.uninstall'), variant: 'destructive' });
if (!ok) return;
setBatchUninstalling(true);
setOperating(new Set(selIds));
try {
await agentApi.batchUninstall(selIds);
toast.success(t('monitor.dashboard.agent.toast.batchUninstall', { count: selIds.length }));
toast.success(i18n.t('monitor.dashboard.agent.toast.batchUninstall', { count: selIds.length }));
setSelected(new Set());
setTimeout(() => { fetch(); setOperating(new Set()); }, 30000);
} catch { toast.error(t('monitor.dashboard.agent.toast.submitFailed')); setOperating(new Set()); }
} catch { toast.error(i18n.t('monitor.dashboard.agent.toast.submitFailed')); setOperating(new Set()); }
finally { setBatchUninstalling(false); }
};
const totalPages = Math.max(1, Math.ceil(hosts.length / pageSize));
@@ -115,7 +116,7 @@ const AgentTab: React.FC = () => {
return next;
});
} catch (e: any) {
toast.error(t('monitor.dashboard.agent.toast.refreshFailed', { msg: (e?.message || t('monitor.dashboard.agent.toast.unknownError')) }));
toast.error(i18n.t('monitor.dashboard.agent.toast.refreshFailed', { msg: (e?.message || i18n.t('monitor.dashboard.agent.toast.unknownError')) }));
} finally {
setLoading(false);
setRefreshing(false);
@@ -158,17 +159,17 @@ const AgentTab: React.FC = () => {
// 执行批量部署
const handleBatchSubmit = async () => {
if (!batchAck) { toast.error(t('monitor.dashboard.agent.toast.ackRequired')); return; }
if (!batchAck) { toast.error(i18n.t('monitor.dashboard.agent.toast.ackRequired')); return; }
setBatchSubmitting(true);
const token = useUserStore.getState().token;
const ids = selectedHosts.map(h => h.server_id);
setOperating(new Set(ids));
try {
if (batchMode === 'install') {
if (!token) { toast.error(t('monitor.dashboard.agent.toast.notLogin')); return; }
if (!token) { toast.error(i18n.t('monitor.dashboard.agent.toast.notLogin')); return; }
// 安装:只安装无 Agent 的
const installIds = needInstall.map(h => h.server_id);
if (installIds.length === 0) { toast.warning(t('monitor.dashboard.agent.toast.allInstalled')); return; }
if (installIds.length === 0) { toast.warning(i18n.t('monitor.dashboard.agent.toast.allInstalled')); return; }
// 为未保存凭据的主机构造临时凭据 map;后端遇到空字段不会覆盖已有凭据 // Logan.Li
const credentials: Record<number, { username?: string; password?: string; ssh_key?: string; auth_type?: string }> = {};
const needCreds = batchCreds.username && (batchCreds.authType === 'key' ? batchCreds.sshKey : batchCreds.password);
@@ -183,63 +184,63 @@ const AgentTab: React.FC = () => {
}
}
const res = await serverApi.batchInstallAgent(installIds, token, Object.keys(credentials).length ? credentials : undefined);
toast.success(t('monitor.dashboard.agent.toast.batchInstall', { count: res?.total ?? installIds.length }));
toast.success(i18n.t('monitor.dashboard.agent.toast.batchInstall', { count: res?.total ?? installIds.length }));
setSelected(new Set());
setTimeout(() => { fetch(); setOperating(new Set()); }, 30000);
} else {
// 升级:只升级在线的
const upgradeIds = needUpgrade.map(h => h.server_id);
if (upgradeIds.length === 0) { toast.warning(t('monitor.dashboard.agent.toast.noOnline')); return; }
if (upgradeIds.length === 0) { toast.warning(i18n.t('monitor.dashboard.agent.toast.noOnline')); return; }
const res = await agentApi.batchUpgrade(upgradeIds);
toast.success(res?.message || t('monitor.dashboard.agent.toast.batchUpgrade', { count: upgradeIds.length }));
toast.success(res?.message || i18n.t('monitor.dashboard.agent.toast.batchUpgrade', { count: upgradeIds.length }));
setSelected(new Set());
setTimeout(() => { fetch(); setOperating(new Set()); }, 30000);
}
setBatchOpen(false);
} catch { toast.error(t('monitor.dashboard.agent.toast.submitFailed')); setOperating(new Set()); }
} catch { toast.error(i18n.t('monitor.dashboard.agent.toast.submitFailed')); setOperating(new Set()); }
finally { setBatchSubmitting(false); }
};
if (loading) return <PageLoading label={t('monitor.dashboard.agent.loadingHosts')} />;
if (loading) return <PageLoading label={i18n.t('monitor.dashboard.agent.loadingHosts')} />;
if (!hosts.length) return (
<PageEmpty
icon={<Server className="h-8 w-8" />}
title={t('monitor.dashboard.agent.emptyTitle')}
description={t('monitor.dashboard.agent.emptyDesc')}
title={i18n.t('monitor.dashboard.agent.emptyTitle')}
description={i18n.t('monitor.dashboard.agent.emptyDesc')}
/>
);
return (
<div>
{/* 工具栏 */}
<div className="flex items-center gap-2 mb-3 flex-wrap">
{selAny && <span className="text-xs text-[var(--dg-text-secondary)]">{t('monitor.dashboard.agent.selected', { count: selected.size })}</span>}
{selAny && <span className="text-xs text-[var(--dg-text-secondary)]">{i18n.t('monitor.dashboard.agent.selected', { count: selected.size })}</span>}
<div className="flex-1" />
<Button size="sm" variant="outline" className="h-7 text-xs gap-1" onClick={() => {
const picked = selAny ? hosts.find(h => selected.has(String(h.server_id))) : null;
setWizardServer(picked ? { id: picked.server_id, hostname: picked.hostname, ip: picked.ip, has_auth: picked.has_auth } : null);
setWizardServer(picked ? { id: picked.server_id, hostname: picked.hostname, ip: picked.ip, has_auth: picked.has_auth, os: picked.os } : null);
setWizardOpen(true);
}}>
<Rocket className="w-3 h-3" />{t('monitor.dashboard.agent.installAgent')}
<Rocket className="w-3 h-3" />{i18n.t('monitor.dashboard.agent.installAgent')}
</Button>
{selAny && (
<>
<Button size="sm" variant="outline" className="h-7 text-xs gap-1 dg-btn-secondary dg-text-blue" onClick={openBatchDialog}>
<PackageOpen className="w-3 h-3" />{t('monitor.dashboard.agent.batchDeploy')} ({selected.size})
<PackageOpen className="w-3 h-3" />{i18n.t('monitor.dashboard.agent.batchDeploy')} ({selected.size})
</Button>
<Button size="sm" variant="outline" className="h-7 text-xs gap-1 border-red-500/30 dg-text-red hover:bg-red-500/10" onClick={handleBatchUninstall} disabled={batchUninstalling}>
<Trash2 className="w-3 h-3" />{t('monitor.dashboard.agent.batchUninstall')} ({selected.size})
<Trash2 className="w-3 h-3" />{i18n.t('monitor.dashboard.agent.batchUninstall')} ({selected.size})
</Button>
</>
)}
<Button size="sm" variant="ghost" className="h-7 text-xs gap-1" onClick={fetch} disabled={refreshing}>
<RefreshCw className={cn('w-3 h-3', refreshing && 'animate-spin')} />{t('monitor.dashboard.refresh')}
<RefreshCw className={cn('w-3 h-3', refreshing && 'animate-spin')} />{i18n.t('monitor.dashboard.refresh')}
</Button>
</div>
<AgentWizard open={wizardOpen} onOpenChange={setWizardOpen} server={wizardServer}
onStart={(id) => { setOperating(prev => new Set(prev).add(id)); }}
onInstalled={() => { toast.success(t('monitor.dashboard.agent.toast.installed')); setWizardOpen(false); fetch(); }}
onFailed={(id) => { toast.error(t('monitor.dashboard.agent.toast.installFailed')); setOperating(prev => { const next = new Set(prev); next.delete(id); return next; }); }} />
onInstalled={() => { toast.success(i18n.t('monitor.dashboard.agent.toast.installed')); setWizardOpen(false); fetch(); }}
onFailed={(id) => { toast.error(i18n.t('monitor.dashboard.agent.toast.installFailed')); setOperating(prev => { const next = new Set(prev); next.delete(id); return next; }); }} />
{/* ────────── 批量部署 Agent 对话框 ────────── */}
<Dialog open={batchOpen} onOpenChange={(v) => { if (!v) { setBatchOpen(false); setBatchAck(false); setBatchCreds({ username: 'root', password: '', sshKey: '', authType: 'password' }); } }}>
@@ -247,10 +248,10 @@ const AgentTab: React.FC = () => {
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-base">
<PackageOpen className="w-5 h-5 dg-text-blue" />
{t('monitor.dashboard.batch.title')}
{i18n.t('monitor.dashboard.batch.title')}
</DialogTitle>
<DialogDescription className="dg-text-secondary text-xs">
{t('monitor.dashboard.batch.summary', { selected: selectedHosts.length, install: needInstall.length, upgrade: needUpgrade.length })}
{i18n.t('monitor.dashboard.batch.summary', { selected: selectedHosts.length, install: needInstall.length, upgrade: needUpgrade.length })}
</DialogDescription>
</DialogHeader>
@@ -266,7 +267,7 @@ const AgentTab: React.FC = () => {
batchMode === m ? 'dg-btn-primary text-white shadow-sm' : 'dg-text-secondary hover:text-[var(--dg-text-primary)]'
}`}
>
{m === 'install' ? t('monitor.dashboard.batch.install', { count: needInstall.length }) : t('monitor.dashboard.batch.upgrade', { count: needUpgrade.length })}
{m === 'install' ? i18n.t('monitor.dashboard.batch.install', { count: needInstall.length }) : i18n.t('monitor.dashboard.batch.upgrade', { count: needUpgrade.length })}
</button>
))}
</div>
@@ -276,11 +277,11 @@ const AgentTab: React.FC = () => {
<table className="w-full text-xs">
<thead className="bg-[var(--dg-bg-tertiary)]">
<tr>
<th className="px-3 py-2 text-left font-medium dg-text-secondary">{t('monitor.dashboard.col.hostname')}</th>
<th className="px-3 py-2 text-left font-medium dg-text-secondary">{i18n.t('monitor.dashboard.col.hostname')}</th>
<th className="px-3 py-2 text-left font-medium dg-text-secondary">IP</th>
<th className="px-3 py-2 text-left font-medium dg-text-secondary">{t('monitor.dashboard.col.status')}</th>
<th className="px-3 py-2 text-left font-medium dg-text-secondary">{t('monitor.dashboard.col.version')}</th>
<th className="px-3 py-2 text-left font-medium dg-text-secondary">{t('monitor.dashboard.col.action')}</th>
<th className="px-3 py-2 text-left font-medium dg-text-secondary">{i18n.t('monitor.dashboard.col.status')}</th>
<th className="px-3 py-2 text-left font-medium dg-text-secondary">{i18n.t('monitor.dashboard.col.version')}</th>
<th className="px-3 py-2 text-left font-medium dg-text-secondary">{i18n.t('monitor.dashboard.col.action')}</th>
</tr>
</thead>
<tbody>
@@ -290,15 +291,15 @@ const AgentTab: React.FC = () => {
<td className="px-3 py-2 font-mono dg-text-secondary">{h.ip}</td>
<td className="px-3 py-2">
{h.agent_status === 'online'
? <Badge className="text-[10px] bg-emerald-500/20 dg-text-green">{t('monitor.dashboard.status.online')}</Badge>
? <Badge className="text-[10px] bg-emerald-500/20 dg-text-green">{i18n.t('monitor.dashboard.status.online')}</Badge>
: <Badge className="text-[10px] bg-[var(--dg-bg-active)]/20 dg-text-secondary">
{h.agent_status === 'not_installed' ? t('monitor.dashboard.status.notInstalled') : h.agent_status || t('monitor.dashboard.status.offline')}
{h.agent_status === 'not_installed' ? i18n.t('monitor.dashboard.status.notInstalled') : h.agent_status || i18n.t('monitor.dashboard.status.offline')}
</Badge>}
</td>
<td className="px-3 py-2 font-mono dg-text-secondary">{h.agent_version || '—'}</td>
<td className="px-3 py-2">
<span className="dg-text-blue text-[11px]">
{batchMode === 'install' ? t('monitor.dashboard.batch.willInstall') : t('monitor.dashboard.batch.willUpgrade')}
{batchMode === 'install' ? i18n.t('monitor.dashboard.batch.willInstall') : i18n.t('monitor.dashboard.batch.willUpgrade')}
</span>
</td>
</tr>
@@ -312,34 +313,34 @@ const AgentTab: React.FC = () => {
<div className="rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]/50 p-3 space-y-3">
<div className="flex items-center gap-2">
<Info className="w-4 h-4 dg-text-blue shrink-0" />
<span className="text-xs text-[var(--dg-text-secondary)]">{t('monitor.dashboard.creds.hint')}</span>
<span className="text-xs text-[var(--dg-text-secondary)]">{i18n.t('monitor.dashboard.creds.hint')}</span>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-[11px] dg-text-secondary mb-1 block">{t('monitor.dashboard.creds.authType')}</Label>
<Label className="text-[11px] dg-text-secondary mb-1 block">{i18n.t('monitor.dashboard.creds.authType')}</Label>
<Select value={batchCreds.authType} onValueChange={(v: 'password' | 'key') => setBatchCreds(c => ({ ...c, authType: v }))}>
<SelectTrigger className="h-8 text-xs dg-input text-[var(--dg-text-primary)]">
<SelectValue />
</SelectTrigger>
<SelectContent className="dg-input text-[var(--dg-text-primary)]">
<SelectItem value="password">{t('monitor.dashboard.creds.password')}</SelectItem>
<SelectItem value="key">{t('monitor.dashboard.creds.sshKey')}</SelectItem>
<SelectItem value="password">{i18n.t('monitor.dashboard.creds.password')}</SelectItem>
<SelectItem value="key">{i18n.t('monitor.dashboard.creds.sshKey')}</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-[11px] dg-text-secondary mb-1 block">{t('monitor.dashboard.creds.username')}</Label>
<Label className="text-[11px] dg-text-secondary mb-1 block">{i18n.t('monitor.dashboard.creds.username')}</Label>
<Input value={batchCreds.username} onChange={e => setBatchCreds(c => ({ ...c, username: e.target.value }))} placeholder="root" className="h-8 text-xs dg-input text-[var(--dg-text-primary)]" />
</div>
</div>
{batchCreds.authType === 'password' ? (
<div>
<Label className="text-[11px] dg-text-secondary mb-1 block">{t('monitor.dashboard.creds.password')}</Label>
<Input type="password" value={batchCreds.password} onChange={e => setBatchCreds(c => ({ ...c, password: e.target.value }))} placeholder={t('monitor.dashboard.creds.passwordPlaceholder')} className="h-8 text-xs dg-input text-[var(--dg-text-primary)]" />
<Label className="text-[11px] dg-text-secondary mb-1 block">{i18n.t('monitor.dashboard.creds.password')}</Label>
<Input type="password" value={batchCreds.password} onChange={e => setBatchCreds(c => ({ ...c, password: e.target.value }))} placeholder={i18n.t('monitor.dashboard.creds.passwordPlaceholder')} className="h-8 text-xs dg-input text-[var(--dg-text-primary)]" />
</div>
) : (
<div>
<Label className="text-[11px] dg-text-secondary mb-1 block">{t('monitor.dashboard.creds.sshKey')}</Label>
<Label className="text-[11px] dg-text-secondary mb-1 block">{i18n.t('monitor.dashboard.creds.sshKey')}</Label>
<textarea value={batchCreds.sshKey} onChange={e => setBatchCreds(c => ({ ...c, sshKey: e.target.value }))} placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" className="w-full h-24 rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] text-[var(--dg-text-primary)] text-xs p-2 resize-none focus:outline-none focus:ring-1 focus:ring-[var(--dg-blue)]" />
</div>
)}
@@ -352,30 +353,30 @@ const AgentTab: React.FC = () => {
<Info className="w-4 h-4 dg-text-yellow mt-0.5 shrink-0" />
<div>
<p className="text-xs font-medium text-amber-300 mb-1">
{(batchMode === 'install' ? t('monitor.dashboard.warn.thisInstall') : t('monitor.dashboard.warn.thisUpgrade')) + t('monitor.dashboard.warn.intro')}
{(batchMode === 'install' ? i18n.t('monitor.dashboard.warn.thisInstall') : i18n.t('monitor.dashboard.warn.thisUpgrade')) + i18n.t('monitor.dashboard.warn.intro')}
</p>
<p className="text-[11px] dg-text-yellow/80 leading-relaxed">
{batchMode === 'upgrade' ? t('monitor.dashboard.warn.upgradeNotice') : t('monitor.dashboard.warn.installNotice')}
{batchMode === 'upgrade' ? i18n.t('monitor.dashboard.warn.upgradeNotice') : i18n.t('monitor.dashboard.warn.installNotice')}
</p>
<ul className="mt-1 space-y-0.5 text-[11px] dg-text-secondary list-disc list-inside">
{batchMode === 'upgrade' && (
<>
<li>{t('monitor.dashboard.warn.u1')}</li>
<li>{t('monitor.dashboard.warn.u2')}</li>
<li>{t('monitor.dashboard.warn.u3')}</li>
<li>{t('monitor.dashboard.warn.u4')}</li>
<li>{i18n.t('monitor.dashboard.warn.u1')}</li>
<li>{i18n.t('monitor.dashboard.warn.u2')}</li>
<li>{i18n.t('monitor.dashboard.warn.u3')}</li>
<li>{i18n.t('monitor.dashboard.warn.u4')}</li>
</>
)}
{batchMode === 'install' && (
<>
<li>{t('monitor.dashboard.warn.i1')}</li>
<li>{t('monitor.dashboard.warn.i2')}</li>
<li>{t('monitor.dashboard.warn.i3')}</li>
<li>{t('monitor.dashboard.warn.i4')}</li>
<li>{i18n.t('monitor.dashboard.warn.i1')}</li>
<li>{i18n.t('monitor.dashboard.warn.i2')}</li>
<li>{i18n.t('monitor.dashboard.warn.i3')}</li>
<li>{i18n.t('monitor.dashboard.warn.i4')}</li>
</>
)}
<li>{t('monitor.dashboard.warn.c1')}</li>
<li>{t('monitor.dashboard.warn.c2')}</li>
<li>{i18n.t('monitor.dashboard.warn.c1')}</li>
<li>{i18n.t('monitor.dashboard.warn.c2')}</li>
</ul>
</div>
</div>
@@ -384,13 +385,13 @@ const AgentTab: React.FC = () => {
{/* 我已知悉 勾选 */}
<div className="flex items-center gap-2">
<Checkbox id="batch-ack" checked={batchAck} onCheckedChange={(c) => setBatchAck(!!c)} />
<label htmlFor="batch-ack" className="text-xs text-[var(--dg-text-secondary)] cursor-pointer select-none">{t('monitor.dashboard.batch.ack')}</label>
<label htmlFor="batch-ack" className="text-xs text-[var(--dg-text-secondary)] cursor-pointer select-none">{i18n.t('monitor.dashboard.batch.ack')}</label>
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] h-8 text-xs" onClick={() => { setBatchOpen(false); setBatchAck(false); }}>
{t('monitor.dashboard.batch.cancel')}
{i18n.t('monitor.dashboard.batch.cancel')}
</Button>
<Button
className="dg-btn-primary h-8 text-xs gap-1"
@@ -398,7 +399,7 @@ const AgentTab: React.FC = () => {
onClick={handleBatchSubmit}
>
{batchSubmitting && <Loader2 className="w-3 h-3 animate-spin" />}
{batchMode === 'install' ? t('monitor.dashboard.batch.confirmInstall') : t('monitor.dashboard.batch.confirmUpgrade')}
{batchMode === 'install' ? i18n.t('monitor.dashboard.batch.confirmInstall') : i18n.t('monitor.dashboard.batch.confirmUpgrade')}
</Button>
</DialogFooter>
</DialogContent>
@@ -412,10 +413,10 @@ const AgentTab: React.FC = () => {
</th>
{['hostname','agent_version','agent_status','cpu_usage','mem_usage','disk_usage','region','ip'].map(f => (
<th key={f} className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)] cursor-pointer hover:text-[var(--dg-text-primary)] select-none whitespace-nowrap" onClick={() => toggle(f as SortField)}>
<div className="flex items-center gap-1">{{hostname:t('monitor.dashboard.col.hostname'),agent_version:t('monitor.dashboard.col.agentVersion'),agent_status:t('monitor.dashboard.col.status'),cpu_usage:t('monitor.dashboard.col.cpu'),mem_usage:t('monitor.dashboard.col.memory'),disk_usage:t('monitor.dashboard.col.disk'),region:t('monitor.dashboard.col.region'),ip:'IP'}[f]}<SI f={f as SortField} /></div>
<div className="flex items-center gap-1">{{hostname:i18n.t('monitor.dashboard.col.hostname'),agent_version:i18n.t('monitor.dashboard.col.agentVersion'),agent_status:i18n.t('monitor.dashboard.col.status'),cpu_usage:i18n.t('monitor.dashboard.col.cpu'),mem_usage:i18n.t('monitor.dashboard.col.memory'),disk_usage:i18n.t('monitor.dashboard.col.disk'),region:i18n.t('monitor.dashboard.col.region'),ip:'IP'}[f]}<SI f={f as SortField} /></div>
</th>
))}
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)] whitespace-nowrap">{t('monitor.dashboard.col.action')}</th></tr></thead>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)] whitespace-nowrap">{i18n.t('monitor.dashboard.col.action')}</th></tr></thead>
<tbody>
{paged.map((h, i) => (
<tr key={h.hostname + i} className="border-b border-[var(--dg-border)] last:border-0 hover:bg-[var(--dg-bg-tertiary)]/50">
@@ -435,13 +436,13 @@ const AgentTab: React.FC = () => {
{operating.has(h.server_id) ? (
<span className="inline-flex items-center gap-1 dg-text-blue">
<Loader2 className="w-3 h-3 animate-spin" />
<span className="text-[10px]">{t('monitor.dashboard.agent.deploying')}</span>
<span className="text-[10px]">{i18n.t('monitor.dashboard.agent.deploying')}</span>
</span>
) : (
h.agent_version || '—'
)}
</td>
<td className="px-3 py-2.5">{h.agent_status==='online'?<Badge className="text-[10px] bg-emerald-500/20 dg-text-green"><Wifi className="w-3 h-3 mr-1"/>{t('monitor.dashboard.status.online')}</Badge>:h.agent_status==='not_installed'?<Badge className="text-[10px] bg-amber-500/20 dg-text-yellow">{t('monitor.dashboard.status.notInstalled')}</Badge>:<Badge className="text-[10px] bg-[var(--dg-bg-active)]/10 dg-text-secondary"><WifiOff className="w-3 h-3 mr-1"/>{t('monitor.dashboard.status.offline')}</Badge>}</td>
<td className="px-3 py-2.5">{h.agent_status==='online'?<Badge className="text-[10px] bg-emerald-500/20 dg-text-green"><Wifi className="w-3 h-3 mr-1"/>{i18n.t('monitor.dashboard.status.online')}</Badge>:h.agent_status==='not_installed'?<Badge className="text-[10px] bg-amber-500/20 dg-text-yellow">{i18n.t('monitor.dashboard.status.notInstalled')}</Badge>:<Badge className="text-[10px] bg-[var(--dg-bg-active)]/10 dg-text-secondary"><WifiOff className="w-3 h-3 mr-1"/>{i18n.t('monitor.dashboard.status.offline')}</Badge>}</td>
<td className="px-3 py-2.5">{bar(Number(h.cpu_usage||0), `${Number(h.cpu_usage||0).toFixed(1)}%`)}</td>
<td className="px-3 py-2.5">{bar(Number(h.mem_usage||0), `${Number(h.mem_usage||0).toFixed(1)}%`)}</td>
<td className="px-3 py-2.5">{bar(Number(h.disk_usage||0), `${Number(h.disk_usage||0).toFixed(1)}%`)}</td>
@@ -450,7 +451,7 @@ const AgentTab: React.FC = () => {
<td className="px-3 py-2.5">
<Button variant="ghost" size="sm" className="h-7 text-xs dg-text-blue hover:bg-[var(--dg-bg-hover)] px-2"
onClick={() => navigate(`/host-monitor?host=${encodeURIComponent(h.hostname)}`)}>
<BarChart3 className="w-3.5 h-3.5 mr-1" />{t('monitor.dashboard.agent.chart')}
<BarChart3 className="w-3.5 h-3.5 mr-1" />{i18n.t('monitor.dashboard.agent.chart')}
</Button>
</td>
</tr>
@@ -460,17 +461,17 @@ const AgentTab: React.FC = () => {
{hosts.length > pageSize && (
<div className="flex items-center justify-between px-3 py-2 bg-[var(--dg-bg-tertiary)] border-t border-[var(--dg-border)] text-xs">
<div className="flex items-center gap-2 text-[var(--dg-text-muted)]">
<span>{t('monitor.dashboard.pagination.total', { count: hosts.length })}</span>
<span>{i18n.t('monitor.dashboard.pagination.total', { count: hosts.length })}</span>
<select className="bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded px-2 py-0.5 text-xs" value={pageSize} onChange={e => { setPageSize(Number(e.target.value)); setPage(1); }}>
<option value={10}>{`10/${t('monitor.dashboard.pagination.perPage')}`}</option>
<option value={20}>{`20/${t('monitor.dashboard.pagination.perPage')}`}</option>
<option value={30}>{`30/${t('monitor.dashboard.pagination.perPage')}`}</option>
<option value={10}>{`10/${i18n.t('monitor.dashboard.pagination.perPage')}`}</option>
<option value={20}>{`20/${i18n.t('monitor.dashboard.pagination.perPage')}`}</option>
<option value={30}>{`30/${i18n.t('monitor.dashboard.pagination.perPage')}`}</option>
</select>
</div>
<div className="flex items-center gap-1">
<button className="px-2 py-0.5 rounded hover:bg-[var(--dg-bg-primary)] disabled:opacity-30" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>{t('monitor.dashboard.pagination.prev')}</button>
<button className="px-2 py-0.5 rounded hover:bg-[var(--dg-bg-primary)] disabled:opacity-30" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>{i18n.t('monitor.dashboard.pagination.prev')}</button>
<span className="px-2 text-[var(--dg-text-primary)]">{page} / {totalPages}</span>
<button className="px-2 py-0.5 rounded hover:bg-[var(--dg-bg-primary)] disabled:opacity-30" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>{t('monitor.dashboard.pagination.next')}</button>
<button className="px-2 py-0.5 rounded hover:bg-[var(--dg-bg-primary)] disabled:opacity-30" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>{i18n.t('monitor.dashboard.pagination.next')}</button>
</div>
</div>
)}
@@ -507,20 +508,20 @@ const DatasourceToolbar: React.FC<{
<div className="flex items-center gap-2 mb-3 flex-wrap">
<Select value={activeId} onValueChange={onChange}>
<SelectTrigger className="w-[280px] h-8 text-xs dg-input">
<SelectValue placeholder={t('monitor.datasourceToolbar.selectDatasource')} />
<SelectValue placeholder={i18n.t('monitor.datasourceToolbar.selectDatasource')} />
</SelectTrigger>
<SelectContent className="dg-input">
{sources.map(ds => (
<SelectItem key={String(ds.id)} value={String(ds.id)}>
{ds.name || ds.url}{ds.is_default ? t('monitor.datasourceToolbar.defaultSuffix') : ''}
{ds.name || ds.url}{ds.is_default ? i18n.t('monitor.datasourceToolbar.defaultSuffix') : ''}
</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="outline" size="sm" className="h-8 text-xs gap-1" onClick={onRefresh} disabled={refreshing}>
<RefreshCw className={cn('w-3 h-3', refreshing && 'animate-spin')} />{t('monitor.datasourceToolbar.refreshDatasource')}
<RefreshCw className={cn('w-3 h-3', refreshing && 'animate-spin')} />{i18n.t('monitor.datasourceToolbar.refreshDatasource')}
</Button>
<span className="text-[11px] text-[var(--dg-text-muted)]">{t('monitor.datasourceToolbar.datasourceSummary', { count: sources.length })}</span>
<span className="text-[11px] text-[var(--dg-text-muted)]">{i18n.t('monitor.datasourceToolbar.datasourceSummary', { count: sources.length })}</span>
</div>
);
@@ -539,12 +540,12 @@ const ZabbixTab: React.FC = () => {
finally { setLoading(false); }
};
useEffect(() => { fetchSources(); }, []);
if (loading) return <PageLoading label={t('monitor.dashboard.zabbix.loading')} />;
if (loading) return <PageLoading label={i18n.t('monitor.dashboard.zabbix.loading')} />;
if (!ds) return (
<PageEmpty
icon={<Database className="h-8 w-8" />}
title={t('monitor.dashboard.zabbix.emptyTitle')}
action={<a href="/datasources" className="text-xs dg-text-blue hover:underline inline-flex items-center gap-1">{t('monitor.dashboard.zabbix.goConfig')} <ExternalLink className="w-3 h-3" /></a>}
title={i18n.t('monitor.dashboard.zabbix.emptyTitle')}
action={<a href="/datasources" className="text-xs dg-text-blue hover:underline inline-flex items-center gap-1">{i18n.t('monitor.dashboard.zabbix.goConfig')} <ExternalLink className="w-3 h-3" /></a>}
/>
);
return (
@@ -570,12 +571,12 @@ const PrometheusTab: React.FC = () => {
finally { setLoading(false); }
};
useEffect(() => { fetchSources(); }, []);
if (loading) return <PageLoading label={t('monitor.dashboard.prometheus.loading')} />;
if (loading) return <PageLoading label={i18n.t('monitor.dashboard.prometheus.loading')} />;
if (!ds) return (
<PageEmpty
icon={<Database className="h-8 w-8" />}
title={t('monitor.dashboard.prometheus.emptyTitle')}
action={<a href="/datasources" className="text-xs dg-text-blue hover:underline inline-flex items-center gap-1">{t('monitor.dashboard.zabbix.goConfig')} <ExternalLink className="w-3 h-3" /></a>}
title={i18n.t('monitor.dashboard.prometheus.emptyTitle')}
action={<a href="/datasources" className="text-xs dg-text-blue hover:underline inline-flex items-center gap-1">{i18n.t('monitor.dashboard.zabbix.goConfig')} <ExternalLink className="w-3 h-3" /></a>}
/>
);
return (
@@ -598,8 +599,8 @@ const GrafanaDashboard: React.FC = () => {
return (
<PageShell
variant="workspace"
title={t('monitor.dashboard.title')}
subtitle={t('monitor.dashboard.subtitle')}
title={i18n.t('monitor.dashboard.title')}
subtitle={i18n.t('monitor.dashboard.subtitle')}
icon={<Monitor className="h-5 w-5" />}
tabs={pageTabs}
activeTab={tab}
@@ -10,6 +10,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { prometheusProxyApi } from '@/api';
import { useTranslation } from 'react-i18next';
import type { JsonData } from '@/types/common';
import i18n from '@/i18n';
interface Props { dsUrl?: string; }
@@ -65,7 +66,7 @@ function parsePrune( result: JsonData[]): JsonData[] {
const Box = (p: JsonData) => <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>;
const fmtCores = (v: number, t: (k: string) => string) => v >= 1 ? `${v.toFixed(2)} ${t('monitor.prometheusPanel.cores')}` : `${(v * 1000).toFixed(0)} m`;
const fmtCores = (v: number) => v >= 1 ? `${v.toFixed(2)} ${i18n.t('monitor.prometheusPanel.cores')}` : `${(v * 1000).toFixed(0)} m`;
const K8sPanel: React.FC<{ d: JsonData}> = ({ d }) => {
const { t } = useTranslation();
@@ -80,7 +81,7 @@ const K8sPanel: React.FC<{ d: JsonData}> = ({ d }) => {
]}
/>
<div className="rounded-lg bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] p-3 max-h-60 overflow-y-auto">
<div className="text-xs font-medium mb-2 flex items-center gap-1"><Cpu className="w-3 h-3 dg-text-purple" />{t('monitor.prometheusPanel.nodeCpuMemUtil')}</div>
<div className="text-xs font-medium mb-2 flex items-center gap-1"><Cpu className="w-3 h-3 dg-text-purple" />{i18n.t('monitor.prometheusPanel.nodeCpuMemUtil')}</div>
{(d.nodeCPU || []).map((n: JsonData, i: number) => {
const mem = (d.nodeMem || []).find((m: JsonData) => m.node === n.node);
return <div key={i} className="flex items-center gap-3 text-[11px] mb-1.5"><span className="w-28 truncate" title={n.node}>{n.node}</span><MiniBar val={Number(n.value||0)} label={`${Number(n.value||0).toFixed(1)}%`} /><MiniBar val={Number(mem?.value||0)} label={`${Number(mem?.value||0).toFixed(1)}%`} color="bg-blue-500" /></div>;
@@ -136,8 +137,8 @@ const NodePanel: React.FC<{ d: JsonData}> = ({ d }) => {
<table className="w-full text-xs">
<thead className="bg-[var(--dg-bg-tertiary)]"><tr>
{[
{ f: 'name' as NodeSortField, l: t('monitor.prometheusPanel.node') }, { f: 'cpu' as NodeSortField, l: t('monitor.prometheusPanel.cpu') },
{ f: 'mem' as NodeSortField, l: t('monitor.prometheusPanel.memory') }, { f: 'disk' as NodeSortField, l: t('monitor.prometheusPanel.disk') }, { f: 'rx' as NodeSortField, l: t('monitor.prometheusPanel.inbound') }, { f: 'tx' as NodeSortField, l: t('monitor.prometheusPanel.outbound') },
{ f: 'name' as NodeSortField, l: i18n.t('monitor.prometheusPanel.node') }, { f: 'cpu' as NodeSortField, l: i18n.t('monitor.prometheusPanel.cpu') },
{ f: 'mem' as NodeSortField, l: i18n.t('monitor.prometheusPanel.memory') }, { f: 'disk' as NodeSortField, l: i18n.t('monitor.prometheusPanel.disk') }, { f: 'rx' as NodeSortField, l: i18n.t('monitor.prometheusPanel.inbound') }, { f: 'tx' as NodeSortField, l: i18n.t('monitor.prometheusPanel.outbound') },
].map(({ f, l }) => (
<th key={f} className="px-3 py-2 text-left cursor-pointer select-none" onClick={() => toggleSort(f)}><div className="flex items-center gap-1">{l}<SI f={f} /></div></th>
))}
@@ -158,15 +159,15 @@ const NodePanel: React.FC<{ d: JsonData}> = ({ d }) => {
{sorted.length > pageSize && (
<div className="flex items-center justify-between px-3 py-2 bg-[var(--dg-bg-tertiary)] border-t border-[var(--dg-border)] text-xs">
<div className="flex items-center gap-2 text-[var(--dg-text-muted)]">
<span>{t('monitor.prometheusPanel.totalNodes', { count: sorted.length })}</span>
<span>{i18n.t('monitor.prometheusPanel.totalNodes', { count: sorted.length })}</span>
<select className="bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded px-2 py-0.5 text-xs" value={pageSize} onChange={e => { setPageSize(Number(e.target.value)); setPage(1); }}>
<option value={10}>10/{t('monitor.prometheusPanel.perPage')}</option><option value={20}>20/{t('monitor.prometheusPanel.perPage')}</option><option value={50}>50/{t('monitor.prometheusPanel.perPage')}</option>
<option value={10}>10/{i18n.t('monitor.prometheusPanel.perPage')}</option><option value={20}>20/{i18n.t('monitor.prometheusPanel.perPage')}</option><option value={50}>50/{i18n.t('monitor.prometheusPanel.perPage')}</option>
</select>
</div>
<div className="flex items-center gap-1">
<button className="px-2 py-0.5 rounded hover:bg-[var(--dg-bg-primary)] disabled:opacity-30" disabled={safePage <= 1} onClick={() => setPage(Math.max(1, safePage - 1))}>{t('monitor.prometheusPanel.prevPage')}</button>
<button className="px-2 py-0.5 rounded hover:bg-[var(--dg-bg-primary)] disabled:opacity-30" disabled={safePage <= 1} onClick={() => setPage(Math.max(1, safePage - 1))}>{i18n.t('monitor.prometheusPanel.prevPage')}</button>
<span className="px-2 text-[var(--dg-text-primary)]">{safePage} / {totalPages}</span>
<button className="px-2 py-0.5 rounded hover:bg-[var(--dg-bg-primary)] disabled:opacity-30" disabled={safePage >= totalPages} onClick={() => setPage(Math.min(totalPages, safePage + 1))}>{t('monitor.prometheusPanel.nextPage')}</button>
<button className="px-2 py-0.5 rounded hover:bg-[var(--dg-bg-primary)] disabled:opacity-30" disabled={safePage >= totalPages} onClick={() => setPage(Math.min(totalPages, safePage + 1))}>{i18n.t('monitor.prometheusPanel.nextPage')}</button>
</div>
</div>
)}
@@ -189,7 +190,7 @@ const SimpleRank: React.FC<{
<CardContent className="p-3 pt-0">
<div className="space-y-2 max-h-[320px] overflow-y-auto pr-1">
{rows.length === 0 ? (
<div className="py-8 text-center text-xs text-[var(--dg-text-muted)]">{t('monitor.prometheusPanel.noData')}</div>
<div className="py-8 text-center text-xs text-[var(--dg-text-muted)]">{i18n.t('monitor.prometheusPanel.noData')}</div>
) : rows.slice(0, 10).map((p: JsonData, i: number) => {
const value = Number(p.value || 0);
return (
@@ -232,7 +233,7 @@ const PodNetworkRank: React.FC<{ rx: JsonData[]; tx: JsonData[] }> = ({ rx, tx }
return (
<Card className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)]">
<CardHeader className="py-2 px-3">
<CardTitle className="text-xs font-medium">{t('monitor.prometheusPanel.podTrafficTop10')}</CardTitle>
<CardTitle className="text-xs font-medium">{i18n.t('monitor.prometheusPanel.podTrafficTop10')}</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
@@ -240,14 +241,14 @@ const PodNetworkRank: React.FC<{ rx: JsonData[]; tx: JsonData[] }> = ({ rx, tx }
<thead className="bg-[var(--dg-bg-tertiary)] border-y border-[var(--dg-border)]">
<tr>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">Pod</th>
<th className="px-3 py-2 text-right font-medium text-[var(--dg-text-secondary)]">{t('monitor.prometheusPanel.inbound')}</th>
<th className="px-3 py-2 text-right font-medium text-[var(--dg-text-secondary)]">{t('monitor.prometheusPanel.outbound')}</th>
<th className="px-3 py-2 text-right font-medium text-[var(--dg-text-secondary)]">{t('monitor.prometheusPanel.totalTraffic')}</th>
<th className="px-3 py-2 text-right font-medium text-[var(--dg-text-secondary)]">{i18n.t('monitor.prometheusPanel.inbound')}</th>
<th className="px-3 py-2 text-right font-medium text-[var(--dg-text-secondary)]">{i18n.t('monitor.prometheusPanel.outbound')}</th>
<th className="px-3 py-2 text-right font-medium text-[var(--dg-text-secondary)]">{i18n.t('monitor.prometheusPanel.totalTraffic')}</th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr><td colSpan={4} className="px-3 py-8 text-center text-[var(--dg-text-muted)]">{t('monitor.prometheusPanel.noData')}</td></tr>
<tr><td colSpan={4} className="px-3 py-8 text-center text-[var(--dg-text-muted)]">{i18n.t('monitor.prometheusPanel.noData')}</td></tr>
) : rows.map((p, i) => (
<tr key={p.name} className="border-b border-[var(--dg-border)] last:border-0 hover:bg-[var(--dg-bg-tertiary)]/50">
<td className="px-3 py-2">
@@ -277,8 +278,8 @@ const PodPanel: React.FC<{ d: JsonData}> = ({ d }) => (
/>
<PodNetworkRank rx={d.netRxTop || []} tx={d.netTxTop || []} />
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<SimpleRank title="Pod CPU Top 10" rows={d.cpuTop || []} valueText={(v) => fmtCores(v, t)} color="bg-purple-500" />
<SimpleRank title={t('monitor.prometheusPanel.podMemTop10')} rows={d.memTop || []} valueText={fmtBytes} color="bg-blue-500" />
<SimpleRank title="Pod CPU Top 10" rows={d.cpuTop || []} valueText={(v) => fmtCores(v)} color="bg-purple-500" />
<SimpleRank title={i18n.t('monitor.prometheusPanel.podMemTop10')} rows={d.memTop || []} valueText={fmtBytes} color="bg-blue-500" />
</div>
</div>
);
@@ -371,7 +372,7 @@ const JvmPanel: React.FC<{ dsUrl?: string; fallback: JsonData }> = ({ dsUrl, fal
<div className="flex items-center gap-2 flex-wrap">
<Select value={application} onValueChange={setApplication}>
<SelectTrigger className="w-[260px] h-8 text-xs dg-input">
<SelectValue placeholder={t('monitor.prometheusPanel.selectApplication')} />
<SelectValue placeholder={i18n.t('monitor.prometheusPanel.selectApplication')} />
</SelectTrigger>
<SelectContent className="dg-input">
{applications.map(app => <SelectItem key={app} value={app}>{app}</SelectItem>)}
@@ -379,13 +380,13 @@ const JvmPanel: React.FC<{ dsUrl?: string; fallback: JsonData }> = ({ dsUrl, fal
</Select>
<Select value={instance} onValueChange={setInstance}>
<SelectTrigger className="w-[280px] h-8 text-xs dg-input">
<SelectValue placeholder={t('monitor.prometheusPanel.selectInstance')} />
<SelectValue placeholder={i18n.t('monitor.prometheusPanel.selectInstance')} />
</SelectTrigger>
<SelectContent className="dg-input">
{instances.map(ins => <SelectItem key={ins} value={ins}>{ins}</SelectItem>)}
</SelectContent>
</Select>
{loading && <span className="text-[11px] text-[var(--dg-text-muted)] inline-flex items-center gap-1"><Loader2 className="w-3 h-3 animate-spin" />{t('monitor.prometheusPanel.loadingJvmMetrics')}</span>}
{loading && <span className="text-[11px] text-[var(--dg-text-muted)] inline-flex items-center gap-1"><Loader2 className="w-3 h-3 animate-spin" />{i18n.t('monitor.prometheusPanel.loadingJvmMetrics')}</span>}
</div>
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-3">
<Card className="bg-[var(--dg-bg-primary)]"><CardContent className="p-3 text-center"><div className="text-lg font-bold">{uptime>86400?`${(uptime/86400).toFixed(1)}d`:`${(uptime/3600).toFixed(1)}h`}</div><div className="text-[10px] text-[var(--dg-text-muted)]">Uptime</div></CardContent></Card>
@@ -420,7 +421,7 @@ export const PrometheusDashboard: React.FC<Props> = ({ dsUrl }) => {
const data: JsonData = await prometheusProxyApi.connect(u);
setConnected(true); setTargets(data.targets||0);
loadAll(u);
} catch (e) { setConnected(false); setError((e as JsonData).message||t('monitor.prometheusPanel.connectionFailed')); }
} catch (e) { setConnected(false); setError((e as JsonData).message||i18n.t('monitor.prometheusPanel.connectionFailed')); }
finally { setLoading(false); }
};
@@ -448,13 +449,13 @@ export const PrometheusDashboard: React.FC<Props> = ({ dsUrl }) => {
<CardHeader className="py-3 px-4 pb-2">
<div className="flex items-center gap-2">
<Activity className="h-4 w-4 dg-text-purple" />
<CardTitle className="text-sm font-medium">{t('monitor.prometheusPanel.prometheusMonitor')}</CardTitle>
<CardTitle className="text-sm font-medium">{i18n.t('monitor.prometheusPanel.prometheusMonitor')}</CardTitle>
{connected && <Badge className="text-[10px] bg-purple-500/10 dg-text-purple">{targets} targets</Badge>}
{connected && <div className="ml-auto"><Button variant="ghost" size="sm" className="h-6 px-2 text-[10px]" onClick={refresh}><RefreshCw className="w-3 h-3 mr-1" />{t('monitor.prometheusPanel.refresh')}</Button></div>}
{connected && <div className="ml-auto"><Button variant="ghost" size="sm" className="h-6 px-2 text-[10px]" onClick={refresh}><RefreshCw className="w-3 h-3 mr-1" />{i18n.t('monitor.prometheusPanel.refresh')}</Button></div>}
</div>
</CardHeader>
<CardContent className="px-4 pb-3 pt-0">
{loading ? (<div className="text-center py-12"><Loader2 className="h-6 w-6 mx-auto mb-2 animate-spin dg-text-purple" /><p className="text-xs text-[var(--dg-text-muted)]">{t('monitor.prometheusPanel.connecting')}</p></div>)
{loading ? (<div className="text-center py-12"><Loader2 className="h-6 w-6 mx-auto mb-2 animate-spin dg-text-purple" /><p className="text-xs text-[var(--dg-text-muted)]">{i18n.t('monitor.prometheusPanel.connecting')}</p></div>)
: error ? (<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-xs dg-text-red"><AlertTriangle className="h-4 w-4 inline mr-1" />{error}</div>)
: (
<Tabs value={tab} onValueChange={setTab}>
@@ -1,5 +1,6 @@
// LogHistogram.tsx — Log Volume 时间分布面积图 // Logan.Li
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import type { LogEngine } from './types-v3';
@@ -31,6 +32,7 @@ function formatBytes(bytes: number): string {
}
export function LogHistogram({ data, engine, onBarClick, loading, totalBytes, lineLimitReached, coverage }: LogHistogramProps) {
const { t } = useTranslation();
const colors = ENGINE_COLORS[engine];
const chartData = useMemo(() => {
@@ -111,7 +113,7 @@ export function LogHistogram({ data, engine, onBarClick, loading, totalBytes, li
fontSize: '11px',
color: 'var(--dg-text-primary)',
}}
formatter={(value: number) => [`${value} 条`, 'logs']}
formatter={(value: number) => [`${value} ${t('logCenter.entries')}`, 'logs']}
labelFormatter={(label: string) => {
const item = chartData.find(d => d.label === label);
return item ? item.timestamp : label;
@@ -1,5 +1,6 @@
// QuickQueryHelper.tsx — 快捷查询语法助手 // Logan.Li
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ChevronDown, ChevronUp, Zap } from 'lucide-react';
import type { LogEngine } from './types-v3';
import { QUICK_TEMPLATES } from './types-v3';
@@ -16,6 +17,7 @@ const ENGINE_NAMES: Record<LogEngine, string> = {
};
export function QuickQueryHelper({ engine, onSelect }: Props) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const templates = QUICK_TEMPLATES[engine];
@@ -27,7 +29,7 @@ export function QuickQueryHelper({ engine, onSelect }: Props) {
style={{ color: 'var(--dg-blue)', background: open ? 'var(--dg-bg-tertiary)' : 'transparent' }}
>
<Zap className="h-3 w-3" />
快捷查询
{t('logCenter.quickQuery')}
{open ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
</button>
@@ -37,7 +39,7 @@ export function QuickQueryHelper({ engine, onSelect }: Props) {
style={{ background: 'var(--dg-bg-secondary)', borderColor: 'var(--dg-border)' }}
>
<p className="text-[10px] mb-2" style={{ color: 'var(--dg-text-muted)' }}>
{ENGINE_NAMES[engine]} 常用查询模板,点击快速填充
{t('logCenter.quickQueryHint', { engine: ENGINE_NAMES[engine] })}
</p>
<div className="grid grid-cols-2 gap-1.5">
{templates.map((t, i) => (
@@ -1,5 +1,6 @@
// SaveQueryDialog.tsx — 另存为查询 / 另存为告警 // Logan.Li
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { X, Save, BellRing, Loader2 } from 'lucide-react';
import { alertApi } from '@/api';
import { useToast } from '@/hooks/use-toast';
@@ -19,6 +20,7 @@ interface SaveQueryProps {
}
export function SaveQueryDialog({ open, onClose, engine, query, timeRange, datasourceId, datasourceName, onSave }: SaveQueryProps) {
const { t } = useTranslation();
const [name, setName] = useState('');
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
@@ -47,24 +49,24 @@ export function SaveQueryDialog({ open, onClose, engine, query, timeRange, datas
<div className="fixed inset-0 z-50" style={{ background: 'rgba(0,0,0,0.5)' }} onClick={onClose} />
<div className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-50 w-full max-w-md rounded-lg border shadow-xl overflow-hidden" style={{ background: 'var(--dg-bg-secondary)', borderColor: 'var(--dg-border)' }}>
<div className="flex items-center justify-between px-4 py-3 border-b" style={{ borderColor: 'var(--dg-border)' }}>
<div className="flex items-center gap-2"><Save className="h-4 w-4" style={{ color: 'var(--dg-blue)' }} /><span className="text-sm font-medium" style={{ color: 'var(--dg-text-primary)' }}>另存为查询</span></div>
<div className="flex items-center gap-2"><Save className="h-4 w-4" style={{ color: 'var(--dg-blue)' }} /><span className="text-sm font-medium" style={{ color: 'var(--dg-text-primary)' }}>{t('logCenter.saveQuery')}</span></div>
<button onClick={onClose} className="h-7 w-7 flex items-center justify-center" style={{ color: 'var(--dg-text-muted)' }}><X className="h-4 w-4" /></button>
</div>
<div className="p-4 space-y-3">
<div>
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>查询名称 *</label>
<input value={name} onChange={e => setName(e.target.value)} placeholder="输入查询名称" className="w-full h-9 px-3 text-xs rounded" style={{ background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)', color: 'var(--dg-text-primary)' }} />
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>{t('logCenter.queryName')} *</label>
<input value={name} onChange={e => setName(e.target.value)} placeholder={t('logCenter.queryNamePlaceholder')} className="w-full h-9 px-3 text-xs rounded" style={{ background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)', color: 'var(--dg-text-primary)' }} />
</div>
<div className="p-2 rounded text-[11px]" style={{ background: 'var(--dg-bg-tertiary)', color: 'var(--dg-text-secondary)' }}>
<span style={{ color: ENGINE_META[engine].color }}>{ENGINE_META[engine].name}</span> · {datasourceName || '默认'}<br />
<span style={{ color: ENGINE_META[engine].color }}>{ENGINE_META[engine].name}</span> · {datasourceName || t('common.default')}<br />
<span className="font-mono">{query}</span><br />
<span style={{ color: 'var(--dg-text-muted)' }}>{timeRange.label} · {timeRange.start} ~ {timeRange.end}</span>
</div>
</div>
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t" style={{ borderColor: 'var(--dg-border)' }}>
<button onClick={onClose} className="h-8 px-3 rounded text-xs" style={{ color: 'var(--dg-text-secondary)', border: '1px solid var(--dg-border)', background: 'var(--dg-bg-tertiary)' }}>取消</button>
<button onClick={onClose} className="h-8 px-3 rounded text-xs" style={{ color: 'var(--dg-text-secondary)', border: '1px solid var(--dg-border)', background: 'var(--dg-bg-tertiary)' }}>{t('common.cancel')}</button>
<button onClick={handleSave} disabled={saving || !name.trim() || saved} className="h-8 px-4 rounded text-xs font-medium text-white flex items-center gap-1" style={{ background: 'var(--dg-blue)' }}>
{saving && <Loader2 className="h-3.5 w-3.5 animate-spin" />}{saved ? '已保存' : '保存'}
{saving && <Loader2 className="h-3.5 w-3.5 animate-spin" />}{saved ? t('common.saved') : t('common.save')}
</button>
</div>
</div>
@@ -84,6 +86,7 @@ interface SaveAlertProps {
}
export function SaveAlertDialog({ open, onClose, engine, query, datasourceId, datasourceName, onSave }: SaveAlertProps) {
const { t } = useTranslation();
const { toast } = useToast();
const [name, setName] = useState('');
const [severity, setSeverity] = useState('warning');
@@ -101,7 +104,7 @@ export function SaveAlertDialog({ open, onClose, engine, query, datasourceId, da
try {
await alertApi.createRule({
name: name.trim(),
description: `从查询页创建: ${datasourceName || '默认数据源'}`,
description: t('logCenter.createFromQuery', { name: datasourceName || t('logCenter.defaultDatasource') }),
type: 'log',
source: engine === 'sls' ? 'loki' : engine,
expression: query.trim(),
@@ -111,11 +114,11 @@ export function SaveAlertDialog({ open, onClose, engine, query, datasourceId, da
notify_channels: [],
});
setSaved(true);
toast({ title: '告警规则已创建' });
toast({ title: t('logCenter.alertRuleCreated') });
onSave?.();
setTimeout(() => { setSaved(false); onClose(); }, 800);
} catch (e: unknown) {
toast({ title: '创建失败', description: getErrorMessage(e), variant: 'destructive' });
toast({ title: t('common.createFailed'), description: getErrorMessage(e), variant: 'destructive' });
} finally {
setSaving(false);
}
@@ -126,35 +129,35 @@ export function SaveAlertDialog({ open, onClose, engine, query, datasourceId, da
<div className="fixed inset-0 z-50" style={{ background: 'rgba(0,0,0,0.5)' }} onClick={onClose} />
<div className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-50 w-full max-w-md rounded-lg border shadow-xl overflow-hidden" style={{ background: 'var(--dg-bg-secondary)', borderColor: 'var(--dg-border)' }}>
<div className="flex items-center justify-between px-4 py-3 border-b" style={{ borderColor: 'var(--dg-border)' }}>
<div className="flex items-center gap-2"><BellRing className="h-4 w-4" style={{ color: '#f59e0b' }} /><span className="text-sm font-medium" style={{ color: 'var(--dg-text-primary)' }}>另存为告警</span></div>
<div className="flex items-center gap-2"><BellRing className="h-4 w-4" style={{ color: '#f59e0b' }} /><span className="text-sm font-medium" style={{ color: 'var(--dg-text-primary)' }}>{t('logCenter.saveAsAlert')}</span></div>
<button onClick={onClose} className="h-7 w-7 flex items-center justify-center" style={{ color: 'var(--dg-text-muted)' }}><X className="h-4 w-4" /></button>
</div>
<div className="p-4 space-y-3">
<div>
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>告警名称 *</label>
<input value={name} onChange={e => setName(e.target.value)} placeholder="输入告警规则名称" className="w-full h-9 px-3 text-xs rounded" style={{ background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)', color: 'var(--dg-text-primary)' }} />
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>{t('logCenter.alertName')} *</label>
<input value={name} onChange={e => setName(e.target.value)} placeholder={t('logCenter.alertNamePlaceholder')} className="w-full h-9 px-3 text-xs rounded" style={{ background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)', color: 'var(--dg-text-primary)' }} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>级别</label>
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>{t('common.severity')}</label>
<select value={severity} onChange={e => setSeverity(e.target.value)} className="w-full h-9 px-2 text-xs rounded" style={{ background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)', color: 'var(--dg-text-primary)' }}>
<option value="info">提示</option><option value="warning">警告</option><option value="critical">严重</option>
<option value="info">{t('common.info')}</option><option value="warning">{t('common.warning')}</option><option value="critical">{t('common.critical')}</option>
</select>
</div>
<div>
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>触发条件</label>
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>{t('logCenter.triggerCondition')}</label>
<input value={condition} onChange={e => setCondition(e.target.value)} className="w-full h-9 px-2 text-xs rounded" style={{ background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)', color: 'var(--dg-text-primary)' }} />
</div>
</div>
<div className="p-2 rounded text-[11px]" style={{ background: 'var(--dg-bg-tertiary)', color: 'var(--dg-text-secondary)' }}>
<span style={{ color: meta.color }}>{meta.name}</span> · {datasourceName || '默认'}<br />
<span style={{ color: meta.color }}>{meta.name}</span> · {datasourceName || t('common.default')}<br />
<span className="font-mono">{query}</span>
</div>
</div>
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t" style={{ borderColor: 'var(--dg-border)' }}>
<button onClick={onClose} className="h-8 px-3 rounded text-xs" style={{ color: 'var(--dg-text-secondary)', border: '1px solid var(--dg-border)', background: 'var(--dg-bg-tertiary)' }}>取消</button>
<button onClick={onClose} className="h-8 px-3 rounded text-xs" style={{ color: 'var(--dg-text-secondary)', border: '1px solid var(--dg-border)', background: 'var(--dg-bg-tertiary)' }}>{t('common.cancel')}</button>
<button onClick={handleSave} disabled={saving || !name.trim() || saved} className="h-8 px-4 rounded text-xs font-medium text-white flex items-center gap-1" style={{ background: '#f59e0b' }}>
{saving && <Loader2 className="h-3.5 w-3.5 animate-spin" />}{saved ? '已创建' : '创建告警'}
{saving && <Loader2 className="h-3.5 w-3.5 animate-spin" />}{saved ? t('common.created') : t('logCenter.createAlert')}
</button>
</div>
</div>
@@ -431,8 +431,8 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
const formatEntryLine = useCallback((e: LogEntry) => buildLogCopyText(e), []);
const handleExportLogs = useCallback(() => {
const t = sortedEntries.map(formatEntryLine).join('\n');
const b = new Blob([t], { type: 'text/plain;charset=utf-8' });
const text = sortedEntries.map(formatEntryLine).join('\n');
const b = new Blob([text], { type: 'text/plain;charset=utf-8' });
const u = URL.createObjectURL(b);
const a = document.createElement('a');
a.href = u;
@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import { Input } from '@/components/ui/input';
import type { Credential } from '@/components/deploy/credential-manager/types';
@@ -28,6 +29,7 @@ export function ImportCredentialSettings({
onSelectedRdpCredentialIdChange,
compact = false,
}: ImportCredentialSettingsProps) {
const { t } = useTranslation();
const labelClass = compact
? 'block text-xs font-medium text-[var(--dg-text-secondary)] mb-1'
: 'block text-sm font-medium text-[var(--dg-text-secondary)] mb-2';
@@ -40,8 +42,8 @@ export function ImportCredentialSettings({
<div className={panelClass}>
<div>
<label className={labelClass}>
SSH 端口
<span className="text-[var(--dg-text-muted)] ml-1">(Linux 默认)</span>
{t('server.importCred.sshPort')}
<span className="text-[var(--dg-text-muted)] ml-1">{t('server.importCred.linuxDefault')}</span>
</label>
<Input
type="number"
@@ -53,18 +55,18 @@ export function ImportCredentialSettings({
</div>
<div>
<label className={labelClass}>
Linux SSH 凭证
<span className="text-[var(--dg-text-muted)] ml-1">(可选)</span>
{t('server.importCred.sshCredential')}
<span className="text-[var(--dg-text-muted)] ml-1">{t('server.importCred.optional')}</span>
</label>
{sshCredentials.length === 0 ? (
<p className="text-xs text-[var(--dg-text-muted)] py-2">暂无 SSH 凭证</p>
<p className="text-xs text-[var(--dg-text-muted)] py-2">{t('server.importCred.noSshCred')}</p>
) : (
<select
value={selectedSshCredentialId}
onChange={(e) => onSelectedSshCredentialIdChange(e.target.value)}
className={`w-full px-3 py-2 ${inputBg} border border-[var(--dg-border)] rounded-md text-[var(--dg-text-primary)] text-sm`}
>
<option value="">不使用凭证</option>
<option value="">{t('server.importCred.noCredential')}</option>
{sshCredentials.map((cred) => (
<option key={cred.id} value={String(cred.id)}>
{credentialLabel(cred)}
@@ -75,18 +77,18 @@ export function ImportCredentialSettings({
</div>
<div>
<label className={labelClass}>
Windows RDP 密码
<span className="text-[var(--dg-text-muted)] ml-1">(可选)</span>
{t('server.importCred.rdpPassword')}
<span className="text-[var(--dg-text-muted)] ml-1">{t('server.importCred.optional')}</span>
</label>
{rdpCredentials.length === 0 ? (
<p className="text-xs text-[var(--dg-text-muted)] py-2">暂无 RDP 凭证</p>
<p className="text-xs text-[var(--dg-text-muted)] py-2">{t('server.importCred.noRdpCred')}</p>
) : (
<select
value={selectedRdpCredentialId}
onChange={(e) => onSelectedRdpCredentialIdChange(e.target.value)}
className={`w-full px-3 py-2 ${inputBg} border border-[var(--dg-border)] rounded-md text-[var(--dg-text-primary)] text-sm`}
>
<option value="">不使用凭证</option>
<option value="">{t('server.importCred.noCredential')}</option>
{rdpCredentials.map((cred) => (
<option key={cred.id} value={String(cred.id)}>
{credentialLabel(cred)}
+339 -88
View File
@@ -1,17 +1,23 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
import { useTranslation } from "react-i18next";
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from '@/components/ui/dialog';
import { cn } from '@/lib/utils';
import { getWsBase } from '@/utils/request';
import { useUserStore } from '@/store';
import { copyToClipboard } from '@/utils/clipboard';
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover';
import { RdpToolsPanel } from './RdpToolsPanel';
import { Loader2, Maximize2, Minimize2, Wrench, X } from 'lucide-react';
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { getWsBase } from "@/utils/request";
import { useSessionSecurityStore, useUserStore } from "@/store";
import { copyToClipboard, readFromClipboard } from "@/utils/clipboard";
import {
Popover,
PopoverAnchor,
PopoverContent,
} from "@/components/ui/popover";
import { RdpToolsPanel } from "./RdpToolsPanel";
import { SessionWatermark } from "@/components/common/SessionWatermark";
import { Loader2, Maximize2, Minimize2, Wrench, X } from "lucide-react";
interface RdpDialogProps {
open: boolean;
@@ -30,6 +36,9 @@ interface RdpFrame {
image?: string;
message?: string;
text?: string;
name?: string;
url?: string;
size?: number;
}
interface RdpStats {
@@ -38,9 +47,15 @@ interface RdpStats {
failed: number;
}
const RDP_WIDTH = 1024;
const RDP_HEIGHT = 800;
const RDP_DEFAULT_WIDTH = 900;
const RDP_DEFAULT_HEIGHT = 620;
const RDP_MIN_WIDTH = 720;
const RDP_MIN_HEIGHT = 520;
const RDP_MAX_WIDTH = 1920;
const RDP_MAX_HEIGHT = 1200;
const RDP_BINARY_HEADER = 20;
const RDP_HEADER_HEIGHT = 41;
const RDP_DIALOG_VERTICAL_GAP = 96;
interface RdpRawFrame {
x: number;
@@ -50,27 +65,87 @@ interface RdpRawFrame {
rgba: Uint8ClampedArray;
}
export function RdpDialog({ open, onOpenChange, serverId, serverName, serverIp }: RdpDialogProps) {
function clampRdpSize(value: number, min: number, max: number) {
const clamped = Math.max(min, Math.min(max, value));
return clamped % 2 === 0 ? clamped : clamped - 1;
}
function calcRdpViewport(fullscreen: boolean) {
if (!fullscreen) {
return {
// 13 寸屏默认保留外层操作空间,最大化时再提升远端分辨率。 // Logan.Li
width: Math.min(
RDP_DEFAULT_WIDTH,
clampRdpSize(
Math.floor(window.innerWidth * 0.78),
RDP_MIN_WIDTH,
RDP_DEFAULT_WIDTH,
),
),
height: Math.min(
RDP_DEFAULT_HEIGHT,
clampRdpSize(
Math.floor(window.innerHeight - 180),
RDP_MIN_HEIGHT,
RDP_DEFAULT_HEIGHT,
),
),
};
}
const maxWidth = fullscreen
? window.innerWidth
: Math.min(window.innerWidth * 0.94, RDP_MAX_WIDTH);
const maxHeight = fullscreen
? window.innerHeight - RDP_HEADER_HEIGHT
: window.innerHeight - RDP_DIALOG_VERTICAL_GAP;
return {
width: clampRdpSize(Math.floor(maxWidth), RDP_MIN_WIDTH, RDP_MAX_WIDTH),
height: clampRdpSize(Math.floor(maxHeight), RDP_MIN_HEIGHT, RDP_MAX_HEIGHT),
};
}
export function RdpDialog({
open,
onOpenChange,
serverId,
serverName,
serverIp,
}: RdpDialogProps) {
const { t } = useTranslation();
const canvasRef = useRef<HTMLCanvasElement>(null);
const wsRef = useRef<WebSocket | null>(null);
const paintQueueRef = useRef<Promise<void>>(Promise.resolve());
const statsRef = useRef<RdpStats>({ received: 0, drawn: 0, failed: 0 });
const pendingMouseMoveRef = useRef<Record<string, unknown> | null>(null);
const mouseMoveTimerRef = useRef<number | null>(null);
const keyModifierRef = useRef({
ctrl: false,
alt: false,
shift: false,
meta: false,
});
const [connected, setConnected] = useState(false);
const [fullscreen, setFullscreen] = useState(false);
const [status, setStatus] = useState('等待连接');
const [status, setStatus] = useState(t("server.rdpDialog.waiting"));
const [hasFrame, setHasFrame] = useState(false);
const [canvasSize, setCanvasSize] = useState({ width: RDP_WIDTH, height: RDP_HEIGHT });
const [displaySize, setDisplaySize] = useState({ width: RDP_WIDTH, height: RDP_HEIGHT });
const [canvasSize, setCanvasSize] = useState(() => calcRdpViewport(false));
const [displaySize, setDisplaySize] = useState(() => calcRdpViewport(false));
const [toolsOpen, setToolsOpen] = useState(false);
const [remoteClipboard, setRemoteClipboard] = useState('');
const [remoteClipboard, setRemoteClipboard] = useState("");
const clipboardEnabled = useSessionSecurityStore(
(state) => state.clipboardEnabled,
);
const fileDownloadEnabled = useSessionSecurityStore(
(state) => state.fileDownloadEnabled,
);
const sendEvent = (event: Record<string, unknown>) => {
const ws = wsRef.current;
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(event));
return true;
}
return false;
};
const flushMouseMove = () => {
@@ -95,17 +170,90 @@ export function RdpDialog({ open, onOpenChange, serverId, serverName, serverIp }
}, 16);
};
const patchKeyModifier = (
e: KeyboardEvent<HTMLCanvasElement>,
down: boolean,
) => {
const next = keyModifierRef.current;
if (e.code === "ControlLeft" || e.code === "ControlRight") next.ctrl = down;
if (e.code === "AltLeft" || e.code === "AltRight") next.alt = down;
if (e.code === "ShiftLeft" || e.code === "ShiftRight") next.shift = down;
if (e.code === "MetaLeft" || e.code === "MetaRight") next.meta = down;
};
const currentModifiers = (e: KeyboardEvent<HTMLCanvasElement>) => {
const state = keyModifierRef.current;
// keyup 事件经常丢失 ctrlKey/metaKey,使用本地状态保证组合键释放也能发到远端。 // Logan.Li
return {
ctrl: state.ctrl || e.ctrlKey,
alt: state.alt || e.altKey,
shift: state.shift || e.shiftKey,
meta: state.meta || e.metaKey,
};
};
const handleKeyDown = async (e: KeyboardEvent<HTMLCanvasElement>) => {
e.preventDefault();
patchKeyModifier(e, true);
const modifiers = currentModifiers(e);
const pasteCombo = (e.metaKey || e.ctrlKey) && e.code === "KeyV";
if (pasteCombo) {
if (!clipboardEnabled) {
setStatus(t("server.sessionPolicy.clipboardDisabled"));
return;
}
const text = await readFromClipboard();
if (text) sendEvent({ type: "clipboard", text });
return;
}
sendEvent({
type: "key",
code: e.code,
key: e.key,
down: true,
...modifiers,
});
};
const handleKeyUp = (e: KeyboardEvent<HTMLCanvasElement>) => {
e.preventDefault();
if ((e.metaKey || e.ctrlKey) && e.code === "KeyV") return;
const modifiers = currentModifiers(e);
patchKeyModifier(e, false);
sendEvent({
type: "key",
code: e.code,
key: e.key,
down: false,
...modifiers,
});
};
const requestRemoteCopy = () => {
// 工具按钮使用独立事件,避免和用户在远端窗口手按 Ctrl+C 的键盘状态互相污染。 // Logan.Li
if (!clipboardEnabled) {
setStatus(t("server.sessionPolicy.clipboardDisabled"));
return;
}
flushMouseMove();
sendEvent({ type: "remote_copy" });
setStatus(t("server.rdpDialog.remoteCopyRequested"));
};
const patchStats = (patch: Partial<RdpStats>) => {
statsRef.current = { ...statsRef.current, ...patch };
};
const paintBitmap = async (ctx: CanvasRenderingContext2D, frame: RdpFrame) => {
const paintBitmap = async (
ctx: CanvasRenderingContext2D,
frame: RdpFrame,
) => {
const dx = frame.x || 0;
const dy = frame.y || 0;
const dw = frame.width || 0;
const dh = frame.height || 0;
try {
if ('createImageBitmap' in window && frame.image) {
if ("createImageBitmap" in window && frame.image) {
const blob = await fetch(frame.image).then((resp) => resp.blob());
const bitmap = await createImageBitmap(blob);
ctx.drawImage(bitmap, dx, dy, dw || bitmap.width, dh || bitmap.height);
@@ -122,12 +270,15 @@ export function RdpDialog({ open, onOpenChange, serverId, serverName, serverIp }
ctx.drawImage(img, dx, dy, dw || img.width, dh || img.height);
resolve();
};
img.onerror = () => reject(new Error('RDP 位图解码失败'));
img.src = frame.image || '';
img.onerror = () =>
reject(new Error(t("server.rdpDialog.bitmapDecodeFailed")));
img.src = frame.image || "";
});
};
const parseRawFrame = async (data: Blob | ArrayBuffer): Promise<RdpRawFrame | null> => {
const parseRawFrame = async (
data: Blob | ArrayBuffer,
): Promise<RdpRawFrame | null> => {
const buffer = data instanceof Blob ? await data.arrayBuffer() : data;
if (buffer.byteLength < RDP_BINARY_HEADER) return null;
const view = new DataView(buffer);
@@ -136,13 +287,18 @@ export function RdpDialog({ open, onOpenChange, serverId, serverName, serverIp }
String.fromCharCode(view.getUint8(1)) +
String.fromCharCode(view.getUint8(2)) +
String.fromCharCode(view.getUint8(3));
if (magic !== 'RDPR') return null;
if (magic !== "RDPR") return null;
const x = view.getUint32(4, true);
const y = view.getUint32(8, true);
const width = view.getUint32(12, true);
const height = view.getUint32(16, true);
const bytes = width * height * 4;
if (width <= 0 || height <= 0 || buffer.byteLength < RDP_BINARY_HEADER + bytes) return null;
if (
width <= 0 ||
height <= 0 ||
buffer.byteLength < RDP_BINARY_HEADER + bytes
)
return null;
return {
x,
y,
@@ -155,45 +311,70 @@ export function RdpDialog({ open, onOpenChange, serverId, serverName, serverIp }
const drawRawFrame = async (data: Blob | ArrayBuffer) => {
const frame = await parseRawFrame(data);
if (!frame) return;
setStatus('');
setStatus("");
const canvas = canvasRef.current;
const ctx = canvas?.getContext('2d');
const ctx = canvas?.getContext("2d");
if (!canvas || !ctx) return;
ctx.imageSmoothingEnabled = false;
patchStats({ received: statsRef.current.received + 1 });
try {
const pixels = new Uint8ClampedArray(frame.rgba.length);
pixels.set(frame.rgba);
ctx.putImageData(new ImageData(pixels, frame.width, frame.height), frame.x, frame.y);
ctx.putImageData(
new ImageData(pixels, frame.width, frame.height),
frame.x,
frame.y,
);
setHasFrame(true);
patchStats({ drawn: statsRef.current.drawn + 1 });
} catch {
patchStats({ failed: statsRef.current.failed + 1 });
setStatus('RDP 原始帧绘制失败');
setStatus(t("server.rdpDialog.rawFrameDrawFailed"));
}
};
const drawFrame = async (frame: RdpFrame) => {
if (frame.type === 'status' || frame.type === 'error') {
setStatus(frame.message || '');
if (frame.type === "status" || frame.type === "error") {
setStatus(frame.message || "");
return;
}
if (frame.type === 'session') {
if (frame.type === "session") {
setConnected(true);
if (frame.width && frame.height) setCanvasSize({ width: frame.width, height: frame.height });
if (frame.width && frame.height)
setCanvasSize({ width: frame.width, height: frame.height });
return;
}
if (frame.type === 'clipboard') {
if (frame.type === "clipboard") {
if (!clipboardEnabled) return;
if (frame.text) {
setRemoteClipboard(frame.text);
await copyToClipboard(frame.text);
}
return;
}
if (frame.type !== 'bitmap' || !frame.image) return;
setStatus('');
if (frame.type === "rdp_file_download") {
if (!fileDownloadEnabled) {
setStatus(t("server.sessionPolicy.fileDownloadDisabled"));
return;
}
if (frame.url) {
const link = document.createElement("a");
link.href = frame.url;
link.download = frame.name || "";
link.style.display = "none";
document.body.appendChild(link);
link.click();
link.remove();
setStatus(
t("server.rdpDialog.downloadStarted", { name: frame.name || "file" }),
);
}
return;
}
if (frame.type !== "bitmap" || !frame.image) return;
setStatus("");
const canvas = canvasRef.current;
const ctx = canvas?.getContext('2d');
const ctx = canvas?.getContext("2d");
if (!canvas || !ctx) return;
ctx.imageSmoothingEnabled = false;
const received = statsRef.current.received + 1;
@@ -204,44 +385,41 @@ export function RdpDialog({ open, onOpenChange, serverId, serverName, serverIp }
patchStats({ drawn: statsRef.current.drawn + 1 });
} catch {
patchStats({ failed: statsRef.current.failed + 1 });
setStatus(`RDP 位图绘制失败,已收 ${received} 帧`);
setStatus(t("server.rdpDialog.bitmapDrawFailed", { count: received }));
}
};
useEffect(() => {
if (!open || !serverId) return;
const token = useUserStore.getState().token || localStorage.getItem('token') || '';
const maxDisplayWidth = fullscreen ? window.innerWidth : Math.min(window.innerWidth * 0.94, RDP_WIDTH);
const maxDisplayHeight = fullscreen ? window.innerHeight - 41 : window.innerHeight - 96;
const scale = Math.min(maxDisplayWidth / RDP_WIDTH, maxDisplayHeight / RDP_HEIGHT, 1);
const displayWidth = Math.round(RDP_WIDTH * scale);
const displayHeight = Math.round(RDP_HEIGHT * scale);
// 固定 1024x800 会话尺寸,先排除分辨率变化对画质诊断的干扰。 // Logan.Li
const width = RDP_WIDTH;
const height = RDP_HEIGHT;
setDisplaySize({ width: displayWidth, height: displayHeight });
const token =
useUserStore.getState().token || localStorage.getItem("token") || "";
const { width, height } = calcRdpViewport(false);
// 会话分辨率跟随弹窗可用空间,减少大屏下固定 1024x800 造成的左右黑边。 // Logan.Li
setDisplaySize({ width, height });
setCanvasSize({ width, height });
setStatus('正在创建 RDP 会话...');
setStatus(t("server.rdpDialog.creating"));
setHasFrame(false);
setRemoteClipboard('');
setRemoteClipboard("");
statsRef.current = { received: 0, drawn: 0, failed: 0 };
const canvas = canvasRef.current;
const ctx = canvas?.getContext('2d');
const ctx = canvas?.getContext("2d");
if (canvas && ctx) {
ctx.imageSmoothingEnabled = false;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#000';
ctx.fillStyle = "#000";
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
const ws = new WebSocket(`${getWsBase()}/ws/rdp?id=${serverId}&width=${width}&height=${height}&token=${encodeURIComponent(token)}`);
ws.binaryType = 'arraybuffer';
const ws = new WebSocket(
`${getWsBase()}/ws/rdp?id=${serverId}&width=${width}&height=${height}&token=${encodeURIComponent(token)}`,
);
ws.binaryType = "arraybuffer";
wsRef.current = ws;
ws.onopen = () => setConnected(true);
ws.onmessage = (event) => {
if (typeof event.data !== 'string') {
if (typeof event.data !== "string") {
paintQueueRef.current = paintQueueRef.current
.catch(() => undefined)
.then(() => drawRawFrame(event.data as Blob | ArrayBuffer));
@@ -253,13 +431,13 @@ export function RdpDialog({ open, onOpenChange, serverId, serverName, serverIp }
.catch(() => undefined)
.then(() => drawFrame(frame));
} catch {
setStatus(String(event.data || '收到无法解析的 RDP 帧'));
setStatus(String(event.data || t("server.rdpDialog.unparseableFrame")));
}
};
ws.onerror = () => setStatus('RDP 连接发生错误');
ws.onerror = () => setStatus(t("server.rdpDialog.connectionError"));
ws.onclose = () => {
setConnected(false);
setStatus((prev) => prev || 'RDP 连接已关闭');
setStatus((prev) => prev || t("server.rdpDialog.connectionClosed"));
};
return () => {
@@ -270,16 +448,23 @@ export function RdpDialog({ open, onOpenChange, serverId, serverName, serverIp }
setConnected(false);
setToolsOpen(false);
};
}, [open, serverId, fullscreen]);
}, [open, serverId]);
const canvasPoint = (event: React.MouseEvent<HTMLCanvasElement>) => {
const rect = event.currentTarget.getBoundingClientRect();
const x = Math.round((event.clientX - rect.left) * (canvasSize.width / rect.width));
const y = Math.round((event.clientY - rect.top) * (canvasSize.height / rect.height));
const x = Math.round(
(event.clientX - rect.left) * (canvasSize.width / rect.width),
);
const y = Math.round(
(event.clientY - rect.top) * (canvasSize.height / rect.height),
);
return { x, y };
};
const showConnectionIssue = Boolean(status && /失败|错误|关闭|无法解析/.test(status));
// 状态文本匹配错误关键词(中英双语,兼容 i18n 切换) // Logan.Li
const showConnectionIssue = Boolean(
status && /失败|错误|关闭|无法解析|fail|error|clos|unpars/i.test(status),
);
const showCenterLoading = open && !hasFrame && !showConnectionIssue;
return (
@@ -287,19 +472,32 @@ export function RdpDialog({ open, onOpenChange, serverId, serverName, serverIp }
<DialogContent
showCloseButton={false}
className={cn(
'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] p-0 gap-0 overflow-hidden',
"bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] p-0 gap-0 overflow-hidden",
fullscreen
? '!max-w-none !w-screen !h-screen !rounded-none !translate-x-0 !translate-y-0 !left-0 !top-0'
: '',
? "!max-w-none !w-screen !h-screen !rounded-none !translate-x-0 !translate-y-0 !left-0 !top-0"
: "",
)}
style={fullscreen ? { maxWidth: '100vw', width: '100vw', height: '100vh' } : { maxWidth: '1120px', width: '94vw' }}
style={
fullscreen
? { maxWidth: "100vw", width: "100vw", height: "100vh" }
: { maxWidth: displaySize.width, width: displaySize.width }
}
>
<DialogTitle className="sr-only">RDP - {serverName}</DialogTitle>
<DialogDescription className="sr-only">Windows RDP 图形会话</DialogDescription>
<DialogDescription className="sr-only">
{t("server.rdpDialog.graphicSession")}
</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">
<div className={cn('w-2 h-2 rounded-full', connected ? 'bg-emerald-400' : 'bg-red-400')} />
<span className="text-[var(--dg-text-primary)] font-medium">{serverName}</span>
<div
className={cn(
"w-2 h-2 rounded-full",
connected ? "bg-emerald-400" : "bg-red-400",
)}
/>
<span className="text-[var(--dg-text-primary)] font-medium">
{serverName}
</span>
<span className="text-[var(--dg-text-muted)]">({serverIp})</span>
</div>
<div className="flex items-center gap-1">
@@ -310,25 +508,35 @@ export function RdpDialog({ open, onOpenChange, serverId, serverName, serverIp }
type="button"
onClick={() => setToolsOpen((v) => !v)}
className={cn(
'p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]',
toolsOpen && 'bg-[var(--dg-bg-hover)] text-[var(--dg-text-primary)]',
"p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]",
toolsOpen &&
"bg-[var(--dg-bg-hover)] text-[var(--dg-text-primary)]",
)}
title="工具"
title={t("server.rdpDialog.tools")}
>
<Wrench className="h-4 w-4" />
</button>
</PopoverAnchor>
<PopoverContent align="end" className="p-0 w-auto border-[var(--dg-border)] bg-[var(--dg-bg-secondary)]">
<PopoverContent
align="end"
className="p-0 w-auto border-[var(--dg-border)] bg-[var(--dg-bg-secondary)]"
>
<RdpToolsPanel
serverId={serverId}
remoteClipboard={remoteClipboard}
onSyncToRemote={(text) => {
sendEvent({ type: 'clipboard', text });
setStatus('已同步到远端');
sendEvent({ type: "clipboard", text });
setStatus(t("server.rdpDialog.synced"));
}}
onAutoExec={(command) => {
sendEvent({ type: 'auto_exec', text: command });
setStatus('正在自动执行上传命令...');
onRequestRemoteCopy={requestRemoteCopy}
onUploadVirtualFile={(name, text) => {
const sent = sendEvent({
type: "rdp_file_upload",
name,
text,
});
if (!sent) return false;
setStatus(t("server.rdpDialog.uploadStaged", { name }));
return true;
}}
/>
</PopoverContent>
@@ -339,7 +547,11 @@ export function RdpDialog({ open, onOpenChange, serverId, serverName, serverIp }
onClick={() => setFullscreen(!fullscreen)}
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
>
{fullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
{fullscreen ? (
<Minimize2 className="h-4 w-4" />
) : (
<Maximize2 className="h-4 w-4" />
)}
</button>
<button
type="button"
@@ -352,7 +564,11 @@ export function RdpDialog({ open, onOpenChange, serverId, serverName, serverIp }
</div>
<div
className="relative bg-black flex items-center justify-center"
style={{ height: fullscreen ? 'calc(100vh - 41px)' : displaySize.height }}
style={{
height: fullscreen
? `calc(100vh - ${RDP_HEADER_HEIGHT}px)`
: displaySize.height,
}}
>
<canvas
ref={canvasRef}
@@ -360,19 +576,54 @@ export function RdpDialog({ open, onOpenChange, serverId, serverName, serverIp }
height={canvasSize.height}
tabIndex={0}
className="max-w-full max-h-full outline-none"
style={{ width: displaySize.width, height: displaySize.height }}
onMouseMove={(e) => sendMouseMove({ type: 'mouse_move', ...canvasPoint(e) })}
onMouseDown={(e) => { e.preventDefault(); e.currentTarget.focus(); flushMouseMove(); sendEvent({ type: 'mouse_down', button: e.button, ...canvasPoint(e) }); }}
onMouseUp={(e) => { e.preventDefault(); flushMouseMove(); sendEvent({ type: 'mouse_up', button: e.button, ...canvasPoint(e) }); }}
onWheel={(e) => { flushMouseMove(); sendEvent({ type: 'wheel', delta_y: Math.trunc(e.deltaY), ...canvasPoint(e) }); }}
style={{
// 最大化只缩放当前画面,不重连 RDP,避免登录过程闪黑。 // Logan.Li
width: fullscreen ? "100%" : displaySize.width,
height: fullscreen ? "100%" : displaySize.height,
}}
onMouseMove={(e) =>
sendMouseMove({ type: "mouse_move", ...canvasPoint(e) })
}
onMouseDown={(e) => {
e.preventDefault();
e.currentTarget.focus();
flushMouseMove();
sendEvent({
type: "mouse_down",
button: e.button,
...canvasPoint(e),
});
}}
onMouseUp={(e) => {
e.preventDefault();
flushMouseMove();
sendEvent({
type: "mouse_up",
button: e.button,
...canvasPoint(e),
});
}}
onWheel={(e) => {
flushMouseMove();
sendEvent({
type: "wheel",
delta_y: Math.trunc(e.deltaY),
...canvasPoint(e),
});
}}
onContextMenu={(e) => e.preventDefault()}
onPaste={(e) => {
const text = e.clipboardData.getData('text/plain');
if (text) sendEvent({ type: 'clipboard', text });
if (!clipboardEnabled) {
setStatus(t("server.sessionPolicy.clipboardDisabled"));
return;
}
const text = e.clipboardData.getData("text/plain");
if (text) sendEvent({ type: "clipboard", text });
}}
onKeyDown={(e) => { e.preventDefault(); sendEvent({ type: 'key', code: e.code, key: e.key, down: true }); }}
onKeyUp={(e) => { e.preventDefault(); sendEvent({ type: 'key', code: e.code, key: e.key, down: false }); }}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
/>
<SessionWatermark />
{showCenterLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-black/25">
<Loader2 className="h-8 w-8 animate-spin text-white/80" />
+295 -181
View File
@@ -1,227 +1,341 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
import { readFromClipboard } from '@/utils/clipboard';
import { rdpFilesApi, type RdpFileItem } from '@/api/server/rdp-files';
import { Button } from '@/components/ui/button';
import { Download, FolderOpen, RefreshCw, Settings2, Upload } from 'lucide-react';
import { toast } from 'sonner';
import { RDP_VIRTUAL_DOWNLOAD_ROOT } from './rdpConstants';
type ToolsTab = 'general' | 'files';
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { readFromClipboard, copyToClipboard } from "@/utils/clipboard";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { cn } from "@/lib/utils";
import { useSessionSecurityStore } from "@/store";
import {
CheckCircle2,
ClipboardPaste,
Copy,
FileUp,
FolderDown,
FolderUp,
Upload,
} from "lucide-react";
import { toast } from "sonner";
interface RdpToolsPanelProps {
serverId: number;
remoteClipboard: string;
onSyncToRemote: (text: string) => void;
onAutoExec: (command: string) => void;
onRequestRemoteCopy: () => void;
onUploadVirtualFile: (name: string, base64: string) => boolean;
}
function fmtSize(size: number): string {
if (size >= 1 << 20) return `${(size / (1 << 20)).toFixed(1)} MB`;
if (size >= 1 << 10) return `${(size / (1 << 10)).toFixed(1)} KB`;
interface UploadState {
name: string;
sizeLabel: string;
progress: number;
status: "reading" | "sending" | "staged" | "failed";
}
const RDP_UPLOAD_LIMIT = 50 * 1024 * 1024;
function formatBytes(size: number) {
if (size >= 1024 * 1024) return `${(size / 1024 / 1024).toFixed(1)} MB`;
if (size >= 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${size} B`;
}
// RDP 工具面板:通用(剪贴板)+ 文件(SMB 直传,失败时自动回退)// Logan.Li
// RDP 工具面板:提供剪贴板同步和 Go-client 上的 AIOps 盘上传。 // Logan.Li
export function RdpToolsPanel({
serverId,
remoteClipboard,
onSyncToRemote,
onAutoExec,
onRequestRemoteCopy,
onUploadVirtualFile,
}: RdpToolsPanelProps) {
const { t } = useTranslation();
const [tab, setTab] = useState<ToolsTab>('general');
const [localClipDraft, setLocalClipDraft] = useState('');
const [files, setFiles] = useState<RdpFileItem[]>([]);
const [loadingFiles, setLoadingFiles] = useState(false);
const [localClipDraft, setLocalClipDraft] = useState("");
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const refreshFiles = useCallback(async () => {
setLoadingFiles(true);
try {
const data = await rdpFilesApi.list(serverId);
setFiles(data?.list ?? []);
} catch (err) {
setFiles([]);
toast.error(err instanceof Error ? err.message : t('server.listFileError'));
} finally {
setLoadingFiles(false);
}
}, [serverId]);
useEffect(() => {
if (tab === 'files') void refreshFiles();
}, [tab, refreshFiles]);
const fillLocalClipOnFocus = async () => {
const text = await readFromClipboard();
if (text) {
setLocalClipDraft(text);
return;
}
toast.info(t('server.clipboardHint'));
};
const [uploadState, setUploadState] = useState<UploadState | null>(null);
const [dragActive, setDragActive] = useState(false);
const clipboardEnabled = useSessionSecurityStore(
(state) => state.clipboardEnabled,
);
const fileUploadEnabled = useSessionSecurityStore(
(state) => state.fileUploadEnabled,
);
const fileDownloadEnabled = useSessionSecurityStore(
(state) => state.fileDownloadEnabled,
);
const syncLocalClip = () => {
const trimmed = localClipDraft.trim();
if (!trimmed) {
toast.info(t('server.emptyContent'));
toast.info(t("server.emptyContent"));
return;
}
onSyncToRemote(trimmed);
toast.success(t('server.syncedToRemote'));
toast.success(t("server.syncedToRemote"));
};
const uploadWithFallback = async (file: File) => {
const uploadToVirtualDrive = async (file: File) => {
if (file.size > RDP_UPLOAD_LIMIT) {
toast.error(
t("server.rdpUploadTooLarge", { limit: formatBytes(RDP_UPLOAD_LIMIT) }),
);
return;
}
if (!fileUploadEnabled) {
toast.info(t("server.sessionPolicy.fileUploadDisabled"));
return;
}
setUploading(true);
setUploadState({
name: file.name,
sizeLabel: formatBytes(file.size),
progress: 0,
status: "reading",
});
try {
try {
await rdpFilesApi.upload(serverId, file);
toast.success(t('server.uploadedToDownload', { name: file.name }));
void refreshFiles();
return;
} catch {
// SMB 不可达时走 AIOps 暂存 + RDP 自动执行 // Logan.Li
}
const staged = await rdpFilesApi.stage(serverId, file);
onAutoExec(staged.deploy_command);
toast.success(t('server.autoSent', { name: file.name }));
} catch (err) {
toast.error(err instanceof Error ? err.message : t('server.rdpUploadFailed'));
const base64 = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error("read failed"));
reader.onprogress = (event) => {
if (!event.lengthComputable) return;
// 当前链路先读入浏览器内存,进度条展示本地读取进度。 // Logan.Li
const progress = Math.min(
90,
Math.round((event.loaded / event.total) * 90),
);
setUploadState((prev) =>
prev ? { ...prev, progress, status: "reading" } : prev,
);
};
reader.onload = () => {
const raw = String(reader.result || "");
resolve(raw.includes(",") ? raw.split(",")[1] : raw);
};
reader.readAsDataURL(file);
});
setUploadState((prev) =>
prev ? { ...prev, progress: 95, status: "sending" } : prev,
);
const sent = onUploadVirtualFile(file.name, base64);
if (!sent) throw new Error("websocket closed");
setUploadState((prev) =>
prev ? { ...prev, progress: 100, status: "staged" } : prev,
);
toast.success(t("server.uploadedToVirtualDrive", { name: file.name }));
} catch {
setUploadState((prev) => (prev ? { ...prev, status: "failed" } : prev));
toast.error(t("server.rdpUploadFailed"));
} finally {
setUploading(false);
}
};
const handleUploadPick = async (picked: File | undefined) => {
if (!picked) return;
await uploadWithFallback(picked);
setTab('files');
};
const handleDownload = async (name: string) => {
try {
await rdpFilesApi.download(serverId, name);
toast.success(t('server.downloadStarted', { name }));
} catch (err) {
toast.error(err instanceof Error ? err.message : t('server.rdpDownloadFailed'));
}
};
return (
<div className="w-[380px]">
<div className="flex border-b border-[var(--dg-border)]">
<button
type="button"
onClick={() => setTab('general')}
className={cn(
'flex-1 flex items-center justify-center gap-1.5 px-3 py-2 text-xs transition-colors',
tab === 'general'
? 'text-[var(--dg-text-primary)] border-b-2 border-[var(--dg-blue)]'
: 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-secondary)]',
)}
>
<Settings2 className="h-3.5 w-3.5" />
{t('server.general')}
</button>
<button
type="button"
onClick={() => setTab('files')}
className={cn(
'flex-1 flex items-center justify-center gap-1.5 px-3 py-2 text-xs transition-colors',
tab === 'files'
? 'text-[var(--dg-text-primary)] border-b-2 border-[var(--dg-blue)]'
: 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-secondary)]',
)}
>
<FolderOpen className="h-3.5 w-3.5" />
{t('server.rdpFiles')}
</button>
</div>
{tab === 'general' && (
<div className="p-3 space-y-3">
<div className="space-y-1.5">
<label className="text-xs text-[var(--dg-text-muted)]">{t('server.remoteClipboard')}</label>
<textarea
readOnly
value={remoteClipboard}
rows={3}
placeholder={t('server.remoteClipPlaceholder')}
className="w-full resize-none rounded border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] px-2 py-1.5 text-xs text-[var(--dg-text-secondary)] outline-none"
/>
<div className="w-[336px] p-3">
<div className="space-y-3">
{/* 剪贴板区块保持两个主动作,避免远端复制和本地粘贴混在一起。 // Logan.Li */}
<section className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-[var(--dg-text-secondary)]">
{t("server.rdpClipboard")}
</span>
{remoteClipboard ? (
<span className="inline-flex items-center gap-1 text-[11px] text-emerald-500">
<CheckCircle2 className="h-3.5 w-3.5" />
{t("server.clipboardReady")}
</span>
) : null}
</div>
<div className="space-y-1.5">
<label className="text-xs text-[var(--dg-text-muted)]">{t('server.syncToRemote')}</label>
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
size="sm"
className="h-9"
disabled={!clipboardEnabled}
onClick={async () => {
if (!clipboardEnabled) return;
// 一键粘贴到远端:读取本地剪贴板 → 发送到 RDP。 // Logan.Li
const text = await readFromClipboard();
if (!text) {
toast.info(t("server.clipboardHint"));
return;
}
onSyncToRemote(text);
toast.success(t("server.syncedToRemote"));
}}
>
<ClipboardPaste className="h-4 w-4 mr-1.5" />
{t("server.pasteToRemote")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-9"
disabled={!clipboardEnabled}
onClick={async () => {
if (!clipboardEnabled) return;
// 一键复制远端剪贴板:无缓存时先请求远端 Ctrl+C,避免误报“远端为空”。 // Logan.Li
if (!remoteClipboard) {
onRequestRemoteCopy();
toast.info(t("server.remoteCopyRequested"));
return;
}
const ok = await copyToClipboard(remoteClipboard);
if (ok) toast.success(t("server.copiedToLocal"));
else toast.error(t("server.copyFailed"));
}}
>
<Copy className="h-4 w-4 mr-1.5" />
{t("server.copyFromRemote")}
</Button>
</div>
</section>
{/* 远端剪贴板预览 // Logan.Li */}
<textarea
readOnly
value={remoteClipboard}
rows={4}
placeholder={t("server.remoteClipPlaceholder")}
className="w-full resize-none rounded border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] px-2 py-1.5 text-xs text-[var(--dg-text-secondary)] outline-none"
/>
{/* 手动输入同步到远端(高级) // Logan.Li */}
<details className="text-xs">
<summary className="cursor-pointer text-[var(--dg-text-muted)] hover:text-[var(--dg-text-secondary)]">
{t("server.manualSync")}
</summary>
<div className="mt-2 space-y-1.5">
<textarea
value={localClipDraft}
onChange={(e) => setLocalClipDraft(e.target.value)}
onFocus={() => { void fillLocalClipOnFocus(); }}
onPaste={(e) => {
const pasted = e.clipboardData.getData('text/plain');
if (pasted) setLocalClipDraft(pasted);
}}
rows={3}
placeholder={t('server.syncPlaceholder')}
className="w-full resize-none rounded border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] px-2 py-1.5 text-xs text-[var(--dg-text-primary)] outline-none"
placeholder={t("server.syncPlaceholder")}
className="w-full resize-none rounded border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] px-2 py-1.5 text-xs text-[var(--dg-text-primary)] outline-none"
/>
<Button type="button" size="sm" className="w-full" onClick={syncLocalClip}>
{t('server.syncButton')}
<Button
type="button"
size="sm"
className="w-full"
onClick={syncLocalClip}
>
{t("server.syncButton")}
</Button>
</div>
</div>
)}
</details>
{tab === 'files' && (
<div className="p-3 space-y-3">
<div className="flex items-center justify-between text-xs text-[var(--dg-text-muted)]">
<span>共享盘 &gt; <span className="text-[var(--dg-text-secondary)]">Download</span></span>
<button type="button" onClick={() => { void refreshFiles(); }} className="p-1 rounded hover:bg-[var(--dg-bg-hover)]" title="刷新">
<RefreshCw className={cn('h-3.5 w-3.5', loadingFiles && 'animate-spin')} />
</button>
<div className="h-px bg-[var(--dg-border)]" />
{/* 虚拟盘区块按 Windows 资源管理器里的展示名称组织。 // Logan.Li */}
<section className="space-y-2">
<div className="flex items-center justify-between">
<div className="min-w-0">
<div className="text-xs font-medium text-[var(--dg-text-secondary)]">
{t("server.aiopsVirtualDrive")}
</div>
<div className="mt-0.5 truncate text-[11px] text-[var(--dg-text-muted)]">
{t("server.aiopsVirtualDriveLocation")}
</div>
</div>
<span
className="shrink-0 rounded bg-[var(--dg-bg-secondary)] px-1.5 py-0.5 text-[10px] text-[var(--dg-text-muted)]"
title={"\\\\tsclient\\AIOps"}
>
UNC
</span>
</div>
<code className="block text-[10px] leading-relaxed text-[var(--dg-cyan)] bg-[var(--dg-bg-primary)] rounded px-2 py-1.5 break-all">
{RDP_VIRTUAL_DOWNLOAD_ROOT}
</code>
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={(e) => {
void handleUploadPick(e.target.files?.[0]);
e.currentTarget.value = '';
}}
/>
<Button type="button" size="sm" className="w-full" disabled={uploading} onClick={() => fileInputRef.current?.click()}>
<Upload className="h-3.5 w-3.5 mr-1.5" />
{uploading ? t('server.rdpUploading') : t('server.uploadFile')}
</Button>
<div className="max-h-40 overflow-y-auto rounded border border-[var(--dg-border)]">
{files.length === 0 ? (
<p className="px-2 py-3 text-[10px] text-[var(--dg-text-muted)] text-center">
{loadingFiles ? t('server.rdpLoading') : t('server.noRdpFiles')}
</p>
) : (
files.map((f) => (
<div key={f.name} className="flex items-center justify-between gap-2 px-2 py-1.5 text-xs border-b border-[var(--dg-border)] last:border-0 hover:bg-[var(--dg-bg-hover)]">
<div className="min-w-0 flex-1">
<p className="truncate text-[var(--dg-text-primary)]">{f.name}</p>
<p className="text-[10px] text-[var(--dg-text-muted)]">{fmtSize(f.size)}</p>
</div>
<button type="button" onClick={() => { void handleDownload(f.name); }} className="p-1 rounded hover:bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)]" title="下载">
<Download className="h-3.5 w-3.5" />
</button>
</div>
))
<div className="grid grid-cols-2 gap-2 text-[11px]">
<div className="flex items-center gap-1.5 rounded border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] px-2 py-1.5 text-[var(--dg-text-secondary)]">
<FolderUp className="h-3.5 w-3.5 text-sky-500" />
Upload
</div>
<div className="flex items-center gap-1.5 rounded border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] px-2 py-1.5 text-[var(--dg-text-secondary)]">
<FolderDown className="h-3.5 w-3.5 text-emerald-500" />
Download
</div>
</div>
<label
className={cn(
"flex min-h-[82px] cursor-pointer flex-col items-center justify-center gap-1 rounded border border-dashed border-[var(--dg-border)] bg-[var(--dg-bg-primary)] px-3 py-3 text-center text-xs text-[var(--dg-text-secondary)] transition-colors",
dragActive &&
"border-primary bg-primary/10 text-[var(--dg-text-primary)]",
uploading && "cursor-not-allowed opacity-70",
)}
onDragEnter={(e) => {
e.preventDefault();
setDragActive(true);
}}
onDragOver={(e) => {
e.preventDefault();
setDragActive(true);
}}
onDragLeave={(e) => {
e.preventDefault();
setDragActive(false);
}}
onDrop={(e) => {
e.preventDefault();
setDragActive(false);
const file = e.dataTransfer.files?.[0];
if (file && !uploading && fileUploadEnabled)
void uploadToVirtualDrive(file);
}}
>
{uploading ? (
<Upload className="h-5 w-5 text-primary" />
) : (
<FileUp className="h-5 w-5 text-primary" />
)}
<span className="font-medium">
{uploading
? t("server.rdpUploading")
: t("server.uploadToVirtualDrive")}
</span>
<span className="text-[11px] text-[var(--dg-text-muted)]">
{t("server.rdpUploadHint", {
limit: formatBytes(RDP_UPLOAD_LIMIT),
})}
</span>
<input
type="file"
className="hidden"
disabled={uploading || !fileUploadEnabled}
onChange={(e) => {
const file = e.target.files?.[0];
e.currentTarget.value = "";
if (file) void uploadToVirtualDrive(file);
}}
/>
</label>
{(!fileUploadEnabled ||
!fileDownloadEnabled ||
!clipboardEnabled) && (
<div className="rounded border border-amber-500/25 bg-amber-500/10 px-2 py-1.5 text-[11px] text-amber-500">
{t("server.sessionPolicy.restricted")}
</div>
)}
</section>
{uploadState && (
<div className="rounded border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] px-3 py-2 text-xs">
<div className="flex items-center justify-between gap-3">
<span
className="truncate text-[var(--dg-text-primary)]"
title={uploadState.name}
>
{uploadState.name}
</span>
<span className="shrink-0 text-[var(--dg-text-muted)]">
{uploadState.sizeLabel}
</span>
</div>
<Progress className="mt-2 h-1.5" value={uploadState.progress} />
<div className="mt-1 flex items-center justify-between gap-2 text-[var(--dg-text-muted)]">
<span className="truncate">
{t(`server.rdpUploadStatus.${uploadState.status}`)}
</span>
<span className="shrink-0">{uploadState.progress}%</span>
</div>
</div>
<p className="text-[10px] text-[var(--dg-text-muted)] leading-relaxed">
{t('server.smbHint')}
</p>
</div>
)}
)}
</div>
</div>
);
}
}
+320 -294
View File
@@ -1,10 +1,9 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useQueryClient } from '@tanstack/react-query';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { useState, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import {
DropdownMenu,
DropdownMenuContent,
@@ -12,7 +11,7 @@ import {
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
} from "@/components/ui/dropdown-menu";
import {
Table,
TableBody,
@@ -20,14 +19,14 @@ import {
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
} from "@/components/ui/table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
} from "@/components/ui/select";
import {
Search,
Plus,
@@ -39,34 +38,32 @@ import {
RefreshCw,
ChevronLeft,
ChevronRight,
} from 'lucide-react';
import { useServers, useServerMutations, queryKeys } from '@/hooks/api';
import { ServerStatsStrip } from './ServerStatsStrip';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { toast } from 'sonner';
import { useUserStore } from '@/store';
import { CloudServerImport } from './CloudServerImport';
import { TerminalDialog } from './TerminalDialog';
import { RdpDialog } from './RdpDialog';
import { ServerFileBrowser } from './ServerFileBrowser';
import type { JsonData } from '@/types/common';
import { ExcelServerImport } from './ExcelServerImport';
import { ServerForm, EMPTY_FORM, type ServerFormData } from './ServerForm';
import { getTags } from './serverHelpers';
import { ServerTableRow } from './ServerTableRow';
import { usePermission } from '@/hooks/usePermission';
import { PageShell } from '@/components/layout/PageShell';
import { ContentPanel } from '@/components/layout/ContentPanel';
import { Server } from 'lucide-react';
} from "lucide-react";
import { useServers, useServerMutations } from "@/hooks/api";
import { ServerStatsStrip } from "./ServerStatsStrip";
import { useConfirm } from "@/components/ui/confirm-dialog";
import { toast } from "sonner";
import { CloudServerImport } from "./CloudServerImport";
import { TerminalDialog } from "./TerminalDialog";
import { RdpDialog } from "./RdpDialog";
import { ServerFileBrowser } from "./ServerFileBrowser";
import type { JsonData } from "@/types/common";
import { ExcelServerImport } from "./ExcelServerImport";
import { ServerForm, EMPTY_FORM, type ServerFormData } from "./ServerForm";
import { getTags } from "./serverHelpers";
import { ServerTableRow } from "./ServerTableRow";
import { usePermission } from "@/hooks/usePermission";
import { PageShell } from "@/components/layout/PageShell";
import { ContentPanel } from "@/components/layout/ContentPanel";
import { Server } from "lucide-react";
export function ServerManager() {
const { t } = useTranslation();
const confirm = useConfirm();
const queryClient = useQueryClient();
const { hasPermission } = usePermission();
const [selectedServers, setSelectedServers] = useState<string[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [cloudImportOpen, setCloudImportOpen] = useState(false);
const [excelImportOpen, setExcelImportOpen] = useState(false);
@@ -79,13 +76,25 @@ export function ServerManager() {
// 终端 Dialog 状态
const [terminalOpen, setTerminalOpen] = useState(false);
const [terminalServer, setTerminalServer] = useState<{ id: number; name: string; ip: string; hasAuth?: boolean } | null>(null);
const [terminalServer, setTerminalServer] = useState<{
id: number;
name: string;
ip: string;
hasAuth?: boolean;
} | null>(null);
const [rdpOpen, setRdpOpen] = useState(false);
const [rdpServer, setRdpServer] = useState<{ id: number; name: string; ip: string } | null>(null);
const [rdpServer, setRdpServer] = useState<{
id: number;
name: string;
ip: string;
} | null>(null);
// 文件浏览 Dialog 状态
const [fileBrowserOpen, setFileBrowserOpen] = useState(false);
const [fileBrowserServer, setFileBrowserServer] = useState<{ id: number; name: string } | null>(null);
const [fileBrowserServer, setFileBrowserServer] = useState<{
id: number;
name: string;
} | null>(null);
// 分页状态
const [page, setPage] = useState(1);
@@ -94,17 +103,32 @@ export function ServerManager() {
const queryParams = useMemo(() => {
const params: Record<string, unknown> = { page, page_size: pageSize };
if (searchQuery) params.keyword = searchQuery;
if (statusFilter && statusFilter !== 'all') params.status = statusFilter;
if (statusFilter && statusFilter !== "all") params.status = statusFilter;
return params;
}, [page, pageSize, searchQuery, statusFilter]);
const { data, isLoading: loading, isFetching, refetch } = useServers(queryParams);
const { createMutation, updateMutation, deleteMutation, batchDeleteMutation } = useServerMutations();
const {
data,
isLoading: loading,
isFetching,
refetch,
} = useServers(queryParams);
const {
createMutation,
updateMutation,
deleteMutation,
batchDeleteMutation,
} = useServerMutations();
const servers = data?.list ?? [];
const total = data?.total ?? 0;
const openTerminal = (server: JsonData) => {
setTerminalServer({ id: server.id, name: server.hostname, ip: server.ip, hasAuth: server.has_auth ?? true });
setTerminalServer({
id: server.id,
name: server.hostname,
ip: server.ip,
hasAuth: server.has_auth ?? true,
});
setTerminalOpen(true);
};
@@ -128,85 +152,6 @@ export function ServerManager() {
setPage(1);
};
// WebSocket 实时接收 Agent 指标推送 // Logan.Li
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
const token = useUserStore.getState().token;
if (!token) return;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/metrics?token=${token}`;
const connect = () => {
if (wsRef.current?.readyState === WebSocket.OPEN) return;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
// WebSocket 已连接,静默接收指标推送
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.type === 'metrics_update') {
queryClient.setQueryData(queryKeys.servers(queryParams), (old: typeof data) => {
if (!old) return old;
return {
...old,
list: old.list.map((s) => {
if (s.hostname === msg.hostname) {
return {
...s,
cpu_usage: msg.cpu_usage,
memory_usage: msg.memory_usage,
disk_usage: msg.disk_usage,
status: msg.status,
tcp_established: msg.tcp_established,
tcp_time_wait: msg.tcp_time_wait,
net_rx_bps: msg.net_rx_bps,
net_tx_bps: msg.net_tx_bps,
disk_read_bps: msg.disk_read_bps,
disk_write_bps: msg.disk_write_bps,
};
}
return s;
}),
};
});
}
} catch {
// 忽略非 JSON 消息
}
};
ws.onclose = () => {
// 连接断开,3 秒后重连
wsRef.current = null;
reconnectTimerRef.current = setTimeout(connect, 3000);
};
ws.onerror = (err) => {
console.error('[MetricsWS] 连接错误:', err);
ws.close();
};
};
connect();
return () => {
if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
if (wsRef.current) {
wsRef.current.onclose = null; // 阻止自动重连
wsRef.current.close();
wsRef.current = null;
}
};
}, [queryClient, queryParams]);
// 导出 CSV
const toggleSelectAll = () => {
if (selectedServers.length === servers.length) {
@@ -227,18 +172,18 @@ export function ServerManager() {
const handleDelete = async (id: string) => {
const server = servers.find((s) => String(s.id) === String(id));
const ok = await confirm({
title: t('server.deleteTitle'),
title: t("server.deleteTitle"),
description: `${server?.hostname || id}`,
confirmText: t('common.delete'),
variant: 'destructive',
confirmText: t("common.delete"),
variant: "destructive",
});
if (!ok) return;
try {
await deleteMutation.mutateAsync(id);
toast.success(t('server.deleted'));
toast.success(t("server.deleted"));
} catch (e) {
console.error('[ServerManager] delete failed:', e);
toast.error(t('server.deleteFailed'));
console.error("[ServerManager] delete failed:", e);
toast.error(t("server.deleteFailed"));
}
};
@@ -246,19 +191,23 @@ export function ServerManager() {
const handleBatchDelete = async () => {
if (selectedServers.length === 0) return;
const ok = await confirm({
title: t('server.batchDeleteTitle'),
title: t("server.batchDeleteTitle"),
description: `${selectedServers.length}`,
confirmText: t('server.batchDelete'),
variant: 'destructive',
confirmText: t("server.batchDelete"),
variant: "destructive",
});
if (!ok) return;
try {
await batchDeleteMutation.mutateAsync(selectedServers.map((id) => Number(id)));
toast.success(t('server.deletedCount', { count: selectedServers.length }));
await batchDeleteMutation.mutateAsync(
selectedServers.map((id) => Number(id)),
);
toast.success(
t("server.deletedCount", { count: selectedServers.length }),
);
setSelectedServers([]);
} catch (e) {
console.error('[ServerManager] batch delete failed:', e);
toast.error(t('server.batchDeleteFailed'));
console.error("[ServerManager] batch delete failed:", e);
toast.error(t("server.batchDeleteFailed"));
}
};
@@ -274,25 +223,27 @@ export function ServerManager() {
const openEditForm = (server: JsonData) => {
setEditingId(server.id);
setFormData({
hostname: server.hostname || '',
ip: server.ip || '',
public_ip: server.public_ip || '',
hostname: server.hostname || "",
ip: server.ip || "",
public_ip: server.public_ip || "",
port: String(server.port || 22),
username: server.username || 'root',
auth_type: server.auth_type || 'password',
os_type: server.os_type || ((server.protocol === 'rdp') ? 'windows' : 'linux'),
protocol: server.protocol || ((server.os_type === 'windows') ? 'rdp' : 'ssh'),
username: server.username || "root",
auth_type: server.auth_type || "password",
os_type:
server.os_type || (server.protocol === "rdp" ? "windows" : "linux"),
protocol:
server.protocol || (server.os_type === "windows" ? "rdp" : "ssh"),
rdp_port: String(server.rdp_port || 3389),
password: '',
ssh_key: '',
password: "",
ssh_key: "",
credential_id: null as number | null,
os: server.os || '',
cpu: String(server.cpu || ''),
memory: server.memory ? String(Math.round(server.memory / 1024)) : '', // MB → GB
disk: String(server.disk || ''),
status: server.status || 'running',
tags: server.tags || '',
description: server.description || '',
os: server.os || "",
cpu: String(server.cpu || ""),
memory: server.memory ? String(Math.round(server.memory / 1024)) : "", // MB → GB
disk: String(server.disk || ""),
status: server.status || "running",
tags: server.tags || "",
description: server.description || "",
});
setDetailsOpen(false);
setFormOpen(true);
@@ -308,11 +259,11 @@ export function ServerManager() {
ip: formData.ip,
public_ip: formData.public_ip || undefined,
port: Number(formData.port) || 22,
os_type: formData.os_type || 'linux',
protocol: formData.protocol || 'ssh',
os_type: formData.os_type || "linux",
protocol: formData.protocol || "ssh",
rdp_port: Number(formData.rdp_port) || 3389,
username: formData.username || 'root',
auth_type: formData.auth_type || 'password',
username: formData.username || "root",
auth_type: formData.auth_type || "password",
os: formData.os,
cpu: Number(formData.cpu) || 0,
memory: (Number(formData.memory) || 0) * 1024, // GB → MB
@@ -338,11 +289,11 @@ export function ServerManager() {
} else {
await createMutation.mutateAsync(payload);
}
toast.success(editingId ? t('server.updated') : t('server.created'));
toast.success(editingId ? t("server.updated") : t("server.created"));
setFormOpen(false);
} catch (e) {
console.error('[ServerManager] save failed:', e);
toast.error(t('server.saveFailed'));
console.error("[ServerManager] save failed:", e);
toast.error(t("server.saveFailed"));
} finally {
setFormSaving(false);
}
@@ -351,34 +302,55 @@ export function ServerManager() {
// 导出 CSV — 将当前服务器列表导出为 CSV 文件 // Logan.Li
const handleExport = () => {
if (servers.length === 0) {
toast.error(t('server.noExportData'));
toast.error(t("server.noExportData"));
return;
}
const headers = [
t('server.csv.hostname'), t('server.csv.ip'), t('server.csv.publicIp'), t('server.csv.port'),
t('server.csv.os'), t('server.csv.cpu'), t('server.csv.memory'), t('server.csv.disk'),
t('server.csv.status'), t('server.csv.tags'),
t("server.csv.hostname"),
t("server.csv.ip"),
t("server.csv.publicIp"),
t("server.csv.port"),
t("server.csv.os"),
t("server.csv.cpu"),
t("server.csv.memory"),
t("server.csv.disk"),
t("server.csv.status"),
t("server.csv.tags"),
];
const rows = servers.map((s) => {
const memGB = s.memory ? Math.round(Number(s.memory) / 1024) : 0;
const tags = getTags(s).join(';');
return [s.hostname || '', s.ip || '', s.public_ip || '', s.port || 22, s.os || '',
s.cpu || 0, memGB, s.disk || 0, s.status || '', tags];
const tags = getTags(s).join(";");
return [
s.hostname || "",
s.ip || "",
s.public_ip || "",
s.port || 22,
s.os || "",
s.cpu || 0,
memGB,
s.disk || 0,
s.status || "",
tags,
];
});
const csv = [headers, ...rows]
.map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
.join('\n');
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8;' });
.map((row) =>
row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(","),
)
.join("\n");
const blob = new Blob(["\ufeff" + csv], {
type: "text/csv;charset=utf-8;",
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
const a = document.createElement("a");
a.href = url;
a.download = `servers_${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
toast.success(t('server.exported', { count: servers.length }));
toast.success(t("server.exported", { count: servers.length }));
};
const colSpanCount = 9;
const colSpanCount = 8;
const tableToolbar = (
<>
@@ -386,7 +358,7 @@ export function ServerManager() {
<div className="relative w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 dg-text-muted" />
<Input
placeholder={t('server.searchPlaceholder')}
placeholder={t("server.searchPlaceholder")}
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
className="pl-9 h-9 dg-input"
@@ -394,24 +366,33 @@ export function ServerManager() {
</div>
<Select value={statusFilter} onValueChange={handleStatusFilter}>
<SelectTrigger className="w-32 h-9 dg-input">
<SelectValue placeholder={t('common.status')} />
<SelectValue placeholder={t("common.status")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('server.allStatus')}</SelectItem>
<SelectItem value="running">{t('common.running')}</SelectItem>
<SelectItem value="stopped">{t('common.stopped')}</SelectItem>
<SelectItem value="maintenance">{t('common.maintenance')}</SelectItem>
<SelectItem value="offline">{t('common.offline')}</SelectItem>
<SelectItem value="all">{t("server.allStatus")}</SelectItem>
<SelectItem value="running">{t("common.running")}</SelectItem>
<SelectItem value="stopped">{t("common.stopped")}</SelectItem>
<SelectItem value="maintenance">
{t("common.maintenance")}
</SelectItem>
<SelectItem value="offline">{t("common.offline")}</SelectItem>
</SelectContent>
</Select>
</div>
{selectedServers.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-sm dg-text-secondary">{t('server.selected', { count: selectedServers.length })}</span>
{hasPermission('server:delete') && (
<Button variant="outline" size="sm" className="dg-btn-danger dg-btn-sm" onClick={handleBatchDelete}>
<span className="text-sm dg-text-secondary">
{t("server.selected", { count: selectedServers.length })}
</span>
{hasPermission("server:delete") && (
<Button
variant="outline"
size="sm"
className="dg-btn-danger dg-btn-sm"
onClick={handleBatchDelete}
>
<Trash2 className="h-4 w-4 mr-2" />
{t('server.batchDelete')}
{t("server.batchDelete")}
</Button>
)}
</div>
@@ -422,150 +403,195 @@ export function ServerManager() {
return (
<PageShell
variant="list"
title={t('server.title')}
subtitle={t('server.subtitle')}
title={t("server.title")}
subtitle={t("server.subtitle")}
icon={<Server className="h-5 w-5" />}
metrics={<ServerStatsStrip servers={servers} />}
actions={(
actions={
<>
<Button variant="outline" className="dg-btn-secondary" onClick={() => refetch()} disabled={isFetching}>
<RefreshCw className={cn('h-4 w-4 mr-2', isFetching && 'animate-spin')} />
{t('common.refresh')}
<Button
variant="outline"
className="dg-btn-secondary"
onClick={() => refetch()}
disabled={isFetching}
>
<RefreshCw
className={cn("h-4 w-4 mr-2", isFetching && "animate-spin")}
/>
{t("common.refresh")}
</Button>
<Button variant="outline" className="dg-btn-secondary" onClick={handleExport}>
<Button
variant="outline"
className="dg-btn-secondary"
onClick={handleExport}
>
<Download className="h-4 w-4 mr-2" />
{t('common.export')}
{t("common.export")}
</Button>
{hasPermission('server:create') && (
{hasPermission("server:create") && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="dg-btn-secondary">
<Upload className="h-4 w-4 mr-2" />
{t('server.batchImport')}
{t("server.batchImport")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>{t('server.importMethod')}</DropdownMenuLabel>
<DropdownMenuLabel>
{t("server.importMethod")}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setCloudImportOpen(true)}>
<Cloud className="h-4 w-4 mr-2 dg-text-blue" />
{t('server.cloudImport')}
<span className="ml-2 text-xs dg-text-muted">{t('server.cloudImportHint')}</span>
{t("server.cloudImport")}
<span className="ml-2 text-xs dg-text-muted">
{t("server.cloudImportHint")}
</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setExcelImportOpen(true)}>
<FileSpreadsheet className="h-4 w-4 mr-2 dg-text-green" />
{t('server.csvImport')}
<span className="ml-2 text-xs dg-text-muted">{t('server.csvImportHint')}</span>
{t("server.csvImport")}
<span className="ml-2 text-xs dg-text-muted">
{t("server.csvImportHint")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
{hasPermission('server:create') && (
{hasPermission("server:create") && (
<Button className="dg-btn-primary" onClick={openCreateForm}>
<Plus className="h-4 w-4 mr-2" />
{t('server.addServer')}
{t("server.addServer")}
</Button>
)}
</>
)}
}
>
<ContentPanel toolbar={tableToolbar}>
<div>
<Table>
<TableHeader>
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
<TableHead className="w-12">
<Checkbox
checked={
selectedServers.length === servers.length && servers.length > 0
}
onCheckedChange={toggleSelectAll}
/>
</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">{t('server.hostname')}</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">{t('server.ipAddress')}</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">{t('server.publicIp')}</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">{t('common.status')}</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">{t('server.config')}</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">{t('server.usage')}</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">{t('server.tags')}</TableHead>
<TableHead className="text-[var(--dg-text-secondary)] text-right">{t('common.actions')}</TableHead>
<div>
<Table>
<TableHeader>
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
<TableHead className="w-12">
<Checkbox
checked={
selectedServers.length === servers.length &&
servers.length > 0
}
onCheckedChange={toggleSelectAll}
/>
</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">
{t("server.hostname")}
</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">
{t("server.ipAddress")}
</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">
{t("server.publicIp")}
</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">
{t("common.status")}
</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">
{t("server.config")}
</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">
{t("server.tags")}
</TableHead>
<TableHead className="text-[var(--dg-text-secondary)] text-right">
{t("common.actions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow className="border-[var(--dg-border)]">
<TableCell
colSpan={colSpanCount}
className="text-center text-[var(--dg-text-muted)] py-12"
>
<RefreshCw className="h-5 w-5 animate-spin mx-auto mb-2" />
{t("common.loading")}
</TableCell>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow className="border-[var(--dg-border)]">
<TableCell colSpan={colSpanCount} className="text-center text-[var(--dg-text-muted)] py-12">
<RefreshCw className="h-5 w-5 animate-spin mx-auto mb-2" />
{t('common.loading')}
</TableCell>
</TableRow>
) : servers.length === 0 ? (
<TableRow className="border-[var(--dg-border)]">
<TableCell colSpan={colSpanCount} className="text-center text-[var(--dg-text-muted)] py-12">
{t('server.noData')}
</TableCell>
</TableRow>
) : (
servers.map((server) => (
<ServerTableRow
key={server.id}
server={server}
selected={selectedServers.includes(String(server.id))}
onToggleSelect={toggleSelect}
onOpenTerminal={openTerminal}
onOpenRdp={openRdp}
onOpenFiles={openFileBrowser}
onOpenEdit={openEditForm}
onDelete={handleDelete}
/>
))
)}
</TableBody>
</Table>
</div>
) : servers.length === 0 ? (
<TableRow className="border-[var(--dg-border)]">
<TableCell
colSpan={colSpanCount}
className="text-center text-[var(--dg-text-muted)] py-12"
>
{t("server.noData")}
</TableCell>
</TableRow>
) : (
servers.map((server) => (
<ServerTableRow
key={server.id}
server={server}
selected={selectedServers.includes(String(server.id))}
onToggleSelect={toggleSelect}
onOpenTerminal={openTerminal}
onOpenRdp={openRdp}
onOpenFiles={openFileBrowser}
onOpenEdit={openEditForm}
onDelete={handleDelete}
/>
))
)}
</TableBody>
</Table>
</div>
{/* 分页 */}
<div className="flex items-center justify-between px-4 py-3 border-t border-[var(--dg-border)]">
<div className="flex items-center gap-2 text-sm text-[var(--dg-text-muted)]">
<span>{t('server.perPage')}</span>
<Select value={String(pageSize)} onValueChange={(v) => { setPageSize(Number(v)); setPage(1); }}>
<SelectTrigger className="w-16 h-8 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
<SelectItem value="10">10</SelectItem>
<SelectItem value="20">20</SelectItem>
<SelectItem value="30">30</SelectItem>
</SelectContent>
</Select>
<span>{t('server.items')}</span>
<span className="ml-4">{t('server.totalItems', { count: total })}</span>
</div>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
className="h-8 border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]"
disabled={page <= 1}
onClick={() => setPage(p => Math.max(1, p - 1))}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-sm text-[var(--dg-text-secondary)] px-2">
{page} / {Math.max(1, Math.ceil(total / pageSize))}
</span>
<Button
variant="outline"
size="sm"
className="h-8 border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]"
disabled={page >= Math.ceil(total / pageSize)}
onClick={() => setPage(p => p + 1)}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
{/* 分页 */}
<div className="flex items-center justify-between px-4 py-3 border-t border-[var(--dg-border)]">
<div className="flex items-center gap-2 text-sm text-[var(--dg-text-muted)]">
<span>{t("server.perPage")}</span>
<Select
value={String(pageSize)}
onValueChange={(v) => {
setPageSize(Number(v));
setPage(1);
}}
>
<SelectTrigger className="w-16 h-8 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
<SelectItem value="10">10</SelectItem>
<SelectItem value="20">20</SelectItem>
<SelectItem value="30">30</SelectItem>
</SelectContent>
</Select>
<span>{t("server.items")}</span>
<span className="ml-4">
{t("server.totalItems", { count: total })}
</span>
</div>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
className="h-8 border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-sm text-[var(--dg-text-secondary)] px-2">
{page} / {Math.max(1, Math.ceil(total / pageSize))}
</span>
<Button
variant="outline"
size="sm"
className="h-8 border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]"
disabled={page >= Math.ceil(total / pageSize)}
onClick={() => setPage((p) => p + 1)}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</ContentPanel>
{/* 创建/编辑服务器 Dialog */}
@@ -584,8 +610,8 @@ export function ServerManager() {
open={rdpOpen}
onOpenChange={setRdpOpen}
serverId={rdpServer?.id ?? null}
serverName={rdpServer?.name || ''}
serverIp={rdpServer?.ip || ''}
serverName={rdpServer?.name || ""}
serverIp={rdpServer?.ip || ""}
/>
{/* Import Dialogs */}
@@ -609,8 +635,8 @@ export function ServerManager() {
open={terminalOpen}
onOpenChange={setTerminalOpen}
serverId={terminalServer?.id ?? null}
serverName={terminalServer?.name ?? ''}
serverIp={terminalServer?.ip ?? ''}
serverName={terminalServer?.name ?? ""}
serverIp={terminalServer?.ip ?? ""}
hasAuth={terminalServer?.hasAuth ?? true}
/>
@@ -619,7 +645,7 @@ export function ServerManager() {
open={fileBrowserOpen}
onOpenChange={setFileBrowserOpen}
serverId={fileBrowserServer?.id ?? null}
serverName={fileBrowserServer?.name ?? ''}
serverName={fileBrowserServer?.name ?? ""}
/>
</PageShell>
);
+118 -115
View File
@@ -1,8 +1,9 @@
// 服务器表格行 — 纯展示子组件(接收 props,不直接调 API) // Logan.Li
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
DropdownMenu,
DropdownMenuContent,
@@ -10,12 +11,20 @@ import {
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { TableCell, TableRow } from '@/components/ui/table';
import { Edit, MoreHorizontal, Server as ServerIcon, Terminal, Trash2, Network, HardDrive, FolderOpen, Monitor } from 'lucide-react';
import { getStatusBadge, getTags, getUsageColor, isRdpServer } from './serverHelpers';
import { usePermission } from '@/hooks/usePermission';
import type { JsonData } from '@/types/common';
} from "@/components/ui/dropdown-menu";
import { TableCell, TableRow } from "@/components/ui/table";
import {
Edit,
MoreHorizontal,
Server as ServerIcon,
Terminal,
Trash2,
FolderOpen,
Monitor,
} from "lucide-react";
import { getStatusBadge, getTags, isRdpServer } from "./serverHelpers";
import { usePermission } from "@/hooks/usePermission";
import type { JsonData } from "@/types/common";
interface ServerTableRowProps {
server?: JsonData;
@@ -38,26 +47,12 @@ export function ServerTableRow({
onOpenFiles,
onDelete,
}: ServerTableRowProps) {
const { t } = useTranslation();
const { hasPermission } = usePermission();
const cpuUsage = Number(server.cpu_usage || 0);
const memoryUsage = Number(server.memory_usage || 0);
const tags = getTags(server);
const memGB = server.memory ? Math.round(server.memory / 1024) : 0;
const tcpEst = server.tcp_established || 0;
const tcpTw = server.tcp_time_wait || 0;
const _rxBps = server.net_rx_bps || 0;
const _txBps = server.net_tx_bps || 0;
const readBps = server.disk_read_bps || 0;
const writeBps = server.disk_write_bps || 0;
const isRdp = isRdpServer(server);
// Format bytes/s to human readable
const fmtBps = (bps: number) => {
if (bps >= 1_000_000) return (bps / 1_000_000).toFixed(1) + 'MB/s';
if (bps >= 1_000) return (bps / 1_000).toFixed(1) + 'KB/s';
return bps.toFixed(0) + 'B/s';
};
return (
<TableRow className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50">
<TableCell>
@@ -70,7 +65,9 @@ export function ServerTableRow({
<div className="flex items-center gap-2">
<ServerIcon className="h-4 w-4 text-[var(--dg-text-muted)]" />
<div>
<p className="font-medium text-[var(--dg-text-primary)]">{server.hostname}</p>
<p className="font-medium text-[var(--dg-text-primary)]">
{server.hostname}
</p>
<p className="text-xs text-[var(--dg-text-muted)]">{server.os}</p>
</div>
</div>
@@ -92,46 +89,16 @@ export function ServerTableRow({
<TableCell>{getStatusBadge(server.status)}</TableCell>
<TableCell>
<div className="text-sm text-[var(--dg-text-secondary)]">
<span className="text-[var(--dg-text-muted)]">{server.cpu}核</span>
<span className="text-[var(--dg-text-muted)]">
{server.cpu}
{t("server.table.cores")}
</span>
<span className="mx-1 text-[var(--dg-text-muted)] opacity-50">/</span>
<span className="text-[var(--dg-text-muted)]">{memGB}GB</span>
<span className="mx-1 text-[var(--dg-text-muted)] opacity-50">/</span>
<span className="text-[var(--dg-text-muted)]">{server.disk}GB</span>
</div>
</TableCell>
<TableCell>
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="text-xs text-[var(--dg-text-muted)] w-8">CPU</span>
<div className="w-16 h-1.5 bg-[var(--dg-bg-tertiary)] rounded-full overflow-hidden">
<div
className={cn('h-full rounded-full', getUsageColor(cpuUsage))}
style={{ width: `${cpuUsage}%` }}
/>
</div>
<span className="text-xs text-[var(--dg-text-secondary)]">{cpuUsage.toFixed(1)}%</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-[var(--dg-text-muted)] w-8">内存</span>
<div className="w-16 h-1.5 bg-[var(--dg-bg-tertiary)] rounded-full overflow-hidden">
<div
className={cn('h-full rounded-full', getUsageColor(memoryUsage))}
style={{ width: `${memoryUsage}%` }}
/>
</div>
<span className="text-xs text-[var(--dg-text-secondary)]">{memoryUsage.toFixed(1)}%</span>
</div>
<div className="flex items-center gap-1 text-xs text-[var(--dg-text-muted)] pt-0.5">
<Network className="h-3 w-3" />
<span>{tcpEst}连接</span>
{tcpTw > 0 && <span className="text-[var(--dg-orange)]">({tcpTw}TW)</span>}
</div>
<div className="flex items-center gap-1 text-xs text-[var(--dg-text-muted)]">
<HardDrive className="h-3 w-3" />
<span>读{fmtBps(readBps)}/写{fmtBps(writeBps)}</span>
</div>
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{tags.slice(0, 3).map((tag) => (
@@ -144,7 +111,10 @@ export function ServerTableRow({
</Badge>
))}
{tags.length > 3 && (
<Badge variant="outline" className="text-xs border-[var(--dg-border)] text-[var(--dg-text-secondary)]">
<Badge
variant="outline"
className="text-xs border-[var(--dg-border)] text-[var(--dg-text-secondary)]"
>
+{tags.length - 3}
</Badge>
)}
@@ -152,62 +122,95 @@ export function ServerTableRow({
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
{hasPermission('server:webshell') && (
<Button variant="ghost" size="icon" className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-blue)]" onClick={() => isRdp ? onOpenRdp(server) : onOpenTerminal(server)} title={isRdp ? 'RDP登录' : '终端登录'}>
{isRdp ? <Monitor className="h-4 w-4" /> : <Terminal className="h-4 w-4" />}
</Button>
{hasPermission("server:webshell") && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-blue)]"
onClick={() =>
isRdp ? onOpenRdp(server) : onOpenTerminal(server)
}
title={
isRdp
? t("server.table.rdpLogin")
: t("server.table.terminalLogin")
}
>
{isRdp ? (
<Monitor className="h-4 w-4" />
) : (
<Terminal className="h-4 w-4" />
)}
</Button>
)}
{(hasPermission('server:update') || hasPermission('server:delete') || hasPermission('server:webshell')) && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
<DropdownMenuLabel className="text-[var(--dg-text-secondary)]">操作</DropdownMenuLabel>
<DropdownMenuSeparator className="bg-[var(--dg-border)]" />
{hasPermission('server:update') && (
<DropdownMenuItem
className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer"
onClick={() => onOpenEdit(server)}
{(hasPermission("server:update") ||
hasPermission("server:delete") ||
hasPermission("server:webshell")) && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-48 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]"
>
<Edit className="h-4 w-4 mr-2" />
编辑
</DropdownMenuItem>
)}
{hasPermission('server:webshell') && (
<DropdownMenuItem
className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer"
onClick={() => isRdp ? onOpenRdp(server) : onOpenTerminal(server)}
>
{isRdp ? <Monitor className="h-4 w-4 mr-2" /> : <Terminal className="h-4 w-4 mr-2" />}
{isRdp ? 'RDP' : '终端'}
</DropdownMenuItem>
)}
{hasPermission('server:update') && (
<DropdownMenuItem
className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer"
onClick={() => onOpenFiles?.(server)}
>
<FolderOpen className="h-4 w-4 mr-2" />
文件
</DropdownMenuItem>
)}
{hasPermission('server:delete') && (
<>
<DropdownMenuSeparator className="bg-[var(--dg-border)]" />
<DropdownMenuItem
className="dg-text-red focus:bg-[var(--dg-bg-hover)] focus:dg-text-red cursor-pointer"
onClick={() => onDelete(server.id)}
>
<Trash2 className="h-4 w-4 mr-2" />
删除
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenuLabel className="text-[var(--dg-text-secondary)]">
{t("common.actions")}
</DropdownMenuLabel>
<DropdownMenuSeparator className="bg-[var(--dg-border)]" />
{hasPermission("server:update") && (
<DropdownMenuItem
className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer"
onClick={() => onOpenEdit(server)}
>
<Edit className="h-4 w-4 mr-2" />
{t("common.edit")}
</DropdownMenuItem>
)}
{hasPermission("server:webshell") && (
<DropdownMenuItem
className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer"
onClick={() =>
isRdp ? onOpenRdp(server) : onOpenTerminal(server)
}
>
{isRdp ? (
<Monitor className="h-4 w-4 mr-2" />
) : (
<Terminal className="h-4 w-4 mr-2" />
)}
{isRdp ? "RDP" : t("server.table.terminal")}
</DropdownMenuItem>
)}
{hasPermission("server:update") && (
<DropdownMenuItem
className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer"
onClick={() => onOpenFiles?.(server)}
>
<FolderOpen className="h-4 w-4 mr-2" />
{t("server.table.files")}
</DropdownMenuItem>
)}
{hasPermission("server:delete") && (
<>
<DropdownMenuSeparator className="bg-[var(--dg-border)]" />
<DropdownMenuItem
className="dg-text-red focus:bg-[var(--dg-bg-hover)] focus:dg-text-red cursor-pointer"
onClick={() => onDelete(server.id)}
>
<Trash2 className="h-4 w-4 mr-2" />
{t("common.delete")}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</TableCell>
@@ -1,23 +1,28 @@
import { useState, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useState, useRef, useEffect } from "react";
import { useTranslation } from "react-i18next";
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Terminal as XTerm } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import 'xterm/css/xterm.css';
import { Maximize2, Minimize2, X, Shield} from 'lucide-react';
import { cn } from '@/lib/utils';
import { attachTerminalResize, fitTerminal, mountXterm } from '@/lib/xterm-utils';
import { TerminalLoadingOverlay } from '@/components/common/TerminalLoadingOverlay';
import { useUserStore } from '@/store';
import { credentialApi, serverCredentialApi } from '@/api';
import { toast } from 'sonner';
import type { JsonData } from '@/types/common';
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Terminal as XTerm } from "xterm";
import { FitAddon } from "xterm-addon-fit";
import "xterm/css/xterm.css";
import { Maximize2, Minimize2, X, Shield } from "lucide-react";
import { cn } from "@/lib/utils";
import {
attachTerminalResize,
fitTerminal,
mountXterm,
} from "@/lib/xterm-utils";
import { TerminalLoadingOverlay } from "@/components/common/TerminalLoadingOverlay";
import { SessionWatermark } from "@/components/common/SessionWatermark";
import { useUserStore } from "@/store";
import { credentialApi, serverCredentialApi } from "@/api";
import { toast } from "sonner";
import type { JsonData } from "@/types/common";
interface TerminalDialogProps {
open: boolean;
@@ -52,14 +57,17 @@ export function TerminalDialog({
// 凭据选择状态(hasAuth=false 时显示) // Logan.Li
const [credentials, setCredentials] = useState<JsonData[]>([]);
const [selectedCredId, setSelectedCredId] = useState<string>('');
const [selectedCredId, setSelectedCredId] = useState<string>("");
const [assigning, setAssigning] = useState(false);
const [authReady, setAuthReady] = useState(hasAuth); // 认证就绪标志
// 加载凭据列表
useEffect(() => {
if (!open || hasAuth) return;
credentialApi.list().then((res: JsonData) => setCredentials(res?.list || [])).catch(() => {});
credentialApi
.list()
.then((res: JsonData) => setCredentials(res?.list || []))
.catch(() => {});
}, [open, hasAuth]);
// 关联凭据
@@ -68,10 +76,10 @@ export function TerminalDialog({
try {
setAssigning(true);
await serverCredentialApi.assign(serverId, Number(selectedCredId));
toast.success(t('common.operationSuccess'));
toast.success(t("common.operationSuccess"));
setAuthReady(true);
} catch (e) {
toast.error(e?.message || t('common.operationFailed'));
toast.error(e?.message || t("common.operationFailed"));
} finally {
setAssigning(false);
}
@@ -81,7 +89,7 @@ export function TerminalDialog({
useEffect(() => {
if (!open) {
setAuthReady(hasAuth);
setSelectedCredId('');
setSelectedCredId("");
}
}, [open, hasAuth]);
@@ -97,10 +105,10 @@ export function TerminalDialog({
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
allowProposedApi: true,
theme: {
background: '#0a0e14',
foreground: '#d6d6d6',
cursor: '#d6d6d6',
selectionBackground: 'rgba(255,255,255,0.2)',
background: "#0a0e14",
foreground: "#d6d6d6",
cursor: "#d6d6d6",
selectionBackground: "rgba(255,255,255,0.2)",
},
});
const fit = new FitAddon();
@@ -109,8 +117,8 @@ export function TerminalDialog({
fitRef.current = fit;
// WebSocket 连接
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const token = useUserStore.getState().token || '';
const proto = location.protocol === "https:" ? "wss" : "ws";
const token = useUserStore.getState().token || "";
const wsUrl = `${proto}://${location.host}/ws/terminal?id=${serverId}&cols=80&rows=24&token=${encodeURIComponent(token)}`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
@@ -124,13 +132,13 @@ export function TerminalDialog({
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.status === 0 && typeof msg.data === 'string') {
if (msg.status === 0 && typeof msg.data === "string") {
setHasOutput(true);
term.write(msg.data);
}
} catch {
setHasOutput(true);
term.write(typeof event.data === 'string' ? event.data : '');
term.write(typeof event.data === "string" ? event.data : "");
}
};
@@ -143,19 +151,21 @@ export function TerminalDialog({
ws.onclose = () => {
setConnected(false);
setHasOutput(true);
term.write(`\r\n\x1b[31m[${t('server.connectionClosed')}]\r\n`);
term.write(`\r\n\x1b[31m[${t("server.connectionClosed")}]\r\n`);
};
ws.onerror = () => {
setHasOutput(true);
term.write(`\r\n\x1b[31m[${t('server.connectionError')}]\r\n`);
term.write(`\r\n\x1b[31m[${t("server.connectionError")}]\r\n`);
};
return () => {
try {
ws.close();
term.dispose();
} catch { /* noop */ }
} catch {
/* noop */
}
termRef.current = null;
wsRef.current = null;
setConnected(false);
@@ -197,22 +207,33 @@ export function TerminalDialog({
// 禁用 Radix Dialog 默认的 ESC 关闭:终端内 vi/top 等会用到 ESC,关闭只能点头部 X 按钮 // Logan.Li
onEscapeKeyDown={(e) => e.preventDefault()}
className={cn(
'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] p-0 gap-0',
"bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] p-0 gap-0",
// 禁用 CSS 过渡:终端窗口应该即时缩放,不需要动画
'!duration-0 !transition-none',
"!duration-0 !transition-none",
fullscreen
? '!max-w-none !w-screen !h-screen !rounded-none !translate-x-0 !translate-y-0 !left-0 !top-0'
: ''
? "!max-w-none !w-screen !h-screen !rounded-none !translate-x-0 !translate-y-0 !left-0 !top-0"
: "",
)}
style={fullscreen ? { maxWidth: '100vw', width: '100vw', height: '100vh' } : { maxWidth: '960px', width: '92vw' }}
style={
fullscreen
? { maxWidth: "100vw", width: "100vw", height: "100vh" }
: { maxWidth: "960px", width: "92vw" }
}
>
<DialogTitle className="sr-only">终端 - {serverName}</DialogTitle>
<DialogDescription className="sr-only">SSH 终端连接</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">
<div className={cn('w-2 h-2 rounded-full', connected ? 'bg-emerald-400' : 'bg-red-400')} />
<span className="text-[var(--dg-text-primary)] font-medium">{serverName}</span>
<div
className={cn(
"w-2 h-2 rounded-full",
connected ? "bg-emerald-400" : "bg-red-400",
)}
/>
<span className="text-[var(--dg-text-primary)] font-medium">
{serverName}
</span>
<span className="text-[var(--dg-text-muted)]">({serverIp})</span>
</div>
<div className="flex items-center gap-1">
@@ -220,7 +241,11 @@ export function TerminalDialog({
onClick={() => setFullscreen(!fullscreen)}
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
>
{fullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
{fullscreen ? (
<Minimize2 className="h-4 w-4" />
) : (
<Maximize2 className="h-4 w-4" />
)}
</button>
<button
onClick={() => onOpenChange(false)}
@@ -230,26 +255,35 @@ export function TerminalDialog({
</button>
</div>
</div>
{/* 凭据选择器(无认证凭据时显示) */}
{/* 凭据选择器(无认证凭据时显示) */}
{!authReady && (
<div className="p-6 flex flex-col items-center justify-center gap-4" style={{ height: '480px' }}>
<div
className="p-6 flex flex-col items-center justify-center gap-4"
style={{ height: "480px" }}
>
<Shield className="h-12 w-12 dg-text-yellow" />
<div className="text-center">
<p className="text-[var(--dg-text-primary)] font-medium mb-1">{t('server.noAuthCred')}</p>
<p className="text-sm text-[var(--dg-text-muted)]">{t('server.selectCredHint')}</p>
<p className="text-[var(--dg-text-primary)] font-medium mb-1">
{t("server.noAuthCred")}
</p>
<p className="text-sm text-[var(--dg-text-muted)]">
{t("server.selectCredHint")}
</p>
</div>
{credentials.length === 0 ? (
<p className="text-xs text-[var(--dg-text-muted)]">{t('server.noCredAvailable')}</p>
<p className="text-xs text-[var(--dg-text-muted)]">
{t("server.noCredAvailable")}
</p>
) : (
<select
value={selectedCredId}
onChange={(e) => setSelectedCredId(e.target.value)}
className="w-64 px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-md text-[var(--dg-text-primary)] text-sm"
>
<option value="">{t('server.selectCred')}</option>
<option value="">{t("server.selectCred")}</option>
{credentials.map((c: JsonData) => (
<option key={c.id} value={String(c.id)}>
{c.type === 'ssh_key' ? '🔑' : '🔒'} {c.name}
{c.type === "ssh_key" ? "🔑" : "🔒"} {c.name}
</option>
))}
</select>
@@ -259,15 +293,22 @@ export function TerminalDialog({
disabled={!selectedCredId || assigning}
className="bg-[var(--dg-blue)] hover:opacity-90 text-white"
>
{assigning ? t('server.linking') : t('server.confirmConnect')}
{assigning ? t("server.linking") : t("server.confirmConnect")}
</Button>
</div>
)}
{/* 终端容器 */}
{authReady && (
<div className="relative bg-[#0a0e14]" style={{ height: fullscreen ? 'calc(100vh - 49px)' : '520px' }}>
<div
className="relative bg-[#0a0e14]"
style={{ height: fullscreen ? "calc(100vh - 49px)" : "520px" }}
>
<div ref={containerRef} className="xterm-terminal-host h-full" />
<TerminalLoadingOverlay show={!hasOutput} label={t('server.terminalLoggingIn')} />
<SessionWatermark className="opacity-60" />
<TerminalLoadingOverlay
show={!hasOutput}
label={t("server.terminalLoggingIn")}
/>
</div>
)}
</DialogContent>
@@ -1,7 +0,0 @@
// RDP 虚拟共享盘根路径,映射为 Windows 本机 Download 目录 // Logan.Li
export const RDP_VIRTUAL_DOWNLOAD_ROOT = 'C:\\Users\\Public\\AIOpsShare\\Download';
export function rdpVirtualDownloadPath(fileName: string): string {
const safe = fileName.replace(/[\\/:*?"<>|]/g, '_').trim() || 'upload.bin';
return `${RDP_VIRTUAL_DOWNLOAD_ROOT}\\${safe}`;
}
@@ -1,5 +1,6 @@
// 服务器管理共享纯函数/常量 // Logan.Li
import { Badge } from '@/components/ui/badge';
import i18n from '@/i18n';
import type { Server as ServerType } from '@/types';
import type { JsonData } from '@/types/common';
@@ -39,26 +40,26 @@ export function getStatusBadge(status: ServerType['status']) {
return (
<Badge variant="outline" className="border-emerald-500/30 dg-text-green bg-emerald-500/10">
<div className="w-1.5 h-1.5 rounded-full bg-emerald-400 mr-1.5 animate-pulse" />
运行中
{i18n.t('server.statusBadge.running')}
</Badge>
);
case 'stopped':
return (
<Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-muted)] bg-[var(--dg-bg-tertiary)]">
已停止
{i18n.t('server.statusBadge.stopped')}
</Badge>
);
case 'maintenance':
return (
<Badge variant="outline" className="border-amber-500/30 dg-text-yellow bg-amber-500/10">
<div className="w-1.5 h-1.5 rounded-full bg-amber-400 mr-1.5" />
维护中
{i18n.t('server.statusBadge.maintenance')}
</Badge>
);
case 'offline':
return (
<Badge variant="outline" className="border-red-500/30 dg-text-red bg-red-500/10">
离线
{i18n.t('server.statusBadge.offline')}
</Badge>
);
default:
@@ -78,6 +78,7 @@ export function RdpAuditViewer({ open, onOpenChange, sessionId, mode }: RdpAudit
const canvasRef = useRef<HTMLCanvasElement>(null);
const wsRef = useRef<WebSocket | null>(null);
const timerRef = useRef<number | null>(null);
const replaySeqRef = useRef(0);
const [fullscreen, setFullscreen] = useState(false);
const [status, setStatus] = useState('');
const [loading, setLoading] = useState(false);
@@ -100,13 +101,26 @@ export function RdpAuditViewer({ open, onOpenChange, sessionId, mode }: RdpAudit
ctx.putImageData(new ImageData(pixels, frame.width, frame.height), frame.x, frame.y);
};
const drawReplay = async (nextIndex: number) => {
const frame = playableFrames[nextIndex];
const clearReplayCanvas = () => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext('2d');
if (!frame || !canvas || !ctx) return;
if (!canvas || !ctx) return null;
ctx.imageSmoothingEnabled = false;
await drawImageFrame(ctx, frame);
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, canvas.width, canvas.height);
return ctx;
};
const drawReplay = async (nextIndex: number, rebuild = false) => {
const ctx = rebuild ? clearReplayCanvas() : canvasRef.current?.getContext('2d');
if (!ctx) return;
ctx.imageSmoothingEnabled = false;
const start = rebuild ? 0 : nextIndex;
for (let i = start; i <= nextIndex; i += 1) {
const frame = playableFrames[i];
if (frame) await drawImageFrame(ctx, frame);
}
};
useEffect(() => {
@@ -161,6 +175,11 @@ export function RdpAuditViewer({ open, onOpenChange, sessionId, mode }: RdpAudit
const text = typeof raw === 'string' ? raw : String(raw || '');
const record = JSON.parse(text || '{}') as RdpRecord;
const list = (record.frames || []).filter((f) => f.type === 'bitmap' && f.image);
const sessionFrame = (record.frames || []).find((f) => f.type === 'session' && f.width && f.height);
const sizeFrame = sessionFrame || list.find((f) => f.width && f.height);
if (sizeFrame?.width && sizeFrame?.height) {
setCanvasSize({ width: sizeFrame.width, height: sizeFrame.height });
}
setFrames(list);
setStatus(list.length ? '' : '该 RDP 会话没有可播放画面帧');
setPlaying(list.length > 0);
@@ -196,7 +215,12 @@ export function RdpAuditViewer({ open, onOpenChange, sessionId, mode }: RdpAudit
}, [playing, mode, playableFrames.length]);
useEffect(() => {
if (mode === 'replay' && playableFrames.length > 0) void drawReplay(index);
if (mode !== 'replay' || playableFrames.length === 0) return;
const seq = replaySeqRef.current + 1;
replaySeqRef.current = seq;
void drawReplay(index, true).then(() => {
if (replaySeqRef.current !== seq) return;
});
}, [index, playableFrames.length, mode]);
const displayHeight = fullscreen ? 'calc(100vh - 88px)' : '560px';
+204 -137
View File
@@ -1,41 +1,44 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useState, useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { Badge } from '@/components/ui/badge';
import { toast } from 'sonner';
import { CheckCircle, XCircle, Settings, Users, Shield } from 'lucide-react';
import { PageShell } from '@/components/layout/PageShell';
import { userApi } from '@/api';
import type { Role } from '@/types';
import { SystemUserTab } from './SystemUserTab';
import { SystemRolesTab } from './SystemRolesTab';
import { SystemSettingsTab } from './SystemSettingsTab';
import { ResetPasswordDialog } from './ResetPasswordDialog';
import { useLdapManagement } from './useLdapManagement';
import { useAuditSettings } from './useAuditSettings';
import type { UserFormData } from './UserFormDialog';
import { useTranslation } from 'react-i18next';
import { useState, useEffect } from "react";
import { useLocation } from "react-router-dom";
import { useConfirm } from "@/components/ui/confirm-dialog";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
import { CheckCircle, XCircle, Settings, Users, Shield } from "lucide-react";
import { PageShell } from "@/components/layout/PageShell";
import { userApi } from "@/api";
import type { Role } from "@/types";
import { SystemUserTab } from "./SystemUserTab";
import { SystemRolesTab } from "./SystemRolesTab";
import { SystemSettingsTab } from "./SystemSettingsTab";
import { ResetPasswordDialog } from "./ResetPasswordDialog";
import { useLdapManagement } from "./useLdapManagement";
import { useAuditSettings } from "./useAuditSettings";
import type { UserFormData } from "./UserFormDialog";
import { useTranslation } from "react-i18next";
// 后端用户数据转换为前端兼容格式
function mapUser( u: any) {
function mapUser(u: any) {
return {
...u,
status: u.status === 1 || u.status === 'active' ? ('active' as const) : ('inactive' as const),
status:
u.status === 1 || u.status === "active"
? ("active" as const)
: ("inactive" as const),
roles: Array.isArray(u.roles) ? u.roles : u.role ? [u.role] : [],
lastLoginAt: u.last_login ?? u.lastLoginAt,
createdAt: u.created_at ?? u.createdAt,
authSource: u.auth_source ?? u.authSource ?? 'local',
authSource: u.auth_source ?? u.authSource ?? "local",
};
}
const emptyForm: UserFormData = {
username: '',
nickname: '',
email: '',
phone: '',
role: '',
password: '',
username: "",
nickname: "",
email: "",
phone: "",
role: "",
password: "",
status: 1 as number,
};
@@ -45,32 +48,35 @@ export function SystemManager() {
const location = useLocation();
// 侧边栏菜单路径映射到 Tab // Logan.Li
// 侧边栏已有独立菜单项,按路由直接渲染对应页面,不再重复 Tab // Logan.Li
const pathToTab: Record<string, 'users' | 'roles' | 'settings'> = {
'/users': 'users',
'/roles': 'roles',
'/settings': 'settings',
const pathToTab: Record<string, "users" | "roles" | "settings"> = {
"/users": "users",
"/roles": "roles",
"/settings": "settings",
};
const activeTab = pathToTab[location.pathname] || 'users';
const activeTab = pathToTab[location.pathname] || "users";
const pageMeta: Record<string, { title: string; subtitle: string; icon: React.ReactNode }> = {
const pageMeta: Record<
string,
{ title: string; subtitle: string; icon: React.ReactNode }
> = {
users: {
title: t('system.users'),
subtitle: t('system.localUserDesc'),
title: t("system.users"),
subtitle: t("system.localUserDesc"),
icon: <Users className="h-5 w-5" />,
},
roles: {
title: t('system.roles'),
subtitle: t('system.roleBindingHint'),
title: t("system.roles"),
subtitle: t("system.roleBindingHint"),
icon: <Shield className="h-5 w-5" />,
},
settings: {
title: t('system.settings'),
subtitle: t('system.generalSubtitle'),
title: t("system.settings"),
subtitle: t("system.generalSubtitle"),
icon: <Settings className="h-5 w-5" />,
},
};
const meta = pageMeta[activeTab];
const [searchQuery, setSearchQuery] = useState('');
const [searchQuery, setSearchQuery] = useState("");
const [users, setUsers] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [selectedUser, setSelectedUser] = useState<any | null>(null);
@@ -84,7 +90,9 @@ export function SystemManager() {
const [form, setForm] = useState<UserFormData>({ ...emptyForm });
// CSV 批量导入弹窗
const [importDialogOpen, setImportDialogOpen] = useState<'csv' | 'ldap' | null>(null);
const [importDialogOpen, setImportDialogOpen] = useState<
"csv" | "ldap" | null
>(null);
const [importPreview, setImportPreview] = useState<any[]>([]);
const [importResult, setImportResult] = useState<any>(null);
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
@@ -96,7 +104,7 @@ export function SystemManager() {
const rawUsers = res?.list ?? [];
setUsers(rawUsers.map(mapUser));
} catch (error) {
console.error('Failed to fetch users:', error);
console.error("Failed to fetch users:", error);
setUsers([]);
} finally {
setLoading(false);
@@ -114,28 +122,34 @@ export function SystemManager() {
// LDAP 导入 Dialog 打开时加载 OU 和用户
useEffect(() => {
if (importDialogOpen === 'ldap') {
if (importDialogOpen === "ldap") {
ldap.loadImportDialogData();
}
}, [importDialogOpen]);
const filteredUsers = users.filter(
(u) =>
(u.username || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
(u.nickname || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
(u.email || '').toLowerCase().includes(searchQuery.toLowerCase())
(u.username || "").toLowerCase().includes(searchQuery.toLowerCase()) ||
(u.nickname || "").toLowerCase().includes(searchQuery.toLowerCase()) ||
(u.email || "").toLowerCase().includes(searchQuery.toLowerCase()),
);
const getUserStatusBadge = (status: 'active' | 'inactive') => {
return status === 'active' ? (
<Badge variant="outline" className="border-[var(--dg-green)]/30 text-[var(--dg-green)] bg-[var(--dg-green)]/10">
const getUserStatusBadge = (status: "active" | "inactive") => {
return status === "active" ? (
<Badge
variant="outline"
className="border-[var(--dg-green)]/30 text-[var(--dg-green)] bg-[var(--dg-green)]/10"
>
<CheckCircle className="h-3 w-3 mr-1" />
{t('common.normal')}
{t("common.normal")}
</Badge>
) : (
<Badge variant="outline" className="border-[var(--dg-text-muted)]/30 text-[var(--dg-text-muted)] bg-[var(--dg-text-muted)]/10">
<Badge
variant="outline"
className="border-[var(--dg-text-muted)]/30 text-[var(--dg-text-muted)] bg-[var(--dg-text-muted)]/10"
>
<XCircle className="h-3 w-3 mr-1" />
{t('system.userInactive')}
{t("system.userInactive")}
</Badge>
);
};
@@ -146,16 +160,16 @@ export function SystemManager() {
setDialogOpen(true);
};
const openEdit = ( user: any) => {
const openEdit = (user: any) => {
setEditingUser(user);
setForm({
username: user.username || '',
nickname: user.nickname || '',
email: user.email || '',
phone: user.phone || '',
role: Array.isArray(user.roles) ? user.roles[0] || '' : user.role || '',
password: '',
status: user.status === 'active' ? 1 : 0,
username: user.username || "",
nickname: user.nickname || "",
email: user.email || "",
phone: user.phone || "",
role: Array.isArray(user.roles) ? user.roles[0] || "" : user.role || "",
password: "",
status: user.status === "active" ? 1 : 0,
});
setDialogOpen(true);
};
@@ -182,41 +196,52 @@ export function SystemManager() {
setDialogOpen(false);
await loadUsers();
} catch (error) {
console.error('Failed to save user:', error);
console.error("Failed to save user:", error);
} finally {
setSaving(false);
}
};
const handleDelete = async ( user: any) => {
const ok = await confirm({ title: t('system.deleteUser'), description: `确认删除用户「${user.nickname || user.username}」吗?`, confirmText: t('common.delete'), variant: 'destructive' });
const handleDelete = async (user: any) => {
const ok = await confirm({
title: t("system.deleteUser"),
description: `确认删除用户「${user.nickname || user.username}」吗?`,
confirmText: t("common.delete"),
variant: "destructive",
});
if (!ok) return;
try {
await userApi.deleteUser(user.id);
setSelectedUserIds((prev) => prev.filter((id) => id !== user.id));
await loadUsers();
toast.success(t('system.userDeleted'));
toast.success(t("system.userDeleted"));
} catch (error: unknown) {
toast.error(error instanceof Error ? error.message : t('common.deleteFailed'));
toast.error(
error instanceof Error ? error.message : t("common.deleteFailed"),
);
}
};
const handleBatchDelete = async () => {
if (selectedUserIds.length === 0) return;
const ok = await confirm({
title: t('system.batchDeleteUsers'),
title: t("system.batchDeleteUsers"),
description: `确定删除选中的 ${selectedUserIds.length} 个用户吗?此操作不可恢复。`,
confirmText: t('system.terminalBatchDelete'),
variant: 'destructive',
confirmText: t("system.terminalBatchDelete"),
variant: "destructive",
});
if (!ok) return;
try {
const res = await userApi.batchDelete(selectedUserIds) as { message?: string };
const res = (await userApi.batchDelete(selectedUserIds)) as {
message?: string;
};
toast.success(res?.message || `已删除 ${selectedUserIds.length} 个用户`);
setSelectedUserIds([]);
await loadUsers();
} catch (error: unknown) {
toast.error(error instanceof Error ? error.message : t('cloud.batchDeleteFailed'));
toast.error(
error instanceof Error ? error.message : t("cloud.batchDeleteFailed"),
);
}
};
@@ -227,44 +252,64 @@ export function SystemManager() {
const reader = new FileReader();
reader.onload = (ev) => {
const text = ev.target?.result as string;
const lines = text.split('\n').filter((l) => l.trim() && !l.startsWith('\xEF\xBB\xBF'));
const lines = text
.split("\n")
.filter((l) => l.trim() && !l.startsWith("\xEF\xBB\xBF"));
if (lines.length < 2) return;
// Find column indices from header
const headers = lines[0].split(',').map((h) => h.trim().toLowerCase());
const idxUsername = headers.findIndex((h) => h.includes('用户名') || h.includes('username'));
const idxNickname = headers.findIndex((h) => h.includes('昵称') || h.includes('name') || h.includes('显示名'));
const idxEmail = headers.findIndex((h) => h.includes('邮箱') || h.includes('email') || h.includes('mail'));
const idxPhone = headers.findIndex((h) => h.includes('手机') || h.includes('phone'));
const idxRole = headers.findIndex((h) => h.includes('角色') || h.includes('role'));
const idxPassword = headers.findIndex((h) => h.includes('密码') || h.includes('password'));
const headers = lines[0].split(",").map((h) => h.trim().toLowerCase());
const idxUsername = headers.findIndex(
(h) => h.includes("用户名") || h.includes("username"),
);
const idxNickname = headers.findIndex(
(h) => h.includes("昵称") || h.includes("name") || h.includes("显示名"),
);
const idxEmail = headers.findIndex(
(h) => h.includes("邮箱") || h.includes("email") || h.includes("mail"),
);
const idxPhone = headers.findIndex(
(h) => h.includes("手机") || h.includes("phone"),
);
const idxRole = headers.findIndex(
(h) => h.includes("角色") || h.includes("role"),
);
const idxPassword = headers.findIndex(
(h) => h.includes("密码") || h.includes("password"),
);
const parsed: Record<string, unknown>[] = [];
for (let i = 1; i < lines.length; i++) {
const cols = lines[i].split(',').map((c) => c.trim());
const cols = lines[i].split(",").map((c) => c.trim());
parsed.push({
username: idxUsername >= 0 ? cols[idxUsername] : '',
nickname: idxNickname >= 0 ? cols[idxNickname] : '',
email: idxEmail >= 0 ? cols[idxEmail] : '',
phone: idxPhone >= 0 ? cols[idxPhone] : '',
role: idxRole >= 0 ? cols[idxRole] : '',
password: idxPassword >= 0 ? cols[idxPassword] : '',
username: idxUsername >= 0 ? cols[idxUsername] : "",
nickname: idxNickname >= 0 ? cols[idxNickname] : "",
email: idxEmail >= 0 ? cols[idxEmail] : "",
phone: idxPhone >= 0 ? cols[idxPhone] : "",
role: idxRole >= 0 ? cols[idxRole] : "",
password: idxPassword >= 0 ? cols[idxPassword] : "",
});
}
setImportPreview(parsed);
setImportResult(null);
};
reader.readAsText(file, 'UTF-8');
reader.readAsText(file, "UTF-8");
};
// 执行 CSV 批量导入
const handleBatchImport = async () => {
ldap.setImporting(true);
try {
const res = await userApi.batchImport({ users: importPreview, skip_existing: true });
const res = await userApi.batchImport({
users: importPreview,
skip_existing: true,
});
setImportResult(res);
await loadUsers();
} catch (error) {
setImportResult({ code: 500, message: error?.message || t('system.importFailed') });
setImportResult({
code: 500,
message: error?.message || t("system.importFailed"),
});
} finally {
ldap.setImporting(false);
}
@@ -272,74 +317,93 @@ export function SystemManager() {
return (
<PageShell
variant={activeTab === 'settings' ? 'config' : 'list'}
maxWidth={activeTab === 'settings' ? 'xl' : 'full'}
variant={activeTab === "settings" ? "config" : "list"}
maxWidth={activeTab === "settings" ? "xl" : "full"}
title={meta.title}
subtitle={meta.subtitle}
icon={meta.icon}
>
<div className="w-full">
{activeTab === 'users' && (
<div className="space-y-4">
<SystemUserTab
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
filteredUsers={filteredUsers}
loading={loading}
selectedUser={selectedUser}
setSelectedUser={setSelectedUser}
getUserStatusBadge={getUserStatusBadge}
dialogOpen={dialogOpen}
setDialogOpen={setDialogOpen}
editingUser={editingUser}
form={form}
setForm={setForm}
saving={saving}
handleSubmit={handleSubmit}
openCreate={openCreate}
openEdit={openEdit}
handleDelete={handleDelete}
handleBatchDelete={handleBatchDelete}
selectedUserIds={selectedUserIds}
setSelectedUserIds={setSelectedUserIds}
onResetPassword={(u) => { setSelectedUser(null); setResetPasswordUser(u); }}
importDialogOpen={importDialogOpen}
setImportDialogOpen={setImportDialogOpen}
importPreview={importPreview}
setImportPreview={setImportPreview}
importing={ldap.importing}
importResult={importResult}
setImportResult={setImportResult}
handleCsvUpload={handleCsvUpload}
handleBatchImport={handleBatchImport}
ldapUsers={ldap.ldapUsers}
ldapSelected={ldap.ldapSelected}
setLdapSelected={ldap.setLdapSelected}
ldapLoading={ldap.ldapLoading}
ldapImportOUs={ldap.ldapImportOUs}
ldapImportOUDN={ldap.ldapImportOUDN}
setLdapImportOUDN={ldap.setLdapImportOUDN}
loadLdapUsers={ldap.loadLdapUsers}
handleLdapImport={() => ldap.handleLdapImport({ setResult: setImportResult })}
setLdapUsers={ldap.setLdapUsers}
/>
</div>
{activeTab === "users" && (
<div className="space-y-4">
<SystemUserTab
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
filteredUsers={filteredUsers}
loading={loading}
selectedUser={selectedUser}
setSelectedUser={setSelectedUser}
getUserStatusBadge={getUserStatusBadge}
dialogOpen={dialogOpen}
setDialogOpen={setDialogOpen}
editingUser={editingUser}
form={form}
setForm={setForm}
saving={saving}
handleSubmit={handleSubmit}
openCreate={openCreate}
openEdit={openEdit}
handleDelete={handleDelete}
handleBatchDelete={handleBatchDelete}
selectedUserIds={selectedUserIds}
setSelectedUserIds={setSelectedUserIds}
onResetPassword={(u) => {
setSelectedUser(null);
setResetPasswordUser(u);
}}
importDialogOpen={importDialogOpen}
setImportDialogOpen={setImportDialogOpen}
importPreview={importPreview}
setImportPreview={setImportPreview}
importing={ldap.importing}
importResult={importResult}
setImportResult={setImportResult}
handleCsvUpload={handleCsvUpload}
handleBatchImport={handleBatchImport}
ldapUsers={ldap.ldapUsers}
ldapSelected={ldap.ldapSelected}
setLdapSelected={ldap.setLdapSelected}
ldapLoading={ldap.ldapLoading}
ldapImportOUs={ldap.ldapImportOUs}
ldapImportOUDN={ldap.ldapImportOUDN}
setLdapImportOUDN={ldap.setLdapImportOUDN}
loadLdapUsers={ldap.loadLdapUsers}
handleLdapImport={() =>
ldap.handleLdapImport({ setResult: setImportResult })
}
setLdapUsers={ldap.setLdapUsers}
/>
</div>
)}
{activeTab === 'roles' && (
{activeTab === "roles" && (
<SystemRolesTab
selectedRole={selectedRole}
setSelectedRole={setSelectedRole}
/>
)}
{activeTab === 'settings' && (
{activeTab === "settings" && (
<SystemSettingsTab
{...ldap.settingsTabProps}
auditRetentionDays={audit.retentionDays}
setAuditRetentionDays={audit.setRetentionDays}
auditRecordAll={audit.recordAll}
setAuditRecordAll={audit.setRecordAll}
sessionWatermarkEnabled={audit.sessionWatermarkEnabled}
setSessionWatermarkEnabled={audit.setSessionWatermarkEnabled}
sessionRecordEnabled={audit.sessionRecordEnabled}
setSessionRecordEnabled={audit.setSessionRecordEnabled}
clipboardEnabled={audit.clipboardEnabled}
setClipboardEnabled={audit.setClipboardEnabled}
fileUploadEnabled={audit.fileUploadEnabled}
setFileUploadEnabled={audit.setFileUploadEnabled}
fileDownloadEnabled={audit.fileDownloadEnabled}
setFileDownloadEnabled={audit.setFileDownloadEnabled}
commandPolicyEnabled={audit.commandPolicyEnabled}
setCommandPolicyEnabled={audit.setCommandPolicyEnabled}
approvalRequired={audit.approvalRequired}
setApprovalRequired={audit.setApprovalRequired}
auditLoading={audit.loading}
auditApplying={audit.applying}
handleAuditApply={audit.handleApply}
@@ -349,7 +413,10 @@ export function SystemManager() {
)}
</div>
<ResetPasswordDialog user={resetPasswordUser} onClose={() => setResetPasswordUser(null)} />
<ResetPasswordDialog
user={resetPasswordUser}
onClose={() => setResetPasswordUser(null)}
/>
</PageShell>
);
}
@@ -1,30 +1,45 @@
import { useState, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { useState, useEffect } from "react";
import { useSearchParams } from "react-router-dom";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Lock, History, Key, Settings, Network, RefreshCw, Loader2 } from 'lucide-react';
import { PageTabs } from '@/components/layout/PageTabs';
import { LdapManagementSection } from './LdapManagementSection';
import type { JsonData } from '@/types/common';
import { useTranslation } from 'react-i18next';
import i18n from '@/i18n';
} from "@/components/ui/select";
import {
Lock,
History,
Key,
Settings,
Network,
RefreshCw,
Loader2,
} from "lucide-react";
import { PageTabs } from "@/components/layout/PageTabs";
import { LdapManagementSection } from "./LdapManagementSection";
import type { JsonData } from "@/types/common";
import { useTranslation } from "react-i18next";
import i18n from "@/i18n";
import { useUserStore } from "@/store";
// 系统设置子 Tab:通用 / 安全 / LDAP // Logan.Li
const SETTINGS_TABS = [
{ id: 'general', label: i18n.t('system.settingsGeneral') },
{ id: 'security', label: i18n.t('system.settingsSecurity') },
{ id: 'ldap', label: 'LDAP' },
{ id: "general", label: i18n.t("system.settingsGeneral") },
{ id: "security", label: i18n.t("system.settingsSecurity") },
{ id: "ldap", label: "LDAP" },
] as const;
type SettingsSubTab = (typeof SETTINGS_TABS)[number]['id'];
type SettingsSubTab = (typeof SETTINGS_TABS)[number]["id"];
export interface LdapFormData {
ldap_host: string;
@@ -42,6 +57,20 @@ interface SystemSettingsTabProps {
setAuditRetentionDays: (v: string) => void;
auditRecordAll: boolean;
setAuditRecordAll: (v: boolean) => void;
sessionWatermarkEnabled: boolean;
setSessionWatermarkEnabled: (v: boolean) => void;
sessionRecordEnabled: boolean;
setSessionRecordEnabled: (v: boolean) => void;
clipboardEnabled: boolean;
setClipboardEnabled: (v: boolean) => void;
fileUploadEnabled: boolean;
setFileUploadEnabled: (v: boolean) => void;
fileDownloadEnabled: boolean;
setFileDownloadEnabled: (v: boolean) => void;
commandPolicyEnabled: boolean;
setCommandPolicyEnabled: (v: boolean) => void;
approvalRequired: boolean;
setApprovalRequired: (v: boolean) => void;
auditLoading: boolean;
auditApplying: boolean;
handleAuditApply: () => void;
@@ -75,12 +104,32 @@ interface SystemSettingsTabProps {
createOUOpen: boolean;
setCreateOUOpen: (v: boolean) => void;
ouForm: { ou_name: string; parent_dn: string; description: string };
setOuForm: React.Dispatch<React.SetStateAction<{ ou_name: string; parent_dn: string; description: string }>>;
setOuForm: React.Dispatch<
React.SetStateAction<{
ou_name: string;
parent_dn: string;
description: string;
}>
>;
handleCreateOU: () => void;
createUserOpen: boolean;
setCreateUserOpen: (v: boolean) => void;
newUser: { username: string; password: string; display_name: string; email: string; ou_dn: string };
setNewUser: React.Dispatch<React.SetStateAction<{ username: string; password: string; display_name: string; email: string; ou_dn: string }>>;
newUser: {
username: string;
password: string;
display_name: string;
email: string;
ou_dn: string;
};
setNewUser: React.Dispatch<
React.SetStateAction<{
username: string;
password: string;
display_name: string;
email: string;
ou_dn: string;
}>
>;
handleCreateLDAPUser: () => void;
ldapUserTestOpen: boolean;
setLdapUserTestOpen: (v: boolean) => void;
@@ -94,7 +143,14 @@ interface SystemSettingsTabProps {
editLdapUser: JsonData | null;
setEditLdapUser: (u: JsonData | null) => void;
editLdapForm: { cn: string; mail: string; phone: string; title: string };
setEditLdapForm: React.Dispatch<React.SetStateAction<{ cn: string; mail: string; phone: string; title: string }>>;
setEditLdapForm: React.Dispatch<
React.SetStateAction<{
cn: string;
mail: string;
phone: string;
title: string;
}>
>;
handleUpdateLdapUser: () => void;
handleDeleteLDAPEntry: (dn: string, silent?: boolean) => void;
openEditLdapUser: (user: JsonData) => void;
@@ -102,11 +158,28 @@ interface SystemSettingsTabProps {
export function SystemSettingsTab(props: SystemSettingsTabProps) {
const { t } = useTranslation();
const currentUsername = useUserStore(
(state) => state.user?.username || "admin",
);
const {
auditRetentionDays,
setAuditRetentionDays,
auditRecordAll,
setAuditRecordAll,
sessionWatermarkEnabled,
setSessionWatermarkEnabled,
sessionRecordEnabled,
setSessionRecordEnabled,
clipboardEnabled,
setClipboardEnabled,
fileUploadEnabled,
setFileUploadEnabled,
fileDownloadEnabled,
setFileDownloadEnabled,
commandPolicyEnabled,
setCommandPolicyEnabled,
approvalRequired,
setApprovalRequired,
auditLoading,
auditApplying,
handleAuditApply,
@@ -123,13 +196,17 @@ export function SystemSettingsTab(props: SystemSettingsTabProps) {
} = props;
const [searchParams, setSearchParams] = useSearchParams();
const tabFromUrl = searchParams.get('tab') as SettingsSubTab | null;
const tabFromUrl = searchParams.get("tab") as SettingsSubTab | null;
const [subTab, setSubTab] = useState<SettingsSubTab>(
SETTINGS_TABS.some(t => t.id === tabFromUrl) ? tabFromUrl! : 'general',
SETTINGS_TABS.some((t) => t.id === tabFromUrl) ? tabFromUrl! : "general",
);
useEffect(() => {
if (tabFromUrl && SETTINGS_TABS.some(t => t.id === tabFromUrl) && tabFromUrl !== subTab) {
if (
tabFromUrl &&
SETTINGS_TABS.some((t) => t.id === tabFromUrl) &&
tabFromUrl !== subTab
) {
setSubTab(tabFromUrl);
}
}, [tabFromUrl, subTab]);
@@ -138,295 +215,538 @@ export function SystemSettingsTab(props: SystemSettingsTabProps) {
const next = id as SettingsSubTab;
setSubTab(next);
const params = new URLSearchParams(searchParams);
if (next === 'general') params.delete('tab');
else params.set('tab', next);
if (next === "general") params.delete("tab");
else params.set("tab", next);
setSearchParams(params, { replace: true });
};
return (
<div className="w-full space-y-4">
<PageTabs items={[...SETTINGS_TABS]} value={subTab} onChange={changeSubTab} />
<PageTabs
items={[...SETTINGS_TABS]}
value={subTab}
onChange={changeSubTab}
/>
{subTab === 'general' && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-2">
<Card className="dg-card">
<CardHeader>
<CardTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
<History className="h-5 w-5 dg-text-green" />
{t('system.audit')}
</CardTitle>
<CardDescription className="dg-text-muted">{t('system.auditSettingsDesc')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-[var(--dg-text-primary)]">{t('system.recordAllOps')}</p>
<p className="text-xs dg-text-muted">{t('system.recordAllOpsDesc')}</p>
</div>
<Switch
checked={auditRecordAll}
onCheckedChange={setAuditRecordAll}
disabled={auditLoading || auditApplying}
className="data-[state=checked]:bg-blue-600"
/>
</div>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-medium text-[var(--dg-text-primary)]">{t('system.logRetention')}</p>
<p className="text-xs dg-text-muted">
{t('system.logRetentionHint')}
</p>
{auditLastCleanupText && (
<p className="text-xs dg-text-muted mt-1">{auditLastCleanupText}</p>
)}
</div>
<Select
value={auditRetentionDays}
onValueChange={setAuditRetentionDays}
disabled={auditLoading || auditApplying}
>
<SelectTrigger className="w-28 h-8 dg-input shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent className="dg-input">
{auditRetentionOptions.map((d) => (
<SelectItem key={d} value={String(d)}>
{d}天
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex justify-end pt-1">
<Button
size="sm"
className="dg-btn-primary"
onClick={handleAuditApply}
disabled={auditLoading || auditApplying}
>
{auditApplying ? (
<>
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
{t('system.applyingDot')}
</>
) : (
t('common.apply')
)}
</Button>
</div>
</CardContent>
</Card>
<Card className="dg-card">
<CardHeader>
<CardTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
<Settings className="h-5 w-5 dg-text-purple" />
{t('system.systemInfo')}
</CardTitle>
<CardDescription className="dg-text-muted">{t('system.systemInfoDesc')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm dg-text-secondary">{t('system.systemVersion')}</span>
<span className="text-sm text-[var(--dg-text-primary)]">v2.5.1</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm dg-text-secondary">{t('system.uptime')}</span>
<span className="text-sm text-[var(--dg-text-primary)]">{t('system.uptimeValue')}</span>
</div>
</CardContent>
</Card>
</div>
)}
{subTab === 'security' && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-2">
<Card className="dg-card">
<CardHeader>
<CardTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
<Lock className="h-5 w-5 dg-text-blue" />
{t('system.security')}
</CardTitle>
<CardDescription className="dg-text-muted">{t('system.securityDesc')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-[var(--dg-text-primary)]">{t('system.forcePasswordComplexity')}</p>
<p className="text-xs dg-text-muted">{t('system.forcePasswordComplexityDesc')}</p>
</div>
<Switch disabled className="data-[state=checked]:bg-blue-600" />
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-[var(--dg-text-primary)]">{t('system.loginLockout')}</p>
<p className="text-xs dg-text-muted">{t('system.loginLockoutDesc')}</p>
</div>
<Switch disabled defaultChecked className="data-[state=checked]:bg-blue-600" />
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-[var(--dg-text-primary)]">{t('system.twoFactor')}</p>
<p className="text-xs dg-text-muted">{t('system.twoFactorDesc')}</p>
</div>
<Switch disabled className="data-[state=checked]:bg-blue-600" />
</div>
</CardContent>
</Card>
<Card className="dg-card">
<CardHeader>
<CardTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
<Key className="h-5 w-5 dg-text-yellow" />
{t('system.sessionSettings')}
</CardTitle>
<CardDescription className="dg-text-muted">{t('system.sessionSettingsDesc')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-[var(--dg-text-primary)]">{t('system.sessionTimeoutTime')}</p>
<p className="text-xs dg-text-muted">{t('system.sessionTimeoutDesc')}</p>
</div>
<Select defaultValue="30" disabled>
<SelectTrigger className="w-32 h-8 dg-input">
<SelectValue />
</SelectTrigger>
<SelectContent className="dg-input">
<SelectItem value="15">{t('monitor.traceMgr.min15')}</SelectItem>
<SelectItem value="30">30分钟</SelectItem>
<SelectItem value="60">{t('monitor.traceMgr.hour1')}</SelectItem>
<SelectItem value="120">2小时</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-[var(--dg-text-primary)]">{t('system.sso')}</p>
<p className="text-xs dg-text-muted">{t('system.singleSignOnDesc')}</p>
</div>
<Switch disabled className="data-[state=checked]:bg-blue-600" />
</div>
</CardContent>
</Card>
</div>
)}
{subTab === 'ldap' && (
<>
<Card className="dg-card pt-2">
<CardHeader>
<div className="flex items-center justify-between">
<div>
{subTab === "general" && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-2">
<Card className="dg-card">
<CardHeader>
<CardTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
<Network className="h-5 w-5 dg-text-cyan" />
LDAP / Active Directory
<History className="h-5 w-5 dg-text-green" />
{t("system.audit")}
</CardTitle>
<CardDescription className="dg-text-muted mt-1">{t('system.ldapConfigHint')}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
<div className="space-y-1.5">
<label className="text-xs dg-text-muted">{t('system.ldapServerAddr')}</label>
<Input
value={ldapForm.ldap_host}
onChange={(e) => setLdapForm({ ...ldapForm, ldap_host: e.target.value })}
className="dg-input"
placeholder="ldap.example.com"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs dg-text-muted">{t('system.ldapPort')}</label>
<Input
type="number"
value={ldapForm.ldap_port}
onChange={(e) => setLdapForm({ ...ldapForm, ldap_port: Number(e.target.value) })}
className="dg-input"
placeholder="389"
/>
</div>
<div className="space-y-1.5 col-span-2">
<label className="text-xs dg-text-muted">{t('system.baseDnHint')}</label>
<Input
value={ldapForm.base_dn}
onChange={(e) => setLdapForm({ ...ldapForm, base_dn: e.target.value })}
className="dg-input"
placeholder="ou=users,dc=example,dc=com"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs dg-text-muted">{t('system.adminBindDn')}</label>
<Input
value={ldapForm.admin_dn}
onChange={(e) => setLdapForm({ ...ldapForm, admin_dn: e.target.value })}
className="dg-input"
placeholder="cn=admin,dc=example,dc=com"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs dg-text-muted">管理员密码 {ldapConfig && t('system.leaveBlankNoModify')}</label>
<Input
type="password"
value={ldapForm.admin_password}
onChange={(e) => setLdapForm({ ...ldapForm, admin_password: e.target.value })}
className="dg-input"
placeholder="******"
/>
</div>
<div className="space-y-1.5 col-span-2">
<label className="text-xs dg-text-muted">{t('system.searchFilterHint')}</label>
<Input
value={ldapForm.search_filter}
onChange={(e) => setLdapForm({ ...ldapForm, search_filter: e.target.value })}
className="dg-input"
placeholder="(uid=%s)"
/>
</div>
<div className="flex items-center gap-2 col-span-2">
<Switch
checked={ldapForm.use_ldaps}
onCheckedChange={(v) => setLdapForm({ ...ldapForm, use_ldaps: v })}
className="data-[state=checked]:bg-blue-600"
/>
<span className="text-sm text-[var(--dg-text-secondary)]">{t('system.enableLdaps')}</span>
</div>
</div>
<CardDescription className="dg-text-muted">
{t("system.auditSettingsDesc")}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-[var(--dg-text-primary)]">
{t("system.recordAllOps")}
</p>
<p className="text-xs dg-text-muted">
{t("system.recordAllOpsDesc")}
</p>
</div>
<Switch
checked={auditRecordAll}
onCheckedChange={setAuditRecordAll}
disabled={auditLoading || auditApplying}
className="data-[state=checked]:bg-blue-600"
/>
</div>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-medium text-[var(--dg-text-primary)]">
{t("system.logRetention")}
</p>
<p className="text-xs dg-text-muted">
{t("system.logRetentionHint")}
</p>
{auditLastCleanupText && (
<p className="text-xs dg-text-muted mt-1">
{auditLastCleanupText}
</p>
)}
</div>
<Select
value={auditRetentionDays}
onValueChange={setAuditRetentionDays}
disabled={auditLoading || auditApplying}
>
<SelectTrigger className="w-28 h-8 dg-input shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent className="dg-input">
{auditRetentionOptions.map((d) => (
<SelectItem key={d} value={String(d)}>
{d}天
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex justify-end pt-1">
<Button
size="sm"
className="dg-btn-primary"
onClick={handleAuditApply}
disabled={auditLoading || auditApplying}
>
{auditApplying ? (
<>
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
{t("system.applyingDot")}
</>
) : (
t("common.apply")
)}
</Button>
</div>
</CardContent>
</Card>
{/* 测试/保存按钮 */}
<div className="flex items-center gap-3 mt-4 pt-4 border-t border-[var(--dg-border)]">
{ldapTestResult && (
<span className={`text-xs ${ldapTestResult.includes('成功') ? 'dg-text-green' : 'dg-text-red'}`}>
{ldapTestResult}
</span>
)}
<div className="flex items-center gap-2 ml-auto">
<Button
variant="outline"
size="sm"
className="dg-btn-secondary"
onClick={handleLdapTest}
disabled={ldapTesting}
>
{ldapTesting ? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5 mr-1" />}
测试连接
</Button>
<Button
size="sm"
className="dg-btn-primary"
onClick={handleLdapSave}
disabled={ldapSaving}
>
{ldapSaving ? t('k8s.topology.saving') : t('aiops.llmSave')}
</Button>
</div>
</div>
</CardContent>
</Card>
<Card className="dg-card">
<CardHeader>
<CardTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
<Settings className="h-5 w-5 dg-text-purple" />
{t("system.systemInfo")}
</CardTitle>
<CardDescription className="dg-text-muted">
{t("system.systemInfoDesc")}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm dg-text-secondary">
{t("system.systemVersion")}
</span>
<span className="text-sm text-[var(--dg-text-primary)]">
v2.5.1
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm dg-text-secondary">
{t("system.uptime")}
</span>
<span className="text-sm text-[var(--dg-text-primary)]">
{t("system.uptimeValue")}
</span>
</div>
</CardContent>
</Card>
</div>
)}
{ldapConfig && <LdapManagementSection {...props} />}
</>
{subTab === "security" && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-2">
<Card className="dg-card">
<CardHeader>
<CardTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
<Lock className="h-5 w-5 dg-text-blue" />
{t("system.security")}
</CardTitle>
<CardDescription className="dg-text-muted">
{t("system.securityDesc")}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-[var(--dg-text-primary)]">
{t("system.forcePasswordComplexity")}
</p>
<p className="text-xs dg-text-muted">
{t("system.forcePasswordComplexityDesc")}
</p>
</div>
<Switch disabled className="data-[state=checked]:bg-blue-600" />
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-[var(--dg-text-primary)]">
{t("system.loginLockout")}
</p>
<p className="text-xs dg-text-muted">
{t("system.loginLockoutDesc")}
</p>
</div>
<Switch
disabled
defaultChecked
className="data-[state=checked]:bg-blue-600"
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-[var(--dg-text-primary)]">
{t("system.twoFactor")}
</p>
<p className="text-xs dg-text-muted">
{t("system.twoFactorDesc")}
</p>
</div>
<Switch disabled className="data-[state=checked]:bg-blue-600" />
</div>
</CardContent>
</Card>
<Card className="dg-card">
<CardHeader>
<CardTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
<Key className="h-5 w-5 dg-text-yellow" />
{t("system.sessionSettings")}
</CardTitle>
<CardDescription className="dg-text-muted">
{t("system.sessionSettingsDesc")}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-medium text-[var(--dg-text-primary)]">
{t("system.sessionWatermark")}
</p>
<p className="text-xs dg-text-muted">
{t("system.sessionWatermarkDesc", {
user: currentUsername,
})}
</p>
</div>
<Switch
checked={sessionWatermarkEnabled}
onCheckedChange={setSessionWatermarkEnabled}
className="data-[state=checked]:bg-blue-600"
/>
</div>
<div className="rounded border border-[var(--dg-border)] bg-[#101418] px-3 py-4 overflow-hidden">
<div className="relative h-24 rounded bg-black/70">
<div
className="absolute inset-0 opacity-80"
style={{
backgroundImage: `repeating-linear-gradient(-24deg, transparent 0 42px, rgba(255,255,255,0.12) 43px 44px, transparent 45px 120px)`,
}}
/>
<div className="absolute inset-0 grid grid-cols-2 place-items-center -rotate-[18deg] text-lg font-semibold tracking-normal text-white/30">
<span>{currentUsername}</span>
<span>{currentUsername}</span>
<span>{currentUsername}</span>
<span>{currentUsername}</span>
</div>
</div>
</div>
<div className="grid grid-cols-1 gap-3">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium text-[var(--dg-text-primary)]">
{t("system.sessionRecordEnabled")}
</p>
<p className="text-xs dg-text-muted">
{t("system.sessionRecordEnabledDesc")}
</p>
</div>
<Switch
checked={sessionRecordEnabled}
onCheckedChange={setSessionRecordEnabled}
className="data-[state=checked]:bg-blue-600"
/>
</div>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium text-[var(--dg-text-primary)]">
{t("system.clipboardEnabled")}
</p>
<p className="text-xs dg-text-muted">
{t("system.clipboardEnabledDesc")}
</p>
</div>
<Switch
checked={clipboardEnabled}
onCheckedChange={setClipboardEnabled}
className="data-[state=checked]:bg-blue-600"
/>
</div>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium text-[var(--dg-text-primary)]">
{t("system.fileUploadEnabled")}
</p>
<p className="text-xs dg-text-muted">
{t("system.fileUploadEnabledDesc")}
</p>
</div>
<Switch
checked={fileUploadEnabled}
onCheckedChange={setFileUploadEnabled}
className="data-[state=checked]:bg-blue-600"
/>
</div>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium text-[var(--dg-text-primary)]">
{t("system.fileDownloadEnabled")}
</p>
<p className="text-xs dg-text-muted">
{t("system.fileDownloadEnabledDesc")}
</p>
</div>
<Switch
checked={fileDownloadEnabled}
onCheckedChange={setFileDownloadEnabled}
className="data-[state=checked]:bg-blue-600"
/>
</div>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium text-[var(--dg-text-primary)]">
{t("system.commandPolicyEnabled")}
</p>
<p className="text-xs dg-text-muted">
{t("system.commandPolicyEnabledDesc")}
</p>
</div>
<Switch
checked={commandPolicyEnabled}
onCheckedChange={setCommandPolicyEnabled}
className="data-[state=checked]:bg-blue-600"
/>
</div>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium text-[var(--dg-text-primary)]">
{t("system.approvalRequired")}
</p>
<p className="text-xs dg-text-muted">
{t("system.approvalRequiredDesc")}
</p>
</div>
<Switch
checked={approvalRequired}
onCheckedChange={setApprovalRequired}
className="data-[state=checked]:bg-blue-600"
/>
</div>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-[var(--dg-text-primary)]">
{t("system.sessionTimeoutTime")}
</p>
<p className="text-xs dg-text-muted">
{t("system.sessionTimeoutDesc")}
</p>
</div>
<Select defaultValue="30" disabled>
<SelectTrigger className="w-32 h-8 dg-input">
<SelectValue />
</SelectTrigger>
<SelectContent className="dg-input">
<SelectItem value="15">
{t("monitor.traceMgr.min15")}
</SelectItem>
<SelectItem value="30">30分钟</SelectItem>
<SelectItem value="60">
{t("monitor.traceMgr.hour1")}
</SelectItem>
<SelectItem value="120">2小时</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-[var(--dg-text-primary)]">
{t("system.sso")}
</p>
<p className="text-xs dg-text-muted">
{t("system.singleSignOnDesc")}
</p>
</div>
<Switch disabled className="data-[state=checked]:bg-blue-600" />
</div>
<div className="flex justify-end pt-1">
<Button
size="sm"
className="dg-btn-primary"
onClick={handleAuditApply}
disabled={auditLoading || auditApplying}
>
{auditApplying ? (
<>
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
{t("system.applyingDot")}
</>
) : (
t("common.apply")
)}
</Button>
</div>
</CardContent>
</Card>
</div>
)}
{subTab === "ldap" && (
<>
<Card className="dg-card pt-2">
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
<Network className="h-5 w-5 dg-text-cyan" />
LDAP / Active Directory
</CardTitle>
<CardDescription className="dg-text-muted mt-1">
{t("system.ldapConfigHint")}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
<div className="space-y-1.5">
<label className="text-xs dg-text-muted">
{t("system.ldapServerAddr")}
</label>
<Input
value={ldapForm.ldap_host}
onChange={(e) =>
setLdapForm({ ...ldapForm, ldap_host: e.target.value })
}
className="dg-input"
placeholder="ldap.example.com"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs dg-text-muted">
{t("system.ldapPort")}
</label>
<Input
type="number"
value={ldapForm.ldap_port}
onChange={(e) =>
setLdapForm({
...ldapForm,
ldap_port: Number(e.target.value),
})
}
className="dg-input"
placeholder="389"
/>
</div>
<div className="space-y-1.5 col-span-2">
<label className="text-xs dg-text-muted">
{t("system.baseDnHint")}
</label>
<Input
value={ldapForm.base_dn}
onChange={(e) =>
setLdapForm({ ...ldapForm, base_dn: e.target.value })
}
className="dg-input"
placeholder="ou=users,dc=example,dc=com"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs dg-text-muted">
{t("system.adminBindDn")}
</label>
<Input
value={ldapForm.admin_dn}
onChange={(e) =>
setLdapForm({ ...ldapForm, admin_dn: e.target.value })
}
className="dg-input"
placeholder="cn=admin,dc=example,dc=com"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs dg-text-muted">
管理员密码 {ldapConfig && t("system.leaveBlankNoModify")}
</label>
<Input
type="password"
value={ldapForm.admin_password}
onChange={(e) =>
setLdapForm({
...ldapForm,
admin_password: e.target.value,
})
}
className="dg-input"
placeholder="******"
/>
</div>
<div className="space-y-1.5 col-span-2">
<label className="text-xs dg-text-muted">
{t("system.searchFilterHint")}
</label>
<Input
value={ldapForm.search_filter}
onChange={(e) =>
setLdapForm({
...ldapForm,
search_filter: e.target.value,
})
}
className="dg-input"
placeholder="(uid=%s)"
/>
</div>
<div className="flex items-center gap-2 col-span-2">
<Switch
checked={ldapForm.use_ldaps}
onCheckedChange={(v) =>
setLdapForm({ ...ldapForm, use_ldaps: v })
}
className="data-[state=checked]:bg-blue-600"
/>
<span className="text-sm text-[var(--dg-text-secondary)]">
{t("system.enableLdaps")}
</span>
</div>
</div>
{/* 测试/保存按钮 */}
<div className="flex items-center gap-3 mt-4 pt-4 border-t border-[var(--dg-border)]">
{ldapTestResult && (
<span
className={`text-xs ${ldapTestResult.includes("成功") ? "dg-text-green" : "dg-text-red"}`}
>
{ldapTestResult}
</span>
)}
<div className="flex items-center gap-2 ml-auto">
<Button
variant="outline"
size="sm"
className="dg-btn-secondary"
onClick={handleLdapTest}
disabled={ldapTesting}
>
{ldapTesting ? (
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5 mr-1" />
)}
测试连接
</Button>
<Button
size="sm"
className="dg-btn-primary"
onClick={handleLdapSave}
disabled={ldapSaving}
>
{ldapSaving ? t("k8s.topology.saving") : t("aiops.llmSave")}
</Button>
</div>
</div>
</CardContent>
</Card>
{ldapConfig && <LdapManagementSection {...props} />}
</>
)}
</div>
);
@@ -1,15 +1,23 @@
// 审计策略配置 — 保留天数与应用后立即清理 // Logan.Li
import { useState, useEffect, useCallback } from 'react';
import { toast } from 'sonner';
import { auditSettingsApi, type AuditSettings } from '@/api';
import i18n from '@/i18n';
import { useState, useEffect, useCallback } from "react";
import { toast } from "sonner";
import { auditSettingsApi, type AuditSettings } from "@/api";
import i18n from "@/i18n";
import { useSessionSecurityStore } from "@/store";
const RETENTION_OPTIONS = [7, 15, 30, 90, 180, 365] as const;
export function useAuditSettings() {
const [settings, setSettings] = useState<AuditSettings | null>(null);
const [retentionDays, setRetentionDays] = useState('180');
const [retentionDays, setRetentionDays] = useState("180");
const [recordAll, setRecordAll] = useState(true);
const [sessionWatermarkEnabled, setSessionWatermarkEnabled] = useState(true);
const [sessionRecordEnabled, setSessionRecordEnabled] = useState(true);
const [clipboardEnabled, setClipboardEnabled] = useState(true);
const [fileUploadEnabled, setFileUploadEnabled] = useState(true);
const [fileDownloadEnabled, setFileDownloadEnabled] = useState(true);
const [commandPolicyEnabled, setCommandPolicyEnabled] = useState(false);
const [approvalRequired, setApprovalRequired] = useState(false);
const [loading, setLoading] = useState(true);
const [applying, setApplying] = useState(false);
@@ -20,8 +28,25 @@ export function useAuditSettings() {
setSettings(data);
setRetentionDays(String(data.retention_days || 180));
setRecordAll(data.record_all !== false);
const policy = {
watermarkEnabled: data.session_watermark_enabled !== false,
recordEnabled: data.session_record_enabled !== false,
clipboardEnabled: data.clipboard_enabled !== false,
fileUploadEnabled: data.file_upload_enabled !== false,
fileDownloadEnabled: data.file_download_enabled !== false,
commandPolicyEnabled: data.command_policy_enabled === true,
approvalRequired: data.approval_required === true,
};
setSessionWatermarkEnabled(policy.watermarkEnabled);
setSessionRecordEnabled(policy.recordEnabled);
setClipboardEnabled(policy.clipboardEnabled);
setFileUploadEnabled(policy.fileUploadEnabled);
setFileDownloadEnabled(policy.fileDownloadEnabled);
setCommandPolicyEnabled(policy.commandPolicyEnabled);
setApprovalRequired(policy.approvalRequired);
useSessionSecurityStore.getState().setPolicy(policy);
} catch {
toast.error(i18n.t('system.useauditsetting.s32e8a76e'));
toast.error(i18n.t("system.useauditsetting.s32e8a76e"));
} finally {
setLoading(false);
}
@@ -33,19 +58,52 @@ export function useAuditSettings() {
const handleApply = async () => {
const days = Number(retentionDays);
if (!RETENTION_OPTIONS.includes(days as (typeof RETENTION_OPTIONS)[number])) {
toast.error(i18n.t('system.useauditsetting.s51680af0'));
if (
!RETENTION_OPTIONS.includes(days as (typeof RETENTION_OPTIONS)[number])
) {
toast.error(i18n.t("system.useauditsetting.s51680af0"));
return;
}
setApplying(true);
try {
const res = await auditSettingsApi.apply({ retention_days: days, record_all: recordAll });
const res = await auditSettingsApi.apply({
retention_days: days,
record_all: recordAll,
session_watermark_enabled: sessionWatermarkEnabled,
session_record_enabled: sessionRecordEnabled,
clipboard_enabled: clipboardEnabled,
file_upload_enabled: fileUploadEnabled,
file_download_enabled: fileDownloadEnabled,
command_policy_enabled: commandPolicyEnabled,
approval_required: approvalRequired,
});
setSettings({
retention_days: res.retention_days ?? days,
record_all: res.record_all ?? recordAll,
session_watermark_enabled:
res.session_watermark_enabled ?? sessionWatermarkEnabled,
session_record_enabled:
res.session_record_enabled ?? sessionRecordEnabled,
clipboard_enabled: res.clipboard_enabled ?? clipboardEnabled,
file_upload_enabled: res.file_upload_enabled ?? fileUploadEnabled,
file_download_enabled: res.file_download_enabled ?? fileDownloadEnabled,
command_policy_enabled:
res.command_policy_enabled ?? commandPolicyEnabled,
approval_required: res.approval_required ?? approvalRequired,
last_cleanup_at: res.last_cleanup_at,
last_cleanup_summary: res.last_cleanup_summary,
});
useSessionSecurityStore.getState().setPolicy({
watermarkEnabled:
res.session_watermark_enabled ?? sessionWatermarkEnabled,
recordEnabled: res.session_record_enabled ?? sessionRecordEnabled,
clipboardEnabled: res.clipboard_enabled ?? clipboardEnabled,
fileUploadEnabled: res.file_upload_enabled ?? fileUploadEnabled,
fileDownloadEnabled: res.file_download_enabled ?? fileDownloadEnabled,
commandPolicyEnabled:
res.command_policy_enabled ?? commandPolicyEnabled,
approvalRequired: res.approval_required ?? approvalRequired,
});
const total = res.cleanup?.total ?? 0;
toast.success(
total > 0
@@ -53,7 +111,9 @@ export function useAuditSettings() {
: `已应用 ${days} 天保留策略,暂无超期记录需清理`,
);
} catch (e: unknown) {
toast.error(e instanceof Error ? e.message : i18n.t('kubernetes.msg.applyFailed'));
toast.error(
e instanceof Error ? e.message : i18n.t("kubernetes.msg.applyFailed"),
);
} finally {
setApplying(false);
}
@@ -61,13 +121,20 @@ export function useAuditSettings() {
const lastCleanupText = (() => {
if (!settings?.last_cleanup_at) return null;
const t = new Date(settings.last_cleanup_at).toLocaleString('zh-CN', { hour12: false });
let detail = '';
const t = new Date(settings.last_cleanup_at).toLocaleString("zh-CN", {
hour12: false,
});
let detail = "";
if (settings.last_cleanup_summary) {
try {
const s = JSON.parse(settings.last_cleanup_summary) as { total?: number };
if (typeof s.total === 'number') {
detail = s.total > 0 ? `,清理 ${s.total} 条` : i18n.t('system.useauditsetting.s7d55bd57');
const s = JSON.parse(settings.last_cleanup_summary) as {
total?: number;
};
if (typeof s.total === "number") {
detail =
s.total > 0
? `,清理 ${s.total} 条`
: i18n.t("system.useauditsetting.s7d55bd57");
}
} catch {
// ignore
@@ -81,10 +148,26 @@ export function useAuditSettings() {
setRetentionDays,
recordAll,
setRecordAll,
sessionWatermarkEnabled,
setSessionWatermarkEnabled,
sessionRecordEnabled,
setSessionRecordEnabled,
clipboardEnabled,
setClipboardEnabled,
fileUploadEnabled,
setFileUploadEnabled,
fileDownloadEnabled,
setFileDownloadEnabled,
commandPolicyEnabled,
setCommandPolicyEnabled,
approvalRequired,
setApprovalRequired,
loading,
applying,
handleApply,
lastCleanupText,
retentionOptions: settings?.allowed_retention_days ?? [...RETENTION_OPTIONS],
retentionOptions: settings?.allowed_retention_days ?? [
...RETENTION_OPTIONS,
],
};
}
}
+4 -4
View File
@@ -412,7 +412,7 @@ export function DataTable<T = Record<string, unknown>>(
)}
{selectable && (selectedIds?.length ?? 0) > 0 && (
<Badge variant="secondary" className="bg-[var(--dg-bg-active)] text-[var(--dg-text-secondary)] border-[var(--dg-border)]">
{t("ui.datatable.s1a2b3c4d", { count: selectedIds!.length })}
{i18n.t("ui.datatable.s1a2b3c4d", { count: selectedIds!.length })}
</Badge>
)}
</div>
@@ -423,7 +423,7 @@ export function DataTable<T = Record<string, unknown>>(
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8 gap-1.5 border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] hover:text-[var(--dg-text-primary)]">
<Eye className="h-3.5 w-3.5" />
<span className="hidden sm:inline">{t("ui.datatable.s2b3c4d5e")}</span>
<span className="hidden sm:inline">{i18n.t("ui.datatable.s2b3c4d5e")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
@@ -579,11 +579,11 @@ export function DataTable<T = Record<string, unknown>>(
<div className="flex items-center justify-between gap-2 px-1 py-2 flex-wrap">
<div className="flex items-center gap-3">
<span className="text-sm text-[var(--dg-text-muted)]">
{t("ui.datatable.s3c4d5e6f", { count: sortedData.length })}
{i18n.t("ui.datatable.s3c4d5e6f", { count: sortedData.length })}
</span>
{selectable && (selectedIds?.length ?? 0) > 0 && (
<span className="text-sm text-[var(--dg-text-muted)]">
/ {t("ui.datatable.s4d5e6f7g", { count: selectedIds!.length })}
/ {i18n.t("ui.datatable.s4d5e6f7g", { count: selectedIds!.length })}
</span>
)}
<div className="flex items-center gap-1.5">
+142 -20
View File
@@ -522,7 +522,14 @@
"zoomOut": "Zoom Out",
"username": "Username",
"password": "Password",
"port": "Port"
"port": "Port",
"unnamed": "Unnamed",
"default": "Default",
"saved": "Saved",
"createFailed": "Create failed",
"created": "Created",
"severity": "Severity",
"critical": "Critical"
},
"menu": {
"dashboard": "Dashboard",
@@ -570,7 +577,7 @@
"scripts": "Scripts",
"remoteExec": "Remote Exec",
"aiops": "AIOps",
"aiAssistant": "AI Assistant",
"aiAssistant": "Assistant",
"aiAlertAnalyzer": "Alert Analysis",
"aiPredictive": "Predictive Analytics",
"aiAgentCli": "Agent CLI",
@@ -997,16 +1004,11 @@
"syncButton": "Sync to Remote",
"uploadFile": "Upload File",
"uploading": "Uploading...",
"noFiles": "No files (SMB 445 must be reachable to list)",
"downloading": "Loading...",
"smbHint": "Prefer SMB direct transfer; if port 445 is blocked, auto-stages to AIOps and uses RDP download command.",
"syncSuccess": "Synced to remote",
"emptyContent": "Content is empty",
"clipboardHint": "Cannot auto-read clipboard, please Ctrl+V to paste (auto-read available under HTTPS)",
"uploadedToDownload": "{{name}} uploaded to Download",
"autoSent": "{{name}} auto-delivered (fallback when SMB unavailable)",
"downloadStarted": "Download started: {{name}}",
"listFileError": "Cannot list remote files (SMB 445 may be blocked)",
"uploadFailed": "Upload failed",
"downloadFailed": "Download failed"
},
@@ -1129,6 +1131,8 @@
"webshellLoggingIn": "Logging in...",
"general": "General",
"rdpFiles": "Files",
"rdpClipboard": "Clipboard",
"clipboardReady": "Captured",
"remoteClipboard": "Remote Clipboard",
"remoteClipPlaceholder": "Remote copied content will display here",
"syncToRemote": "Sync to Remote",
@@ -1136,18 +1140,81 @@
"syncButton": "Sync to Remote",
"uploadFile": "Upload File",
"rdpUploading": "Uploading...",
"noRdpFiles": "No files (requires SMB 445 reachable to list)",
"rdpLoading": "Loading...",
"smbHint": "Prefer SMB direct transfer; if port 445 unreachable, auto-stage to AIOps and RDP executes download command.",
"syncedToRemote": "Synced to remote",
"emptyContent": "Content is empty",
"clipboardHint": "Cannot auto-read clipboard, please Ctrl+V to paste (auto-read available under HTTPS)",
"uploadedToDownload": "{{name}} uploaded to Download",
"autoSent": "{{name}} auto-delivered (fallback when SMB unavailable)",
"uploadedToVirtualDrive": "{{name}} staged in AIOps\\Upload",
"aiopsVirtualDrive": "AIOps on Go-client",
"aiopsVirtualDriveLocation": "This PC > AIOps on Go-client",
"uploadToVirtualDrive": "Upload to Drive",
"rdpUploadHint": "Select or drop a file, max {{limit}}",
"downloadStarted": "Download started: {{name}}",
"listFileError": "Cannot list remote files (SMB 445 may be unreachable)",
"rdpUploadFailed": "Upload failed",
"rdpDownloadFailed": "Download failed"
"rdpUploadTooLarge": "File exceeds {{limit}}, please upload in batches",
"rdpUploadStatus": {
"reading": "Reading local file",
"sending": "Sending to RDP session",
"staged": "Staged, copy it from AIOps\\Upload on the remote desktop",
"failed": "Upload failed, please retry"
},
"rdpDownloadFailed": "Download failed",
"rdpDialog": {
"waiting": "Waiting for connection",
"bitmapDecodeFailed": "RDP bitmap decode failed",
"rawFrameDrawFailed": "RDP raw frame draw failed",
"bitmapDrawFailed": "RDP bitmap draw failed, received {{count}} frames",
"creating": "Creating RDP session...",
"unparseableFrame": "Received unparseable RDP frame",
"connectionError": "RDP connection error",
"connectionClosed": "RDP connection closed",
"graphicSession": "Windows RDP graphic session",
"tools": "Tools",
"synced": "Synced to remote",
"remoteCopyRequested": "Requested remote copy, waiting for clipboard data",
"downloadStarted": "Download started: {{name}}",
"uploadStaged": "Staged in AIOps\\Upload: {{name}}",
"autoExecuting": "Auto-executing upload command..."
},
"importCred": {
"sshPort": "SSH Port",
"linuxDefault": "(Linux default)",
"sshCredential": "Linux SSH Credential",
"optional": "(optional)",
"noSshCred": "No SSH credentials",
"noCredential": "No credential",
"rdpPassword": "Windows RDP Password",
"noRdpCred": "No RDP credentials"
},
"statusBadge": {
"running": "Running",
"stopped": "Stopped",
"maintenance": "Maintenance",
"offline": "Offline"
},
"table": {
"cores": " cores",
"memory": "Mem",
"connections": " conn",
"read": "R",
"write": "W",
"terminal": "Terminal",
"files": "Files",
"rdpLogin": "RDP Login",
"terminalLogin": "Terminal Login"
},
"pasteToRemote": "Paste to Remote",
"copyFromRemote": "Copy Remote",
"remoteClipEmpty": "Remote clipboard is empty",
"remoteCopyRequested": "Sent Ctrl+C to remote, waiting for clipboard data",
"copiedToLocal": "Copied to local clipboard",
"copyFailed": "Copy failed",
"manualSync": "Manual input & sync",
"sessionPolicy": {
"restricted": "Current session policy restricts some clipboard or file actions",
"clipboardDisabled": "Clipboard is disabled by session policy",
"fileUploadDisabled": "File upload is disabled by session policy",
"fileDownloadDisabled": "File download is disabled by session policy"
}
},
"alert": {
"stat": {
@@ -2044,6 +2111,22 @@
"allType": "All Types",
"pagination": "{{n}}/page"
},
"credForm": {
"pageSubtitle": "Manage Git repositories, registries, environments, build nodes, and deploy tools",
"gitRepo": "Git Repository",
"imageRegistry": "Image Registry",
"newRepo": "New Repository",
"newRegistry": "New Registry",
"noRepo": "No Git repositories",
"noRegistry": "No image registries",
"namespace": "Namespace",
"repoNamePlaceholder": "e.g. aiops-backend",
"registryNamePlaceholder": "e.g. harbor-prod",
"namespacePlaceholder": "e.g. library / devops",
"credHint": "Use system credential",
"dockerPwd": "Docker password",
"noCredHint": "No {{type}} credential. Create one in Credential Management first."
},
"releaseCreate": {
"commitMsg": "Commit Message",
"commitMsgPlaceholder": "feat: add new feature",
@@ -4657,6 +4740,12 @@
"lastMonth": "Last month",
"thisQuarter": "This quarter",
"thisYear": "This year"
},
"channel": {
"dingtalk": "DingTalk",
"wechat": "WeChat Work",
"email": "Email",
"sms": "SMS"
}
},
"task": {
@@ -7087,6 +7176,20 @@
"batchDeleteFailed": "Batch delete failed",
"singleSignOnDesc": "One account can only be logged in on one device",
"sessionSettings": "Session Settings",
"sessionWatermark": "Session User Watermark",
"sessionWatermarkDesc": "Show current login user \"{{user}}\" on RDP/terminal views",
"sessionRecordEnabled": "Session Recording",
"sessionRecordEnabledDesc": "Record SSH/RDP sessions and keep replay data",
"clipboardEnabled": "Allow Clipboard",
"clipboardEnabledDesc": "Allow bidirectional text clipboard sync between browser and remote host",
"fileUploadEnabled": "Allow File Upload",
"fileUploadEnabledDesc": "Allow file upload through RDP AIOps drive or terminal file features",
"fileDownloadEnabled": "Allow File Download",
"fileDownloadEnabledDesc": "Allow downloading remote files to the local browser",
"commandPolicyEnabled": "Command Policy",
"commandPolicyEnabledDesc": "Enable command allow/deny lists and high-risk command blocking progressively",
"approvalRequired": "Require Approval",
"approvalRequiredDesc": "Require temporary access approval before remote sessions progressively",
"roleManageDesc": "Modify role information and permissions",
"terminalLiveMonitor": "Live Terminal Monitor",
"systemInfoDesc": "Version and system status",
@@ -7245,7 +7348,8 @@
"title": "AIOps",
"subtitle": "AI Assistant · Alert Analysis · Capacity Forecast",
"aiAssistant": "AI Assistant",
"assistantTitle": "AIOps Assistant",
"assistantTitle": "Assistant",
"aiDrivenAssistant": "AI chat, analysis, and remediation suggestions for ops scenarios",
"assistantPlaceholder": "Ask an ops question to start",
"assistantHint": "Enter an ops question and the AI assistant will analyze and suggest",
"sendMessage": "Send",
@@ -7291,7 +7395,9 @@
"assistantSummary": "Summary",
"assistantNoData": "No data",
"alertAnalysis": "Alert Analysis",
"alertAnalysisTitle": "AI Alert Analysis",
"aiAlertAnalysis": "Alert Analysis",
"alertAnalysisTitle": "Alert Analysis",
"alertRootCauseAnalysis": "Alert root cause analysis and remediation suggestions",
"alertAnalysisDesc": "LLM-based alert root cause analysis and remediation",
"alertAnalysisHint": "Select an alert for AI analysis",
"alertRootCause": "Root Cause Analysis",
@@ -7438,7 +7544,7 @@
"capacityPlanning": "Capacity Planning",
"notConnectedRetry": "Connection not ready; please retry later",
"safetyHighRiskConfirm": "High-risk + confirm",
"aiAssistantShort": "AI Assistant",
"aiAssistantShort": "Assistant",
"recommended": "Recommended",
"endpointLabel": "Endpoint",
"wsConnectFailed": "WebSocket connection failed",
@@ -7820,7 +7926,7 @@
"predictive.s1a2b3c4d": "Metric Forecast",
"predictive.s2b3c4d5e": "Capacity Planning",
"predictive.s3c4d5e6f": "Cost Forecast",
"predictive.s4d5e6f7g": "AI Predictive Analytics",
"predictive.s4d5e6f7g": "Predictive Analysis",
"predictive.s5e6f7g8h": "Linear regression prediction, capacity planning and cost forecasting based on real-time metrics and cloud billing",
"predictive.s6f7g8h9i": "Confidence",
"predictive.s7g8h9i0j": "Predicted month-end",
@@ -7896,7 +8002,7 @@
"approvalPermission": "Permission Approval",
"approvalWorkflow": "Approval Workflow",
"scripts": "Scripts",
"aiAssistant": "AI Assistant",
"aiAssistant": "Assistant",
"aiAlertAnalyzer": "Alert Analysis",
"aiPredictive": "Predictive Analysis",
"users": "Users",
@@ -7966,5 +8072,21 @@
"apiaccesskey.s4b5c6d7e": "Credential copied",
"apiaccesskey.s5c6d7e8f": "I've saved it",
"apiaccesskey.s6d7e8f9g": "Your access credential"
},
"logCenter": {
"saveQuery": "Save as Query",
"saveAsAlert": "Save as Alert",
"queryName": "Query Name",
"queryNamePlaceholder": "Enter query name",
"alertName": "Alert Name",
"alertNamePlaceholder": "Enter alert rule name",
"triggerCondition": "Trigger Condition",
"createAlert": "Create Alert",
"createFromQuery": "Created from query: {{name}}",
"defaultDatasource": "Default datasource",
"alertRuleCreated": "Alert rule created",
"quickQuery": "Quick Query",
"quickQueryHint": "{{engine}} common query templates, click to fill",
"entries": "entries"
}
}
}
+142 -20
View File
@@ -522,7 +522,14 @@
"zoomOut": "缩小",
"username": "用户名",
"password": "密码",
"port": "端口"
"port": "端口",
"unnamed": "未命名",
"default": "默认",
"saved": "已保存",
"createFailed": "创建失败",
"created": "已创建",
"severity": "级别",
"critical": "严重"
},
"menu": {
"dashboard": "仪表盘",
@@ -997,16 +1004,11 @@
"syncButton": "同步到远端",
"uploadFile": "上传文件",
"uploading": "上传中...",
"noFiles": "暂无文件(需 SMB 445 可达才能列出)",
"downloading": "加载中...",
"smbHint": "优先 SMB 直传(一键);若 445 不通则自动暂存到 AIOps 并由 RDP 执行下载命令。",
"syncSuccess": "已同步到远端",
"emptyContent": "内容为空",
"clipboardHint": "无法自动读取剪贴板,请 Ctrl+V 粘贴(HTTPS 下可自动读取)",
"uploadedToDownload": "{{name}} 已上传到 Download",
"autoSent": "{{name}} 已自动下发(SMB 不可用时的回退模式)",
"downloadStarted": "已开始下载 {{name}}",
"listFileError": "无法列出远端文件(SMB 445 可能未开放)",
"uploadFailed": "上传失败",
"downloadFailed": "下载失败"
},
@@ -1129,6 +1131,8 @@
"webshellLoggingIn": "正在登录...",
"general": "通用",
"rdpFiles": "文件",
"rdpClipboard": "剪贴板",
"clipboardReady": "已捕获",
"remoteClipboard": "远端剪贴板",
"remoteClipPlaceholder": "远端复制内容将显示在此",
"syncToRemote": "同步到远端",
@@ -1136,18 +1140,81 @@
"syncButton": "同步到远端",
"uploadFile": "上传文件",
"rdpUploading": "上传中...",
"noRdpFiles": "暂无文件(需 SMB 445 可达才能列出)",
"rdpLoading": "加载中...",
"smbHint": "优先 SMB 直传(一键);若 445 不通则自动暂存到 AIOps 并由 RDP 执行下载命令。",
"syncedToRemote": "已同步到远端",
"emptyContent": "内容为空",
"clipboardHint": "无法自动读取剪贴板,请 Ctrl+V 粘贴(HTTPS 下可自动读取)",
"uploadedToDownload": "{{name}} 已上传到 Download",
"autoSent": "{{name}} 已自动下发(SMB 不可用时的回退模式)",
"uploadedToVirtualDrive": "{{name}} 已暂存到 AIOps\\Upload",
"aiopsVirtualDrive": "Go-client 上的 AIOps",
"aiopsVirtualDriveLocation": "此电脑 > Go-client 上的 AIOps",
"uploadToVirtualDrive": "上传到虚拟盘",
"rdpUploadHint": "选择或拖入文件,最大 {{limit}}",
"downloadStarted": "已开始下载 {{name}}",
"listFileError": "无法列出远端文件(SMB 445 可能未开放)",
"rdpUploadFailed": "上传失败",
"rdpDownloadFailed": "下载失败"
"rdpUploadTooLarge": "文件超过 {{limit}},请分批上传",
"rdpUploadStatus": {
"reading": "正在读取本地文件",
"sending": "正在发送到 RDP 会话",
"staged": "已暂存,远端可从 AIOps\\Upload 复制",
"failed": "上传失败,请重试"
},
"rdpDownloadFailed": "下载失败",
"rdpDialog": {
"waiting": "等待连接",
"bitmapDecodeFailed": "RDP 位图解码失败",
"rawFrameDrawFailed": "RDP 原始帧绘制失败",
"bitmapDrawFailed": "RDP 位图绘制失败,已收 {{count}} 帧",
"creating": "正在创建 RDP 会话...",
"unparseableFrame": "收到无法解析的 RDP 帧",
"connectionError": "RDP 连接发生错误",
"connectionClosed": "RDP 连接已关闭",
"graphicSession": "Windows RDP 图形会话",
"tools": "工具",
"synced": "已同步到远端",
"remoteCopyRequested": "已请求远端复制,等待剪贴板回传",
"downloadStarted": "已开始下载 {{name}}",
"uploadStaged": "已放入 AIOps\\Upload:{{name}}",
"autoExecuting": "正在自动执行上传命令..."
},
"importCred": {
"sshPort": "SSH 端口",
"linuxDefault": "(Linux 默认)",
"sshCredential": "Linux SSH 凭证",
"optional": "(可选)",
"noSshCred": "暂无 SSH 凭证",
"noCredential": "不使用凭证",
"rdpPassword": "Windows RDP 密码",
"noRdpCred": "暂无 RDP 凭证"
},
"statusBadge": {
"running": "运行中",
"stopped": "已停止",
"maintenance": "维护中",
"offline": "离线"
},
"table": {
"cores": "核",
"memory": "内存",
"connections": "连接",
"read": "读",
"write": "写",
"terminal": "终端",
"files": "文件",
"rdpLogin": "RDP 登录",
"terminalLogin": "终端登录"
},
"pasteToRemote": "粘贴到远端",
"copyFromRemote": "复制远端内容",
"remoteClipEmpty": "远端剪贴板为空",
"remoteCopyRequested": "已发送 Ctrl+C 到远端,等待剪贴板回传",
"copiedToLocal": "已复制到本地剪贴板",
"copyFailed": "复制失败",
"manualSync": "手动输入并同步",
"sessionPolicy": {
"restricted": "当前会话策略限制了部分剪贴板或文件操作",
"clipboardDisabled": "会话策略已禁用剪贴板",
"fileUploadDisabled": "会话策略已禁用文件上传",
"fileDownloadDisabled": "会话策略已禁用文件下载"
}
},
"alert": {
"stat": {
@@ -2044,6 +2111,22 @@
"allType": "全部类型",
"pagination": "{{n}}条/页"
},
"credForm": {
"pageSubtitle": "统一管理 Git 仓库、镜像仓库、环境、构建节点和发布工具",
"gitRepo": "Git 仓库",
"imageRegistry": "镜像仓库",
"newRepo": "新建仓库",
"newRegistry": "新建镜像仓库",
"noRepo": "暂无 Git 仓库",
"noRegistry": "暂无镜像仓库",
"namespace": "命名空间",
"repoNamePlaceholder": "例如 aiops-backend",
"registryNamePlaceholder": "例如 harbor-prod",
"namespacePlaceholder": "例如 library / devops",
"credHint": "使用系统凭证",
"dockerPwd": "Docker 密码",
"noCredHint": "暂无{{type}}凭证,可先在凭证管理中创建"
},
"releaseCreate": {
"commitMsg": "提交信息",
"commitMsgPlaceholder": "feat: 新增功能",
@@ -4657,6 +4740,12 @@
"lastMonth": "上月",
"thisQuarter": "本季度",
"thisYear": "本年度"
},
"channel": {
"dingtalk": "钉钉",
"wechat": "企业微信",
"email": "邮件",
"sms": "短信"
}
},
"task": {
@@ -7087,6 +7176,20 @@
"batchDeleteFailed": "批量删除失败",
"singleSignOnDesc": "同一账号只能在一个设备登录",
"sessionSettings": "会话设置",
"sessionWatermark": "会话用户水印",
"sessionWatermarkDesc": "在 RDP/终端画面显示当前登录用户「{{user}}」",
"sessionRecordEnabled": "会话录像",
"sessionRecordEnabledDesc": "记录 SSH/RDP 会话内容并支持回放",
"clipboardEnabled": "允许剪贴板",
"clipboardEnabledDesc": "允许浏览器与远端主机双向同步文本剪贴板",
"fileUploadEnabled": "允许文件上传",
"fileUploadEnabledDesc": "允许通过 RDP AIOps 盘或终端文件能力上传文件",
"fileDownloadEnabled": "允许文件下载",
"fileDownloadEnabledDesc": "允许从远端主机下载文件到本地浏览器",
"commandPolicyEnabled": "命令策略",
"commandPolicyEnabledDesc": "启用命令黑白名单和高危命令拦截(逐步接入)",
"approvalRequired": "访问需审批",
"approvalRequiredDesc": "远程访问前要求提交临时授权申请(逐步接入)",
"roleManageDesc": "修改角色信息与权限",
"terminalLiveMonitor": "终端实时监控",
"systemInfoDesc": "版本与系统状态",
@@ -7243,11 +7346,12 @@
},
"aiops": {
"title": "AIOps 智能",
"subtitle": "AI 助手 · 告警分析 · 容量预测",
"subtitle": "智能助手 · 告警分析 · 预测分析",
"aiAssistant": "智能助手",
"assistantTitle": "AIOps 智能助手",
"assistantTitle": "智能助手",
"aiDrivenAssistant": "面向运维场景的 AI 对话、分析和处置建议",
"assistantPlaceholder": "输入运维问题开始对话",
"assistantHint": "输入运维问题,AI 助手将分析并给出建议",
"assistantHint": "输入运维问题,智能助手将分析并给出建议",
"sendMessage": "发送",
"newConversation": "新对话",
"conversationHistory": "历史对话",
@@ -7291,7 +7395,9 @@
"assistantSummary": "总结",
"assistantNoData": "暂无数据",
"alertAnalysis": "告警分析",
"alertAnalysisTitle": "智能告警分析",
"aiAlertAnalysis": "告警分析",
"alertAnalysisTitle": "告警分析",
"alertRootCauseAnalysis": "告警根因分析与修复建议",
"alertAnalysisDesc": "基于大模型的告警根因分析和修复建议",
"alertAnalysisHint": "选择左侧告警进行 AI 智能分析",
"alertRootCause": "根因分析",
@@ -7438,7 +7544,7 @@
"capacityPlanning": "容量规划",
"notConnectedRetry": "连接未就绪,请稍后重试",
"safetyHighRiskConfirm": "高风险+确认",
"aiAssistantShort": "AI 助手",
"aiAssistantShort": "智能助手",
"recommended": "推荐",
"endpointLabel": "接口地址(Endpoint)",
"wsConnectFailed": "WebSocket 连接失败",
@@ -7820,7 +7926,7 @@
"predictive.s1a2b3c4d": "指标预测",
"predictive.s2b3c4d5e": "容量规划",
"predictive.s3c4d5e6f": "成本预测",
"predictive.s4d5e6f7g": "AI 预测分析",
"predictive.s4d5e6f7g": "预测分析",
"predictive.s5e6f7g8h": "基于实时指标与云账单的线性回归预测、容量规划与成本前瞻",
"predictive.s6f7g8h9i": "置信度",
"predictive.s7g8h9i0j": "预测月末",
@@ -7966,5 +8072,21 @@
"apiaccesskey.s4b5c6d7e": "凭证已复制",
"apiaccesskey.s5c6d7e8f": "我已保存",
"apiaccesskey.s6d7e8f9g": "你的访问凭证"
},
"logCenter": {
"saveQuery": "另存为查询",
"saveAsAlert": "另存为告警",
"queryName": "查询名称",
"queryNamePlaceholder": "输入查询名称",
"alertName": "告警名称",
"alertNamePlaceholder": "输入告警规则名称",
"triggerCondition": "触发条件",
"createAlert": "创建告警",
"createFromQuery": "从查询页创建: {{name}}",
"defaultDatasource": "默认数据源",
"alertRuleCreated": "告警规则已创建",
"quickQuery": "快捷查询",
"quickQueryHint": "{{engine}} 常用查询模板,点击快速填充",
"entries": "条"
}
}
}
+87 -24
View File
@@ -1,8 +1,8 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { User } from '@/types';
import { removeItem, STORAGE_KEYS } from '@/utils/storage';
import i18n from '@/i18n';
import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { User } from "@/types";
import { removeItem, STORAGE_KEYS } from "@/utils/storage";
import i18n from "@/i18n";
// 主题状态
interface ThemeState {
@@ -12,11 +12,12 @@ interface ThemeState {
export const useThemeStore = create<ThemeState>((set) => ({
sidebarCollapsed: false,
toggleSidebar: () => set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })),
toggleSidebar: () =>
set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })),
}));
// 语言状态(i18n — U3 国际化)
type AppLanguage = 'zh-CN' | 'en-US';
type AppLanguage = "zh-CN" | "en-US";
interface LanguageState {
language: AppLanguage;
@@ -26,21 +27,63 @@ interface LanguageState {
export const useLanguageStore = create<LanguageState>()(
persist(
(set) => ({
language: 'zh-CN' as AppLanguage,
language: "zh-CN" as AppLanguage,
setLanguage: (lang) => {
// 同步切换 i18next 实例
import('@/i18n').then(({ default: i18n }) => {
i18n.changeLanguage(lang);
}).catch(() => { /* i18n not yet loaded */ });
import("@/i18n")
.then(({ default: i18n }) => {
i18n.changeLanguage(lang);
})
.catch(() => {
/* i18n not yet loaded */
});
set({ language: lang });
},
}),
{
name: 'language-storage',
name: "language-storage",
},
),
);
// 会话安全策略:用于 RDP/SSH/POD 等远程会话的水印、剪贴板和文件管控。 // Logan.Li
interface SessionSecurityState {
watermarkEnabled: boolean;
recordEnabled: boolean;
clipboardEnabled: boolean;
fileUploadEnabled: boolean;
fileDownloadEnabled: boolean;
commandPolicyEnabled: boolean;
approvalRequired: boolean;
setWatermarkEnabled: (enabled: boolean) => void;
setPolicy: (
policy: Partial<
Omit<SessionSecurityState, "setWatermarkEnabled" | "setPolicy">
>,
) => void;
}
export const useSessionSecurityStore = create<SessionSecurityState>()(
persist(
(set) => ({
watermarkEnabled: true,
recordEnabled: true,
clipboardEnabled: true,
fileUploadEnabled: true,
fileDownloadEnabled: true,
commandPolicyEnabled: false,
approvalRequired: false,
setWatermarkEnabled: (watermarkEnabled) => set({ watermarkEnabled }),
setPolicy: (policy) => set(policy),
}),
{
name: "session-security-storage",
},
),
);
export const useSessionWatermarkStore = useSessionSecurityStore;
// 用户状态
interface UserState {
user: User | null;
@@ -62,21 +105,32 @@ export const useUserStore = create<UserState>()(
user: null as User | null,
token: null as string | null,
isAuthenticated: false,
theme: 'ocean-deep',
theme: "ocean-deep",
permissions: null as string[] | null,
setUser: (user) => set({ user }),
setToken: (token) => set({ token }),
setPermissions: (permissions) => set({ permissions }),
login: (user, token, permissions) => set({ user, token, isAuthenticated: true, permissions: permissions ?? null }),
login: (user, token, permissions) =>
set({
user,
token,
isAuthenticated: true,
permissions: permissions ?? null,
}),
logout: () => {
removeItem(STORAGE_KEYS.token);
set({ user: null as User | null, token: null as string | null, isAuthenticated: false, permissions: null });
set({
user: null as User | null,
token: null as string | null,
isAuthenticated: false,
permissions: null,
});
},
}),
{
name: 'user-storage',
}
)
name: "user-storage",
},
),
);
// 菜单状态
@@ -88,7 +142,7 @@ interface MenuState {
}
export const useMenuStore = create<MenuState>((set) => ({
activeMenu: 'dashboard',
activeMenu: "dashboard",
openMenus: [],
setActiveMenu: (menu) => set({ activeMenu: menu }),
toggleMenu: (menu) =>
@@ -116,8 +170,15 @@ interface TabState {
}
export const useTabStore = create<TabState>((set) => ({
tabs: [{ id: 'dashboard', title: i18n.t('monitor.dashboard.custom.typeGauge'), path: '/', closable: false }],
activeTab: 'dashboard',
tabs: [
{
id: "dashboard",
title: i18n.t("monitor.dashboard.custom.typeGauge"),
path: "/",
closable: false,
},
],
activeTab: "dashboard",
addTab: (tab) =>
set((state) => {
const exists = state.tabs.find((t) => t.id === tab.id);
@@ -140,7 +201,7 @@ export const useTabStore = create<TabState>((set) => ({
// 通知状态
interface Notification {
id: string;
type: 'info' | 'success' | 'warning' | 'error';
type: "info" | "success" | "warning" | "error";
title: string;
message: string;
timestamp: number;
@@ -150,7 +211,9 @@ interface Notification {
interface NotificationState {
notifications: Notification[];
unreadCount: number;
addNotification: (notification: Omit<Notification, 'id' | 'timestamp' | 'read'>) => void;
addNotification: (
notification: Omit<Notification, "id" | "timestamp" | "read">,
) => void;
markAsRead: (id: string) => void;
markAllAsRead: () => void;
removeNotification: (id: string) => void;
@@ -178,7 +241,7 @@ export const useNotificationStore = create<NotificationState>((set) => ({
if (notification && !notification.read) {
return {
notifications: state.notifications.map((n) =>
n.id === id ? { ...n, read: true } : n
n.id === id ? { ...n, read: true } : n,
),
unreadCount: Math.max(0, state.unreadCount - 1),
};