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 回复中的结构化诊断计划可以一键回填到这里,执行结果会实时流入输出面板。
-
-
-
- {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 ? "命令执行中" : "空闲"}
+
+
+
+ 检查能力只读取通道权限;执行命令前必须先建会话。输出优先看下方“实时输出”,审计记录看“审计历史”。
+
+
+ clearOutput(tab.id)}>
+ 清空输出
+
+ void loadAuditRecords()} loading={historyLoading}>
+ 刷新历史
+
+
+ {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}`;
};