diff --git a/frontend/src/components/JVMDiagnosticConsole.test.tsx b/frontend/src/components/JVMDiagnosticConsole.test.tsx index 38f78d8..a82c478 100644 --- a/frontend/src/components/JVMDiagnosticConsole.test.tsx +++ b/frontend/src/components/JVMDiagnosticConsole.test.tsx @@ -1,45 +1,166 @@ import React from "react"; import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; -import JVMDiagnosticConsole from "./JVMDiagnosticConsole"; +import JVMDiagnosticConsole, { + createJVMDiagnosticLocalPendingChunk, + createJVMDiagnosticRunningRecord, + isJVMDiagnosticTerminalPhase, +} from "./JVMDiagnosticConsole"; + +const baseState = { + connections: [ + { + id: "conn-1", + name: "orders-jvm", + config: { + host: "orders.internal", + jvm: { + diagnostic: { + enabled: true, + transport: "agent-bridge", + }, + }, + }, + }, + ], + jvmDiagnosticDrafts: {}, + jvmDiagnosticOutputs: {}, + setJVMDiagnosticDraft: vi.fn(), + appendJVMDiagnosticOutput: vi.fn(), + clearJVMDiagnosticOutput: vi.fn(), +}; + +let mockState: any = baseState; +let registeredCompletionProvider: any = null; +const mockMonaco = { + Range: class { + startLineNumber: number; + startColumn: number; + endLineNumber: number; + endColumn: number; + + constructor( + startLineNumber: number, + startColumn: number, + endLineNumber: number, + endColumn: number, + ) { + this.startLineNumber = startLineNumber; + this.startColumn = startColumn; + this.endLineNumber = endLineNumber; + this.endColumn = endColumn; + } + }, + KeyMod: { CtrlCmd: 2048 }, + KeyCode: { Enter: 3 }, + editor: { + setTheme: vi.fn(), + }, + languages: { + CompletionItemKind: { + Keyword: 1, + Snippet: 2, + Value: 3, + }, + CompletionItemInsertTextRule: { + InsertAsSnippet: 4, + }, + register: vi.fn(), + registerCompletionItemProvider: vi.fn((language: string, provider: any) => { + if (language === "jvm-diagnostic") { + registeredCompletionProvider = provider; + } + return { dispose: vi.fn() }; + }), + }, +}; +const mockEditor = { + addCommand: vi.fn(), +}; vi.mock("@monaco-editor/react", () => ({ - default: ({ language, value }: { language?: string; value?: string }) => ( -
- {value} -
- ), + default: ({ + beforeMount, + language, + onMount, + value, + }: { + beforeMount?: (monaco: any) => void; + language?: string; + onMount?: (editor: any, monaco: any) => void; + value?: string; + }) => { + beforeMount?.(mockMonaco); + onMount?.(mockEditor, mockMonaco); + return ( +
+ {value} +
+ ); + }, })); vi.mock("../store", () => ({ - useStore: (selector: (state: any) => any) => - selector({ - connections: [ - { - id: "conn-1", - name: "orders-jvm", - config: { - host: "orders.internal", - jvm: { - diagnostic: { - enabled: true, - transport: "agent-bridge", - }, - }, - }, - }, - ], - jvmDiagnosticDrafts: {}, - jvmDiagnosticOutputs: {}, - setJVMDiagnosticDraft: vi.fn(), - appendJVMDiagnosticOutput: vi.fn(), - clearJVMDiagnosticOutput: vi.fn(), - }), + useStore: (selector: (state: any) => any) => selector(mockState), })); describe("JVMDiagnosticConsole", () => { - it("shows observe command presets by default", () => { + beforeEach(() => { + registeredCompletionProvider = null; + mockMonaco.editor.setTheme.mockClear(); + mockMonaco.languages.register.mockClear(); + mockMonaco.languages.registerCompletionItemProvider.mockClear(); + mockEditor.addCommand.mockClear(); + }); + + it("builds local pending output and history while a command is waiting for backend events", () => { + const chunk = createJVMDiagnosticLocalPendingChunk({ + sessionId: "session-1", + commandId: "cmd-1", + command: "thread -n 5", + }); + const record = createJVMDiagnosticRunningRecord({ + connectionId: "conn-1", + sessionId: "session-1", + commandId: "cmd-1", + transport: "arthas-tunnel", + command: "thread -n 5", + source: "manual", + reason: "排查线程", + }); + + expect(chunk).toMatchObject({ + sessionId: "session-1", + commandId: "cmd-1", + event: "diagnostic", + phase: "running", + }); + expect(chunk.content).toContain("thread -n 5"); + expect(record).toMatchObject({ + connectionId: "conn-1", + sessionId: "session-1", + commandId: "cmd-1", + transport: "arthas-tunnel", + command: "thread -n 5", + status: "running", + reason: "排查线程", + }); + expect(isJVMDiagnosticTerminalPhase("completed")).toBe(true); + expect(isJVMDiagnosticTerminalPhase("failed")).toBe(true); + expect(isJVMDiagnosticTerminalPhase("running")).toBe(false); + }); + + it("explains the workflow and hides command inputs before session creation", () => { + mockState = { + ...baseState, + jvmDiagnosticDrafts: {}, + }; + const markup = renderToStaticMarkup( { />, ); + expect(markup).toContain("使用流程"); + expect(markup).toContain("检查能力(可选)"); + expect(markup).toContain("先新建会话,再显示命令编辑区"); + expect(markup).not.toContain("命令模板"); + expect(markup).not.toContain('data-monaco-editor-mock="true"'); + }); + + it("shows command input, reason field, and presets after a session exists", () => { + mockState = { + ...baseState, + jvmDiagnosticDrafts: { + "tab-1": { + sessionId: "session-1", + command: "thread -n 5", + reason: "排查 CPU 线程", + }, + }, + }; + + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("overflow:auto"); + expect(markup).toContain("JVM 诊断工作台"); + expect(markup).toContain("会话与能力"); + expect(markup).toContain("实时输出"); + expect(markup).toContain("审计历史"); + expect(markup.indexOf("命令输入")).toBeGreaterThanOrEqual(0); + expect(markup).toContain("诊断命令"); + expect(markup).toContain("诊断原因(可选)"); + expect(markup).toContain("用于审计记录"); + expect(markup.indexOf("命令输入")).toBeLessThan(markup.indexOf("实时输出")); expect(markup).toContain("观察类命令"); expect(markup).toContain("thread"); expect(markup).toContain("执行命令"); expect(markup).toContain('data-monaco-editor-mock="true"'); expect(markup).toContain('data-language="jvm-diagnostic"'); }); + + it("uses the same styled editor shell and registers command completion before mount", () => { + mockState = { + ...baseState, + jvmDiagnosticDrafts: { + "tab-1": { + sessionId: "session-1", + command: "thr", + reason: "排查 CPU 线程", + }, + }, + }; + + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain( + 'data-jvm-diagnostic-command-editor-shell="true"', + ); + expect(markup).toContain('data-before-mount="true"'); + expect(markup).toContain("border-radius:14px"); + expect(registeredCompletionProvider).toBeTruthy(); + + const result = registeredCompletionProvider.provideCompletionItems( + { + getValueInRange: () => "thr", + getWordUntilPosition: () => ({ startColumn: 1, endColumn: 4 }), + }, + { lineNumber: 1, column: 4 }, + ); + + expect(result.suggestions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + label: "thread", + insertText: "thread ", + }), + ]), + ); + }); }); diff --git a/frontend/src/components/JVMDiagnosticConsole.tsx b/frontend/src/components/JVMDiagnosticConsole.tsx index 962f6d9..e9b354f 100644 --- a/frontend/src/components/JVMDiagnosticConsole.tsx +++ b/frontend/src/components/JVMDiagnosticConsole.tsx @@ -1,5 +1,5 @@ -import React, { useCallback, useEffect, useMemo, useState } from "react"; -import Editor, { type OnMount } from "@monaco-editor/react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import Editor, { type BeforeMount, type OnMount } from "@monaco-editor/react"; import { Alert, Button, @@ -30,8 +30,7 @@ import JVMDiagnosticOutput from "./jvm/JVMDiagnosticOutput"; const { Text, Paragraph } = Typography; const JVM_DIAGNOSTIC_EDITOR_LANGUAGE = "jvm-diagnostic"; -let jvmDiagnosticLanguageRegistered = false; -let jvmDiagnosticCompletionRegistered = false; +let jvmDiagnosticCompletionDisposable: { dispose?: () => void } | null = null; type JVMDiagnosticConsoleProps = { tab: TabData; @@ -41,6 +40,131 @@ const DEFAULT_COMMAND = JVM_DIAGNOSTIC_COMMAND_PRESETS.find((item) => item.category === "observe") ?.command || "thread -n 5"; +const commandEditorShellStyle = (darkMode: boolean): React.CSSProperties => ({ + borderRadius: 14, + border: darkMode ? "1px solid #303030" : "1px solid #e6eef8", + background: darkMode ? "rgba(255,255,255,0.04)" : "rgba(0,0,0,0.04)", + overflow: "hidden", +}); + +const registerJVMDiagnosticMonacoSupport = (monaco: any) => { + const languageRegistry = monaco.languages as Record; + if (!languageRegistry.__gonaviJvmDiagnosticLanguageRegistered) { + languageRegistry.__gonaviJvmDiagnosticLanguageRegistered = true; + monaco.languages.register({ id: JVM_DIAGNOSTIC_EDITOR_LANGUAGE }); + } + + if (jvmDiagnosticCompletionDisposable?.dispose) { + jvmDiagnosticCompletionDisposable.dispose(); + } + + jvmDiagnosticCompletionDisposable = + monaco.languages.registerCompletionItemProvider( + JVM_DIAGNOSTIC_EDITOR_LANGUAGE, + { + triggerCharacters: [" ", "-", ".", "@", "'", "\"", "{", "/"], + provideCompletionItems: (model: any, position: any) => { + const textBeforeCursor = model.getValueInRange( + new monaco.Range(1, 1, position.lineNumber, position.column), + ); + const word = model.getWordUntilPosition(position); + const range = { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: word.startColumn, + endColumn: word.endColumn, + }; + + const suggestions = resolveJVMDiagnosticCompletionItems( + textBeforeCursor, + ).map((item, index) => ({ + label: item.label, + kind: + item.scope === "command" + ? monaco.languages.CompletionItemKind.Keyword + : item.isSnippet + ? monaco.languages.CompletionItemKind.Snippet + : monaco.languages.CompletionItemKind.Value, + insertText: + item.scope === "command" + ? `${item.insertText} ` + : item.insertText, + insertTextRules: item.isSnippet + ? monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet + : undefined, + detail: item.detail, + documentation: item.documentation, + range, + sortText: `${item.scope === "command" ? "0" : "1"}-${String(index).padStart(3, "0")}`, + command: + item.scope === "command" + ? { id: "editor.action.triggerSuggest" } + : undefined, + })); + + return { suggestions }; + }, + }, + ); +}; + +export const isJVMDiagnosticTerminalPhase = (phase?: string): boolean => + ["completed", "failed", "canceled"].includes( + String(phase || "").toLowerCase().trim(), + ); + +export const createJVMDiagnosticLocalPendingChunk = ({ + sessionId, + commandId, + command, + timestamp = Date.now(), +}: { + sessionId: string; + commandId: string; + command: string; + timestamp?: number; +}): JVMDiagnosticEventChunk => ({ + sessionId, + commandId, + event: "diagnostic", + phase: "running", + content: `已提交诊断命令,等待后端输出:${command}`, + timestamp, + metadata: { + source: "local-pending", + }, +}); + +export const createJVMDiagnosticRunningRecord = ({ + connectionId, + sessionId, + commandId, + transport, + command, + source, + reason, + timestamp = Date.now(), +}: { + connectionId: string; + sessionId: string; + commandId: string; + transport: string; + command: string; + source?: string; + reason?: string; + timestamp?: number; +}): JVMDiagnosticAuditRecord => ({ + timestamp, + connectionId, + sessionId, + commandId, + transport, + command, + source, + reason, + status: "running", +}); + const JVMDiagnosticConsole: React.FC = ({ tab }) => { const connection = useStore((state) => state.connections.find((item) => item.id === tab.connectionId), @@ -63,6 +187,17 @@ const JVMDiagnosticConsole: React.FC = ({ tab }) => { const [commandRunning, setCommandRunning] = useState(false); const [activeCommandId, setActiveCommandId] = useState(""); const [error, setError] = useState(""); + const activeCommandIdRef = useRef(""); + const terminalCommandIdsRef = useRef>(new Set()); + + const finishActiveCommand = useCallback((commandId: string) => { + if (!commandId || activeCommandIdRef.current !== commandId) { + return; + } + activeCommandIdRef.current = ""; + setCommandRunning(false); + setActiveCommandId(""); + }, []); useEffect(() => { if (!draft.command) { @@ -93,6 +228,7 @@ const JVMDiagnosticConsole: React.FC = ({ tab }) => { : null), [diagnosticTransport, draft.sessionId, session], ); + const hasSession = Boolean(effectiveSession?.sessionId); const loadAuditRecords = useCallback(async () => { if (!connection) { @@ -165,14 +301,9 @@ const JVMDiagnosticConsole: React.FC = ({ tab }) => { if (payload.chunk.phase === "failed") { setError(payload.chunk.content || "诊断命令执行失败"); } - if ( - payload.chunk.commandId && - ["completed", "failed", "canceled"].includes(payload.chunk.phase || "") - ) { - if (payload.chunk.commandId === activeCommandId) { - setCommandRunning(false); - setActiveCommandId(""); - } + if (payload.chunk.commandId && isJVMDiagnosticTerminalPhase(payload.chunk.phase)) { + terminalCommandIdsRef.current.add(payload.chunk.commandId); + finishActiveCommand(payload.chunk.commandId); void loadAuditRecords(); } }); @@ -182,7 +313,7 @@ const JVMDiagnosticConsole: React.FC = ({ tab }) => { stopListening(); } }; - }, [activeCommandId, appendOutput, loadAuditRecords, tab.id]); + }, [appendOutput, finishActiveCommand, loadAuditRecords, tab.id]); const handleProbe = async () => { if (!rpcConnectionConfig) { @@ -201,12 +332,12 @@ const JVMDiagnosticConsole: React.FC = ({ tab }) => { rpcConnectionConfig, ); if (result?.success === false) { - throw new Error(String(result?.message || "探测诊断能力失败")); + throw new Error(String(result?.message || "检查诊断能力失败")); } setCapabilities(Array.isArray(result?.data) ? result.data : []); } catch (err: any) { setCapabilities([]); - setError(err?.message || "探测诊断能力失败"); + setError(err?.message || "检查诊断能力失败"); } finally { setLoading(false); } @@ -262,25 +393,50 @@ const JVMDiagnosticConsole: React.FC = ({ tab }) => { setError("请先创建诊断会话,再执行命令"); return; } - if (!draft.command.trim()) { + const command = draft.command.trim(); + if (!command) { setError("诊断命令不能为空"); return; } + const sessionId = effectiveSession.sessionId; const commandId = `diag-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const source = draft.source || "manual"; + const reason = (draft.reason || "").trim(); + activeCommandIdRef.current = commandId; + terminalCommandIdsRef.current.delete(commandId); setCommandRunning(true); setActiveCommandId(commandId); setError(""); + appendOutput(tab.id, [ + createJVMDiagnosticLocalPendingChunk({ + sessionId, + commandId, + command, + }), + ]); + setRecords((current) => [ + createJVMDiagnosticRunningRecord({ + connectionId: connection?.id || rpcConnectionConfig.id || "", + sessionId, + commandId, + transport: diagnosticTransport, + command, + source, + reason, + }), + ...current.filter((record) => record.commandId !== commandId), + ].slice(0, 20)); try { const result = await backendApp.JVMExecuteDiagnosticCommand( rpcConnectionConfig, tab.id, { - sessionId: effectiveSession.sessionId, + sessionId, commandId, - command: draft.command.trim(), - source: draft.source || "manual", - reason: (draft.reason || "").trim(), + command, + source, + reason, }, ); if (result?.success === false) { @@ -289,11 +445,75 @@ const JVMDiagnosticConsole: React.FC = ({ tab }) => { if (result?.message) { message.warning(result.message); } + const terminalSeen = terminalCommandIdsRef.current.has(commandId); + if (!terminalSeen) { + appendOutput(tab.id, [ + { + sessionId, + commandId, + event: "diagnostic", + phase: "completed", + content: "诊断命令调用已返回,但未收到后端终态事件,前端已兜底结束等待状态。", + timestamp: Date.now(), + metadata: { + source: "frontend-fallback", + }, + }, + ]); + } + finishActiveCommand(commandId); await loadAuditRecords(); + if (!terminalSeen) { + setRecords((current) => { + const index = current.findIndex((record) => record.commandId === commandId); + if (index >= 0) { + const next = [...current]; + next[index] = { ...next[index], status: "completed" }; + return next; + } + return [ + { + ...createJVMDiagnosticRunningRecord({ + connectionId: connection?.id || rpcConnectionConfig.id || "", + sessionId, + commandId, + transport: diagnosticTransport, + command, + source, + reason, + }), + status: "completed", + }, + ...current, + ].slice(0, 20); + }); + } } catch (err: any) { - setCommandRunning(false); - setActiveCommandId(""); - setError(err?.message || "执行诊断命令失败"); + const messageText = err?.message || "执行诊断命令失败"; + if (!terminalCommandIdsRef.current.has(commandId)) { + appendOutput(tab.id, [ + { + sessionId, + commandId, + event: "diagnostic", + phase: "failed", + content: messageText, + timestamp: Date.now(), + metadata: { + source: "frontend-fallback", + }, + }, + ]); + setRecords((current) => + current.map((record) => + record.commandId === commandId + ? { ...record, status: "failed" } + : record, + ), + ); + } + finishActiveCommand(commandId); + setError(messageText); } }; @@ -327,198 +547,305 @@ const JVMDiagnosticConsole: React.FC = ({ tab }) => { } }; + const handleCommandEditorBeforeMount: BeforeMount = (monaco) => { + registerJVMDiagnosticMonacoSupport(monaco); + }; + const handleCommandEditorMount: OnMount = (editor, monaco) => { monaco.editor.setTheme(darkMode ? "transparent-dark" : "transparent-light"); editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, () => { void handleExecuteCommand(); }); - - if (!jvmDiagnosticLanguageRegistered) { - jvmDiagnosticLanguageRegistered = true; - monaco.languages.register({ id: JVM_DIAGNOSTIC_EDITOR_LANGUAGE }); - } - - if (!jvmDiagnosticCompletionRegistered) { - jvmDiagnosticCompletionRegistered = true; - monaco.languages.registerCompletionItemProvider( - JVM_DIAGNOSTIC_EDITOR_LANGUAGE, - { - triggerCharacters: [" ", "-", ".", "@", "'", "\"", "{", "/"], - provideCompletionItems: (model: any, position: any) => { - const textBeforeCursor = model.getValueInRange( - new monaco.Range(1, 1, position.lineNumber, position.column), - ); - const word = model.getWordUntilPosition(position); - const range = { - startLineNumber: position.lineNumber, - endLineNumber: position.lineNumber, - startColumn: word.startColumn, - endColumn: word.endColumn, - }; - - const suggestions = resolveJVMDiagnosticCompletionItems( - textBeforeCursor, - ).map((item, index) => ({ - label: item.label, - kind: - item.scope === "command" - ? monaco.languages.CompletionItemKind.Keyword - : item.isSnippet - ? monaco.languages.CompletionItemKind.Snippet - : monaco.languages.CompletionItemKind.Value, - insertText: - item.scope === "command" - ? `${item.insertText} ` - : item.insertText, - insertTextRules: item.isSnippet - ? monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet - : undefined, - detail: item.detail, - documentation: item.documentation, - range, - sortText: `${item.scope === "command" ? "0" : "1"}-${String(index).padStart(3, "0")}`, - command: - item.scope === "command" - ? { id: "editor.action.triggerSuggest" } - : undefined, - })); - - return { suggestions }; - }, - }, - ); - } }; if (!connection) { return ; } + const pageBackground = darkMode + ? "linear-gradient(135deg, #101820 0%, #141414 48%, #1f1f1f 100%)" + : "linear-gradient(135deg, #eef4ff 0%, #f7f9fc 45%, #ffffff 100%)"; + const heroBackground = darkMode + ? "linear-gradient(135deg, rgba(22,119,255,0.22), rgba(82,196,26,0.08))" + : "linear-gradient(135deg, rgba(22,119,255,0.14), rgba(19,194,194,0.08))"; + const cardStyle = { + borderRadius: 16, + boxShadow: darkMode + ? "0 18px 44px rgba(0, 0, 0, 0.22)" + : "0 18px 44px rgba(24, 54, 96, 0.08)", + }; + return (
- - - - {connection.name} - {diagnosticTransport} - {effectiveSession ? 会话已建立 : 未建会话} + +
+
+ JVM Diagnostics + + JVM 诊断工作台 + + + {connection.name} + + {" "}· {connection.config.host || "unknown"}:{connection.config.port || 0} + {" "}· {diagnosticTransport} + + +
+ + + + {hasSession ? "会话已建立" : "未建会话"} + {commandRunning ? 命令执行中 : null} - - - 先创建诊断会话,再执行命令;AI 回复中的结构化诊断计划可以一键回填到这里,执行结果会实时流入输出面板。 - - - - - - - + {hasSession ? ( + + ) : null} + {hasSession ? ( + + ) : null} - {error ? : null} - {capabilities.length ? ( - - {capabilities.map((item) => ( - - {item.transport} - - ))} - - ) : null} - - - - - - setDraft(tab.id, { - command: preset.command, - reason: preset.description, - source: "manual", - }) - } - /> - - - -
- - setDraft(tab.id, { - command: value || "", - source: "manual", - }) - } - /> - setDraft(tab.id, { reason: event.target.value })} - />
+ {error ? : null}
+ {!hasSession ? ( + +
+ {[ + ["1", "检查能力(可选)", "读取诊断通道、流式输出和命令权限,不创建会话、不执行命令。"], + ["2", "新建会话", "创建一次诊断上下文。Arthas Tunnel 的目标连接会在测试或执行命令时发生。"], + ["3", "执行命令", "先新建会话,再显示命令编辑区;会话创建后才显示原因输入和模板区。"], + ].map(([index, title, description]) => ( +
+ {index} + {title} + + {description} + +
+ ))} +
+
+ ) : ( +
+
+ +
+
+ 诊断命令 + + 输入 Arthas/诊断命令,例如 thread -n 5、dashboard、jvm;也可以从下方模板一键回填。按 Ctrl/Cmd + Enter 可执行。 + +
+ + setDraft(tab.id, { + command: value || "", + source: "manual", + }) + } + /> +
+
+
+ 诊断原因(可选) + setDraft(tab.id, { reason: event.target.value })} + /> + + 用于审计记录和 AI 上下文理解,不会作为 Arthas 命令发送到目标 JVM。 + +
+
+
+ + + + setDraft(tab.id, { + command: preset.command, + reason: preset.description, + source: "manual", + }) + } + /> + +
+ + + +
+ {effectiveSession?.sessionId} + {effectiveSession?.transport || diagnosticTransport} + + {commandRunning ? "命令执行中" : "空闲"} + +
+ + 检查能力只读取通道权限;执行命令前必须先建会话。输出优先看下方“实时输出”,审计记录看“审计历史”。 + + + + + + {capabilities.length ? ( + + {capabilities.map((item) => ( + + {item.transport} + + {item.canOpenSession ? "可建会话" : "不可建会话"} + + + {item.canStream ? "支持流式输出" : "不支持流式输出"} + + + {item.allowObserveCommands ? "允许观察命令" : "禁止观察命令"} + + {item.allowTraceCommands ? 允许 Trace : null} + {item.allowMutatingCommands ? 允许变更类命令 : null} + + ))} +
+ } + /> + ) : ( + + )} + +
+
+ )} +
- + - +
diff --git a/frontend/src/components/jvm/JVMDiagnosticHistory.tsx b/frontend/src/components/jvm/JVMDiagnosticHistory.tsx index 0ab6b57..1b4ca87 100644 --- a/frontend/src/components/jvm/JVMDiagnosticHistory.tsx +++ b/frontend/src/components/jvm/JVMDiagnosticHistory.tsx @@ -5,6 +5,13 @@ import type { JVMDiagnosticAuditRecord, JVMDiagnosticSessionHandle, } from "../../types"; +import { + formatJVMDiagnosticCommandTypeLabel, + formatJVMDiagnosticRiskLabel, + formatJVMDiagnosticSourceLabel, + formatJVMDiagnosticPhaseLabel, + formatJVMDiagnosticTransportLabel, +} from "../../utils/jvmDiagnosticPresentation"; const { Text } = Typography; @@ -23,7 +30,7 @@ const JVMDiagnosticHistory: React.FC = ({ {session ? (
{session.sessionId} - {session.transport} + {formatJVMDiagnosticTransportLabel(session.transport)}
) : ( = ({ {record.command}
- {record.status ? {record.status} : null} - {record.riskLevel ? {record.riskLevel} : null} - {record.commandType ? {record.commandType} : null} - {record.source ? {record.source} : null} + {record.status ? ( + {formatJVMDiagnosticPhaseLabel(record.status)} + ) : null} + {record.riskLevel ? ( + {formatJVMDiagnosticRiskLabel(record.riskLevel)} + ) : null} + {record.commandType ? ( + {formatJVMDiagnosticCommandTypeLabel(record.commandType)} + ) : null} + {record.source ? {formatJVMDiagnosticSourceLabel(record.source)} : null}
{record.reason || "未填写诊断原因"} diff --git a/frontend/src/components/jvm/JVMDiagnosticOutput.tsx b/frontend/src/components/jvm/JVMDiagnosticOutput.tsx index f31e4ad..6189db6 100644 --- a/frontend/src/components/jvm/JVMDiagnosticOutput.tsx +++ b/frontend/src/components/jvm/JVMDiagnosticOutput.tsx @@ -2,7 +2,11 @@ import React from "react"; import { Empty, List, Tag, Typography } from "antd"; import type { JVMDiagnosticEventChunk } from "../../types"; -import { formatJVMDiagnosticChunkText } from "../../utils/jvmDiagnosticPresentation"; +import { + formatJVMDiagnosticChunkText, + formatJVMDiagnosticEventLabel, + formatJVMDiagnosticPhaseLabel, +} from "../../utils/jvmDiagnosticPresentation"; const { Text } = Typography; @@ -35,8 +39,10 @@ const JVMDiagnosticOutput: React.FC = ({ chunks }) => {formatJVMDiagnosticChunkText(chunk)}
- {chunk.phase ? {chunk.phase} : null} - {chunk.event ? {chunk.event} : null} + {chunk.phase ? ( + {formatJVMDiagnosticPhaseLabel(chunk.phase)} + ) : null} + {chunk.event ? {formatJVMDiagnosticEventLabel(chunk.event)} : null} {chunk.commandId ? {chunk.commandId} : null}
diff --git a/frontend/src/utils/jvmDiagnosticCompletion.test.ts b/frontend/src/utils/jvmDiagnosticCompletion.test.ts index 9cc1455..d22eb7e 100644 --- a/frontend/src/utils/jvmDiagnosticCompletion.test.ts +++ b/frontend/src/utils/jvmDiagnosticCompletion.test.ts @@ -13,6 +13,12 @@ describe("jvmDiagnosticCompletion", () => { expect(items.some((item) => item.label === "trace")).toBe(true); }); + it("suggests the jvm command from the command input hint", () => { + const items = resolveJVMDiagnosticCompletionItems("jv"); + + expect(items.some((item) => item.label === "jvm")).toBe(true); + }); + it("switches to argument mode after the command head", () => { expect(resolveJVMDiagnosticCompletionMode("thread -")).toEqual({ head: "thread", diff --git a/frontend/src/utils/jvmDiagnosticCompletion.ts b/frontend/src/utils/jvmDiagnosticCompletion.ts index f871b54..761d8a6 100644 --- a/frontend/src/utils/jvmDiagnosticCompletion.ts +++ b/frontend/src/utils/jvmDiagnosticCompletion.ts @@ -29,6 +29,11 @@ const BASE_COMMAND_DEFINITIONS: DiagnosticCommandDefinition[] = [ detail: "观察类命令", documentation: "查看 JVM 运行总览。", }, + { + head: "jvm", + detail: "观察类命令", + documentation: "查看 JVM 内存、线程、类加载、GC 和运行参数信息。", + }, { head: "thread", detail: "观察类命令", @@ -158,6 +163,15 @@ const ARGUMENT_ITEMS_BY_HEAD: Record = { scope: "argument", }, ], + jvm: [ + { + label: "jvm", + insertText: "", + detail: "直接执行", + documentation: "查看 JVM 内存、线程、类加载、GC 和运行参数信息。", + scope: "argument", + }, + ], thread: [ { label: "繁忙线程 TOP N (-n)", diff --git a/frontend/src/utils/jvmDiagnosticPresentation.test.ts b/frontend/src/utils/jvmDiagnosticPresentation.test.ts index ddb2a88..0d3e144 100644 --- a/frontend/src/utils/jvmDiagnosticPresentation.test.ts +++ b/frontend/src/utils/jvmDiagnosticPresentation.test.ts @@ -2,6 +2,11 @@ import { describe, expect, it } from "vitest"; import { formatJVMDiagnosticChunkText, + formatJVMDiagnosticCommandTypeLabel, + formatJVMDiagnosticPhaseLabel, + formatJVMDiagnosticRiskLabel, + formatJVMDiagnosticSourceLabel, + formatJVMDiagnosticTransportLabel, groupJVMDiagnosticPresets, resolveJVMDiagnosticRiskColor, } from "./jvmDiagnosticPresentation"; @@ -17,14 +22,22 @@ describe("jvmDiagnosticPresentation", () => { expect(groups[0].items.some((item) => item.label === "thread")).toBe(true); }); - it("formats chunk text with phase prefix when content exists", () => { + it("formats chunk text with localized phase prefix when content exists", () => { expect( formatJVMDiagnosticChunkText({ sessionId: "sess-1", phase: "running", content: "thread -n 5", }), - ).toBe("running: thread -n 5"); + ).toBe("执行中:thread -n 5"); + }); + + it("localizes diagnostic status, transport, risk and source labels", () => { + expect(formatJVMDiagnosticPhaseLabel("completed")).toBe("已完成"); + expect(formatJVMDiagnosticTransportLabel("arthas-tunnel")).toBe("Arthas Tunnel"); + expect(formatJVMDiagnosticRiskLabel("high")).toBe("高风险"); + expect(formatJVMDiagnosticCommandTypeLabel("trace")).toBe("跟踪类"); + expect(formatJVMDiagnosticSourceLabel("ai-plan")).toBe("AI 计划"); }); it("maps risk levels to tag colors", () => { diff --git a/frontend/src/utils/jvmDiagnosticPresentation.ts b/frontend/src/utils/jvmDiagnosticPresentation.ts index 929b7bf..9af1444 100644 --- a/frontend/src/utils/jvmDiagnosticPresentation.ts +++ b/frontend/src/utils/jvmDiagnosticPresentation.ts @@ -66,6 +66,43 @@ const RISK_COLORS: Record<"low" | "medium" | "high", string> = { high: "red", }; +const PHASE_LABELS: Record = { + running: "执行中", + completed: "已完成", + failed: "失败", + canceled: "已取消", + canceling: "取消中", + diagnostic: "诊断事件", +}; + +const EVENT_LABELS: Record = { + diagnostic: "诊断输出", + chunk: "输出片段", + done: "执行结束", +}; + +const TRANSPORT_LABELS: Record = { + "agent-bridge": "Agent Bridge", + "arthas-tunnel": "Arthas Tunnel", +}; + +const RISK_LABELS: Record = { + low: "低风险", + medium: "中风险", + high: "高风险", +}; + +const COMMAND_TYPE_LABELS: Record = { + observe: "观察类", + trace: "跟踪类", + mutating: "高风险类", +}; + +const SOURCE_LABELS: Record = { + manual: "手动输入", + "ai-plan": "AI 计划", +}; + export const formatJVMDiagnosticPresetCategory = ( category: JVMDiagnosticPresetCategory, ): string => CATEGORY_LABELS[category]; @@ -74,6 +111,41 @@ export const resolveJVMDiagnosticRiskColor = ( riskLevel: "low" | "medium" | "high", ): string => RISK_COLORS[riskLevel]; +const normalizeLabelKey = (value?: string | null): string => + String(value || "").trim().toLowerCase(); + +const formatWithFallback = ( + value: string | undefined | null, + labels: Record, + fallback = "未知", +): string => { + const normalized = normalizeLabelKey(value); + if (!normalized) { + return fallback; + } + return labels[normalized] || String(value || "").trim(); +}; + +export const formatJVMDiagnosticPhaseLabel = (phase?: string | null): string => + formatWithFallback(phase, PHASE_LABELS); + +export const formatJVMDiagnosticEventLabel = (event?: string | null): string => + formatWithFallback(event, EVENT_LABELS); + +export const formatJVMDiagnosticTransportLabel = ( + transport?: string | null, +): string => formatWithFallback(transport, TRANSPORT_LABELS); + +export const formatJVMDiagnosticRiskLabel = (risk?: string | null): string => + formatWithFallback(risk, RISK_LABELS); + +export const formatJVMDiagnosticCommandTypeLabel = ( + type?: string | null, +): string => formatWithFallback(type, COMMAND_TYPE_LABELS); + +export const formatJVMDiagnosticSourceLabel = (source?: string | null): string => + formatWithFallback(source, SOURCE_LABELS); + export const groupJVMDiagnosticPresets = ( presets: JVMDiagnosticCommandPreset[] = JVM_DIAGNOSTIC_COMMAND_PRESETS, ): Array<{ @@ -90,16 +162,19 @@ export const groupJVMDiagnosticPresets = ( export const formatJVMDiagnosticChunkText = ( chunk: JVMDiagnosticEventChunk, ): string => { - const phase = String(chunk.phase || chunk.event || "").trim(); + const rawPhase = String(chunk.phase || chunk.event || "").trim(); + const phase = chunk.phase + ? formatJVMDiagnosticPhaseLabel(chunk.phase) + : formatJVMDiagnosticEventLabel(chunk.event); const content = String(chunk.content || "").trim(); - if (!phase && !content) { + if (!rawPhase && !content) { return "空事件"; } - if (!phase) { + if (!rawPhase) { return content; } if (!content) { return phase; } - return `${phase}: ${content}`; + return `${phase}:${content}`; };