feat: 完善堡垒机与 Agent 智能运维能力
- 支持 Windows Agent 构建、安装脚本和统一上报协议 - 增强 RDP 剪贴板、虚拟盘和文件传输能力 - 新增 AIOps 上下文工具、告警 RCA trace 落库和回放接口 - 重构 docs 模块文档并补充终端、堡垒机、监控、AI Agent 设计
This commit is contained in:
1 parent
a9513c2056
commit
e0d652d211
169 files changed
+12192
-9116
No files matched your search
Generated
+2
-25
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)}`),
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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,
|
||||
),
|
||||
};
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
// 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"
|
||||
/>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>共享盘 > <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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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')}][0m\r\n`);
|
||||
term.write(`\r\n\x1b[31m[${t("server.connectionClosed")}][0m\r\n`);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
setHasOutput(true);
|
||||
term.write(`\r\n\x1b[31m[${t('server.connectionError')}][0m\r\n`);
|
||||
term.write(`\r\n\x1b[31m[${t("server.connectionError")}][0m\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';
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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),
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user